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>
141 lines
5.4 KiB
TypeScript
141 lines
5.4 KiB
TypeScript
"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>
|
||
</>
|
||
);
|
||
}
|