mandant-crm/frontend/pages/clients/index.vue

171 lines
6.3 KiB
Vue
Raw Permalink Normal View History

<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { clients, search, fetchClients } = useClient()
const q = ref('')
let debounceTimer: ReturnType<typeof setTimeout>
await fetchClients()
type SortKey = 'name' | 'number' | 'recent' | 'updates'
const sortOptions: { key: SortKey; label: string }[] = [
{ key: 'name', label: 'Name (AZ)' },
{ key: 'number', label: 'Mandantennummer' },
{ key: 'recent', label: 'Letzte Änderung' },
{ key: 'updates', label: 'Anzahl Einträge' },
]
const sortKey = ref<SortKey>('name')
// Real timestamps are large positives, so clients without history (0) always
// sort below those with history, and ties stay stable (no NaN comparisons).
const lastChangeTs = (c: (typeof clients.value)[number]): number =>
c.last_history_at ? new Date(c.last_history_at).getTime() : 0
const sortedClients = computed(() => {
const list = [...clients.value]
switch (sortKey.value) {
case 'number':
return list.sort((a, b) =>
a.client_number.localeCompare(b.client_number, 'de', { numeric: true }),
)
case 'recent':
// Most recently changed first; clients with no history sink to the bottom.
return list.sort((a, b) => lastChangeTs(b) - lastChangeTs(a))
case 'updates':
return list.sort((a, b) => (b.history_count ?? 0) - (a.history_count ?? 0))
case 'name':
default:
return list.sort((a, b) => a.name.localeCompare(b.name, 'de'))
}
})
const onSearch = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => search(q.value), 250)
}
const { user } = useAuth()
const isAdmin = computed(() => user.value?.role === 'admin')
const showCreate = ref(false)
const newNumber = ref('')
const newName = ref('')
const creating = ref(false)
const createError = ref('')
const { createClient } = useClient()
const submitCreate = async () => {
creating.value = true
createError.value = ''
try {
const client = await createClient({ client_number: newNumber.value, name: newName.value })
showCreate.value = false
newNumber.value = ''
newName.value = ''
await navigateTo(`/clients/${client.id}`)
} catch (e: any) {
createError.value = e?.data?.message ?? 'Fehler beim Erstellen.'
} finally {
creating.value = false
}
}
</script>
<template>
<div class="min-h-screen bg-gray-50">
<header class="grid grid-cols-3 items-center bg-white border-b border-gray-200 px-6 py-3">
<h1 class="text-base font-semibold text-gray-800">Markus Grote CRM</h1>
<input
v-model="q"
@input="onSearch"
type="search"
placeholder="Mandant suchen (Name oder Nr.)…"
class="w-full max-w-md justify-self-center border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<div class="justify-self-end flex items-center gap-3">
<!-- Settings: hover-/focus-revealed menu -->
<div v-if="isAdmin" class="relative group/settings">
<button
type="button"
aria-label="Einstellungen"
title="Einstellungen"
class="p-1.5 text-gray-500 hover:text-gray-700"
>
<IconSettings />
</button>
<div
class="absolute right-0 top-full pt-1 z-20 hidden group-hover/settings:block group-focus-within/settings:block"
>
<div class="bg-white border border-gray-200 shadow-md py-1 min-w-[150px]">
<NuxtLink to="/settings/accounts" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Benutzer
</NuxtLink>
<NuxtLink to="/settings/fields" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Felder
</NuxtLink>
<NuxtLink to="/settings/history-types" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Verlaufstypen
</NuxtLink>
<NuxtLink to="/settings/import" class="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
Mandanten-Import
</NuxtLink>
</div>
</div>
</div>
<LogoutButton />
</div>
</header>
<main class="max-w-4xl mx-auto py-4 px-6 space-y-1">
<div class="flex items-center justify-between pb-2">
<span class="text-xs text-gray-500">{{ clients.length }} Mandanten</span>
<label class="flex items-center gap-2 text-xs text-gray-500">
Sortieren
<select
v-model="sortKey"
class="border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option v-for="opt in sortOptions" :key="opt.key" :value="opt.key">
{{ opt.label }}
</option>
</select>
</label>
</div>
<p v-if="clients.length === 0" class="text-sm text-gray-500 py-8 text-center">
Keine Mandanten gefunden.
</p>
<ClientCard v-for="client in sortedClients" :key="client.id" :client="client" />
</main>
<!-- Create modal -->
<div v-if="showCreate" class="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<div class="bg-white shadow-lg p-6 w-full max-w-sm">
<h2 class="text-base font-semibold mb-4">Neuer Mandant</h2>
<form @submit.prevent="submitCreate" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700">Mandantennummer</label>
<input v-model="newNumber" required class="mt-1 block w-full border border-gray-300 px-3 py-2 text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="newName" required class="mt-1 block w-full border border-gray-300 px-3 py-2 text-sm" />
</div>
<p v-if="createError" class="text-sm text-red-600">{{ createError }}</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="showCreate = false" class="px-3 py-1.5 text-sm border border-gray-300">
Abbrechen
</button>
<button type="submit" :disabled="creating" class="px-3 py-1.5 text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
Anlegen
</button>
</div>
</form>
</div>
</div>
</div>
</template>