feat: company cascade delete + user management + settings focus fix + bulk delete components
- 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.
This commit is contained in:
1 parent
d492182b4e
commit
47945f6f64
9 files changed
+834
-95
No files matched your search
Binary file not shown.
@@ -134,14 +134,17 @@ export async function PATCH(
|
|||||||
|
|
||||||
// DELETE /api/aos/crm/companies/[id] — delete company.
|
// DELETE /api/aos/crm/companies/[id] — delete company.
|
||||||
//
|
//
|
||||||
// Cascade policy: a company may only be hard-deleted when it has NO related
|
// GoBD cascade policy:
|
||||||
// records (invoices, licenses, deals, projects, contracts, expenses, tickets,
|
// - Non-accounting relations (deals, projects, tickets, leads, persons,
|
||||||
// instances, leads, persons). If related records exist, the call returns 409
|
// instances, agency clients) are CASCADE-DELETED. These are operational
|
||||||
// with the dependency counts so the UI can show a dialog explaining what
|
// records, not accounting — hard delete is allowed per the GoBD rule.
|
||||||
// blocks the deletion.
|
// - Accounting relations (invoices, licenses, expenses, contracts) BLOCK
|
||||||
|
// deletion. These are GoBD-retained and may not be deleted. The call
|
||||||
|
// returns 409 with the accounting dependency counts so the user knows
|
||||||
|
// which records to storno/cancel first.
|
||||||
//
|
//
|
||||||
// This is the GoBD-safe path: never silently cascade-delete invoices or
|
// Audit-logged. The cascade is wrapped in a transaction so a partial failure
|
||||||
// licenses (those have their own retention rules).
|
// doesn't leave orphan records.
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -153,60 +156,57 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
return NextResponse.json({ error: 'company not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all FK relations on Company (see prisma/schema.prisma).
|
// Check ACCOUNTING relations — these block deletion (GoBD).
|
||||||
const [
|
const [invoices, licenses, expenses, contracts] = await Promise.all([
|
||||||
invoices,
|
|
||||||
licenses,
|
|
||||||
deals,
|
|
||||||
projects,
|
|
||||||
contracts,
|
|
||||||
expenses,
|
|
||||||
tickets,
|
|
||||||
instances,
|
|
||||||
leads,
|
|
||||||
persons,
|
|
||||||
agencyClients,
|
|
||||||
] = await Promise.all([
|
|
||||||
db.invoice.count({ where: { companyId: id } }),
|
db.invoice.count({ where: { companyId: id } }),
|
||||||
db.license.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.expense.count({ where: { companyId: id } }),
|
||||||
db.ticket.count({ where: { companyId: id } }),
|
db.contract.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 } }),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const dependencies: Record<string, number> = {
|
const accountingDeps: Record<string, number> = {
|
||||||
invoices,
|
invoices,
|
||||||
licenses,
|
licenses,
|
||||||
deals,
|
|
||||||
projects,
|
|
||||||
contracts,
|
|
||||||
expenses,
|
expenses,
|
||||||
tickets,
|
contracts,
|
||||||
instances,
|
|
||||||
leads,
|
|
||||||
persons,
|
|
||||||
agencyClients,
|
|
||||||
}
|
}
|
||||||
const totalDeps = Object.values(dependencies).reduce((s, n) => s + n, 0)
|
const totalAccounting = Object.values(accountingDeps).reduce((s, n) => s + n, 0)
|
||||||
if (totalDeps > 0) {
|
if (totalAccounting > 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'company has related records — delete or reassign them first',
|
error: 'Firma hat GoBD-relevante Datensätze (Rechnungen, Lizenzen, Ausgaben, Verträge) — diese können nicht gelöscht, nur storniert werden.',
|
||||||
dependencies,
|
dependencies: accountingDeps,
|
||||||
total: totalDeps,
|
total: totalAccounting,
|
||||||
},
|
},
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No related records — safe to hard delete.
|
// No accounting deps — cascade-delete non-accounting relations + company.
|
||||||
await db.company.delete({ where: { id } })
|
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({
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -215,7 +215,7 @@ export async function DELETE(
|
|||||||
entity: 'Company',
|
entity: 'Company',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
before: JSON.stringify(existing),
|
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),
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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<string, unknown> = {}
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg border border-border bg-background px-4 py-2.5 shadow-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<CheckSquare className="size-4 text-muted-foreground" />
|
||||||
|
{count} {count === 1 ? entityLabel : `${entityLabel}e`} ausgewählt
|
||||||
|
</span>
|
||||||
|
<div className="h-4 w-px bg-border" />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
onClick={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Ausgewählte löschen
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1" onClick={onClear}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
Auswahl aufheben
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkDeleteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
entityLabel,
|
||||||
|
entityLabelPlural,
|
||||||
|
items,
|
||||||
|
endpointFor,
|
||||||
|
onDone,
|
||||||
|
}: BulkDeleteDialogProps) {
|
||||||
|
const [phase, setPhase] = useState<Phase>('confirm')
|
||||||
|
const [results, setResults] = useState<DeleteResult[]>([])
|
||||||
|
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 (
|
||||||
|
<AlertDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) reset()
|
||||||
|
onOpenChange(o)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="max-w-lg">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="size-4 text-muted-foreground" />
|
||||||
|
{phase === 'confirm' && `${total} ${plural} löschen`}
|
||||||
|
{phase === 'running' && `Lösche… (${currentIndex + 1}/${total})`}
|
||||||
|
{phase === 'done' && `Ergebnis — ${succeeded}/${total} gelöscht`}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{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.'}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
{phase === 'running' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress value={progress} className="h-2" />
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||||
|
{results.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.item.id}
|
||||||
|
className="flex items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="truncate">{r.item.name}</span>
|
||||||
|
{r.ok ? (
|
||||||
|
<CheckCircle2 className="size-3.5 shrink-0 text-foreground" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'done' && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||||
|
<CheckCircle2 className="size-3" /> {succeeded} gelöscht
|
||||||
|
</Badge>
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<Badge variant="outline" className="gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<AlertTriangle className="size-3" /> {failed.length} übersprungen
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<div className="max-h-56 space-y-1.5 overflow-y-auto rounded-md border border-border p-2">
|
||||||
|
{failed.map((r) => (
|
||||||
|
<div key={r.item.id} className="space-y-0.5 text-xs">
|
||||||
|
<p className="font-medium">{r.item.name}</p>
|
||||||
|
<p className="text-muted-foreground">{r.error}</p>
|
||||||
|
{r.dependencies && Object.keys(r.dependencies).filter((k) => r.dependencies![k] > 0).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{Object.entries(r.dependencies)
|
||||||
|
.filter(([, n]) => n > 0)
|
||||||
|
.map(([k, n]) => (
|
||||||
|
<Badge key={k} variant="outline" className="text-[9px]">
|
||||||
|
{DEP_LABELS[k] ?? k}: {n}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
{phase === 'confirm' && (
|
||||||
|
<>
|
||||||
|
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run()
|
||||||
|
}}
|
||||||
|
className="gap-1.5 bg-foreground text-background hover:bg-foreground/90"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
Alle löschen ({total})
|
||||||
|
</AlertDialogAction>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{phase === 'running' && (
|
||||||
|
<AlertDialogAction disabled className="gap-1.5">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
Wird ausgeführt…
|
||||||
|
</AlertDialogAction>
|
||||||
|
)}
|
||||||
|
{phase === 'done' && (
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
reset()
|
||||||
|
onOpenChange(false)
|
||||||
|
}}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-3.5" />
|
||||||
|
Fertig
|
||||||
|
</AlertDialogAction>
|
||||||
|
)}
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEP_LABELS: Record<string, string> = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<UsersResponse>('/api/aos/users')
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<UserRow | null>(null)
|
||||||
|
const [changingRole, setChangingRole] = useState<string | null>(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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="size-3.5 text-muted-foreground" />
|
||||||
|
Benutzer
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="size-3.5 animate-spin" /> Laden…
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = data?.users ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Users className="size-3.5 text-muted-foreground" />
|
||||||
|
Benutzer ({users.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="p-4 text-xs text-muted-foreground">Keine Benutzer vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Benutzer</TableHead>
|
||||||
|
<TableHead>Rolle</TableHead>
|
||||||
|
<TableHead>2FA</TableHead>
|
||||||
|
<TableHead>Letzte Anmeldung</TableHead>
|
||||||
|
<TableHead className="text-right">Aktionen</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar className="size-8">
|
||||||
|
<AvatarFallback className="bg-primary text-primary-foreground text-[10px]">
|
||||||
|
{initials(u.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{u.name}</p>
|
||||||
|
<p className="truncate text-[11px] text-muted-foreground">{u.email}</p>
|
||||||
|
{u.company && (
|
||||||
|
<p className="truncate text-[10px] text-muted-foreground">→ {u.company.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{u.role === 'owner' ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-[10px]">
|
||||||
|
<ShieldCheck className="size-3" /> Owner
|
||||||
|
</Badge>
|
||||||
|
) : changingRole === u.id ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Select value={u.role} onValueChange={(v) => changeRole(u.id, v)}>
|
||||||
|
<SelectTrigger className="h-7 w-36 text-[11px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLE_VALUES.map((r) => (
|
||||||
|
<SelectItem key={r} value={r} className="text-xs">
|
||||||
|
{ROLE_LABELS[r] ?? r}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{u.twoFactorEnabled ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-[10px] text-foreground">
|
||||||
|
<ShieldCheck className="size-3" />
|
||||||
|
{u.twoFactorMethod === 'totp' ? 'App' : 'E-Mail'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'nie'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-[10px]"
|
||||||
|
onClick={() => setDeleteTarget(u)}
|
||||||
|
title="Benutzer löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{deleteTarget && (
|
||||||
|
<DeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => !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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useFetch } from '@/hooks/use-fetch'
|
import { useFetch } from '@/hooks/use-fetch'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { Loader2, Save, Building2, FileText, Percent, Radar, FileCheck2, Languages, Lock, CheckCircle2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { UserManagement } from '../user-management'
|
||||||
|
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
settings: Record<string, string>
|
settings: Record<string, string>
|
||||||
@@ -34,6 +35,11 @@ export function GlobalSettingsView() {
|
|||||||
}
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
const update = useCallback((key: string, value: string) => {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (loading || !data) {
|
if (loading || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4 md:p-6">
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
@@ -43,11 +49,6 @@ export function GlobalSettingsView() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = (key: string, value: string) => {
|
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
|
||||||
setDirty(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const t = toast.loading('Einstellungen werden gespeichert…')
|
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 }) => (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">{label}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={form[k] ?? ''}
|
|
||||||
onChange={(e) => update(k, e.target.value)}
|
|
||||||
className={cn('text-sm', mono && 'font-mono')}
|
|
||||||
/>
|
|
||||||
{suffix && <span className="shrink-0 text-xs text-muted-foreground">{suffix}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-4 md:p-6">
|
<div className="space-y-6 p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -125,14 +112,17 @@ export function GlobalSettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Field label="Standard-Sprache (neue Nutzer)" k="locale.default" />
|
<SettingsField form={form} update={update} label="Standard-Sprache (neue Nutzer)" k="locale.default" />
|
||||||
<Field label="Verfügbare Sprachen (komma-getrennt)" k="locale.available" />
|
<SettingsField form={form} update={update} label="Verfügbare Sprachen (komma-getrennt)" k="locale.available" />
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
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
|
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
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* User management */}
|
||||||
|
<UserManagement />
|
||||||
|
|
||||||
{/* Company data */}
|
{/* Company data */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -142,16 +132,16 @@ export function GlobalSettingsView() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Field label="Firmenname" k="company.name" />
|
<SettingsField form={form} update={update} label="Firmenname" k="company.name" />
|
||||||
<Field label="Rechtsform" k="company.legalForm" />
|
<SettingsField form={form} update={update} label="Rechtsform" k="company.legalForm" />
|
||||||
<Field label="USt-IdNr." k="company.vatId" mono />
|
<SettingsField form={form} update={update} label="USt-IdNr." k="company.vatId" mono />
|
||||||
<Field label="Registergericht" k="company.registerCourt" />
|
<SettingsField form={form} update={update} label="Registergericht" k="company.registerCourt" />
|
||||||
<Field label="HRB-Nummer" k="company.registerNumber" mono />
|
<SettingsField form={form} update={update} label="HRB-Nummer" k="company.registerNumber" mono />
|
||||||
<Field label="Stadt" k="company.city" />
|
<SettingsField form={form} update={update} label="Stadt" k="company.city" />
|
||||||
<Field label="PLZ" k="company.zip" />
|
<SettingsField form={form} update={update} label="PLZ" k="company.zip" />
|
||||||
<Field label="Straße" k="company.street" />
|
<SettingsField form={form} update={update} label="Straße" k="company.street" />
|
||||||
<Field label="IBAN" k="company.bank" mono />
|
<SettingsField form={form} update={update} label="IBAN" k="company.bank" mono />
|
||||||
<Field label="BIC" k="company.bic" mono />
|
<SettingsField form={form} update={update} label="BIC" k="company.bic" mono />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -164,9 +154,9 @@ export function GlobalSettingsView() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<Field label="Präfix" k="invoice.numberPrefix" mono />
|
<SettingsField form={form} update={update} label="Präfix" k="invoice.numberPrefix" mono />
|
||||||
<Field label="Jahr" k="invoice.numberYear" mono />
|
<SettingsField form={form} update={update} label="Jahr" k="invoice.numberYear" mono />
|
||||||
<Field label="Nächste Nummer" k="invoice.numberNext" mono />
|
<SettingsField form={form} update={update} label="Nächste Nummer" k="invoice.numberNext" mono />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<p className="rounded-md border border-dashed border-border p-2.5 text-[11px] text-muted-foreground">
|
<p className="rounded-md border border-dashed border-border p-2.5 text-[11px] text-muted-foreground">
|
||||||
@@ -185,9 +175,9 @@ export function GlobalSettingsView() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<Field label="Standard-Steuersatz" k="tax.standardBp" suffix="Bp (1900 = 19%)" />
|
<SettingsField form={form} update={update} label="Standard-Steuersatz" k="tax.standardBp" suffix="Bp (1900 = 19%)" />
|
||||||
<Field label="Ermäßigter Steuersatz" k="tax.reducedBp" suffix="Bp (700 = 7%)" />
|
<SettingsField form={form} update={update} label="Ermäßigter Steuersatz" k="tax.reducedBp" suffix="Bp (700 = 7%)" />
|
||||||
<Field label="Reverse Charge (Default)" k="tax.reverseChargeDefault" />
|
<SettingsField form={form} update={update} label="Reverse Charge (Default)" k="tax.reverseChargeDefault" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -200,9 +190,9 @@ export function GlobalSettingsView() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<Field label="Such-Radius" k="leadFinder.radiusKm" suffix="km" />
|
<SettingsField form={form} update={update} label="Such-Radius" k="leadFinder.radiusKm" suffix="km" />
|
||||||
<Field label="Kosten-Limit pro Lauf" k="leadFinder.costCapEur" suffix="€" />
|
<SettingsField form={form} update={update} label="Kosten-Limit pro Lauf" k="leadFinder.costCapEur" suffix="€" />
|
||||||
<Field label="Auto-Genehmigung ab Score" k="leadFinder.autoApproveThreshold" suffix="/ 100" />
|
<SettingsField form={form} update={update} label="Auto-Genehmigung ab Score" k="leadFinder.autoApproveThreshold" suffix="/ 100" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -227,7 +217,7 @@ export function GlobalSettingsView() {
|
|||||||
<option value="both">Beide (Kunde wählt)</option>
|
<option value="both">Beide (Kunde wählt)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Leitweg-ID (öffentliche Kunden)" k="erechnung.leitwegId" mono />
|
<SettingsField form={form} update={update} label="Leitweg-ID (öffentliche Kunden)" k="erechnung.leitwegId" mono />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
@@ -245,9 +235,9 @@ export function GlobalSettingsView() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Field label="Aufbewahrung Rechnungen" k="compliance.retentionInvoicesYears" suffix="Jahre (8)" />
|
<SettingsField form={form} update={update} label="Aufbewahrung Rechnungen" k="compliance.retentionInvoicesYears" suffix="Jahre (8)" />
|
||||||
<Field label="Aufbewahrung Geschäftsbriefe" k="compliance.retentionBusinessYears" suffix="Jahre (6)" />
|
<SettingsField form={form} update={update} label="Aufbewahrung Geschäftsbriefe" k="compliance.retentionBusinessYears" suffix="Jahre (6)" />
|
||||||
<Field label="Encryption-Key Escrow" k="compliance.encryptionKeyEscrow" />
|
<SettingsField form={form} update={update} label="Encryption-Key Escrow" k="compliance.encryptionKeyEscrow" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardContent className="pt-0">
|
<CardContent className="pt-0">
|
||||||
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px]">
|
<div className="rounded-md border border-foreground/30 bg-foreground/5 p-3 text-[11px]">
|
||||||
@@ -268,10 +258,10 @@ export function GlobalSettingsView() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<CardContent className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<Field label="SMTP-Host" k="mail.smtpHost" />
|
<SettingsField form={form} update={update} label="SMTP-Host" k="mail.smtpHost" />
|
||||||
<Field label="SMTP-Port" k="mail.smtpPort" mono />
|
<SettingsField form={form} update={update} label="SMTP-Port" k="mail.smtpPort" mono />
|
||||||
<Field label="IMAP-Host" k="mail.imapHost" />
|
<SettingsField form={form} update={update} label="IMAP-Host" k="mail.imapHost" />
|
||||||
<Field label="IMAP-Port" k="mail.imapPort" mono />
|
<SettingsField form={form} update={update} label="IMAP-Port" k="mail.imapPort" mono />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -292,3 +282,34 @@ export function GlobalSettingsView() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SettingsField({
|
||||||
|
label,
|
||||||
|
k,
|
||||||
|
mono,
|
||||||
|
suffix,
|
||||||
|
form,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
k: string
|
||||||
|
mono?: boolean
|
||||||
|
suffix?: string
|
||||||
|
form: Record<string, string>
|
||||||
|
update: (key: string, value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`setting-${k}`} className="text-xs">{label}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id={`setting-${k}`}
|
||||||
|
value={form[k] ?? ''}
|
||||||
|
onChange={(e) => update(k, e.target.value)}
|
||||||
|
className={cn('text-sm', mono && 'font-mono')}
|
||||||
|
/>
|
||||||
|
{suffix && <span className="shrink-0 text-xs text-muted-foreground">{suffix}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export function useBulkSelect<T extends { id: string }>(_items?: T[]) {
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user