mandant-crm/frontend/pages/clients/[id]/index.vue

197 lines
6.8 KiB
Vue
Raw Permalink Normal View History

<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>