From 47945f6f64e88da470d1be213160b466a630aeeb Mon Sep 17 00:00:00 2001 From: Z User Date: Fri, 30 Oct 2026 02:02:52 +0000 Subject: [PATCH] feat: company cascade delete + user management + settings focus fix + bulk delete components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Company DELETE: cascade non-accounting deps (deals, projects, tickets, persons, leads, instances), block only on accounting (invoices, licenses, expenses, contracts) per GoBD rule. Transaction-wrapped. - User management: GET /api/aos/users + PATCH role + DELETE (guards: no self-delete, no last owner delete, no env-admin delete). - User management UI: table with inline role select, 2FA status, delete button. - Settings focus fix: moved Field component outside render (was causing unmount/remount on each keystroke → focus loss). - Bulk delete: useBulkSelect hook + BulkDeleteDialog + BulkActionBar. - Cleaned all demo/mock data; re-bootstrapped admin account. --- db/custom.db | Bin 569344 -> 569344 bytes src/app/api/aos/crm/companies/[id]/route.ts | 92 +++---- src/app/api/aos/users/[id]/route.ts | 119 ++++++++ src/app/api/aos/users/route.ts | 31 +++ src/components/aos/crud/bulk-action-bar.tsx | 49 ++++ .../aos/crud/bulk-delete-dialog.tsx | 256 ++++++++++++++++++ src/components/aos/user-management.tsx | 217 +++++++++++++++ src/components/aos/views/global-settings.tsx | 119 ++++---- src/hooks/use-bulk-select.ts | 46 ++++ 9 files changed, 834 insertions(+), 95 deletions(-) create mode 100644 src/app/api/aos/users/[id]/route.ts create mode 100644 src/app/api/aos/users/route.ts create mode 100644 src/components/aos/crud/bulk-action-bar.tsx create mode 100644 src/components/aos/crud/bulk-delete-dialog.tsx create mode 100644 src/components/aos/user-management.tsx create mode 100644 src/hooks/use-bulk-select.ts diff --git a/db/custom.db b/db/custom.db index ad909f5b2ae755a7f22190470a686fdaba2472b3..38d9d130d691a7b347c181f82b96a3051e12b37a 100755 GIT binary patch delta 1544 zcmZvcUx*t;9LIN(+w9(X$!vV6T%|U>TFyVz-Oc8bT@SoaL|@d)qX(rV+1+Tl{OetF zrG@gU@NgRid`>lH@`xpC+ z)cfrIAxqgXjP45cA@-pez(xS=06GEm0_X?eKEP5G0@IbiQ^Wt@zgqFcWH-6r66O@jT+ndOwYW+l> z-9ihL%H+c>RQ7`5*Y_F(Gj?a3-$sv9&&wZdq6waAdWKxuM*FF4p4_SpzGG`#8C+Tma=op6vzm)Ulh zEin@KDU7m=a*Xnf>LbI0{u~?3)@IaU)MM0VYU;5j5F5eRebcqF%+NGrk>y~ z%<-HE$>n$+lHVvcmfmhC@GK^mmEvM`*{Yvb$;9XUk(E+W)r_QRTE&!_EGZdXHB8mg z3h>jCY2A3Sq$O=#pSDtlrWMp_)7H&WQ8%=VX4y(QnO4n$rWQ*jOIK}EF$$)trP7A2 zm}a3ctr%(a@!I-3x4idl|IKa-n(Xlo|F>71j?e>vcJs{0<%66`ksupNB=E~J$}!3_ zsxxW=`Qo`$yzjB&ZbhDM`pU0i86-XoH6;t*J(3LX6uv48a0+LIgHnV_>RlB=u!OuY z{MWk@qSyQeu|A5DYZZ||8KSSrD^;;YX^Q+(72l<$Nl2w868|1q#a{F85pTB3E23A_ z^JK0n?iFw&qt{DiNMZ6+(N-(-M6`zq|wG)lLn0H$5KZ;44M9w;jGzq8`8fcR&{N!8i4RjG4cq8(m(x?Bs=Qh(Sz0MbX zMW9u7i!ZVWL}*97&{&9*t$5ISYYJ%)yofOe$6J#Gt>xddBNjH0*AbN9>^sGvH24vd| zDQ7h_N#2+s)tm=No|qx`mN>bHWIYYYed+!~TaFxQYKq4iYL;)Slw-TU>m zG#QdhGo*$JctVNUQpw@Zm6#8Zu(v9aM|6(qn?NOQki|F^kq|nfm&iv!K95^Zao$p5JU|AVRALH|slx>guEGUXmZhISuH{5T zsx!Py{Wz!Bxim_!7x^F~YX7W6K`+ix9)22gX`qH40@Xi9M`P3s?Z9g!gHfmkRr3#B zicuapjKbM}p|U^8Mp1Yh3NLYbP|R+2k#ZZINUg@H{y{eCBKbF}T_t7z3?^4tS9A) zf6Uxwv=cX(Shqr)M?wVqSQ_kE)PkKKM)3i%f?>}GC<2Y>Ajh%i&O5jYMzNN1cwdRQ zfwvsm(X|zXT$O`DIXrNn?E$%k?xE1`q412kCj@ z>$@B%wf`wkVEdDz)-|k8HA@`BzY{An^{pev2Ti^*A-`orbQ$+?T7xd } @@ -153,60 +156,57 @@ export async function DELETE( return NextResponse.json({ error: 'company not found' }, { status: 404 }) } - // Check all FK relations on Company (see prisma/schema.prisma). - const [ - invoices, - licenses, - deals, - projects, - contracts, - expenses, - tickets, - instances, - leads, - persons, - agencyClients, - ] = await Promise.all([ + // Check ACCOUNTING relations — these block deletion (GoBD). + const [invoices, licenses, expenses, contracts] = await Promise.all([ db.invoice.count({ where: { companyId: id } }), db.license.count({ where: { companyId: id } }), - db.deal.count({ where: { companyId: id } }), - db.project.count({ where: { companyId: id } }), - db.contract.count({ where: { companyId: id } }), db.expense.count({ where: { companyId: id } }), - db.ticket.count({ where: { companyId: id } }), - db.instance.count({ where: { companyId: id } }), - db.lead.count({ where: { companyId: id } }), - db.person.count({ where: { companyId: id } }), - db.company.count({ where: { parentAgencyId: id } }), + db.contract.count({ where: { companyId: id } }), ]) - const dependencies: Record = { + const accountingDeps: Record = { invoices, licenses, - deals, - projects, - contracts, expenses, - tickets, - instances, - leads, - persons, - agencyClients, + contracts, } - const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0) - if (totalDeps > 0) { + const totalAccounting = Object.values(accountingDeps).reduce((s, n) => s + n, 0) + if (totalAccounting > 0) { return NextResponse.json( { - error: 'company has related records — delete or reassign them first', - dependencies, - total: totalDeps, + error: 'Firma hat GoBD-relevante Datensätze (Rechnungen, Lizenzen, Ausgaben, Verträge) — diese können nicht gelöscht, nur storniert werden.', + dependencies: accountingDeps, + total: totalAccounting, }, { status: 409 } ) } - // No related records — safe to hard delete. - await db.company.delete({ where: { id } }) + // No accounting deps — cascade-delete non-accounting relations + company. + await db.$transaction(async (tx) => { + // Cascade non-accounting records (these have no GoBD retention). + await tx.lead.deleteMany({ where: { companyId: id } }) + await tx.person.deleteMany({ where: { companyId: id } }) + await tx.ticket.deleteMany({ where: { companyId: id } }) + const projectIds = (await tx.project.findMany({ where: { companyId: id }, select: { id: true } })).map((p) => p.id) + if (projectIds.length > 0) { + await tx.task.deleteMany({ where: { projectId: { in: projectIds } } }) + await tx.timeEntry.deleteMany({ where: { projectId: { in: projectIds } } }) + try { await tx.portalApproval.deleteMany({ where: { projectId: { in: projectIds } } }) } catch {} + } + await tx.project.deleteMany({ where: { companyId: id } }) + await tx.deal.deleteMany({ where: { companyId: id } }) + await tx.instance.deleteMany({ where: { companyId: id } }) + // Agency application + repo assignments (if this company applied/is a reseller). + try { await tx.agencyApplication.deleteMany({ where: { companyId: id } }) } catch {} + try { await tx.repoAssignment.deleteMany({ where: { companyId: id } }) } catch {} + // Unlink agency clients (set parentAgencyId = null so they become direct clients). + await tx.company.updateMany({ where: { parentAgencyId: id }, data: { parentAgencyId: null } }) + // Unlink users (set companyId = null so they keep their accounts). + await tx.user.updateMany({ where: { companyId: id }, data: { companyId: null } }) + // Finally delete the company itself. + await tx.company.delete({ where: { id } }) + }) await db.auditLog.create({ data: { @@ -215,7 +215,7 @@ export async function DELETE( entity: 'Company', entityId: id, before: JSON.stringify(existing), - after: JSON.stringify({ deleted: true }), + after: JSON.stringify({ deleted: true, cascaded: 'deals,projects,tickets,leads,persons,instances' }), hash: 'sha256:' + Math.random().toString(36).slice(2), }, }) diff --git a/src/app/api/aos/users/[id]/route.ts b/src/app/api/aos/users/[id]/route.ts new file mode 100644 index 0000000..4a0f8eb --- /dev/null +++ b/src/app/api/aos/users/[id]/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// PATCH /api/aos/users/[id] — update user role (admin only). +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await req.json().catch(() => ({})) + const { role } = body as { role?: string } + + const existing = await db.user.findUnique({ where: { id } }) + if (!existing) { + return NextResponse.json({ error: 'user not found' }, { status: 404 }) + } + + const data: Record = {} + if (role) { + const validRoles = ['owner', 'admin', 'sales', 'pm', 'developer', 'finance', 'readonly', 'client', 'member', 'reseller'] + if (!validRoles.includes(role)) { + return NextResponse.json({ error: `invalid role: ${role}` }, { status: 400 }) + } + data.role = role + } + + if (Object.keys(data).length === 0) { + return NextResponse.json({ error: 'no fields to update' }, { status: 400 }) + } + + const updated = await db.user.update({ where: { id }, data }) + await db.auditLog.create({ + data: { + actor: 'admin:ui', + action: 'user.update', + entity: 'User', + entityId: id, + before: JSON.stringify({ role: existing.role }), + after: JSON.stringify({ role: updated.role }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ user: { id: updated.id, email: updated.email, role: updated.role } }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} + +// DELETE /api/aos/users/[id] — hard-delete a user. +// Guard rails: +// - Cannot delete the env-admin (ADMIN_EMAIL). 403. +// - Cannot delete the last owner (prevents lockout). 409. +// - Cannot delete yourself (session check). 409. +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const existing = await db.user.findUnique({ where: { id } }) + if (!existing) { + return NextResponse.json({ error: 'user not found' }, { status: 404 }) + } + + // Block deletion of the env-managed admin account. + const adminEmail = process.env.ADMIN_EMAIL + if (adminEmail && existing.email.toLowerCase() === adminEmail.toLowerCase()) { + return NextResponse.json( + { error: 'Der Admin-Account (Umgebungsvariable) kann nicht gelöscht werden.' }, + { status: 403 } + ) + } + + // Block deleting the last owner — prevents lockout. + if (existing.role === 'owner') { + const ownerCount = await db.user.count({ where: { role: 'owner' } }) + if (ownerCount <= 1) { + return NextResponse.json( + { error: 'Der letzte Owner kann nicht gelöscht werden — mindestens ein Owner muss erhalten bleiben.' }, + { status: 409 } + ) + } + } + + // Prevent self-deletion via session check. + const { getCurrentUser } = await import('@/lib/session') + const me = await getCurrentUser() + if (me?.id === id) { + return NextResponse.json( + { error: 'Sie können sich nicht selbst löschen.' }, + { status: 409 } + ) + } + + await db.user.delete({ where: { id } }) + + await db.auditLog.create({ + data: { + actor: me ? `user:${me.email}` : 'admin:ui', + action: 'user.delete', + entity: 'User', + entityId: id, + before: JSON.stringify({ email: existing.email, name: existing.name, role: existing.role }), + after: JSON.stringify({ deleted: true }), + hash: 'sha256:' + Math.random().toString(36).slice(2), + }, + }) + + return NextResponse.json({ ok: true, deleted: true, id }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/app/api/aos/users/route.ts b/src/app/api/aos/users/route.ts new file mode 100644 index 0000000..a0523ad --- /dev/null +++ b/src/app/api/aos/users/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' + +export const dynamic = 'force-dynamic' + +// GET /api/aos/users — list all users (admin management). +// Excludes passwordHash + 2FA secrets (never sent to the client). +export async function GET() { + try { + const users = await db.user.findMany({ + orderBy: { createdAt: 'asc' }, + select: { + id: true, + email: true, + name: true, + role: true, + companyId: true, + emailVerifiedAt: true, + twoFactorEnabled: true, + twoFactorMethod: true, + lastLoginAt: true, + createdAt: true, + company: { select: { id: true, name: true } }, + }, + }) + return NextResponse.json({ users }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'internal server error' + return NextResponse.json({ error: msg }, { status: 500 }) + } +} diff --git a/src/components/aos/crud/bulk-action-bar.tsx b/src/components/aos/crud/bulk-action-bar.tsx new file mode 100644 index 0000000..6451c98 --- /dev/null +++ b/src/components/aos/crud/bulk-action-bar.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Trash2, X, CheckSquare } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export function BulkActionBar({ + count, + entityLabel, + onDelete, + onClear, + className, +}: { + count: number + entityLabel: string + onDelete: () => void + onClear: () => void + className?: string +}) { + if (count === 0) return null + + return ( +
+ + + {count} {count === 1 ? entityLabel : `${entityLabel}e`} ausgewählt + +
+ + +
+ ) +} diff --git a/src/components/aos/crud/bulk-delete-dialog.tsx b/src/components/aos/crud/bulk-delete-dialog.tsx new file mode 100644 index 0000000..83a42f0 --- /dev/null +++ b/src/components/aos/crud/bulk-delete-dialog.tsx @@ -0,0 +1,256 @@ +'use client' + +import { useState } from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Progress } from '@/components/ui/progress' +import { Badge } from '@/components/ui/badge' +import { Loader2, Trash2, AlertTriangle, CheckCircle2, XCircle } from 'lucide-react' +import { toast } from 'sonner' + +export interface BulkDeleteItem { + id: string + name: string +} + +interface BulkDeleteDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + entityLabel: string + entityLabelPlural?: string + items: BulkDeleteItem[] + endpointFor: (id: string) => string + onDone?: () => void +} + +type Phase = 'confirm' | 'running' | 'done' + +interface DeleteResult { + item: BulkDeleteItem + ok: boolean + error?: string + dependencies?: Record +} + +export function BulkDeleteDialog({ + open, + onOpenChange, + entityLabel, + entityLabelPlural, + items, + endpointFor, + onDone, +}: BulkDeleteDialogProps) { + const [phase, setPhase] = useState('confirm') + const [results, setResults] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + + const plural = entityLabelPlural ?? `${entityLabel}n` + const total = items.length + const succeeded = results.filter((r) => r.ok).length + const failed = results.filter((r) => !r.ok) + + const reset = () => { + setPhase('confirm') + setResults([]) + setCurrentIndex(0) + } + + const run = async () => { + setPhase('running') + setResults([]) + setCurrentIndex(0) + const collected: DeleteResult[] = [] + const t = toast.loading(`${total} ${plural} werden gelöscht…`) + + for (let i = 0; i < items.length; i++) { + const item = items[i] + setCurrentIndex(i) + try { + const r = await fetch(endpointFor(item.id), { method: 'DELETE' }) + const data = await r.json().catch(() => ({})) + if (r.ok) { + collected.push({ item, ok: true }) + } else { + collected.push({ + item, + ok: false, + error: data.error || `HTTP ${r.status}`, + dependencies: data.dependencies, + }) + } + } catch (e) { + collected.push({ + item, + ok: false, + error: e instanceof Error ? e.message : 'network error', + }) + } + setResults([...collected]) + } + + const okCount = collected.filter((r) => r.ok).length + const failCount = collected.length - okCount + if (failCount === 0) { + toast.success(`${okCount} ${plural} gelöscht`, { id: t }) + } else if (okCount === 0) { + toast.error(`Keine ${plural} gelöscht — ${failCount} blockiert`, { id: t }) + } else { + toast.warning(`${okCount} gelöscht, ${failCount} übersprungen`, { id: t, duration: 6000 }) + } + + setPhase('done') + onDone?.() + } + + const progress = total > 0 ? Math.round((results.length / total) * 100) : 0 + + return ( + { + if (!o) reset() + onOpenChange(o) + }} + > + + + + + {phase === 'confirm' && `${total} ${plural} löschen`} + {phase === 'running' && `Lösche… (${currentIndex + 1}/${total})`} + {phase === 'done' && `Ergebnis — ${succeeded}/${total} gelöscht`} + + + {phase === 'confirm' && + `Möchtest du wirklich ${total} ${plural} löschen? Diese Aktion kann nicht rückgängig gemacht werden. Einträge mit Abhängigkeiten werden übersprungen.`} + {phase === 'running' && 'Bitte warten — die Einträge werden nacheinander gelöscht.'} + {phase === 'done' && 'Überprüfe die Ergebnisse unten. Blockierte Einträge müssen zuerst bereinigt werden.'} + + + + {phase === 'running' && ( +
+ +
+ {results.map((r) => ( +
+ {r.item.name} + {r.ok ? ( + + ) : ( + + )} +
+ ))} +
+
+ )} + + {phase === 'done' && ( +
+
+ + {succeeded} gelöscht + + {failed.length > 0 && ( + + {failed.length} übersprungen + + )} +
+ + {failed.length > 0 && ( +
+ {failed.map((r) => ( +
+

{r.item.name}

+

{r.error}

+ {r.dependencies && Object.keys(r.dependencies).filter((k) => r.dependencies![k] > 0).length > 0 && ( +
+ {Object.entries(r.dependencies) + .filter(([, n]) => n > 0) + .map(([k, n]) => ( + + {DEP_LABELS[k] ?? k}: {n} + + ))} +
+ )} +
+ ))} +
+ )} +
+ )} + + + {phase === 'confirm' && ( + <> + Abbrechen + { + e.preventDefault() + void run() + }} + className="gap-1.5 bg-foreground text-background hover:bg-foreground/90" + > + + Alle löschen ({total}) + + + )} + {phase === 'running' && ( + + + Wird ausgeführt… + + )} + {phase === 'done' && ( + { + e.preventDefault() + reset() + onOpenChange(false) + }} + className="gap-1.5" + > + + Fertig + + )} + +
+
+ ) +} + +const DEP_LABELS: Record = { + invoices: 'Rechnungen', + licenses: 'Lizenzen', + deals: 'Deals', + projects: 'Projekte', + contracts: 'Verträge', + expenses: 'Ausgaben', + tickets: 'Tickets', + instances: 'Instanzen', + leads: 'Leads', + persons: 'Ansprechpartner', + agencyClients: 'Agentur-Kunden', + tasks: 'Aufgaben', + timeEntries: 'Zeiteinträge', + approvals: 'Freigaben', + activations: 'Aktivierungen', + modules: 'Module', +} diff --git a/src/components/aos/user-management.tsx b/src/components/aos/user-management.tsx new file mode 100644 index 0000000..6ad2aec --- /dev/null +++ b/src/components/aos/user-management.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useState } from 'react' +import { useFetch } from '@/hooks/use-fetch' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Users, Trash2, ShieldCheck, Loader2 } from 'lucide-react' +import { DeleteDialog } from './crud/delete-dialog' +import { formatRelative } from '@/lib/format' +import { toast } from 'sonner' + +interface UserRow { + id: string + email: string + name: string + role: string + companyId: string | null + emailVerifiedAt: string | null + twoFactorEnabled: boolean + twoFactorMethod: string | null + lastLoginAt: string | null + createdAt: string + company: { id: string; name: string } | null +} + +interface UsersResponse { + users: UserRow[] +} + +const ROLE_LABELS: Record = { + owner: 'Owner', + admin: 'Admin', + sales: 'Vertrieb', + pm: 'Projektmanager', + developer: 'Entwickler', + finance: 'Buchhaltung', + readonly: 'Lesend', + client: 'Kunde', + member: 'Mitarbeiter', + reseller: 'Reseller', +} + +const ROLE_VALUES = ['owner', 'admin', 'member', 'sales', 'pm', 'developer', 'finance', 'readonly', 'client', 'reseller'] + +export function UserManagement() { + const { data, loading, refetch } = useFetch('/api/aos/users') + const [deleteTarget, setDeleteTarget] = useState(null) + const [changingRole, setChangingRole] = useState(null) + + const changeRole = async (userId: string, role: string) => { + setChangingRole(userId) + const t = toast.loading('Rolle wird geändert…') + try { + const r = await fetch(`/api/aos/users/${userId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ role }), + }) + const res = await r.json() + if (!r.ok) throw new Error(res.error || 'Fehlgeschlagen') + toast.success(`Rolle geändert: ${ROLE_LABELS[role] ?? role}`, { id: t }) + refetch() + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Fehlgeschlagen', { id: t }) + } finally { + setChangingRole(null) + } + } + + const initials = (name: string) => + name.split(' ').map((p) => p[0]).slice(0, 2).join('').toUpperCase() + + if (loading) { + return ( + + + + + Benutzer + + + +
+ Laden… +
+
+
+ ) + } + + const users = data?.users ?? [] + + return ( + + + + + Benutzer ({users.length}) + + + + {users.length === 0 ? ( +

Keine Benutzer vorhanden.

+ ) : ( + + + + Benutzer + Rolle + 2FA + Letzte Anmeldung + Aktionen + + + + {users.map((u) => ( + + +
+ + + {initials(u.name)} + + +
+

{u.name}

+

{u.email}

+ {u.company && ( +

→ {u.company.name}

+ )} +
+
+
+ + {u.role === 'owner' ? ( + + Owner + + ) : changingRole === u.id ? ( + + ) : ( + + )} + + + {u.twoFactorEnabled ? ( + + + {u.twoFactorMethod === 'totp' ? 'App' : 'E-Mail'} + + ) : ( + — + )} + + + {u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'nie'} + + + + +
+ ))} +
+
+ )} +
+ + {deleteTarget && ( + !o && setDeleteTarget(null)} + entityLabel="Benutzer" + entityName={deleteTarget.name} + endpoint={`/api/aos/users/${deleteTarget.id}`} + description={`Möchtest du „${deleteTarget.name}" (${deleteTarget.email}) wirklich löschen? Der Benutzer kann sich danach nicht mehr anmelden. Audit-Log bleibt erhalten.`} + onDeleted={refetch} + /> + )} +
+ ) +} diff --git a/src/components/aos/views/global-settings.tsx b/src/components/aos/views/global-settings.tsx index 7faf063..0ecb5c7 100755 --- a/src/components/aos/views/global-settings.tsx +++ b/src/components/aos/views/global-settings.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useFetch } from '@/hooks/use-fetch' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' @@ -15,6 +15,7 @@ import { LOCALES } from '@/lib/i18n' import { Loader2, Save, Building2, FileText, Percent, Radar, FileCheck2, Languages, Lock, CheckCircle2 } from 'lucide-react' import { toast } from 'sonner' import { cn } from '@/lib/utils' +import { UserManagement } from '../user-management' interface SettingsData { settings: Record @@ -34,6 +35,11 @@ export function GlobalSettingsView() { } }, [data]) + const update = useCallback((key: string, value: string) => { + setForm((f) => ({ ...f, [key]: value })) + setDirty(true) + }, []) + if (loading || !data) { return (
@@ -43,11 +49,6 @@ export function GlobalSettingsView() { ) } - const update = (key: string, value: string) => { - setForm((f) => ({ ...f, [key]: value })) - setDirty(true) - } - const save = async () => { setSaving(true) const t = toast.loading('Einstellungen werden gespeichert…') @@ -69,20 +70,6 @@ export function GlobalSettingsView() { } } - const Field = ({ label, k, mono, suffix }: { label: string; k: string; mono?: boolean; suffix?: string }) => ( -
- -
- update(k, e.target.value)} - className={cn('text-sm', mono && 'font-mono')} - /> - {suffix && {suffix}} -
-
- ) - return (
- - + +

Zahlen-/Datumsformat folgt der UI-Sprache: DE = 1.234,56 € / 26.10.2026, EN = 1,234.56 € / 10/26/2026, TR = 1.234,56 ₺ / 26.10.2026

+ {/* User management */} + + {/* Company data */} @@ -142,16 +132,16 @@ export function GlobalSettingsView() { - - - - - - - - - - + + + + + + + + + + @@ -164,9 +154,9 @@ export function GlobalSettingsView() { - - - + + +

@@ -185,9 +175,9 @@ export function GlobalSettingsView() { - - - + + + @@ -200,9 +190,9 @@ export function GlobalSettingsView() { - - - + + + @@ -227,7 +217,7 @@ export function GlobalSettingsView() {

- +

@@ -245,9 +235,9 @@ export function GlobalSettingsView() { - - - + + +

@@ -268,10 +258,10 @@ export function GlobalSettingsView() { - - - - + + + + @@ -292,3 +282,34 @@ export function GlobalSettingsView() {
) } + +function SettingsField({ + label, + k, + mono, + suffix, + form, + update, +}: { + label: string + k: string + mono?: boolean + suffix?: string + form: Record + update: (key: string, value: string) => void +}) { + return ( +
+ +
+ update(k, e.target.value)} + className={cn('text-sm', mono && 'font-mono')} + /> + {suffix && {suffix}} +
+
+ ) +} diff --git a/src/hooks/use-bulk-select.ts b/src/hooks/use-bulk-select.ts new file mode 100644 index 0000000..3c8fb78 --- /dev/null +++ b/src/hooks/use-bulk-select.ts @@ -0,0 +1,46 @@ +'use client' + +import { useState, useCallback } from 'react' + +export function useBulkSelect(_items?: T[]) { + const [selected, setSelected] = useState>(new Set()) + + const isSelected = useCallback((id: string) => selected.has(id), [selected]) + + const toggle = useCallback((id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const toggleAll = useCallback((ids: string[]) => { + setSelected((prev) => { + const allIn = ids.every((id) => prev.has(id)) + if (allIn) return new Set() + return new Set(ids) + }) + }, []) + + const allSelected = useCallback( + (ids: string[]) => ids.length > 0 && ids.every((id) => selected.has(id)), + [selected] + ) + + const clear = useCallback(() => setSelected(new Set()), []) + + const selectedIds = Array.from(selected) + + return { + selected, + selectedIds, + count: selected.size, + isSelected, + toggle, + toggleAll, + allSelected, + clear, + } +}