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:
parent
4d684f38ad
commit
570f17e8b8
37 changed files with 1396 additions and 435 deletions
16
frontend/components/IconArrowDown.vue
Normal file
16
frontend/components/IconArrowDown.vue
Normal 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>
|
||||
16
frontend/components/IconArrowLeft.vue
Normal file
16
frontend/components/IconArrowLeft.vue
Normal 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>
|
||||
16
frontend/components/IconArrowUp.vue
Normal file
16
frontend/components/IconArrowUp.vue
Normal 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>
|
||||
12
frontend/components/IconClose.vue
Normal file
12
frontend/components/IconClose.vue
Normal 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>
|
||||
16
frontend/components/IconEdit.vue
Normal file
16
frontend/components/IconEdit.vue
Normal 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>
|
||||
21
frontend/components/IconEye.vue
Normal file
21
frontend/components/IconEye.vue
Normal 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>
|
||||
16
frontend/components/IconEyeOff.vue
Normal file
16
frontend/components/IconEyeOff.vue
Normal 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>
|
||||
16
frontend/components/IconLogout.vue
Normal file
16
frontend/components/IconLogout.vue
Normal 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>
|
||||
16
frontend/components/IconPrint.vue
Normal file
16
frontend/components/IconPrint.vue
Normal 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>
|
||||
16
frontend/components/IconTrash.vue
Normal file
16
frontend/components/IconTrash.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
113
frontend/components/client/ClientCustomFieldCard.vue
Normal file
113
frontend/components/client/ClientCustomFieldCard.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
frontend/composables/useApiFetch.ts
Normal file
30
frontend/composables/useApiFetch.ts
Normal 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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
42
frontend/composables/useCustomFields.ts
Normal file
42
frontend/composables/useCustomFields.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
40
frontend/composables/useHistoryTypes.ts
Normal file
40
frontend/composables/useHistoryTypes.ts
Normal 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 }
|
||||
}
|
||||
16
frontend/composables/useImport.ts
Normal file
16
frontend/composables/useImport.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
15
frontend/middleware/admin.ts
Normal file
15
frontend/middleware/admin.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
export default defineNuxtConfig({
|
||||
ssr: false,
|
||||
experimental: {
|
||||
viteEnvironmentApi: true,
|
||||
},
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
devServer: {
|
||||
port: 3000,
|
||||
|
|
|
|||
164
frontend/package-lock.json
generated
164
frontend/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
196
frontend/pages/clients/[id]/index.vue
Normal file
196
frontend/pages/clients/[id]/index.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
195
frontend/pages/settings/fields.vue
Normal file
195
frontend/pages/settings/fields.vue
Normal 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 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>
|
||||
158
frontend/pages/settings/history-types.vue
Normal file
158
frontend/pages/settings/history-types.vue
Normal 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>
|
||||
131
frontend/pages/settings/import.vue
Normal file
131
frontend/pages/settings/import.vue
Normal 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
17
frontend/utils/datetime.ts
Normal file
17
frontend/utils/datetime.ts
Normal 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(',', '')
|
||||
}
|
||||
Loading…
Reference in a new issue