This local working tree had accumulated a substantial amount of finished-but-never-committed frontend work: custom client fields (replacing the old static ClientFields with a dynamic ClientCustomFieldCard + useCustomFields), CSV import (useImport + settings/import page), history type management (useHistoryTypes + settings/history-types page), an admin-only route middleware, several new icon components, a shared useApiFetch composable, and a client detail page restructure ([id].vue -> [id]/index.vue). None of it had ever been pushed, so production was running a stale build that was already linking to routes/components that didn't exist server-side (missing IconSettings, 404s on /settings/import, etc). This commit brings the repo in line with local state.
195 lines
7.2 KiB
Vue
195 lines
7.2 KiB
Vue
<script setup lang="ts">
|
|
import type { CustomFieldDefinition, CustomFieldType } from '~/types'
|
|
import type { CustomFieldDefinitionPayload } from '~/composables/useCustomFields'
|
|
|
|
definePageMeta({ middleware: ['auth', 'admin'] })
|
|
|
|
const { definitions, fetchDefinitions, createDefinition, updateDefinition, deleteDefinition, reorderDefinitions } = useCustomFields()
|
|
|
|
await fetchDefinitions()
|
|
|
|
const typeLabels: Record<CustomFieldType, string> = {
|
|
text: 'Text (einzeilig)',
|
|
textarea: 'Text (mehrzeilig)',
|
|
number: 'Zahl',
|
|
date: 'Datum',
|
|
select: 'Auswahl',
|
|
}
|
|
|
|
const emptyForm = (): CustomFieldDefinitionPayload & { optionsText: string } => ({
|
|
label: '',
|
|
type: 'text',
|
|
options: [],
|
|
is_required: false,
|
|
optionsText: '',
|
|
})
|
|
|
|
const editingId = ref<number | null>(null)
|
|
const form = ref(emptyForm())
|
|
const error = ref('')
|
|
|
|
const resetForm = () => {
|
|
editingId.value = null
|
|
form.value = emptyForm()
|
|
error.value = ''
|
|
}
|
|
|
|
const startEdit = (def: CustomFieldDefinition) => {
|
|
editingId.value = def.id
|
|
error.value = ''
|
|
form.value = {
|
|
label: def.label,
|
|
type: def.type,
|
|
options: def.options,
|
|
is_required: def.is_required,
|
|
optionsText: def.options.join('\n'),
|
|
}
|
|
}
|
|
|
|
const submit = async () => {
|
|
error.value = ''
|
|
const payload: CustomFieldDefinitionPayload = {
|
|
label: form.value.label.trim(),
|
|
type: form.value.type,
|
|
is_required: form.value.is_required,
|
|
options: form.value.type === 'select'
|
|
? form.value.optionsText.split('\n').map(o => o.trim()).filter(Boolean)
|
|
: undefined,
|
|
}
|
|
|
|
if (!payload.label) {
|
|
error.value = 'Bitte einen Feldnamen angeben.'
|
|
return
|
|
}
|
|
if (form.value.type === 'select' && !(payload.options && payload.options.length)) {
|
|
error.value = 'Auswahlfelder benötigen mindestens eine Option.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (editingId.value) {
|
|
await updateDefinition(editingId.value, payload)
|
|
} else {
|
|
await createDefinition(payload)
|
|
}
|
|
resetForm()
|
|
} catch (e: any) {
|
|
error.value = e?.data?.message ?? 'Speichern fehlgeschlagen.'
|
|
}
|
|
}
|
|
|
|
const remove = async (def: CustomFieldDefinition) => {
|
|
if (!confirm(`Feld „${def.label}“ wirklich löschen? Bestehende Werte bleiben in der Historie erhalten.`)) return
|
|
await deleteDefinition(def.id)
|
|
if (editingId.value === def.id) resetForm()
|
|
}
|
|
|
|
const move = async (index: number, direction: -1 | 1) => {
|
|
const target = index + direction
|
|
if (target < 0 || target >= definitions.value.length) return
|
|
const ids = definitions.value.map(d => d.id)
|
|
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
|
await reorderDefinitions(ids)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-gray-50">
|
|
<header class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-4">
|
|
<NuxtLink
|
|
to="/clients"
|
|
title="Alle Mandanten"
|
|
aria-label="Alle Mandanten"
|
|
class="text-gray-500 hover:text-gray-700"
|
|
>
|
|
<IconArrowLeft />
|
|
</NuxtLink>
|
|
<h1 class="text-base font-semibold text-gray-800">Stammdatenfelder konfigurieren</h1>
|
|
</header>
|
|
|
|
<div class="max-w-3xl mx-auto py-6 px-6 space-y-6">
|
|
<!-- Existing definitions -->
|
|
<section class="bg-white border border-gray-200">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="border-b border-gray-200 text-left text-xs text-gray-500">
|
|
<th class="px-4 py-2 font-medium">Feldname</th>
|
|
<th class="px-4 py-2 font-medium">Typ</th>
|
|
<th class="px-4 py-2 font-medium">Pflicht</th>
|
|
<th class="px-4 py-2 font-medium text-right">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(def, index) in definitions" :key="def.id" class="border-b border-gray-100">
|
|
<td class="px-4 py-2 text-gray-800">{{ def.label }}</td>
|
|
<td class="px-4 py-2 text-gray-600">{{ typeLabels[def.type] }}</td>
|
|
<td class="px-4 py-2 text-gray-600">{{ def.is_required ? 'Ja' : 'Nein' }}</td>
|
|
<td class="px-4 py-2 text-right">
|
|
<div class="inline-flex gap-1">
|
|
<button @click="move(index, -1)" :disabled="index === 0" title="Nach oben" aria-label="Nach oben" class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:hover:text-gray-400">
|
|
<IconArrowUp />
|
|
</button>
|
|
<button @click="move(index, 1)" :disabled="index === definitions.length - 1" title="Nach unten" aria-label="Nach unten" class="p-1 text-gray-400 hover:text-blue-600 disabled:opacity-30 disabled:hover:text-gray-400">
|
|
<IconArrowDown />
|
|
</button>
|
|
<button @click="startEdit(def)" title="Bearbeiten" aria-label="Bearbeiten" class="p-1 text-gray-400 hover:text-blue-600">
|
|
<IconEdit />
|
|
</button>
|
|
<button @click="remove(def)" title="Löschen" aria-label="Löschen" class="p-1 text-gray-400 hover:text-red-600">
|
|
<IconTrash />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="definitions.length === 0">
|
|
<td colspan="4" class="px-4 py-6 text-center text-sm text-gray-400 italic">
|
|
Noch keine Felder konfiguriert.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
|
|
<!-- Create / edit form -->
|
|
<section class="bg-white border border-gray-200 p-4 space-y-3">
|
|
<h2 class="text-sm font-semibold text-gray-700">
|
|
{{ editingId ? 'Feld bearbeiten' : 'Neues Feld' }}
|
|
</h2>
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Feldname</label>
|
|
<input v-model="form.label" class="w-full border border-gray-300 px-3 py-2 text-sm" placeholder="z. B. Beratungsschwerpunkt" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Typ</label>
|
|
<select v-model="form.type" class="w-full border border-gray-300 px-3 py-2 text-sm">
|
|
<option v-for="(label, value) in typeLabels" :key="value" :value="value">{{ label }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="form.type === 'select'">
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Optionen (eine pro Zeile)</label>
|
|
<textarea v-model="form.optionsText" rows="4" class="w-full border border-gray-300 px-3 py-2 text-sm resize-y" placeholder="Steuerrecht Erbrecht" />
|
|
</div>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
|
<input v-model="form.is_required" type="checkbox" />
|
|
Pflichtfeld
|
|
</label>
|
|
</div>
|
|
|
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
|
|
|
<div class="flex gap-2">
|
|
<button @click="submit" class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700">
|
|
{{ editingId ? 'Änderungen speichern' : 'Feld anlegen' }}
|
|
</button>
|
|
<button v-if="editingId" @click="resetForm" class="text-sm text-gray-500 hover:underline px-2">Abbrechen</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</template>
|