WFDASH-WEBAPP/src/components/audit-timeline.tsx
Dave 7555196166 feat(audit): implement audit trail for delivery notes and orders
- Added an audit trail feature that logs every create, update, and delete action on delivery notes and orders, capturing detailed changes per field.
- Introduced the `AuditTimeline` component to display the history of changes for delivery notes and orders, enhancing traceability.
- Updated the `page.tsx` files for delivery notes and orders to fetch and display audit logs, ensuring users can view the history of modifications.
- Enhanced the `package.json` with a new migration script for setting up the audit log functionality.

This feature improves accountability and transparency in the management of delivery notes and orders.
2026-09-30 01:48:44 +02:00

254 lines
8.7 KiB
TypeScript
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.

"use client";
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, FileText, History, Loader2, RefreshCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
MARKUP_FIELDS,
PRICE_FIELDS,
SOURCE_LABELS,
describeAuditEntry,
fieldLabel,
formatAuditValue,
type AuditEntry,
type AuditParentType,
type AuditTrail,
} from "@/lib/audit-format";
import getPocketBase from "@/lib/pocketbase";
import { cn } from "@/lib/utils";
async function fetchAuditTrail(parentType: AuditParentType, parentId: string): Promise<AuditTrail> {
const token = getPocketBase().authStore.token;
if (!token) throw new Error("Your session has expired. Please sign in again.");
const res = await fetch(
`/api/v1/audit/${parentType}/${encodeURIComponent(parentId)}`,
{ headers: { Authorization: token }, cache: "no-store" },
);
const body = (await res.json().catch(() => ({}))) as AuditTrail & { error?: string };
if (!res.ok) throw new Error(body.error || `Failed to load history (${res.status})`);
return body;
}
function formatWhen(iso: string): string {
// PB timestamps come back as "2026-09-25 09:03:16.340Z".
const d = new Date(iso.replace(" ", "T"));
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("en-ZA", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function isPricing(entry: AuditEntry): boolean {
return (
entry.action === "pdf_generated" ||
entry.changes.some((c) => PRICE_FIELDS.has(c.field) || MARKUP_FIELDS.has(c.field))
);
}
function ChangeLine({ field, from, to, appended }: AuditEntry["changes"][number]) {
const pricing = PRICE_FIELDS.has(field) || MARKUP_FIELDS.has(field);
return (
<li className="flex flex-wrap items-baseline gap-x-1.5 text-xs">
<span className={cn("text-muted-foreground", pricing && "font-medium text-foreground")}>
{fieldLabel(field)}
{appended ? " +" : ":"}
</span>
{appended ? (
<span className="break-words">{formatAuditValue(field, to)}</span>
) : (
<>
{from !== null && from !== undefined && from !== "" && (
<>
<span className="line-through text-muted-foreground break-words">
{formatAuditValue(field, from)}
</span>
<span className="text-muted-foreground">→</span>
</>
)}
<span className="break-words">{formatAuditValue(field, to)}</span>
</>
)}
</li>
);
}
function EntryRow({ entry }: { entry: AuditEntry }) {
const isPdf = entry.action === "pdf_generated";
return (
<li
className={cn(
"rounded-md border px-3 py-2 space-y-1",
entry.afterPdf !== null && "border-warn/40 bg-warn/5",
isPdf && "border-primary/30 bg-primary/5",
)}
data-testid="audit-entry"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 text-sm min-w-0">
{isPdf && <FileText className="h-3.5 w-3.5 shrink-0" aria-hidden />}
<span className="truncate">{describeAuditEntry(entry)}</span>
</span>
<span className="text-xs tabular-nums text-muted-foreground shrink-0">
{formatWhen(entry.created)}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{entry.actorName}</span>
{entry.source && entry.source !== "web" && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{SOURCE_LABELS[entry.source] ?? entry.source}
</Badge>
)}
{entry.afterPdf !== null && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-warn border-warn/40 gap-1">
<AlertTriangle className="h-3 w-3" aria-hidden />
after PDF #{entry.afterPdf}
</Badge>
)}
</div>
{entry.changes.length > 0 && !isPdf && (
<ul className="space-y-0.5 pt-0.5">
{entry.changes.map((c) => (
<ChangeLine key={c.field} {...c} />
))}
</ul>
)}
</li>
);
}
/**
* Change history for a quote / order / delivery note: who changed what
* and when, with summary counts (PDFs generated, price / markup edits,
* edits made after a PDF went out). Data comes from the `audit_log`
* PB collection via GET /api/v1/audit/{type}/{id}.
*
* Bump `refreshKey` after the page saves something to reload.
*/
export function AuditTimeline({
parentType,
parentId,
refreshKey = 0,
className,
}: {
parentType: AuditParentType;
parentId: string;
refreshKey?: number;
className?: string;
}) {
const [pricingOnly, setPricingOnly] = useState(false);
const [reloads, setReloads] = useState(0);
const requestKey = `${parentType}:${parentId}:${refreshKey}:${reloads}`;
const [result, setResult] = useState<{ key: string; trail?: AuditTrail; error?: string } | null>(null);
useEffect(() => {
let cancelled = false;
fetchAuditTrail(parentType, parentId).then(
(trail) => {
if (!cancelled) setResult({ key: requestKey, trail });
},
(err: unknown) => {
if (!cancelled) {
setResult({ key: requestKey, error: err instanceof Error ? err.message : "Failed to load history" });
}
},
);
return () => {
cancelled = true;
};
}, [parentType, parentId, requestKey]);
const loading = result?.key !== requestKey;
const trail = result?.trail ?? null;
const error = result?.error ?? "";
const visible = useMemo(
() => (trail ? (pricingOnly ? trail.entries.filter(isPricing) : trail.entries) : []),
[trail, pricingOnly],
);
const summary = trail?.summary;
return (
<div className={cn("space-y-3", className)} data-testid="audit-timeline">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<History className="h-3.5 w-3.5" aria-hidden />
Change history
</span>
<div className="flex items-center gap-1">
{parentType !== "delivery_note" && (
<Button
variant={pricingOnly ? "secondary" : "ghost"}
size="sm"
className="h-7 text-xs"
onClick={() => setPricingOnly((v) => !v)}
aria-pressed={pricingOnly}
>
Pricing only
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setReloads((n) => n + 1)}
aria-label="Refresh history"
disabled={loading}
>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{trail && !trail.enabled && (
<p className="text-xs text-muted-foreground">
The audit trail isn&apos;t switched on yet: the <code>audit_log</code> collection and PocketBase hook
still need to be installed. Changes will be recorded from then on.
</p>
)}
{summary && trail?.enabled && (
<div className="flex flex-wrap gap-1.5 text-xs" data-testid="audit-summary">
{parentType === "quote" && (
<Badge variant="outline">PDF generated {summary.pdfCount}×</Badge>
)}
<Badge variant="outline">Price edits: {summary.priceChanges}</Badge>
<Badge variant="outline">Markup edits: {summary.markupChanges}</Badge>
{summary.changesAfterPdf > 0 && (
<Badge variant="outline" className="text-warn border-warn/40 gap-1">
<AlertTriangle className="h-3 w-3" aria-hidden />
{summary.changesAfterPdf} pricing edit{summary.changesAfterPdf === 1 ? "" : "s"} after a PDF
</Badge>
)}
{summary.editors.length > 0 && (
<span className="text-muted-foreground self-center">
Edited by {summary.editors.map((e) => `${e.name} (${e.count})`).join(", ")}
</span>
)}
</div>
)}
{trail?.enabled && visible.length === 0 && !loading && (
<p className="text-xs text-muted-foreground">
{pricingOnly ? "No pricing changes recorded." : "No changes recorded yet."}
</p>
)}
{visible.length > 0 && (
<ul className="space-y-2" data-testid="audit-entries">
{visible.map((entry) => (
<EntryRow key={entry.id} entry={entry} />
))}
</ul>
)}
</div>
);
}