- Remove Filament /admin panel completely (AdminPanelProvider, UserResource, routes) - Update composer.json to remove filament/filament dependency - Add User model soft deletes with SoftDeletes trait + migration - Strengthen UserPolicy::delete to prevent deleting last remaining admin - Build out User module following pattern: CreateUserAction, UpdateUserAction, GetUsersAction, UserResource, UserController, Create/UpdateUserRequest, UserData DTO - Add /api/users routes (index, store, update, destroy), all admin-gated via policy - Create frontend Settings > Accounts page (settings/accounts.vue) for admin user management - Add useUsers composable mirroring useClient pattern - Add "Benutzer" link to settings dropdown in clients/index.vue - All tests pass; User soft-delete and last-admin protection verified Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
170 lines
6.3 KiB
Vue
170 lines
6.3 KiB
Vue
<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 (A–Z)' },
|
||
{ 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>
|