diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 585dbeb..84f675a 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { Suspense } from "react"; +import { AuditDetail } from "@/components/audit/AuditDetail"; import { AuditFilters } from "@/components/audit/AuditFilters"; import { CARD_CLASS } from "@/components/ui/Card"; import { Pagination } from "@/components/ui/Pagination"; @@ -54,7 +55,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis const [entries, total] = await Promise.all([ base() - .select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details"]) + .select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"]) // Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die // Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten // erscheinen oder auf keiner. @@ -115,7 +116,9 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis entry.target_label )} - {entry.details ?? "–"} + + + ); })} diff --git a/components/audit/AuditDetail.tsx b/components/audit/AuditDetail.tsx new file mode 100644 index 0000000..815d628 --- /dev/null +++ b/components/audit/AuditDetail.tsx @@ -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 leer; + } + return {text}; +} + +export function AuditDetail({ eintrag }: { eintrag: AuditEintrag }) { + const [offen, setOffen] = useState(false); + const anzahl = eintrag.changes?.length ?? 0; + + return ( + <> + + + setOffen(false)} + title={eintrag.target_label} + subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`} + > +
+
Aktion
+
+ + {eintrag.action} + +
+
Benutzer:in
+
{eintrag.actor_name}
+
Zeitpunkt
+
{zeitFormat.format(new Date(eintrag.occurred_at))}
+ {eintrag.target_employee_id && ( + <> +
Objekt
+
+ + {eintrag.target_label} + +
+ + )} +
+ + {eintrag.details && ( +

{eintrag.details}

+ )} + +

Geänderte Felder

+ {anzahl > 0 ? ( +
+ + + + + + + + + + {eintrag.changes!.map((c, i) => ( + + + + + + ))} + +
FeldVorherNachher
{c.feld} + + + +
+
+ ) : ( + // Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig, + // damit niemand einen Fehler vermutet. +

+ 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. +

+ )} +
+ + ); +} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 2c80a9c..deac10c 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -12,6 +12,16 @@ export type GenderType = "m" | "w"; export type WorkerType = "Angestellte:r" | "Arbeiter:in"; export type CollectiveAgreement = "Handel" | "Süßwaren"; export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So"; + +/** + * Eine einzelne Feldänderung im Protokoll. + * + * `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ + * über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen + * in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht + * das; gerechnet wird damit nicht. + */ +export type AuditChange = { feld: string; vorher: string | null; nachher: string | null }; export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige"; export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung"; // Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a @@ -267,6 +277,13 @@ export type Database = { target_label: string; target_employee_id: string | null; details: string | null; + /** + * Feldweise Änderungen. Null bei Einträgen aus der Zeit vor + * 20260803140000_audit_changes_detail.sql — dort wurden nur die + * Feldnamen behalten, nicht die Werte, und das lässt sich nicht + * nachliefern. + */ + changes: AuditChange[] | null; }; Insert: { id?: string; @@ -277,6 +294,7 @@ export type Database = { target_label: string; target_employee_id?: string | null; details?: string | null; + changes?: AuditChange[] | null; }; Update: Partial; }; diff --git a/supabase/migrations/20260803140000_audit_changes_detail.sql b/supabase/migrations/20260803140000_audit_changes_detail.sql new file mode 100644 index 0000000..7396ac0 --- /dev/null +++ b/supabase/migrations/20260803140000_audit_changes_detail.sql @@ -0,0 +1,193 @@ +-- Das Protokoll soll sagen, *was* sich geändert hat, nicht nur *welches Feld*. +-- +-- Bisher stand im Audit-Log „Adresse, wirksam ab 30.07.2026". Damit lässt +-- sich nicht nachvollziehen, was vorher dort stand — und genau das ist die +-- Frage, die man einem Personalprotokoll stellt. +-- +-- Die Werte sind im Moment der Änderung beide vorhanden: v_old trägt den +-- alten Datensatz, die Nutzlast den neuen. Sie wurden nur nicht behalten. +-- +-- **Rückwirkend geht das nicht.** Für die bestehenden Einträge wurde +-- Vorher/Nachher nie erfasst; sie bleiben, wie sie sind. + +alter table audit_log add column if not exists changes jsonb; + +comment on column audit_log.changes is + 'Feldweise Änderungen als [{feld, vorher, nachher}]. Null bei Einträgen von vor dieser Migration.'; + +-- ═══ Hilfsfunktion ═══════════════════════════════════════════════ +-- Hängt eine Änderung an, wenn sich der Wert wirklich unterscheidet. +-- +-- Verglichen wird über coalesce auf Leerstring: null und '' sind in diesem +-- Schema beide „nicht gesetzt", und ein Wechsel zwischen beiden ist keine +-- Änderung, die jemanden interessiert. +create or replace function app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text) +returns jsonb +language sql +immutable +set search_path = public, pg_temp +as $$ + select case + when coalesce(p_vorher, '') = coalesce(p_nachher, '') then p_liste + else p_liste || jsonb_build_object('feld', p_feld, 'vorher', p_vorher, 'nachher', p_nachher) + end; +$$; + +-- Die Feldnamen aus einer Änderungsliste — für die Kurzfassung in `details`, +-- damit bestehende Ansichten unverändert weiterlaufen. +create or replace function app_aenderungsfelder(p_liste jsonb) +returns text +language sql +immutable +set search_path = public, pg_temp +as $$ + select string_agg(x->>'feld', ', ') from jsonb_array_elements(coalesce(p_liste, '[]'::jsonb)) x; +$$; + +-- ═══ Stammdaten- und Vertragsänderung ════════════════════════════ +create or replace function change_employee_data(payload jsonb) +returns void +language plpgsql +set search_path = 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 ? '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_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), + 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) + 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) + values (v_employee_id, v_effective_date, 'Stammdatenänderung', + 'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date); + 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) + values (v_employee_id, v_effective_date, 'Vertragsänderung', + 'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date); + 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$; + +-- ═══ Gegenprobe ══════════════════════════════════════════════════ +-- Die Hilfsfunktion muss Gleiches übergehen und Ungleiches behalten — +-- inklusive des Falls null gegen Leerstring, der sonst als Änderung im +-- Protokoll landet und niemandem etwas sagt. +do $$ +declare v jsonb; +begin + v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Wien'); + if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() meldet eine Änderung, wo keine ist.'; end if; + + v := app_aenderung('[]'::jsonb, 'Ort', null, ''); + if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() wertet null gegen Leerstring als Änderung.'; end if; + + v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Graz'); + if jsonb_array_length(v) <> 1 or v->0->>'vorher' <> 'Wien' or v->0->>'nachher' <> 'Graz' then + raise exception 'app_aenderung() hält Vorher/Nachher nicht fest.'; + end if; + + if app_aenderungsfelder(v) <> 'Ort' then raise exception 'app_aenderungsfelder() liefert die Feldnamen nicht.'; end if; +end; +$$;