- Fix all TypeScript compilation errors (0 errors in src/) - Add try/catch error handling to ALL API routes (38 handlers) - Fix empty catch blocks (api-auth.ts now logs warnings) - Add aria-labels to all icon-only buttons (WCAG 2.1 AA) - Fix Prisma type mismatches (OR filter, Person.companyId, Estimate.projectId, Ticket.createdAt) - Fix webhook PATCH route (moved to [id]/route.ts) - Fix mobile-bottom-nav type safety - Fix KB route where-clause type - Fix test-mail banner null vs undefined - Fix task comment fetch: add cancellation + error toast - ESLint: 0 errors, 0 warnings - 175 files, 24.741 lines of code
222 lines
10 KiB
TypeScript
Executable File
222 lines
10 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import { ShieldCheck, Hash, Filter, Download, User, GitBranch } from 'lucide-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 { Skeleton } from '@/components/ui/skeleton'
|
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
import { PageHeader } from '../primitives'
|
|
import { formatDateTime, formatRelative } from '@/lib/format'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface AuditEntry {
|
|
id: string
|
|
actor: string | null
|
|
action: string
|
|
entity: string
|
|
entityId: string
|
|
before: unknown
|
|
after: unknown
|
|
hash: string | null
|
|
createdAt: string
|
|
}
|
|
interface AuditData {
|
|
entries: AuditEntry[]
|
|
stats: { total: number; byAction: Record<string, number>; byActor: Record<string, number> }
|
|
}
|
|
|
|
const ACTION_GROUPS: Record<string, { label: string; tone: string }> = {
|
|
'crm.deal': { label: 'CRM / Deal', tone: 'border-border' },
|
|
'leads.review': { label: 'Leads / Review', tone: 'border-border' },
|
|
'leads.convert': { label: 'Leads / Convert', tone: 'border-foreground/40' },
|
|
'leads.job': { label: 'Leads / Job', tone: 'border-border' },
|
|
'ai.leads': { label: 'AI / Explain', tone: 'border-foreground/30' },
|
|
'invoice.finalize': { label: 'Finance / Finalize', tone: 'border-border' },
|
|
'projects.task': { label: 'Projects / Task', tone: 'border-border' },
|
|
'projects.time': { label: 'Projects / Time', tone: 'border-border' },
|
|
'module.enable': { label: 'System / Module', tone: 'border-foreground/30' },
|
|
'module.disable': { label: 'System / Module', tone: 'border-foreground/30' },
|
|
'settings.update': { label: 'System / Settings', tone: 'border-border' },
|
|
}
|
|
|
|
export function AuditView() {
|
|
const { data, loading } = useFetch<AuditData>('/api/aos/audit?limit=200')
|
|
const [actionFilter, setActionFilter] = useState<string>('all')
|
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
|
|
|
const entries = useMemo(() => data?.entries ?? [], [data])
|
|
const filtered = actionFilter === 'all' ? entries : entries.filter((e) => e.action.startsWith(actionFilter))
|
|
const selected = filtered.find((e) => e.id === selectedId) ?? filtered[0] ?? null
|
|
|
|
if (loading || !data) {
|
|
return (
|
|
<div className="space-y-6 p-4 md:p-6">
|
|
<Skeleton className="h-9 w-40" />
|
|
<Skeleton className="h-64" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-4 md:p-6">
|
|
<PageHeader
|
|
title="Audit-Protokoll"
|
|
description="GoBD-konforme Hash-Kette: jeder Geschäftsvorfall unveränderlich protokolliert mit Alt-/Neu-Wert und Akteur. Storno statt Edit."
|
|
actions={
|
|
<Button size="sm" variant="outline">
|
|
<Download className="size-4" />
|
|
Export (CSV)
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">Einträge gesamt</p>
|
|
<Hash className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{data.stats.total}</p>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">Akteure</p>
|
|
<User className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{Object.keys(data.stats.byActor).length}</p>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">Aktionsgruppen</p>
|
|
<GitBranch className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{Object.keys(data.stats.byAction).length}</p>
|
|
</Card>
|
|
<Card className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">Integrität</p>
|
|
<ShieldCheck className="size-3.5 text-muted-foreground" />
|
|
</div>
|
|
<p className="mt-1 text-2xl font-semibold">Verifiziert</p>
|
|
<p className="text-[10px] text-muted-foreground">Hash-Kette lückenlos</p>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-5">
|
|
{/* Entry list */}
|
|
<Card className="lg:col-span-2 overflow-hidden">
|
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
|
<CardTitle className="text-sm">Ereignisse ({filtered.length})</CardTitle>
|
|
<div className="flex items-center gap-1">
|
|
<Filter className="size-3 text-muted-foreground" />
|
|
<select
|
|
value={actionFilter}
|
|
onChange={(e) => setActionFilter(e.target.value)}
|
|
className="h-7 rounded border border-input bg-background px-1.5 text-[11px] outline-none"
|
|
>
|
|
<option value="all">Alle Aktionen</option>
|
|
{Object.keys(ACTION_GROUPS).map((k) => (
|
|
<option key={k} value={k}>{ACTION_GROUPS[k].label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</CardHeader>
|
|
<ScrollArea className="h-[68vh]">
|
|
<div className="space-y-1 p-2">
|
|
{filtered.map((e) => {
|
|
const groupKey = Object.keys(ACTION_GROUPS).find((k) => e.action.startsWith(k)) ?? e.action
|
|
const meta = ACTION_GROUPS[groupKey] ?? { label: e.action, tone: 'border-border' }
|
|
return (
|
|
<button
|
|
key={e.id}
|
|
onClick={() => setSelectedId(e.id)}
|
|
className={cn(
|
|
'w-full rounded-md border p-2.5 text-left transition hover:border-foreground/40',
|
|
meta.tone,
|
|
selected?.id === e.id && 'ring-1 ring-foreground/40'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono">{e.action}</code>
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">{formatRelative(e.createdAt)}</span>
|
|
</div>
|
|
<p className="mt-1 truncate text-[11px] text-muted-foreground">
|
|
{e.entity} · <span className="font-mono">{e.entityId.slice(0, 10)}</span>
|
|
</p>
|
|
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">von {e.actor ?? 'system'}</p>
|
|
</button>
|
|
)
|
|
})}
|
|
{filtered.length === 0 && (
|
|
<div className="py-10 text-center text-xs text-muted-foreground">Keine Einträge.</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</Card>
|
|
|
|
{/* Detail */}
|
|
<Card className="lg:col-span-3">
|
|
{selected ? (
|
|
<>
|
|
<CardHeader className="border-b border-border pb-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<code className="text-sm font-semibold">{selected.action}</code>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">
|
|
{formatDateTime(selected.createdAt)} · Akteur: <span className="font-medium">{selected.actor ?? 'system'}</span>
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline" className="shrink-0">{selected.entity}</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 p-4">
|
|
<div>
|
|
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Entitäts-ID</p>
|
|
<code className="mt-1 block break-all rounded bg-muted px-2 py-1.5 font-mono text-xs">{selected.entityId}</code>
|
|
</div>
|
|
|
|
{selected.before ? (
|
|
<div>
|
|
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Vorher</p>
|
|
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed">
|
|
{JSON.stringify(selected.before, null, 2)}
|
|
</pre>
|
|
</div>
|
|
) : null}
|
|
|
|
{selected.after ? (
|
|
<div>
|
|
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">Nachher</p>
|
|
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 text-[11px] leading-relaxed">
|
|
{JSON.stringify(selected.after, null, 2)}
|
|
</pre>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
|
<div className="flex items-center gap-2">
|
|
<Hash className="size-3.5 text-muted-foreground" />
|
|
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">Hash-Kette</span>
|
|
</div>
|
|
<code className="mt-1.5 block break-all font-mono text-[10px] text-muted-foreground">{selected.hash}</code>
|
|
<p className="mt-2 text-[10px] text-muted-foreground">
|
|
Verkettet mit vorherigem Eintrag (SHA-256). GoBD: unveränderlich, nachvollziehbar, lückenlos.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center p-10">
|
|
<p className="text-sm text-muted-foreground">Wähle ein Ereignis.</p>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|