Let the history say what an address was before

HR reported it from testing: change someone's address and their history
shows "Geänderte Felder: Adresse, Ort" — the new address is on the
Stammdaten tab, the old one is nowhere. It was recorded, but only in the
audit log, which is a different page sorted by time and actor rather
than by person. So you had to already know what you were looking for to
find out whether an address had ever changed, let alone what it used to
be.

The field-by-field diff was being built anyway and written to the audit
log. employee_history now carries the same list, and the person's
history renders it as an expandable Feld / Vorher / Nachher table — the
same table the audit log uses, lifted into a shared component so the two
views don't drift into reading differently. It expands with <details>,
so the values are in the page: findable with Ctrl+F, present when
printed, no script involved.

The duplication with audit_log is deliberate. A person's history should
be readable on its own, including after the log is eventually thinned by
a retention rule.

Rows written before today stay without values. They could only be
reconstructed from the audit log, and the link is not reliable — no key,
only a timestamp and a person. Honestly empty beats plausibly wrong.

The migration was generated from the live function definition rather
than retyped, and the diff is four lines: two column lists, two value
lists. It carries a self-check that raises if either insert failed to
pick up the new column, and it was rehearsed inside a rolled-back
transaction against real data first — the probe confirmed the old street
name lands in the history row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 17:57:10 +02:00
parent 00df973824
commit 5f50cb97f3
6 changed files with 336 additions and 31 deletions

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useState } from "react";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { SlideOver } from "@/components/ui/SlideOver";
import { actionBadgeStyle } from "@/lib/colors";
import type { AuditChange } from "@/lib/supabase/types";
@@ -33,14 +34,6 @@ const zeitFormat = new Intl.DateTimeFormat("de-AT", {
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;
@@ -101,29 +94,8 @@ export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) {
<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 className="mt-2">
<AenderungsTabelle changes={eintrag.changes!} />
</div>
) : (
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,

View File

@@ -1,9 +1,22 @@
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
// Die Geschichte einer Person — und zwar aufklappbar bis auf die Werte.
//
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
// vorher dort stand. Die alte Adresse lag allein im Protokoll, und das ist
// eine andere Seite, nach Zeitpunkt sortiert statt nach Person — man hätte
// also erst wissen müssen, wonach man sucht.
//
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
// Ausdruck sichtbar, und es braucht kein Skript dafür.
export function HistorieTab({ history }: { history: HistoryRow[] }) {
const today = todayIso();
@@ -15,6 +28,7 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) {
<ul className="flex flex-col divide-y divide-border">
{history.map((h) => {
const isFuture = h.event_date > today;
const changes = h.changes ?? [];
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
@@ -27,6 +41,23 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) {
)}
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
{changes.length > 0 && (
<details className="group mt-1.5">
<summary
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
›
</span>
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
</summary>
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
<AenderungsTabelle changes={changes} />
</div>
</details>
)}
</li>
);
})}

View File

@@ -0,0 +1,42 @@
import type { AuditChange } from "@/lib/supabase/types";
// Was sich geändert hat, feldweise — im Protokoll und in der Historie einer
// Person dieselbe Darstellung. Zwei Ansichten derselben Sache verschieden zu
// setzen, hiesse dass jemand sie zweimal lesen lernen muss.
/** 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 AenderungsTabelle({ changes }: { changes: AuditChange[] }) {
return (
<div className="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>
{changes.map((c, i) => (
<tr key={`${c.feld}-${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>
);
}

View File

@@ -238,6 +238,8 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
/** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */
changes: AuditChange[] | null;
created_at: string;
};
Insert: {
@@ -246,6 +248,7 @@ export type Database = {
event_date: string;
event_type: HistoryEventType;
description: string;
changes?: AuditChange[] | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;

View File

@@ -0,0 +1,179 @@
-- Die Historie trägt die Werte mit, nicht nur die Feldnamen.
--
-- Beim Testen fiel auf: ändert jemand eine Adresse, steht in der Historie der
-- Person nur "Geänderte Felder: Adresse, Ort". Die alte Adresse ist nirgends
-- zu sehen — sie steckt allein im Audit-Log, und das ist eine andere Seite,
-- nach Zeitpunkt und handelnder Person sortiert statt nach Person. Wer wissen
-- will, ob eine Anschrift je geändert wurde und wie sie vorher lautete, kommt
-- also nicht hin, obwohl die Anwendung es weiß.
--
-- Die Feldliste wird beim Ändern ohnehin gebaut (app_aenderung) und ins
-- Audit-Log geschrieben. Sie wandert jetzt zusätzlich in die Historienzeile.
-- Das ist bewusst redundant: die Historie ist die Geschichte *einer Person*
-- und soll für sich allein lesbar sein — auch dann noch, wenn das Protokoll
-- irgendwann nach Aufbewahrungsfrist ausgedünnt wird.
--
-- Alte Zeilen bleiben ohne Werte. Nachliefern ließe sich das nur aus dem
-- Audit-Log, und die Zuordnung dorthin ist nicht eindeutig (kein Schlüssel,
-- nur Zeitpunkt und Person). Lieber ehrlich leer als falsch verknüpft.
alter table employee_history add column if not exists changes jsonb;
comment on column employee_history.changes is
'Feldweise Änderungen als [{feld, vorher, nachher}] — dieselbe Form wie audit_log.changes. Null bei Ereignissen ohne Einzelfelder (Eintritt, Austritt, Import) und bei Zeilen von vor dieser Migration.';
CREATE OR REPLACE FUNCTION public.change_employee_data(payload jsonb)
RETURNS void
LANGUAGE plpgsql
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_old employees%rowtype;
v_name text;
v_person_changes jsonb := '[]'::jsonb;
v_contract_changes jsonb := '[]'::jsonb;
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days text[];
v_new_title_prefix text[];
v_new_title_suffix text[];
begin
perform require_hr_admin();
select * into v_old from employees where id = v_employee_id;
v_name := v_old.first_name || ' ' || v_old.last_name;
v_immediate := v_effective_date <= current_date;
-- Der `?`-Test bleibt: ein fehlender Schlüssel heisst „nicht übermittelt",
-- nicht „geleert". Ohne ihn würde jedes nicht gesendete Feld als Änderung
-- auf null gemeldet.
if v_person ? 'first_name' then v_person_changes := app_aenderung(v_person_changes, 'Vorname', v_old.first_name, v_person->>'first_name'); end if;
if v_person ? 'last_name' then v_person_changes := app_aenderung(v_person_changes, 'Nachname', v_old.last_name, v_person->>'last_name'); end if;
if v_person ? 'gender' then v_person_changes := app_aenderung(v_person_changes, 'Geschlecht', v_old.gender::text, v_person->>'gender'); end if;
-- Datumswerte über ::date::text vergleichen, damit „2026-8-3" und
-- „2026-08-03" nicht als Änderung gelten.
if v_person ? 'birth_date' then v_person_changes := app_aenderung(v_person_changes, 'Geburtsdatum', v_old.birth_date::text, (nullif(v_person->>'birth_date','')::date)::text); end if;
if v_person ? 'sv_nummer' then v_person_changes := app_aenderung(v_person_changes, 'SV-Nummer', v_old.sv_nummer, v_person->>'sv_nummer'); end if;
if v_person ? 'nationality' then v_person_changes := app_aenderung(v_person_changes, 'Staatsbürgerschaft', v_old.nationality, v_person->>'nationality'); end if;
if v_person ? 'address' then v_person_changes := app_aenderung(v_person_changes, 'Adresse', v_old.address, v_person->>'address'); end if;
if v_person ? 'postal_code' then v_person_changes := app_aenderung(v_person_changes, 'Postleitzahl', v_old.postal_code, v_person->>'postal_code'); end if;
if v_person ? 'city' then v_person_changes := app_aenderung(v_person_changes, 'Ort', v_old.city, v_person->>'city'); end if;
if v_person ? 'address_country' then v_person_changes := app_aenderung(v_person_changes, 'Land', v_old.address_country, v_person->>'address_country'); end if;
if v_person ? 'email' then v_person_changes := app_aenderung(v_person_changes, 'E-Mail', v_old.email, v_person->>'email'); end if;
if v_person ? 'phone' then v_person_changes := app_aenderung(v_person_changes, 'Telefon', v_old.phone, v_person->>'phone'); end if;
if v_person ? 'emergency_contact_name' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt', v_old.emergency_contact_name, v_person->>'emergency_contact_name'); end if;
if v_person ? 'emergency_contact_phone' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Telefon', v_old.emergency_contact_phone, v_person->>'emergency_contact_phone'); end if;
if v_person ? 'emergency_contact_relation' then v_person_changes := app_aenderung(v_person_changes, 'Notfallkontakt Verhältnis', v_old.emergency_contact_relation, v_person->>'emergency_contact_relation'); end if;
if v_person ? 'title_prefix' then
v_new_title_prefix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_prefix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (vorangestellt)',
array_to_string(v_old.title_prefix, ', '), array_to_string(v_new_title_prefix, ', '));
end if;
if v_person ? 'title_suffix' then
v_new_title_suffix := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_person->'title_suffix') elem), '{}');
v_person_changes := app_aenderung(v_person_changes, 'Titel (nachgestellt)',
array_to_string(v_old.title_suffix, ', '), array_to_string(v_new_title_suffix, ', '));
end if;
if v_contract ? 'employment_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_contract->>'employment_type'); end if;
-- Über ::numeric::text, damit „38.50" und „38.5" gleich zählen.
if v_contract ? 'weekly_hours' then v_contract_changes := app_aenderung(v_contract_changes, 'Wochenstunden', v_old.weekly_hours::text, (nullif(v_contract->>'weekly_hours','')::numeric)::text); end if;
if v_contract ? 'contract_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Vertragsart', v_old.contract_type::text, v_contract->>'contract_type'); end if;
if v_contract ? 'contract_end_date' then v_contract_changes := app_aenderung(v_contract_changes, 'Befristet bis', v_old.contract_end_date::text, (nullif(v_contract->>'contract_end_date','')::date)::text); end if;
if v_role ? 'worker_type' then v_contract_changes := app_aenderung(v_contract_changes, 'Angestellte:r/Arbeiter:in', v_old.worker_type::text, v_role->>'worker_type'); end if;
if v_role ? 'collective_agreement' then v_contract_changes := app_aenderung(v_contract_changes, 'Kollektivvertrag', v_old.collective_agreement::text, v_role->>'collective_agreement'); end if;
if v_role ? 'work_days' then
v_new_work_days := coalesce((select array_agg(elem) from jsonb_array_elements_text(v_role->'work_days') elem), '{}');
v_contract_changes := app_aenderung(v_contract_changes, 'Arbeitstage',
array_to_string(v_old.work_days, ', '), array_to_string(v_new_work_days, ', '));
end if;
if v_role ? 'is_betriebsrat' then v_contract_changes := app_aenderung(v_contract_changes, 'Betriebsrat', v_old.is_betriebsrat::text, v_role->>'is_betriebsrat'); end if;
if v_role ? 'has_dienstwagen' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen', v_old.has_dienstwagen::text, v_role->>'has_dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' then v_contract_changes := app_aenderung(v_contract_changes, 'Laterale Führung', v_old.is_laterale_fuehrung::text, v_role->>'is_laterale_fuehrung'); end if;
if v_role ? 'is_c_level' then v_contract_changes := app_aenderung(v_contract_changes, 'C-Level', v_old.is_c_level::text, v_role->>'is_c_level'); end if;
if v_role ? 'dienstwagen_art' then v_contract_changes := app_aenderung(v_contract_changes, 'Dienstwagen Antrieb', v_old.dienstwagen_art, nullif(v_role->>'dienstwagen_art', '')); end if;
if v_immediate then
update employees set
first_name = coalesce(v_person->>'first_name', first_name),
last_name = coalesce(v_person->>'last_name', last_name),
gender = coalesce((v_person->>'gender')::gender_type, gender),
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
nationality = coalesce(v_person->>'nationality', nationality),
address = coalesce(v_person->>'address', address),
postal_code = coalesce(v_person->>'postal_code', postal_code),
city = coalesce(v_person->>'city', city),
address_country = coalesce(v_person->>'address_country', address_country),
email = coalesce(v_person->>'email', email),
phone = coalesce(v_person->>'phone', phone),
emergency_contact_name = case when v_person ? 'emergency_contact_name' then nullif(v_person->>'emergency_contact_name', '') else emergency_contact_name end,
emergency_contact_phone = case when v_person ? 'emergency_contact_phone' then nullif(v_person->>'emergency_contact_phone', '') else emergency_contact_phone end,
emergency_contact_relation = case when v_person ? 'emergency_contact_relation' then nullif(v_person->>'emergency_contact_relation', '') else emergency_contact_relation end,
title_prefix = case when v_person ? 'title_prefix' then v_new_title_prefix else title_prefix end,
title_suffix = case when v_person ? 'title_suffix' then v_new_title_suffix else title_suffix end,
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_role->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_role->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_role ? 'work_days' then v_new_work_days else work_days end,
is_betriebsrat = coalesce((v_role->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_role->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_role->>'is_c_level')::boolean, is_c_level),
dienstwagen_art = case
when coalesce((v_role->>'has_dienstwagen')::boolean, has_dienstwagen) then
coalesce(nullif(v_role->>'dienstwagen_art', ''), dienstwagen_art, 'Verbrenner')
else null
end
where id = v_employee_id;
elsif jsonb_array_length(v_person_changes) > 0 or jsonb_array_length(v_contract_changes) > 0 then
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'contract_change', v_effective_date, payload);
end if;
if jsonb_array_length(v_person_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id,
app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date, v_person_changes);
end if;
if jsonb_array_length(v_contract_changes) > 0 then
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_effective_date, 'Vertragsänderung',
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details, changes)
values (app_current_user_id(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id,
app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date, v_contract_changes);
end if;
end;
$function$;
-- Selbstprüfung: schlägt die Migration fehl, statt still nichts zu tun.
do $$
declare
v_def text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure);
begin
if not exists (
select 1 from information_schema.columns
where table_schema = 'public' and table_name = 'employee_history' and column_name = 'changes'
) then
raise exception 'employee_history.changes fehlt';
end if;
if (length(v_def) - length(replace(v_def, 'description, changes)', ''))) / length('description, changes)') <> 2 then
raise exception 'change_employee_data schreibt changes nicht in beide Historien-Einträge';
end if;
end
$$;

View File

@@ -0,0 +1,78 @@
import { render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { HistorieTab } from "@/components/employees/tabs/HistorieTab";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
// Der Anlass: eine Adressänderung war in der Historie der Person nur als
// „Geänderte Felder: Adresse" zu sehen. Die alte Anschrift stand allein im
// Protokoll — einer nach Zeitpunkt sortierten Seite, auf der man erst wissen
// muss, wonach man sucht. Damit war nicht nachvollziehbar, ob und wie sich
// eine Adresse je geändert hat.
function eintrag(teil: Partial<HistoryRow>): HistoryRow {
return {
id: "h1",
employee_id: "e1",
event_date: "2026-03-02",
event_type: "Stammdatenänderung",
description: "Geänderte Felder: Adresse, Ort, wirksam ab 2026-03-02",
changes: null,
created_at: "2026-03-02T08:00:00.000Z",
...teil,
};
}
const ADRESSWECHSEL = eintrag({
changes: [
{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" },
{ feld: "Ort", vorher: "Brno", nachher: "Linz" },
],
});
describe("HistorieTab", () => {
it("zeigt die alte Anschrift, nicht nur die neue", () => {
render(<HistorieTab history={[ADRESSWECHSEL]} />);
expect(screen.getByText("Feldweg 66")).toBeInTheDocument();
expect(screen.getByText("Bahnhofstrasse 5")).toBeInTheDocument();
});
it("stellt Vorher und Nachher je Feld gegenüber", () => {
render(<HistorieTab history={[ADRESSWECHSEL]} />);
const zeile = screen.getByText("Adresse").closest("tr")!;
const zellen = within(zeile).getAllByRole("cell");
expect(zellen[1]).toHaveTextContent("Feldweg 66");
expect(zellen[2]).toHaveTextContent("Bahnhofstrasse 5");
});
it("nennt die Zahl der Felder am Aufklapper", () => {
render(<HistorieTab history={[ADRESSWECHSEL]} />);
expect(screen.getByText(/2 Felder im Detail/)).toBeInTheDocument();
});
it("benennt ein einzelnes Feld im Singular", () => {
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Ort", vorher: "Brno", nachher: "Linz" }] })]} />);
expect(screen.getByText(/1 Feld im Detail/)).toBeInTheDocument();
});
it("macht aus einem leeren Vorher-Wert eine Aussage statt einer Lücke", () => {
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Telefon", vorher: null, nachher: "0664 1234567" }] })]} />);
const zeile = screen.getByText("Telefon").closest("tr")!;
expect(within(zeile).getAllByRole("cell")[1]).toHaveTextContent("leer");
});
it("hängt keinen Aufklapper an Ereignisse ohne Feldwerte", () => {
// Eintritt, Austritt und die Zeilen von vor dieser Erweiterung haben
// keine Werte. Ein leerer Aufklapper würde einen Fehler vermuten lassen.
render(<HistorieTab history={[eintrag({ event_type: "Eintritt", description: "Eintritt als Elektrotechniker:in" })]} />);
expect(screen.getByText("Eintritt als Elektrotechniker:in")).toBeInTheDocument();
expect(screen.queryByText(/im Detail/)).not.toBeInTheDocument();
expect(document.querySelector("details")).toBeNull();
});
it("bleibt bei leerer Historie bei einem Satz", () => {
render(<HistorieTab history={[]} />);
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
});
});