feat(frontend): commit uncommitted settings, custom fields, and import work

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.
This commit is contained in:
Tim Stollberg 2026-07-18 16:27:19 +07:00
parent 4d684f38ad
commit 570f17e8b8
37 changed files with 1396 additions and 435 deletions

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m19.5 8.25-7.5 7.5-7.5-7.5"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m4.5 15.75 7.5-7.5 7.5 7.5"
/>
</svg>
</template>

View file

@ -0,0 +1,12 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"
/>
</svg>
</template>

View file

@ -0,0 +1,21 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l3 3m0 0-3 3m3-3H2.25"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Z"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.02-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</template>

View file

@ -3,5 +3,13 @@ const { logout } = useAuth()
</script>
<template>
<button @click="logout" class="text-sm text-gray-500 hover:text-gray-700">Abmelden</button>
<button
type="button"
@click="logout"
aria-label="Abmelden"
title="Abmelden"
class="p-1.5 text-gray-500 hover:text-gray-700"
>
<IconLogout />
</button>
</template>

View file

@ -2,6 +2,25 @@
import type { Client } from '~/types'
const props = defineProps<{ client: Client }>()
const daysAgo = (iso: string): number => {
const diffMs = Date.now() - new Date(iso).getTime()
return Math.max(0, Math.floor(diffMs / 86_400_000))
}
const info = computed(() => {
const count = props.client.history_count
if (count == null || count === 0) return null
const updates = `${count} Verlauf-Update${count > 1 ? 's' : ''}`
const last = props.client.last_history_at
if (!last) return updates
const d = daysAgo(last)
const when =
d === 0 ? 'heute' : `vor ${d} ${d === 1 ? 'Tag' : 'Tagen'}`
return `${updates} · zuletzt ${when}`
})
</script>
<template>
@ -13,5 +32,6 @@ const props = defineProps<{ client: Client }>()
<span class="text-sm font-mono text-gray-500 w-20 shrink-0">{{ client.client_number }}</span>
<span class="text-sm font-medium text-gray-900">{{ client.name }}</span>
</div>
<p v-if="info" class="mt-1 pl-24 text-xs text-gray-400">{{ info }}</p>
</NuxtLink>
</template>

View file

@ -0,0 +1,113 @@
<script setup lang="ts">
import type { ClientCustomField } from '~/types'
const props = defineProps<{ field: ClientCustomField }>()
const emit = defineEmits<{ save: [definitionId: number, value: string | null] }>()
const editing = ref(false)
const saving = ref(false)
const draft = ref<string>(props.field.value ?? '')
// Keep the draft in sync if the field reloads from the server.
watch(() => props.field.value, (v) => {
if (!editing.value) draft.value = v ?? ''
})
const startEdit = () => {
draft.value = props.field.value ?? ''
editing.value = true
}
const cancel = () => {
draft.value = props.field.value ?? ''
editing.value = false
}
const save = async () => {
saving.value = true
try {
const next = draft.value === '' ? null : draft.value
emit('save', props.field.definition_id, next)
editing.value = false
} finally {
saving.value = false
}
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
// Human-readable value for display (dates rendered in German short form).
const displayValue = computed(() => {
const v = props.field.value
if (v === null || v === '') return null
if (props.field.type === 'date') {
const d = new Date(v)
return Number.isNaN(d.getTime()) ? v : d.toLocaleDateString('de-DE', { dateStyle: 'medium' })
}
return v
})
</script>
<template>
<div class="border border-gray-200 bg-white p-4">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-semibold text-gray-800">{{ field.label }}</h3>
<button
v-if="!editing"
@click="startEdit"
:title="`${field.label} bearbeiten`"
class="shrink-0 text-gray-400 hover:text-blue-600"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M2.695 14.762l-1.262 3.155a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.886L17.5 5.501a2.121 2.121 0 00-3-3L3.58 13.419a4 4 0 00-.885 1.343z" />
</svg>
</button>
</div>
<!-- Value display -->
<div v-if="!editing" class="mt-1">
<p v-if="displayValue !== null" class="text-sm text-gray-800 whitespace-pre-wrap">{{ displayValue }}</p>
<p v-else class="text-sm text-gray-400 italic">Nicht gesetzt</p>
</div>
<!-- Inline editor (type-aware) -->
<div v-else class="mt-2 space-y-2">
<textarea
v-if="field.type === 'textarea'"
v-model="draft"
rows="4"
class="w-full border border-gray-300 px-3 py-2 text-sm resize-y"
/>
<select
v-else-if="field.type === 'select'"
v-model="draft"
class="w-full border border-gray-300 px-3 py-2 text-sm"
>
<option value=""> Bitte wählen </option>
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<input
v-else
v-model="draft"
:type="field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
<div class="flex gap-2">
<button
@click="save"
:disabled="saving"
class="bg-blue-600 text-white px-3 py-1 text-sm hover:bg-blue-700 disabled:opacity-50"
>
Speichern
</button>
<button @click="cancel" class="text-sm text-gray-500 hover:underline px-2">Abbrechen</button>
</div>
</div>
<!-- Metadata: last change date + author -->
<p v-if="!editing && field.updated_at" class="mt-2 text-xs text-gray-400">
{{ formatDate(field.updated_at) }}<template v-if="field.author_name"> · {{ field.author_name }}</template>
</p>
</div>
</template>

View file

@ -1,52 +0,0 @@
<script setup lang="ts">
import type { Client } from '~/types'
const props = defineProps<{ client: Client; editing: boolean }>()
const emit = defineEmits<{ save: [notes: Record<string, string>] }>()
const localNotes = ref<Record<string, string>>({ ...props.client.notes })
const newKey = ref('')
const newVal = ref('')
const addField = () => {
if (!newKey.value.trim()) return
localNotes.value[newKey.value.trim()] = newVal.value
newKey.value = ''
newVal.value = ''
}
const removeField = (key: string) => {
const copy = { ...localNotes.value }
delete copy[key]
localNotes.value = copy
}
</script>
<template>
<div>
<div v-if="!editing" class="space-y-1">
<div v-for="(val, key) in client.notes" :key="key" class="flex gap-4 text-sm">
<span class="text-gray-500 w-40 shrink-0">{{ key }}</span>
<span class="text-gray-800">{{ val }}</span>
</div>
<p v-if="!Object.keys(client.notes ?? {}).length" class="text-sm text-gray-400 italic">
Keine Felder konfiguriert.
</p>
</div>
<div v-else class="space-y-2">
<div v-for="(val, key) in localNotes" :key="key" class="flex gap-2 items-center text-sm">
<span class="text-gray-600 w-40 shrink-0">{{ key }}</span>
<input v-model="localNotes[key]" class="flex-1 border border-gray-300 px-2 py-1 text-sm" />
<button @click="removeField(key)" class="text-red-500 text-xs hover:underline">Entfernen</button>
</div>
<div class="flex gap-2 items-center">
<input v-model="newKey" placeholder="Feldname" class="w-40 border border-gray-300 px-2 py-1 text-sm" />
<input v-model="newVal" placeholder="Wert" class="flex-1 border border-gray-300 px-2 py-1 text-sm" />
<button type="button" @click="addField" class="text-sm text-blue-600 hover:underline">Hinzufügen</button>
</div>
<button @click="emit('save', localNotes)" class="bg-blue-600 text-white px-3 py-1.5 text-sm hover:bg-blue-700">
Felder speichern
</button>
</div>
</div>
</template>

View file

@ -2,22 +2,29 @@
import type { HistoryEntry } from '~/types'
const props = defineProps<{ entry: HistoryEntry }>()
const emit = defineEmits<{ delete: [id: number]; update: [id: number, body: string] }>()
const emit = defineEmits<{
delete: [id: number]
update: [id: number, payload: { body: string; occurred_at: string | null }]
}>()
const labelMap: Record<string, string> = {
phone_call: 'Telefonat',
consultation: 'Beratungsgespräch',
recommendation: 'Empfehlung',
instruction: 'Anweisung',
remark: 'Bemerkung',
other: 'Sonstiges',
}
const { user } = useAuth()
// Auto-generated change entries are read-only audit records; only the author
// may edit their own manual notes.
const canEdit = computed(() => !props.entry.is_field_change && props.entry.author_id === user.value?.id)
// Only admins may delete Verlauf entries (manual notes and change entries alike).
const canDelete = computed(() => user.value?.role === 'admin')
const editing = ref(false)
const editBody = ref(props.entry.body)
// datetime-local wants "YYYY-MM-DDTHH:MM" (no seconds); occurred_at carries seconds.
const editOccurredAt = ref(props.entry.occurred_at?.slice(0, 16) ?? '')
const saveEdit = () => {
emit('update', props.entry.id, editBody.value)
const occurred_at = props.entry.requires_datetime && editOccurredAt.value
? `${editOccurredAt.value}:00`
: null
emit('update', props.entry.id, { body: editBody.value, occurred_at })
editing.value = false
}
@ -30,7 +37,7 @@ const formatDate = (iso: string) =>
<div class="flex items-start justify-between gap-2 mb-2">
<div class="flex items-center gap-2">
<span class="text-xs font-medium bg-gray-100 text-gray-600 px-2 py-0.5">
{{ labelMap[entry.type] ?? entry.type }}
{{ entry.type_name ?? '—' }}<template v-if="entry.occurred_at"> {{ formatEventDateTime(entry.occurred_at) }}</template>
</span>
<span class="text-xs text-gray-400">{{ formatDate(entry.created_at) }}</span>
<span class="text-xs text-gray-400">{{ entry.author_name }}</span>
@ -38,23 +45,44 @@ const formatDate = (iso: string) =>
(bearb. {{ entry.updated_by_name }})
</span>
</div>
<div class="flex gap-2 shrink-0">
<button @click="editing = !editing" class="text-xs text-blue-600 hover:underline">
{{ editing ? 'Abbrechen' : 'Bearbeiten' }}
<div class="flex gap-1 shrink-0">
<button
v-if="canEdit"
@click="editing = !editing"
:title="editing ? 'Abbrechen' : 'Bearbeiten'"
:aria-label="editing ? 'Abbrechen' : 'Bearbeiten'"
class="p-1 text-gray-400 hover:text-blue-600"
>
<IconClose v-if="editing" />
<IconEdit v-else />
</button>
<button @click="emit('delete', entry.id)" class="text-xs text-red-500 hover:underline">
Löschen
<button
v-if="canDelete"
@click="emit('delete', entry.id)"
title="Löschen"
aria-label="Löschen"
class="p-1 text-gray-400 hover:text-red-600"
>
<IconTrash />
</button>
</div>
</div>
<div v-if="!editing" class="text-sm text-gray-800 whitespace-pre-wrap">{{ entry.body }}</div>
<div v-else>
<div v-else class="space-y-2">
<div v-if="entry.requires_datetime">
<label class="block text-xs font-medium text-gray-600 mb-1">Datum/Uhrzeit</label>
<input
v-model="editOccurredAt"
type="datetime-local"
class="border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<textarea
v-model="editBody"
rows="4"
class="w-full border border-gray-300 px-3 py-2 text-sm resize-y"
/>
<button @click="saveEdit" class="mt-2 bg-blue-600 text-white px-3 py-1 text-sm hover:bg-blue-700">
<button @click="saveEdit" class="bg-blue-600 text-white px-3 py-1 text-sm hover:bg-blue-700">
Speichern
</button>
</div>

View file

@ -1,24 +1,30 @@
<script setup lang="ts">
const emit = defineEmits<{ submit: [payload: { type: string; body: string }] }>()
const emit = defineEmits<{
submit: [payload: { history_entry_type_id: number; body: string; occurred_at: string | null }]
}>()
const type = ref('phone_call')
const { types, fetchTypes } = useHistoryTypes()
await fetchTypes()
const typeId = ref<number | null>(types.value[0]?.id ?? null)
const body = ref('')
const occurredAt = ref('') // datetime-local value: "YYYY-MM-DDTHH:MM"
const submitting = ref(false)
const types = [
{ value: 'phone_call', label: 'Telefonat' },
{ value: 'consultation', label: 'Beratungsgespräch' },
{ value: 'recommendation', label: 'Empfehlung' },
{ value: 'instruction', label: 'Anweisung' },
{ value: 'remark', label: 'Bemerkung' },
{ value: 'other', label: 'Sonstiges' },
]
const selectedType = computed(() => types.value.find(t => t.id === typeId.value) ?? null)
const requiresDatetime = computed(() => selectedType.value?.requires_datetime ?? false)
const submit = async () => {
if (!body.value.trim()) return
if (!body.value.trim() || typeId.value === null) return
if (requiresDatetime.value && !occurredAt.value) return
submitting.value = true
emit('submit', { type: type.value, body: body.value })
// Backend expects a naive local datetime with seconds (Y-m-d\TH:i:s); the
// datetime-local input omits seconds, so append :00.
const occurred_at = requiresDatetime.value && occurredAt.value ? `${occurredAt.value}:00` : null
emit('submit', { history_entry_type_id: typeId.value, body: body.value, occurred_at })
body.value = ''
occurredAt.value = ''
submitting.value = false
}
</script>
@ -28,10 +34,18 @@ const submit = async () => {
<div class="flex gap-3 items-start">
<div class="shrink-0">
<label class="block text-xs font-medium text-gray-600 mb-1">Typ</label>
<select v-model="type" class="border border-gray-300 px-2 py-1.5 text-sm">
<option v-for="t in types" :key="t.value" :value="t.value">{{ t.label }}</option>
<select v-model="typeId" class="border border-gray-300 px-2 py-1.5 text-sm">
<option v-for="t in types" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div v-if="requiresDatetime" class="shrink-0">
<label class="block text-xs font-medium text-gray-600 mb-1">Datum/Uhrzeit</label>
<input
v-model="occurredAt"
type="datetime-local"
class="border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-600 mb-1">Eintrag</label>
<textarea
@ -43,7 +57,11 @@ const submit = async () => {
</div>
</div>
<div class="flex justify-end">
<button type="submit" :disabled="submitting || !body.trim()" class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700 disabled:opacity-50">
<button
type="submit"
:disabled="submitting || !body.trim() || typeId === null || (requiresDatetime && !occurredAt)"
class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700 disabled:opacity-50"
>
Eintrag speichern
</button>
</div>

View file

@ -0,0 +1,30 @@
/**
* Central API fetch wrapper for the Sanctum SPA.
*
* - `credentials: 'include'` sends the session + XSRF cookies.
* - `Accept` / `X-Requested-With` make Laravel's `expectsJson()` return true,
* so an unauthenticated request yields a clean 401 instead of redirecting
* to the (non-existent) `login` route.
* - The decoded `XSRF-TOKEN` cookie is forwarded as `X-XSRF-TOKEN` so mutating
* requests pass CSRF verification.
*/
export const useApiFetch = <T = unknown>(
request: string,
options: Record<string, unknown> = {},
): Promise<T> => {
const getXsrfToken = (): string => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
return $fetch<T>(request, {
credentials: 'include',
...options,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getXsrfToken(),
...(options.headers as Record<string, string> | undefined),
},
})
}

View file

@ -4,19 +4,12 @@ export const useAuth = () => {
const user = useState<User | null>('auth.user', () => null)
const getCsrfCookie = async () => {
await $fetch('/sanctum/csrf-cookie', { credentials: 'include' })
}
const getXsrfToken = (): string => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
await useApiFetch('/sanctum/csrf-cookie')
}
const fetchUser = async (): Promise<void> => {
try {
const response = await $fetch<{ data: User }>('/api/user', {
credentials: 'include',
})
const response = await useApiFetch<{ data: User }>('/api/user')
user.value = response.data
} catch {
user.value = null
@ -25,23 +18,21 @@ export const useAuth = () => {
const login = async (email: string, password: string): Promise<void> => {
await getCsrfCookie()
await $fetch('/api/login', {
await useApiFetch('/api/login', {
method: 'POST',
body: { email, password },
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
await fetchUser()
}
const logout = async (): Promise<void> => {
await $fetch('/api/logout', {
method: 'POST',
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
user.value = null
await navigateTo('/login')
try {
await useApiFetch('/api/logout', { method: 'POST' })
} finally {
// Always clear local state and leave, even if the request failed.
user.value = null
await navigateTo('/login')
}
}
return { user, login, logout, fetchUser }

View file

@ -4,15 +4,8 @@ export const useClient = () => {
const clients = useState<Client[]>('clients.list', () => [])
const searchQuery = ref('')
const getXsrfToken = (): string => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
const fetchClients = async (): Promise<void> => {
const response = await $fetch<PaginatedResponse<Client>>('/api/clients', {
credentials: 'include',
})
const response = await useApiFetch<PaginatedResponse<Client>>('/api/clients')
clients.value = response.data
}
@ -21,39 +14,44 @@ export const useClient = () => {
await fetchClients()
return
}
const response = await $fetch<{ data: Client[] }>('/api/search', {
const response = await useApiFetch<{ data: Client[] }>('/api/search', {
query: { q },
credentials: 'include',
})
clients.value = response.data
}
const fetchClient = async (id: number): Promise<Client> => {
const response = await $fetch<{ data: Client }>(`/api/clients/${id}`, {
credentials: 'include',
})
const response = await useApiFetch<{ data: Client }>(`/api/clients/${id}`)
return response.data
}
const createClient = async (payload: { client_number: string; name: string; notes?: Record<string, string> }): Promise<Client> => {
const response = await $fetch<{ data: Client }>('/api/clients', {
const response = await useApiFetch<{ data: Client }>('/api/clients', {
method: 'POST',
body: payload,
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
return response.data
}
const updateClient = async (id: number, payload: Partial<Pick<Client, 'name' | 'notes'>>): Promise<Client> => {
const response = await $fetch<{ data: Client }>(`/api/clients/${id}`, {
const response = await useApiFetch<{ data: Client }>(`/api/clients/${id}`, {
method: 'PUT',
body: payload,
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
return response.data
}
return { clients, searchQuery, fetchClients, search, fetchClient, createClient, updateClient }
const updateCustomFieldValue = async (
clientId: number,
definitionId: number,
value: string | null,
): Promise<Client> => {
const response = await useApiFetch<{ data: Client }>(
`/api/clients/${clientId}/custom-fields/${definitionId}`,
{ method: 'PUT', body: { value } },
)
return response.data
}
return { clients, searchQuery, fetchClients, search, fetchClient, createClient, updateClient, updateCustomFieldValue }
}

View file

@ -0,0 +1,42 @@
import type { CustomFieldDefinition, CustomFieldType } from '~/types'
export interface CustomFieldDefinitionPayload {
label: string
type: CustomFieldType
options?: string[]
is_required?: boolean
}
export const useCustomFields = () => {
const definitions = useState<CustomFieldDefinition[]>('customFields.definitions', () => [])
const fetchDefinitions = async (): Promise<void> => {
const response = await useApiFetch<{ data: CustomFieldDefinition[] }>('/api/custom-fields')
definitions.value = response.data
}
const createDefinition = async (payload: CustomFieldDefinitionPayload): Promise<void> => {
await useApiFetch('/api/custom-fields', { method: 'POST', body: payload })
await fetchDefinitions()
}
const updateDefinition = async (id: number, payload: CustomFieldDefinitionPayload): Promise<void> => {
await useApiFetch(`/api/custom-fields/${id}`, { method: 'PUT', body: payload })
await fetchDefinitions()
}
const deleteDefinition = async (id: number): Promise<void> => {
await useApiFetch(`/api/custom-fields/${id}`, { method: 'DELETE' })
definitions.value = definitions.value.filter(d => d.id !== id)
}
const reorderDefinitions = async (ids: number[]): Promise<void> => {
const response = await useApiFetch<{ data: CustomFieldDefinition[] }>('/api/custom-fields/reorder', {
method: 'POST',
body: { ids },
})
definitions.value = response.data
}
return { definitions, fetchDefinitions, createDefinition, updateDefinition, deleteDefinition, reorderDefinitions }
}

View file

@ -3,43 +3,37 @@ import type { HistoryEntry } from '~/types'
export const useHistory = (clientId: number) => {
const entries = ref<HistoryEntry[]>([])
const getXsrfToken = (): string => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
return match ? decodeURIComponent(match[1]) : ''
}
const fetchEntries = async (): Promise<void> => {
const response = await $fetch<{ data: HistoryEntry[] }>(`/api/clients/${clientId}/history`, {
credentials: 'include',
})
const response = await useApiFetch<{ data: HistoryEntry[] }>(`/api/clients/${clientId}/history`)
entries.value = response.data
}
const addEntry = async (payload: { type: string; body: string }): Promise<void> => {
await $fetch(`/api/clients/${clientId}/history`, {
const addEntry = async (payload: {
history_entry_type_id: number
body: string
occurred_at: string | null
}): Promise<void> => {
await useApiFetch(`/api/clients/${clientId}/history`, {
method: 'POST',
body: payload,
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
await fetchEntries()
}
const updateEntry = async (id: number, body: string): Promise<void> => {
await $fetch(`/api/history/${id}`, {
const updateEntry = async (
id: number,
payload: { body: string; occurred_at: string | null }
): Promise<void> => {
await useApiFetch(`/api/history/${id}`, {
method: 'PUT',
body: { body },
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
body: payload,
})
await fetchEntries()
}
const deleteEntry = async (id: number): Promise<void> => {
await $fetch(`/api/history/${id}`, {
await useApiFetch(`/api/history/${id}`, {
method: 'DELETE',
credentials: 'include',
headers: { 'X-XSRF-TOKEN': getXsrfToken() },
})
entries.value = entries.value.filter(e => e.id !== id)
}

View file

@ -0,0 +1,40 @@
import type { HistoryType } from '~/types'
export interface HistoryTypePayload {
name: string
requires_datetime?: boolean
}
export const useHistoryTypes = () => {
const types = useState<HistoryType[]>('historyTypes.list', () => [])
const fetchTypes = async (): Promise<void> => {
const response = await useApiFetch<{ data: HistoryType[] }>('/api/history-types')
types.value = response.data
}
const createType = async (payload: HistoryTypePayload): Promise<void> => {
await useApiFetch('/api/history-types', { method: 'POST', body: payload })
await fetchTypes()
}
const updateType = async (id: number, payload: HistoryTypePayload): Promise<void> => {
await useApiFetch(`/api/history-types/${id}`, { method: 'PUT', body: payload })
await fetchTypes()
}
const deleteType = async (id: number): Promise<void> => {
await useApiFetch(`/api/history-types/${id}`, { method: 'DELETE' })
types.value = types.value.filter(t => t.id !== id)
}
const reorderTypes = async (ids: number[]): Promise<void> => {
const response = await useApiFetch<{ data: HistoryType[] }>('/api/history-types/reorder', {
method: 'POST',
body: { ids },
})
types.value = response.data
}
return { types, fetchTypes, createType, updateType, deleteType, reorderTypes }
}

View file

@ -0,0 +1,16 @@
import type { ImportResult } from '~/types'
export const useImport = () => {
const importClients = async (file: File): Promise<ImportResult> => {
const form = new FormData()
form.append('file', file)
const response = await useApiFetch<{ data: ImportResult }>('/api/clients/import', {
method: 'POST',
body: form,
})
return response.data
}
return { importClients }
}

View file

@ -4,7 +4,7 @@ export const useUsers = () => {
const users = useState<User[]>('users.list', () => [])
const fetchUsers = async (): Promise<void> => {
const { data } = await $fetch('/api/users', { credentials: 'include' })
const { data } = await useApiFetch<{ data: User[] }>('/api/users')
users.value = data
}
@ -15,9 +15,8 @@ export const useUsers = () => {
password_confirmation: string
role: 'admin' | 'staff'
}): Promise<User> => {
const { data } = await $fetch('/api/users', {
const { data } = await useApiFetch<{ data: User }>('/api/users', {
method: 'POST',
credentials: 'include',
body: payload,
})
users.value.push(data)
@ -34,9 +33,8 @@ export const useUsers = () => {
role: 'admin' | 'staff'
}
): Promise<User> => {
const { data } = await $fetch(`/api/users/${id}`, {
const { data } = await useApiFetch<{ data: User }>(`/api/users/${id}`, {
method: 'PUT',
credentials: 'include',
body: payload,
})
const index = users.value.findIndex((u) => u.id === id)
@ -45,10 +43,7 @@ export const useUsers = () => {
}
const deleteUser = async (id: number): Promise<void> => {
await $fetch(`/api/users/${id}`, {
method: 'DELETE',
credentials: 'include',
})
await useApiFetch(`/api/users/${id}`, { method: 'DELETE' })
users.value = users.value.filter((u) => u.id !== id)
}

View file

@ -0,0 +1,15 @@
export default defineNuxtRouteMiddleware(async () => {
const { user, fetchUser } = useAuth()
if (!user.value) {
await fetchUser()
}
if (!user.value) {
return navigateTo('/login')
}
if (user.value.role !== 'admin') {
return navigateTo('/clients')
}
})

View file

@ -1,5 +1,8 @@
export default defineNuxtConfig({
ssr: false,
experimental: {
viteEnvironmentApi: true,
},
modules: ['@nuxtjs/tailwindcss'],
devServer: {
port: 3000,

View file

@ -11,7 +11,7 @@
"nuxt": "^3.21.8",
"tailwindcss": "^3.4.19",
"vue": "^3.5.38",
"vue-router": "^5.1.0"
"vue-router": "^4.6.4"
}
},
"node_modules/@alloc/quick-lru": {
@ -3614,12 +3614,6 @@
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"license": "MIT"
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
"license": "MIT"
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -3853,13 +3847,10 @@
}
},
"node_modules/@vue/devtools-api": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.3.tgz",
"integrity": "sha512-73NMCvxXh8Hyozc/jiwqTFWVcCMyi11U1zmrq4DoukQJnuo8JHt6FsNu4HdeUDa8SpIp5vb7Q22GWgIq0efsXg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^8.1.3"
}
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/devtools-core": {
"version": "8.1.3",
@ -4267,23 +4258,6 @@
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/ast-walker-scope": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz",
"integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@babel/types": "^7.29.0",
"ast-kit": "^2.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/sxzz"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -7808,12 +7782,6 @@
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/nuxt/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/nuxt/node_modules/ast-walker-scope": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
@ -7886,21 +7854,6 @@
"node": ">=18.12.0"
}
},
"node_modules/nuxt/node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/nypm": {
"version": "0.6.7",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.7.tgz",
@ -11814,115 +11767,18 @@
"license": "MIT"
},
"node_modules/vue-router": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz",
"integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==",
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@babel/generator": "^8.0.0-rc.4",
"@vue-macros/common": "^3.1.1",
"@vue/devtools-api": "^8.1.2",
"ast-walker-scope": "^0.9.0",
"chokidar": "^5.0.0",
"json5": "^2.2.3",
"local-pkg": "^1.1.2",
"magic-string": "^0.30.21",
"mlly": "^1.8.2",
"muggle-string": "^0.4.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"scule": "^1.3.0",
"tinyglobby": "^0.2.16",
"unplugin": "^3.0.0",
"unplugin-utils": "^0.3.1",
"yaml": "^2.9.0"
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"@pinia/colada": ">=0.21.2",
"@vue/compiler-sfc": "^3.5.34",
"pinia": "^3.0.4",
"vite": "^7.0.0 || ^8.0.0",
"vue": "^3.5.34"
},
"peerDependenciesMeta": {
"@pinia/colada": {
"optional": true
},
"@vue/compiler-sfc": {
"optional": true
},
"pinia": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/vue-router/node_modules/@babel/generator": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
"integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^8.0.0",
"@babel/types": "^8.0.0",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"@types/jsesc": "^2.5.0",
"jsesc": "^3.0.2"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/vue-router/node_modules/@babel/helper-string-parser": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/vue-router/node_modules/@babel/helper-validator-identifier": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz",
"integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==",
"license": "MIT",
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/vue-router/node_modules/@babel/parser": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz",
"integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==",
"license": "MIT",
"dependencies": {
"@babel/types": "^8.0.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
}
},
"node_modules/vue-router/node_modules/@babel/types": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz",
"integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^8.0.0",
"@babel/helper-validator-identifier": "^8.0.0"
},
"engines": {
"node": "^22.18.0 || >=24.11.0"
"vue": "^3.5.0"
}
},
"node_modules/webidl-conversions": {

View file

@ -14,6 +14,6 @@
"nuxt": "^3.21.8",
"tailwindcss": "^3.4.19",
"vue": "^3.5.38",
"vue-router": "^5.1.0"
"vue-router": "^4.6.4"
}
}

View file

@ -1,99 +0,0 @@
<script setup lang="ts">
import type { Client } from '~/types'
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const clientId = Number(route.params.id)
const { fetchClient, updateClient } = useClient()
const { entries, fetchEntries, addEntry, updateEntry, deleteEntry } = useHistory(clientId)
const client = ref<Client | null>(null)
client.value = await fetchClient(clientId)
await fetchEntries()
const editingName = ref(false)
const editName = ref(client.value?.name ?? '')
const editingFields = ref(false)
const saveName = async () => {
if (!client.value) return
client.value = await updateClient(clientId, { name: editName.value })
editingName.value = false
}
const saveFields = async (notes: Record<string, string>) => {
if (!client.value) return
client.value = await updateClient(clientId, { notes })
editingFields.value = false
}
const onAddEntry = async (payload: { type: string; body: string }) => {
await addEntry(payload)
}
const onUpdateEntry = async (id: number, body: string) => {
await updateEntry(id, body)
}
const onDeleteEntry = async (id: number) => {
if (!confirm('Eintrag wirklich löschen?')) return
await deleteEntry(id)
}
</script>
<template>
<div v-if="client" 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" class="text-sm text-blue-600 hover:underline"> Alle Mandanten</NuxtLink>
<h1 class="text-base font-semibold text-gray-800">
<span class="font-mono text-gray-500">{{ client.client_number }}</span>
<span class="mx-2 text-gray-400">·</span>
<span v-if="!editingName">{{ client.name }}</span>
<span v-else class="inline-flex gap-2 items-center">
<input v-model="editName" class="border border-gray-300 px-2 py-0.5 text-sm" />
<button @click="saveName" class="text-sm bg-blue-600 text-white px-2 py-0.5 hover:bg-blue-700">OK</button>
<button @click="editingName = false" class="text-sm text-gray-500">Abbrechen</button>
</span>
</h1>
<button v-if="!editingName" @click="editingName = true" class="text-xs text-gray-400 hover:text-gray-600">
Name ändern
</button>
<NuxtLink :to="`/clients/${clientId}/print`" class="ml-auto text-sm text-gray-500 hover:underline">
Drucken
</NuxtLink>
</header>
<div class="max-w-4xl mx-auto py-6 px-6 space-y-6">
<!-- Configurable fields -->
<section class="bg-white border border-gray-200 p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">Stammdaten</h2>
<button @click="editingFields = !editingFields" class="text-xs text-blue-600 hover:underline">
{{ editingFields ? 'Abbrechen' : 'Bearbeiten' }}
</button>
</div>
<ClientFields :client="client" :editing="editingFields" @save="saveFields" />
</section>
<!-- History -->
<section>
<h2 class="text-sm font-semibold text-gray-700 mb-3">Verlauf</h2>
<HistoryForm @submit="onAddEntry" />
<div class="mt-3 space-y-2">
<HistoryEntry
v-for="entry in entries"
:key="entry.id"
:entry="entry"
@update="onUpdateEntry"
@delete="onDeleteEntry"
/>
<p v-if="entries.length === 0" class="text-sm text-gray-400 italic py-4 text-center">
Noch keine Einträge.
</p>
</div>
</section>
</div>
</div>
</template>

View file

@ -0,0 +1,196 @@
<script setup lang="ts">
import type { Client } from '~/types'
definePageMeta({ middleware: 'auth' })
const route = useRoute()
const clientId = Number(route.params.id)
const { fetchClient, updateClient, updateCustomFieldValue } = useClient()
const { entries, fetchEntries, addEntry, updateEntry, deleteEntry } = useHistory(clientId)
const client = ref<Client | null>(null)
client.value = await fetchClient(clientId)
await fetchEntries()
const editingName = ref(false)
const editName = ref(client.value?.name ?? '')
const saveName = async () => {
if (!client.value) return
client.value = await updateClient(clientId, { name: editName.value })
editingName.value = false
}
const onSaveCustomField = async (definitionId: number, value: string | null) => {
// Persisting a value may append a Verlauf entry, so refresh both.
client.value = await updateCustomFieldValue(clientId, definitionId, value)
await fetchEntries()
}
// The entry form is hidden behind the "Neuer Eintrag" button and collapses
// again once an entry has been saved.
const showForm = ref(false)
const onAddEntry = async (payload: { history_entry_type_id: number; body: string; occurred_at: string | null }) => {
await addEntry(payload)
showForm.value = false
}
const onUpdateEntry = async (id: number, payload: { body: string; occurred_at: string | null }) => {
await updateEntry(id, payload)
}
const onDeleteEntry = async (id: number) => {
if (!confirm('Eintrag wirklich löschen?')) return
await deleteEntry(id)
}
// Client-side filter over the already-loaded entries. Matches the same text the
// user sees: note body, type name, author, and the formatted event date/time.
const search = ref('')
// Auto-generated Stammdaten (base-data) change entries are hidden by default so the
// Verlauf reads as manual notes; the toggle reveals the full audit trail.
const showFieldChanges = ref(false)
const filteredEntries = computed(() => {
const base = showFieldChanges.value
? entries.value
: entries.value.filter(entry => !entry.is_field_change)
const q = search.value.trim().toLowerCase()
if (!q) return base
return base.filter((entry) => {
const haystack = [
entry.body,
entry.type_name,
entry.author_name,
entry.occurred_at ? formatEventDateTime(entry.occurred_at) : '',
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(q)
})
})
</script>
<template>
<div v-if="client" 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">
<span class="font-mono text-gray-500">{{ client.client_number }}</span>
<span class="mx-2 text-gray-400">·</span>
<span v-if="!editingName">{{ client.name }}</span>
<span v-else class="inline-flex gap-2 items-center">
<input v-model="editName" class="border border-gray-300 px-2 py-0.5 text-sm" />
<button @click="saveName" class="text-sm bg-blue-600 text-white px-2 py-0.5 hover:bg-blue-700">OK</button>
<button @click="editingName = false" class="text-sm text-gray-500">Abbrechen</button>
</span>
</h1>
<button
v-if="!editingName"
@click="editingName = true"
title="Name ändern"
aria-label="Name ändern"
class="text-gray-400 hover:text-gray-600"
>
<IconEdit />
</button>
<NuxtLink
:to="`/clients/${clientId}/print`"
title="Mandantenakte drucken"
aria-label="Mandantenakte drucken"
class="ml-auto inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
>
<IconPrint />
<span>Drucken</span>
</NuxtLink>
</header>
<div class="max-w-4xl mx-auto py-6 px-6 space-y-6">
<!-- Configurable base-data fields -->
<section class="space-y-3">
<h2 class="text-sm font-semibold text-gray-700">Stammdaten</h2>
<div class="grid grid-cols-2 gap-3">
<ClientCustomFieldCard
v-for="field in client.custom_fields ?? []"
:key="field.definition_id"
:field="field"
@save="onSaveCustomField"
/>
</div>
<p v-if="!(client.custom_fields ?? []).length" class="text-sm text-gray-400 italic">
Keine Felder konfiguriert.
</p>
</section>
<!-- History -->
<section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-semibold text-gray-700">Verlauf</h2>
<button
@click="showForm = true"
class="bg-blue-600 text-white px-3 py-1.5 text-sm hover:bg-blue-700"
>
+ Neuer Eintrag
</button>
</div>
<div v-if="entries.length > 0" class="mt-3 flex items-center gap-3">
<div class="relative flex-1">
<input
v-model="search"
type="search"
placeholder="Verlauf durchsuchen…"
class="w-full border border-gray-300 px-3 py-1.5 pr-9 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
v-if="search"
@click="search = ''"
type="button"
title="Suche zurücksetzen"
aria-label="Suche zurücksetzen"
class="absolute right-1 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-700"
>
<IconClose />
</button>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-600 cursor-pointer select-none whitespace-nowrap">
<input v-model="showFieldChanges" type="checkbox" class="h-4 w-4 accent-blue-600" />
<span>Stammdaten-Änderungen</span>
</label>
</div>
<HistoryForm v-if="showForm" @submit="onAddEntry" class="mt-10" />
<div class="mt-3 space-y-2">
<HistoryEntry
v-for="entry in filteredEntries"
:key="entry.id"
:entry="entry"
@update="onUpdateEntry"
@delete="onDeleteEntry"
/>
<p v-if="entries.length === 0" class="text-sm text-gray-400 italic py-4 text-center">
Noch keine Einträge.
</p>
<p v-else-if="filteredEntries.length === 0 && search" class="text-sm text-gray-400 italic py-4 text-center">
Keine Treffer für {{ search }}".
</p>
<p v-else-if="filteredEntries.length === 0" class="text-sm text-gray-400 italic py-4 text-center">
Keine Einträge sichtbar Stammdaten-Änderungen einblenden.
</p>
</div>
</section>
</div>
</div>
</template>

View file

@ -13,20 +13,23 @@ const client = ref<Client | null>(null)
client.value = await fetchClient(clientId)
await fetchEntries()
const labelMap: Record<string, string> = {
phone_call: 'Telefonat',
consultation: 'Beratungsgespräch',
recommendation: 'Empfehlung',
instruction: 'Anweisung',
remark: 'Bemerkung',
other: 'Sonstiges',
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
onMounted(() => {
window.print()
const formatValue = (field: { type: string; value: string | null }) => {
if (field.value === null || field.value === '') return '—'
if (field.type === 'date') {
const d = new Date(field.value)
return Number.isNaN(d.getTime()) ? field.value : d.toLocaleDateString('de-DE', { dateStyle: 'medium' })
}
return field.value
}
onMounted(async () => {
// Wait for the freshly navigated DOM to render and paint before opening the
// print dialog, otherwise the preview can capture a blank page.
await nextTick()
requestAnimationFrame(() => requestAnimationFrame(() => window.print()))
})
</script>
@ -44,10 +47,10 @@ onMounted(() => {
<td class="py-1 text-gray-500">Name</td>
<td class="py-1">{{ client.name }}</td>
</tr>
<template v-for="(val, key) in client.notes" :key="key">
<template v-for="field in client.custom_fields ?? []" :key="field.definition_id">
<tr class="border-b border-gray-200">
<td class="py-1 text-gray-500">{{ key }}</td>
<td class="py-1">{{ val }}</td>
<td class="py-1 text-gray-500">{{ field.label }}</td>
<td class="py-1">{{ formatValue(field) }}</td>
</tr>
</template>
</table>
@ -55,7 +58,9 @@ onMounted(() => {
<h2 class="font-semibold mb-3">Verlauf</h2>
<div v-for="entry in entries" :key="entry.id" class="mb-4 border-t border-gray-200 pt-3">
<div class="flex gap-3 text-xs text-gray-500 mb-1">
<span class="font-medium text-gray-700">{{ labelMap[entry.type] ?? entry.type }}</span>
<span class="font-medium text-gray-700">
{{ entry.type_name ?? '—' }}<template v-if="entry.occurred_at"> {{ formatEventDateTime(entry.occurred_at) }}</template>
</span>
<span>{{ formatDate(entry.created_at) }}</span>
<span>{{ entry.author_name }}</span>
</div>

View file

@ -22,6 +22,7 @@ const form = ref<{
role: 'staff',
})
const error = ref('')
const showPassword = ref(false)
const resetForm = () => {
editingId.value = null
@ -33,11 +34,13 @@ const resetForm = () => {
role: 'staff',
}
error.value = ''
showPassword.value = false
}
const startEdit = (user: User) => {
editingId.value = user.id
error.value = ''
showPassword.value = false
form.value = {
name: user.name,
email: user.email,
@ -175,7 +178,8 @@ const roleLabels = {
<input
v-model="form.email"
type="email"
class="w-full border border-gray-300 px-3 py-2 text-sm"
class="w-full border border-gray-300 px-3 py-2 text-sm lowercase"
@input="form.email = form.email.toLowerCase()"
/>
</div>
@ -183,20 +187,44 @@ const roleLabels = {
<label class="block text-xs font-medium text-gray-600 mb-1">
{{ editingId ? 'Passwort (leer = nicht ändern)' : 'Passwort' }}
</label>
<input
v-model="form.password"
type="password"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
<div class="relative">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
class="w-full border border-gray-300 px-3 py-2 pr-10 text-sm"
/>
<button
type="button"
@click="showPassword = !showPassword"
:title="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
class="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-600"
>
<IconEyeOff v-if="showPassword" class="w-4 h-4" />
<IconEye v-else class="w-4 h-4" />
</button>
</div>
</div>
<div v-if="form.password">
<label class="block text-xs font-medium text-gray-600 mb-1">Passwort wiederholen</label>
<input
v-model="form.password_confirmation"
type="password"
class="w-full border border-gray-300 px-3 py-2 text-sm"
/>
<div class="relative">
<input
v-model="form.password_confirmation"
:type="showPassword ? 'text' : 'password'"
class="w-full border border-gray-300 px-3 py-2 pr-10 text-sm"
/>
<button
type="button"
@click="showPassword = !showPassword"
:title="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
:aria-label="showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'"
class="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-600"
>
<IconEyeOff v-if="showPassword" class="w-4 h-4" />
<IconEye v-else class="w-4 h-4" />
</button>
</div>
</div>
<div>

View file

@ -0,0 +1,195 @@
<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&#10;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>

View file

@ -0,0 +1,158 @@
<script setup lang="ts">
import type { HistoryType } from '~/types'
import type { HistoryTypePayload } from '~/composables/useHistoryTypes'
definePageMeta({ middleware: ['auth', 'admin'] })
const { types, fetchTypes, createType, updateType, deleteType, reorderTypes } = useHistoryTypes()
await fetchTypes()
const emptyForm = (): HistoryTypePayload => ({
name: '',
requires_datetime: false,
})
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 = (type: HistoryType) => {
editingId.value = type.id
error.value = ''
form.value = {
name: type.name,
requires_datetime: type.requires_datetime,
}
}
const submit = async () => {
error.value = ''
const payload: HistoryTypePayload = {
name: form.value.name.trim(),
requires_datetime: form.value.requires_datetime,
}
if (!payload.name) {
error.value = 'Bitte einen Namen angeben.'
return
}
try {
if (editingId.value) {
await updateType(editingId.value, payload)
} else {
await createType(payload)
}
resetForm()
} catch (e: any) {
error.value = e?.data?.message ?? 'Speichern fehlgeschlagen.'
}
}
const remove = async (type: HistoryType) => {
if (!confirm(`Verlaufstyp „${type.name}“ wirklich löschen? Bestehende Einträge bleiben erhalten.`)) return
await deleteType(type.id)
if (editingId.value === type.id) resetForm()
}
const move = async (index: number, direction: -1 | 1) => {
const target = index + direction
if (target < 0 || target >= types.value.length) return
const ids = types.value.map(t => t.id)
;[ids[index], ids[target]] = [ids[target], ids[index]]
await reorderTypes(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">Verlaufstypen konfigurieren</h1>
</header>
<div class="max-w-3xl mx-auto py-6 px-6 space-y-6">
<!-- Existing types -->
<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">Name</th>
<th class="px-4 py-2 font-medium">Datum/Uhrzeit nötig</th>
<th class="px-4 py-2 font-medium text-right">Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="(type, index) in types" :key="type.id" class="border-b border-gray-100">
<td class="px-4 py-2 text-gray-800">{{ type.name }}</td>
<td class="px-4 py-2 text-gray-600">{{ type.requires_datetime ? '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 === types.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(type)" title="Bearbeiten" aria-label="Bearbeiten" class="p-1 text-gray-400 hover:text-blue-600">
<IconEdit />
</button>
<button @click="remove(type)" 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="types.length === 0">
<td colspan="3" class="px-4 py-6 text-center text-sm text-gray-400 italic">
Noch keine Verlaufstypen 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 ? 'Verlaufstyp bearbeiten' : 'Neuer Verlaufstyp' }}
</h2>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Name</label>
<input v-model="form.name" class="w-full border border-gray-300 px-3 py-2 text-sm" placeholder="z. B. Telefonat" />
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm text-gray-700">
<input v-model="form.requires_datetime" type="checkbox" />
Datum/Uhrzeit erforderlich
</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' : 'Typ 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>

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import type { ImportResult } from '~/types'
definePageMeta({ middleware: ['auth', 'admin'] })
const { importClients } = useImport()
const file = ref<File | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const error = ref('')
const result = ref<ImportResult | null>(null)
const onFile = (e: Event) => {
const target = e.target as HTMLInputElement
file.value = target.files?.[0] ?? null
error.value = ''
}
const submit = async () => {
if (!file.value) {
error.value = 'Bitte eine CSV-Datei auswählen.'
return
}
uploading.value = true
error.value = ''
result.value = null
try {
result.value = await importClients(file.value)
file.value = null
if (fileInput.value) fileInput.value.value = ''
} catch (e: any) {
error.value = e?.data?.message ?? 'Import fehlgeschlagen.'
} finally {
uploading.value = false
}
}
</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">Mandanten-Import</h1>
</header>
<div class="max-w-3xl mx-auto py-6 px-6 space-y-6">
<!-- Upload -->
<section class="bg-white border border-gray-200 p-4 space-y-3">
<p class="text-sm text-gray-600">
CSV-Datei (Semikolon-getrennt) hochladen. Bekannte Mandantennummern werden
übersprungen außer die Spalte Ueberschreiben enthält ja, dann werden
die Stammdatenfelder aktualisiert.
</p>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">CSV-Datei</label>
<input
ref="fileInput"
type="file"
accept=".csv,.txt"
@change="onFile"
class="block w-full text-sm text-gray-700 file:mr-3 file:border-0 file:bg-gray-100 file:px-3 file:py-1.5 file:text-sm file:text-gray-700 hover:file:bg-gray-200"
/>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<div>
<button
@click="submit"
:disabled="uploading || !file"
class="bg-blue-600 text-white px-4 py-1.5 text-sm hover:bg-blue-700 disabled:opacity-50"
>
{{ uploading ? 'Importiere…' : 'Import starten' }}
</button>
</div>
</section>
<!-- Result -->
<section v-if="result" class="bg-white border border-gray-200 p-4 space-y-3">
<h2 class="text-sm font-semibold text-gray-700">Ergebnis</h2>
<div class="grid grid-cols-4 gap-3 text-center">
<div class="border border-gray-100 py-3">
<div class="text-lg font-semibold text-gray-800">{{ result.total }}</div>
<div class="text-xs text-gray-500">Zeilen</div>
</div>
<div class="border border-gray-100 py-3">
<div class="text-lg font-semibold text-green-700">{{ result.created }}</div>
<div class="text-xs text-gray-500">Neu angelegt</div>
</div>
<div class="border border-gray-100 py-3">
<div class="text-lg font-semibold text-blue-700">{{ result.updated }}</div>
<div class="text-xs text-gray-500">Aktualisiert</div>
</div>
<div class="border border-gray-100 py-3">
<div class="text-lg font-semibold text-gray-500">{{ result.skipped }}</div>
<div class="text-xs text-gray-500">Übersprungen</div>
</div>
</div>
<div v-if="result.errors.length" class="space-y-2">
<h3 class="text-xs font-medium text-red-600">Fehler ({{ result.errors.length }})</h3>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs text-gray-500">
<th class="px-3 py-1.5 font-medium">Zeile</th>
<th class="px-3 py-1.5 font-medium">Mandantennr.</th>
<th class="px-3 py-1.5 font-medium">Meldung</th>
</tr>
</thead>
<tbody>
<tr v-for="(err, i) in result.errors" :key="i" class="border-b border-gray-100">
<td class="px-3 py-1.5 text-gray-600">{{ err.row }}</td>
<td class="px-3 py-1.5 text-gray-600">{{ err.client_number ?? '—' }}</td>
<td class="px-3 py-1.5 text-gray-700">{{ err.message }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</div>
</template>

View file

@ -5,20 +5,60 @@ export interface User {
role: 'admin' | 'staff'
}
export type CustomFieldType = 'text' | 'textarea' | 'number' | 'date' | 'select'
export interface CustomFieldDefinition {
id: number
label: string
key: string
type: CustomFieldType
options: string[]
is_required: boolean
sort_order: number
}
export interface ClientCustomField {
definition_id: number
key: string
label: string
type: CustomFieldType
options: string[]
is_required: boolean
value: string | null
author_name: string | null
updated_at: string | null
}
export interface Client {
id: number
client_number: string
name: string
notes: Record<string, string>
custom_fields?: ClientCustomField[]
// Present on list/search responses only.
history_count?: number
last_history_at?: string | null
created_at: string
updated_at: string
}
export interface HistoryType {
id: number
name: string
requires_datetime: boolean
sort_order: number
}
export interface HistoryEntry {
id: number
client_id: number
type: 'phone_call' | 'consultation' | 'recommendation' | 'instruction' | 'remark' | 'other'
is_field_change: boolean
history_entry_type_id: number | null
type_name: string | null
requires_datetime: boolean
occurred_at: string | null
body: string
custom_field_definition_id: number | null
author_id: number
author_name: string | null
updated_by: number | null
@ -27,6 +67,20 @@ export interface HistoryEntry {
updated_at: string
}
export interface ImportError {
row: number
client_number: string | null
message: string
}
export interface ImportResult {
total: number
created: number
updated: number
skipped: number
errors: ImportError[]
}
export interface PaginatedResponse<T> {
data: T[]
meta: {

View file

@ -0,0 +1,17 @@
// Renders a naive local datetime string (e.g. "2026-06-25T14:30:00", no Z/offset)
// as "DD.MM.YYYY HH:MM". Because occurred_at carries no timezone, new Date()
// parses it as local time and the user-typed HH:MM is preserved.
export const formatEventDateTime = (s: string): string => {
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
return d
.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
// de-DE renders as "25.06.2026, 14:30" — drop the comma to get "25.06.2026 14:30".
.replace(',', '')
}