mandant-crm/frontend/pages/clients/index.vue
Tim Stollberg 71730fef06 feat: remove Filament, add user account management
- 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>
2026-07-13 13:05:40 +07:00

170 lines
6.3 KiB
Vue
Raw 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">
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>