197 lines
6.8 KiB
Vue
197 lines
6.8 KiB
Vue
|
|
<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>
|