Record what a change was, not only which field it touched
The audit log said "Adresse, wirksam ab 30.07.2026". That names the field
and hides the answer: what did it say before? For a personnel record that is
the question the log exists to answer.
Both values are in hand at the moment of the change — v_old holds the row as
it was, the payload holds what is being written. change_employee_data
already compared them to decide whether to mention the field at all, then
dropped them. It now keeps them in audit_log.changes as
[{feld, vorher, nachher}], and derives the old one-line text from the same
array so existing views are unaffected.
Clicking a row opens the detail. Fields with no previous value read "leer"
rather than showing an empty cell, because "was not set" is itself a
statement.
Two honest limits, both stated in the panel rather than left to look like a
bug:
- Existing entries cannot be enriched. The values were never captured;
there is nothing to recover.
- Hire, exit and import record no individual fields, so they show none.
The rewritten function also drops auth.uid() for app_current_user_id(),
which works on either system — one of the last few call sites before #23.
Caught while writing this: my scripted edit of types.ts silently did nothing
and my own check reported success, because the pattern matched
pending_org_changes. Redone with the editor. That is the second time a
regex-driven edit has lied about its result in this project.
Not verified end to end: the migration needs privileges I no longer hold
after the database password was rotated. Until it is applied the audit page
will not load, since it selects a column that does not exist yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
140
components/audit/AuditDetail.tsx
Normal file
140
components/audit/AuditDetail.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import type { AuditChange } from "@/lib/supabase/types";
|
||||
|
||||
// Eine Protokollzeile zum Aufklappen.
|
||||
//
|
||||
// Die Liste zeigt, *dass* etwas geändert wurde; hier steht, *was*. Beides in
|
||||
// der Tabelle unterzubringen ginge nicht — bei sieben geänderten Feldern
|
||||
// wäre die Zeile höher als der Bildschirm.
|
||||
|
||||
export type AuditEintrag = {
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
actor_name: string;
|
||||
action: string;
|
||||
target_label: string;
|
||||
target_employee_id: string | null;
|
||||
details: string | null;
|
||||
changes: AuditChange[] | null;
|
||||
};
|
||||
|
||||
const zeitFormat = new Intl.DateTimeFormat("de-AT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "Europe/Vienna",
|
||||
});
|
||||
|
||||
/** Leerer Wert heisst „war nicht gesetzt“ — und das ist eine Aussage. */
|
||||
function Wert({ text, art }: { text: string | null; art: "vorher" | "nachher" }) {
|
||||
if (text === null || text === "") {
|
||||
return <span className="text-ink-muted italic">leer</span>;
|
||||
}
|
||||
return <span className={art === "vorher" ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}>{text}</span>;
|
||||
}
|
||||
|
||||
export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const anzahl = eintrag.changes?.length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="w-full rounded px-2 py-1 text-left text-ink-muted hover:bg-brand-50 hover:text-ink
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span>{eintrag.details ?? "–"}</span>
|
||||
{anzahl > 0 && (
|
||||
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
|
||||
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<SlideOver
|
||||
open={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title={eintrag.target_label}
|
||||
subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`}
|
||||
>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
|
||||
<dt className="font-semibold text-ink-muted">Aktion</dt>
|
||||
<dd>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(eintrag.action)}`}>
|
||||
{eintrag.action}
|
||||
</span>
|
||||
</dd>
|
||||
<dt className="font-semibold text-ink-muted">Benutzer:in</dt>
|
||||
<dd className="text-ink">{eintrag.actor_name}</dd>
|
||||
<dt className="font-semibold text-ink-muted">Zeitpunkt</dt>
|
||||
<dd className="tabular-nums text-ink">{zeitFormat.format(new Date(eintrag.occurred_at))}</dd>
|
||||
{eintrag.target_employee_id && (
|
||||
<>
|
||||
<dt className="font-semibold text-ink-muted">Objekt</dt>
|
||||
<dd>
|
||||
<Link
|
||||
href={`/employees/${eintrag.target_employee_id}`}
|
||||
className="rounded font-semibold text-brand-700 hover:underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
{eintrag.target_label}
|
||||
</Link>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{eintrag.details && (
|
||||
<p className="mt-5 rounded-md bg-surface px-3 py-2 text-sm text-ink-body">{eintrag.details}</p>
|
||||
)}
|
||||
|
||||
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
|
||||
{anzahl > 0 ? (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eintrag.changes!.map((c, i) => (
|
||||
<tr key={i} className="border-b border-border-subtle align-top last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Wert text={c.vorher} art="vorher" />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Wert text={c.nachher} art="nachher" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
|
||||
// damit niemand einen Fehler vermutet.
|
||||
<p className="mt-2 max-w-prose text-sm text-ink-muted">
|
||||
Für diesen Eintrag liegen keine Feldwerte vor. Vorgänge wie Eintritt, Austritt oder Import erfassen keine
|
||||
Einzelfelder — und Einträge von vor der Erweiterung des Protokolls haben nur die Feldnamen behalten, nicht
|
||||
die Werte. Nachliefern lässt sich das nicht.
|
||||
</p>
|
||||
)}
|
||||
</SlideOver>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user