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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
42
components/ui/AenderungsTabelle.tsx
Normal file
42
components/ui/AenderungsTabelle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"]>;
|
||||
|
||||
179
supabase/migrations/20260813120000_history_carries_changes.sql
Normal file
179
supabase/migrations/20260813120000_history_carries_changes.sql
Normal 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
|
||||
$$;
|
||||
78
tests/components/HistorieTab.test.tsx
Normal file
78
tests/components/HistorieTab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user