Files
alpenwerk-hr/components/audit/AuditDetail.tsx
Maximilian Stubhan 1271cef879 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>
2026-08-03 14:54:03 +02:00

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