Files
craftvia/src/components/audit-trail.tsx
T
msolarczekandClaude Opus 5 952e74cddd L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests
- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente,
  eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit,
  Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen,
  Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads
  (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle
- services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung,
  Registry für Ops anderer Lanes, lane-lokaler requireApiContext
- /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id]
- lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts
- Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3),
  getSiteHistory (L1)
- Processor image-derivatives + Registrierung, Audit-Entity-Labels
- Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:29:48 +02:00

145 lines
4.7 KiB
TypeScript

import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
/**
* Wiederverwendbarer Audit-Trail (Aktivitätsprotokoll) als server-gerendertes
* Popup — gleiches Muster wie die übrigen Popups (searchParam öffnet/schließt,
* kein Client-State). Wird an zwei Stellen eingebunden:
* - Mandanten-Einstellungen (/settings?audit=1): eigener Mandant, RLS-gefiltert.
* - Plattform-Konsole je Mandant (/admin/[id]?audit=1): Superadmin liest den
* Trail des jeweiligen Mandanten cross-tenant über den Owner-Client.
* Die Zeilen werden bereits gefiltert/aufbereitet übergeben — die Komponente
* rendert nur; das hält den Datenpfad (Mandant vs. Plattform) in der Seite.
*/
export type AuditRow = {
id: string;
createdAt: Date;
actorId: string | null;
action: string;
entity: string;
entityId: string | null;
scope: string;
};
const ACTION_LABEL: Record<string, string> = {
create: "Erstellt",
update: "Geändert",
delete: "Gelöscht",
login: "Anmeldung",
logout: "Abmeldung",
denied: "Abgelehnt",
export: "Export",
import: "Import",
approve: "Freigegeben",
reject: "Zurückgewiesen",
};
const ACTION_TONE: Record<string, "ok" | "warn" | "mut" | "info"> = {
create: "ok",
update: "info",
approve: "ok",
delete: "warn",
denied: "warn",
reject: "warn",
login: "mut",
logout: "mut",
};
const ENTITY_LABEL: Record<string, string> = {
user: "Benutzer",
role: "Rolle",
tenant: "Mandant",
tenant_settings: "Einstellungen",
tenant_module: "Modul",
tenant_locale: "Mandantensprache",
tenant_mfa_policy: "MFA-Pflicht",
module: "Modul",
session: "Sitzung",
platformAdmin: "Plattform-Admin",
// Craftvia-Fachobjekte (Labels vorab, Module folgen)
customer: "Kunde",
site: "Objekt",
team: "Team",
work_order: "Auftrag",
import: "Auftragsimport",
report: "Bericht",
emergency: "Notdienst",
document: "Dokument",
// Einsatz mobil (L4)
work_session: "Einsatz-Zeiterfassung", time_entry: "Zeitabschnitt", checklist_item: "Checklistenpunkt", material_usage: "Materialverbrauch", photo: "Foto", voice_note: "Sprachnotiz", activity_note: "Tätigkeitsnotiz", sync_operation: "Sync-Vorgang",
};
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
function actionLabel(a: string) {
return ACTION_LABEL[a] ?? a.charAt(0).toUpperCase() + a.slice(1);
}
function entityLabel(e: string) {
return ENTITY_LABEL[e] ?? e;
}
export function AuditTrailModal({
rows,
actorNames,
closeHref,
sub,
}: {
rows: AuditRow[];
actorNames: Record<string, string>;
closeHref: string;
sub?: string;
}) {
return (
<Modal
title="Audit-Trail"
sub={sub ?? "Nachvollziehbare Aktivitäten (neueste zuerst)"}
closeHref={closeHref}
closeLabel="Schließen"
>
<div className="max-h-[65vh] overflow-y-auto p-5">
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Noch keine Einträge protokolliert.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-[12px] text-muted-foreground">
<th className="py-2 pr-3 font-semibold">Zeitpunkt</th>
<th className="py-2 pr-3 font-semibold">Akteur</th>
<th className="py-2 pr-3 font-semibold">Aktion</th>
<th className="py-2 pr-3 font-semibold">Objekt</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b last:border-0 align-top">
<td className="whitespace-nowrap py-2 pr-3 text-xs text-muted-foreground">
{fmt.format(r.createdAt)}
</td>
<td className="py-2 pr-3">
{r.actorId ? actorNames[r.actorId] ?? "System" : "System"}
</td>
<td className="py-2 pr-3">
<Pill tone={ACTION_TONE[r.action] ?? "mut"}>{actionLabel(r.action)}</Pill>
</td>
<td className="py-2 pr-3">
<span className="font-medium">{entityLabel(r.entity)}</span>
{r.entityId && (
<span className="ml-1 text-[11px] text-muted-foreground">
#{r.entityId.slice(0, 8)}
</span>
)}
{r.scope === "platform" && (
<span className="ml-1 text-[11px] text-muted-foreground">· Plattform</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Modal>
);
}