mandant-crm/frontend/pages/clients/[id]/index.vue
Tim Stollberg 570f17e8b8 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.
2026-07-18 16:27:19 +07:00

196 lines
6.8 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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