diff --git a/actions/employees.ts b/actions/employees.ts index 8dca97d..a59f96f 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -191,6 +191,26 @@ export async function deleteHistoryEntry(payload: { return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]); } +/** + * Berichtigt Wert und/oder Datum eines Historieneintrags. + * + * Geändert wird nur das „Nachher" — was vor der Änderung galt, ist nicht + * nachträglich beschliessbar. Welcher Wert danach in den Stammdaten steht, + * leitet die Datenbank je Feld aus dem jüngsten Eintrag ab, der es trägt. + */ +export async function updateHistoryEntry(payload: { + history_id: string; + employee_id: string; + event_date?: string; + werte: { feld: string; nachher: string | null }[]; +}): Promise { + return callRpc( + "update_history_entry", + { history_id: payload.history_id, event_date: payload.event_date, werte: payload.werte }, + [`/employees/${payload.employee_id}`, "/audit"] + ); +} + export async function addEmployeeNote(payload: { employee_id: string; category: NoteCategory; diff --git a/components/employees/HistorieBearbeiten.tsx b/components/employees/HistorieBearbeiten.tsx new file mode 100644 index 0000000..2a93609 --- /dev/null +++ b/components/employees/HistorieBearbeiten.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { Pencil } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { updateHistoryEntry } from "@/actions/employees"; +import { Button } from "@/components/ui/Button"; +import { TextField } from "@/components/ui/Field"; +import { Modal } from "@/components/ui/Modal"; +import { useToast } from "@/components/ui/Toast"; +import { fmtDate } from "@/lib/format"; +import type { AuditChange } from "@/lib/supabase/types"; + +// Berichtigen, nicht neu erfassen. +// +// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas +// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang +// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg +// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste +// nie stattgefunden hat. +// +// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder- +// lich: es beschreibt, was vor der Änderung galt, und das lässt sich +// nachträglich nicht anders beschliessen. + +export function HistorieBearbeiten({ + historyId, + employeeId, + bezeichnung, + datum, + changes, + istZukunft, + heute, +}: { + historyId: string; + employeeId: string; + bezeichnung: string; + datum: string; + changes: AuditChange[]; + /** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */ + istZukunft: boolean; + /** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner + * eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts + * verloren. */ + heute: string; +}) { + const [offen, setOffen] = useState(false); + const [laeuft, setLaeuft] = useState(false); + const [neuesDatum, setNeuesDatum] = useState(datum); + const [werte, setWerte] = useState>(() => + Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])) + ); + const { showToast } = useToast(); + const router = useRouter(); + + // Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers. + const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10); + const etwasGeaendert = + neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? "")); + + function abbrechen() { + // Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites + // Öffnen nicht die verworfenen Eingaben zeigt. + setNeuesDatum(datum); + setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))); + setOffen(false); + } + + async function speichern() { + // Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene + // Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse, + // Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die + // fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es + // nur früher und freundlicher. + if (istZukunft && neuesDatum <= heute) { + showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error"); + return; + } + if (!istZukunft && neuesDatum > heute) { + showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error"); + return; + } + setLaeuft(true); + const ergebnis = await updateHistoryEntry({ + history_id: historyId, + employee_id: employeeId, + event_date: neuesDatum, + werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })), + }); + setLaeuft(false); + if (ergebnis.success) { + showToast("Eintrag berichtigt."); + setOffen(false); + router.refresh(); + } else { + showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error"); + } + } + + return ( + <> + + + + + + + } + > +

+ {bezeichnung} + {istZukunft + ? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll." + : " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."} +

+ +
+ +
+ +
+ + + + + + + + + + {changes.map((c) => ( + + + + + + ))} + +
FeldVorherNachher
{c.feld} + {c.vorher === null || c.vorher === "" ? leer : c.vorher} + + setWerte((v) => ({ ...v, [c.feld]: e.target.value }))} + className="w-full rounded border border-border px-2 py-1 text-sm text-ink + focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500" + /> +
+
+ +

+ {istZukunft + ? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst." + : "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "} + Die Berichtigung selbst steht im Protokoll. +

+
+ + ); +} diff --git a/components/employees/HistorieLoeschen.tsx b/components/employees/HistorieLoeschen.tsx index dbb65cc..bfe93d9 100644 --- a/components/employees/HistorieLoeschen.tsx +++ b/components/employees/HistorieLoeschen.tsx @@ -24,12 +24,15 @@ export function HistorieLoeschen({ bezeichnung, datum, vorschau, + istZukunft, }: { historyId: string; employeeId: string; bezeichnung: string; datum: string; vorschau: Vorschau[]; + /** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */ + istZukunft: boolean; }) { const [offen, setOffen] = useState(false); const [laeuft, setLaeuft] = useState(false); @@ -80,10 +83,30 @@ export function HistorieLoeschen({ } >

- {bezeichnung} vom {fmtDate(datum)} wird aus der Historie entfernt. + {bezeichnung} {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der + Historie entfernt.

- {zurueck.length > 0 && ( + {istZukunft && ( +
+

Wird nicht mehr passieren

+
    + {vorschau.map((v) => ( +
  • + {v.feld}{" "} + sollte auf {v.von || "leer"}{" "} + gesetzt werden +
  • + ))} +
+

+ An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang + noch weitere Felder, läuft er mit diesen weiter. +

+
+ )} + + {!istZukunft && zurueck.length > 0 && (

Wird zurückgesetzt

    @@ -98,7 +121,7 @@ export function HistorieLoeschen({
)} - {bleibt.length > 0 && ( + {!istZukunft && bleibt.length > 0 && (

Bleibt unverändert

    diff --git a/components/employees/tabs/HistorieTab.tsx b/components/employees/tabs/HistorieTab.tsx index 695bea6..e5ec93b 100644 --- a/components/employees/tabs/HistorieTab.tsx +++ b/components/employees/tabs/HistorieTab.tsx @@ -1,8 +1,9 @@ +import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten"; import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen"; import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle"; import { actionBadgeStyle } from "@/lib/colors"; import { fmtDate, todayIso } from "@/lib/format"; -import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history"; +import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history"; import type { Database } from "@/lib/supabase/types"; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; @@ -37,7 +38,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em {history.map((h) => { const isFuture = h.event_date > today; const changes = h.changes ?? []; - const urteil = darfGeloeschtWerden(h, today); + const urteil = darfKorrigiertWerden(h, today); return (
  • @@ -49,13 +50,23 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em )} {urteil.erlaubt && ( - + + )} diff --git a/components/ui/useDialogFocus.ts b/components/ui/useDialogFocus.ts index d16cece..2348076 100644 --- a/components/ui/useDialogFocus.ts +++ b/components/ui/useDialogFocus.ts @@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] { export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject) { const restoreToRef = useRef(null); + // onClose wird von den meisten Aufrufern bei jedem Rendern neu erzeugt. + // Stünde es in den Abhängigkeiten, liefe dieser Effekt nach jedem Rendern + // erneut — und sein Aufräumen holt den Fokus dorthin zurück, wo er vor dem + // Öffnen war. In einem Dialog mit Eingabefeldern hiesse das: nach dem + // ersten Zeichen springt der Fokus auf den Knopf, der ihn geöffnet hat, und + // der Rest der Eingabe geht ins Leere. Über eine Referenz bleibt der + // aktuelle Rückruf erreichbar, ohne dass der Effekt daran hängt. + const schliessen = useRef(onClose); + useEffect(() => { + schliessen.current = onClose; + }); + useEffect(() => { if (!open) return; const container = containerRef.current; @@ -39,7 +51,7 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef: function onKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { - onClose(); + schliessen.current(); return; } if (e.key !== "Tab" || !container) return; @@ -74,5 +86,5 @@ export function useDialogFocus(open: boolean, onClose: () => void, containerRef: // there instead of restarting at the top of the page. restoreToRef.current?.focus(); }; - }, [open, onClose, containerRef]); + }, [open, containerRef]); } diff --git a/docs/datenkatalog.md b/docs/datenkatalog.md index 18ce808..64d4229 100644 --- a/docs/datenkatalog.md +++ b/docs/datenkatalog.md @@ -21,7 +21,7 @@ kommt. | Spalten | 142 | | Aufzählungstypen | 10 | | Sichten (Views) | 0 | -| Eigene SQL-Funktionen | 36 (plus 31 aus der Erweiterung `pg_trgm`) | +| Eigene SQL-Funktionen | 38 (plus 31 aus der Erweiterung `pg_trgm`) | | RLS-Policies | 21, auf jeder Tabelle mindestens eine | --- @@ -51,11 +51,12 @@ kein Termin im Kalender, sondern eine Zeile, die erst dann greift. `select` und `insert` zu. Eine Korrektur ist ein neuer Eintrag, nie eine geänderte Zeile. -Eine Ausnahme gibt es, und sie ist eng: `delete_history_entry` nimmt eine +Zwei Ausnahmen gibt es, und sie sind eng: `delete_history_entry` nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung samt ihrer Wirkung -zurück. Die Policies bleiben dabei unangetastet — die Funktion läuft als -`SECURITY DEFINER` an ihnen vorbei und prüft die Berechtigung selbst. Das -Protokoll behält den Vorgang, dort verschwindet nichts. +zurück, `update_history_entry` berichtigt Wert und Datum einer solchen. Die +Policies bleiben dabei unangetastet — beide laufen als `SECURITY DEFINER` an +ihnen vorbei und prüfen die Berechtigung selbst. Das Protokoll behält den +Vorgang, dort verschwindet nichts. Die Namen der OM-Tabellen sind nicht zufällig gewählt: `org_units` ist der SAP-Objekttyp O, `jobs` ist C, `om_positions` ist S, `employees` ist P, und @@ -317,11 +318,15 @@ Transaktion mit. **Planstellen:** `create_position`, `update_position`, `delete_position`, `next_position_number` -**Historie:** `delete_history_entry` — nimmt eine irrtümliche Stammdaten- -oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein -späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile. Der -einzige Weg an der fehlenden `delete`-Policy vorbei, deshalb `SECURITY -DEFINER` und mit `require_hr_admin()` davor. +**Historie:** `delete_history_entry` nimmt eine irrtümliche Stammdaten- oder +Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein +späterer Eintrag dasselbe Feld angefasst hat, und entfernt die Zeile. +`update_history_entry` berichtigt stattdessen Wert und Datum — das „vorher" +bleibt unangetastet, und der heutige Stand wird je Feld aus dem jüngsten +Eintrag abgeleitet, der es trägt. Beide sind der einzige Weg an den fehlenden +`update`- und `delete`-Policies vorbei, deshalb `SECURITY DEFINER` und mit +`require_hr_admin()` davor. `app_feld_karte` liefert beiden die Zuordnung +Beschriftung → Spalte und Typ. **Umfeld:** `add_employee_dependent`, `delete_employee_dependent`, `add_employee_note`, `complete_employee_note` @@ -338,14 +343,15 @@ wen berichtet, samt Vertretung bei Abwesenheit (`acting_manager_id` neben (hängt am Ereignis-Trigger `ensure_rls`: neue Tabellen bekommen sofort RLS), die vier `fn_*`-Trigger, `is_valid_svnr` -Fünf Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer +Sechs Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer Eigentümerin statt der aufrufenden Person: `is_hr_user`, -`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes` und -`delete_history_entry`. Die ersten drei müssen es sein, weil sie sonst gegen -dieselben Policies liefen, die sie gerade auswerten sollen — eine Rekursion. -Die vierte läuft ohne angemeldete Person, es gibt ja nur den Zeitplan. Die -fünfte muss löschen können, wo es absichtlich keine `delete`-Policy gibt — -und prüft die Berechtigung deshalb selbst, in ihrer ersten Zeile. +`app_current_user_id`, `app_upsert_user`, `apply_due_pending_changes`, +`delete_history_entry` und `update_history_entry`. Die ersten drei müssen es +sein, weil sie sonst gegen dieselben Policies liefen, die sie gerade auswerten +sollen — eine Rekursion. Die vierte läuft ohne angemeldete Person, es gibt ja +nur den Zeitplan. Die letzten beiden müssen an `employee_history` schreiben, +wo es absichtlich weder eine `update`- noch eine `delete`-Policy gibt; beide +prüfen die Berechtigung deshalb selbst, in ihrer ersten Zeile. **Der Türsteher:** `require_hr_admin()` steht am Anfang von **16** Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und diff --git a/lib/history.ts b/lib/history.ts index bd769a3..23a7a11 100644 --- a/lib/history.ts +++ b/lib/history.ts @@ -13,15 +13,17 @@ import type { AuditChange, HistoryEventType } from "./supabase/types"; // Läuft eine Seite der anderen davon, gewinnt die Datenbank: sie weist ab, // und die Oberfläche zeigt ihre Begründung. -export type LoeschUrteil = { erlaubt: true } | { erlaubt: false; grund: string }; +export type KorrekturUrteil = { erlaubt: true } | { erlaubt: false; grund: string }; type Eintrag = { event_type: HistoryEventType; event_date: string; changes: AuditChange[] | null; + /** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */ + pending_id?: string | null; }; -export function darfGeloeschtWerden(eintrag: Eintrag, heute: string): LoeschUrteil { +export function darfKorrigiertWerden(eintrag: Eintrag, heute: string): KorrekturUrteil { if (eintrag.event_type === "Eintritt") { return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." }; } @@ -35,10 +37,16 @@ export function darfGeloeschtWerden(eintrag: Eintrag, heute: string): LoeschUrte }; } - if (eintrag.event_date > heute) { + // Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten + // Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie + // liessen sich nur über Person und Datum zuordnen, und das ist nicht + // eindeutig genug, um daraufhin eine geplante Änderung abzubrechen. + if (eintrag.event_date > heute && !eintrag.pending_id) { return { erlaubt: false, - grund: "Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.", + grund: + "Zu dieser geplanten Änderung ist kein Vorgang hinterlegt — sie stammt aus der Zeit vor dieser Verknüpfung. " + + "Zurücknehmen lässt sie sich nur, indem der Vorgang selbst abgebrochen wird.", }; } diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 6bffee9..96b6ed7 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -240,6 +240,8 @@ export type Database = { description: string; /** Feldweise Änderungen — dieselbe Form wie audit_log.changes. */ changes: AuditChange[] | null; + /** Der geplante Vorgang, solange die Änderung noch nicht wirksam ist. */ + pending_id: string | null; created_at: string; }; Insert: { @@ -249,6 +251,7 @@ export type Database = { event_type: HistoryEventType; description: string; changes?: AuditChange[] | null; + pending_id?: string | null; created_at?: string; }; Update: Partial; @@ -505,6 +508,8 @@ export type Database = { // Nimmt eine irrtümliche Stammdaten-/Vertragsänderung zurück. Der einzige // Weg an der fehlenden delete-Policy auf employee_history vorbei. delete_history_entry: { Args: { payload: Record }; Returns: void }; + // Berichtigt Wert und/oder Datum eines Historieneintrags. + update_history_entry: { Args: { payload: Record }; Returns: void }; // Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf // om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle. create_position: { Args: { payload: Record }; Returns: string }; diff --git a/supabase/migrations/20260813180000_update_history_entry.sql b/supabase/migrations/20260813180000_update_history_entry.sql new file mode 100644 index 0000000..844c04c --- /dev/null +++ b/supabase/migrations/20260813180000_update_history_entry.sql @@ -0,0 +1,366 @@ +-- Historieneinträge berichtigen — und die Feldtabelle nur noch einmal führen. +-- +-- Drei Teile: die gemeinsame Feldtabelle, die bisher im Rumpf der +-- Löschfunktion stand; dieselbe Löschfunktion, nun auf die gemeinsame +-- Tabelle umgestellt; und das Berichtigen als neue Funktion. + +-- Die Feldtabelle einmal, für alle, die sie brauchen. +-- +-- Beschriftung → Spalte und Typ. Sie stand bisher im Rumpf von +-- delete_history_entry; mit dem Bearbeiten kam eine zweite Stelle dazu, die +-- sie genauso braucht. Zwei Kopien einer solchen Liste laufen auseinander, +-- sobald ein Feld hinzukommt — und dann lässt sich ein Feld löschen, aber +-- nicht korrigieren, ohne dass es jemandem auffällt. +-- +-- Geschlossene Liste: was change_employee_data schreiben kann, steht hier, +-- sonst nichts. Die Spaltennamen gehen in dynamisches SQL und dürfen nur +-- von hier kommen. +create or replace function app_feld_karte() +returns jsonb +language sql +immutable +set search_path to 'public', 'pg_temp' +as $function$ + select jsonb_build_object( + 'Vorname', jsonb_build_array('first_name', 'text'), + 'Nachname', jsonb_build_array('last_name', 'text'), + 'Geschlecht', jsonb_build_array('gender', 'gender_type'), + 'Geburtsdatum', jsonb_build_array('birth_date', 'date'), + 'SV-Nummer', jsonb_build_array('sv_nummer', 'text'), + 'Staatsbürgerschaft', jsonb_build_array('nationality', 'text'), + 'Adresse', jsonb_build_array('address', 'text'), + 'Postleitzahl', jsonb_build_array('postal_code', 'text'), + 'Ort', jsonb_build_array('city', 'text'), + 'Land', jsonb_build_array('address_country', 'text'), + 'E-Mail', jsonb_build_array('email', 'text'), + 'Telefon', jsonb_build_array('phone', 'text'), + 'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text'), + 'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text'), + 'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text'), + 'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste'), + 'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste'), + 'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type'), + 'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric'), + 'Vertragsart', jsonb_build_array('contract_type', 'contract_type'), + 'Befristet bis', jsonb_build_array('contract_end_date', 'date'), + 'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type'), + 'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement'), + 'Arbeitstage', jsonb_build_array('work_days', 'liste'), + 'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean'), + 'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean'), + 'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean'), + 'C-Level', jsonb_build_array('is_c_level', 'boolean'), + 'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text') + ); +$function$; + +comment on function app_feld_karte() is 'Beschriftung eines Feldes → [Spalte, Typ]. Quelle für das Zurücksetzen und Korrigieren von Historieneinträgen.'; + +CREATE OR REPLACE FUNCTION public.delete_history_entry(payload jsonb) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +declare + v_id uuid := (payload->>'history_id')::uuid; + v_eintrag employee_history%rowtype; + v_name text; + v_aenderung jsonb; + v_feld text; + v_wert text; + v_spalte text; + v_typ text; + v_spaeter boolean; + v_zurueckgesetzt jsonb := '[]'::jsonb; + v_setz text[] := '{}'; + -- Feldbeschriftung → Spalte und Typ. Geschlossene Liste: was + -- change_employee_data schreiben kann, steht hier, sonst nichts. Der + -- Spaltenname geht in dynamisches SQL, deshalb darf er nur von hier kommen. + v_karte constant jsonb := app_feld_karte(); +begin + perform require_hr_admin(); + + select * into v_eintrag from employee_history where id = v_id; + if not found then + raise exception 'Historieneintrag nicht gefunden.'; + end if; + + if v_eintrag.event_type = 'Eintritt' then + raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.'; + end if; + + if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then + raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type; + end if; + + if v_eintrag.event_date > current_date then + raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.'; + end if; + + if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then + raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.'; + end if; + + select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id; + + -- Je Feld: nur zurücksetzen, wenn kein späterer Eintrag dasselbe Feld + -- angefasst hat. Sonst gilt der spätere Wert weiter. + for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop + v_feld := v_aenderung->>'feld'; + + if not v_karte ? v_feld then + continue; -- unbekannte Beschriftung: nichts anfassen + end if; + + select exists ( + select 1 + from employee_history h, + lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a + where h.employee_id = v_eintrag.employee_id + and h.id <> v_eintrag.id + and a->>'feld' = v_feld + and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at) + ) into v_spaeter; + + if v_spaeter then + continue; + end if; + + v_spalte := v_karte->v_feld->>0; + v_typ := v_karte->v_feld->>1; + v_wert := v_aenderung->>'vorher'; + + if v_typ = 'liste' then + v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, '')); + else + v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ); + end if; + + v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object( + 'feld', v_feld, + 'vorher', v_aenderung->>'nachher', + 'nachher', v_wert + ); + end loop; + + -- Alle Felder in *einem* UPDATE. Einzeln nacheinander zu schreiben war der + -- Fehler der ersten Fassung: chk_weekly_hours verknüpft Beschäftigungsausmaß + -- und Wochenstunden, und zwischen zwei getrennten Anweisungen steht + -- zwangsläufig ein Zwischenstand, den die Bedingung verbietet — „Vollzeit + -- mit 37 Stunden". Gemeinsam gesetzt gibt es diesen Zwischenstand nicht. + if array_length(v_setz, 1) > 0 then + begin + execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id); + exception when check_violation then + -- Bleibt trotzdem etwas übrig: dann wurde eines von zwei zusammen- + -- gehörenden Feldern später einzeln geändert, und der alte Wert passt + -- nicht mehr zum heutigen Stand. Lieber verständlich abweisen. + raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm; + end; + end if; + + delete from employee_history where id = v_id; + + 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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' vom ' || v_eintrag.event_date || + case when jsonb_array_length(v_zurueckgesetzt) = 0 + then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)' + else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end, + v_zurueckgesetzt); +end; +$function$; + +-- Einen Historieneintrag berichtigen. +-- +-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier +-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert +-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu +-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung, +-- von denen der erste nie stattgefunden hat. +-- +-- Geändert werden darf das **Nachher** und das **Datum**. Das Vorher bleibt: +-- es beschreibt, was vor der Änderung galt, und das lässt sich nachträglich +-- nicht anders beschliessen. +-- +-- ═══ Wie der heutige Stand danach zustande kommt ═══ +-- +-- Nicht durch Zurückrechnen, sondern durch Nachsehen: für jedes betroffene +-- Feld gewinnt der **jüngste** Historieneintrag, der es trägt. Das ist +-- dieselbe Regel wie beim Löschen — „die letztgültige Änderung ist die +-- schlagende" — nur von der anderen Seite gelesen, und sie trägt hier +-- zusätzlich den Fall, dass sich durch ein neues Datum die Reihenfolge +-- verschiebt. +-- +-- ═══ Was nicht geht ═══ +-- +-- Dieselben Grenzen wie beim Löschen: kein Eintritt, nur Stammdaten- und +-- Vertragsänderungen, nichts Zukünftiges, nichts ohne Feldwerte. Ein Datum +-- in der Zukunft würde aus dem Eintrag eine geplante Änderung machen, und +-- die lebt in pending_org_changes — dorthin führt kein verlässlicher Weg +-- zurück (kein Schlüssel zwischen beiden Tabellen). + +create or replace function update_history_entry(payload jsonb) +returns void +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + v_id uuid := (payload->>'history_id')::uuid; + v_eintrag employee_history%rowtype; + v_datum date; + v_name text; + v_karte constant jsonb := app_feld_karte(); + v_alt jsonb; + v_feld text; + v_neuer_wert text; + v_neu jsonb := '[]'::jsonb; + v_korrektur jsonb := '[]'::jsonb; + v_setz text[] := '{}'; + v_spalte text; + v_typ text; + v_gueltig text; +begin + perform require_hr_admin(); + + select * into v_eintrag from employee_history where id = v_id; + if not found then + raise exception 'Historieneintrag nicht gefunden.'; + end if; + + if v_eintrag.event_type = 'Eintritt' then + raise exception 'Der Eintritt lässt sich hier nicht berichtigen.'; + end if; + + if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then + raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type; + end if; + + if v_eintrag.event_date > current_date then + raise exception 'Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort berichtigt werden.'; + end if; + + if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then + raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.'; + end if; + + v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date); + if v_datum > current_date then + raise exception 'Ein Datum in der Zukunft macht daraus eine geplante Änderung. Dafür ist dieser Weg nicht gedacht.'; + end if; + + select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id; + + -- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden. + for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop + v_feld := v_alt->>'feld'; + select w->>'nachher' into v_neuer_wert + from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w + where w->>'feld' = v_feld; + + if v_neuer_wert is null then + v_neu := v_neu || v_alt; + else + v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, '')); + if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then + v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, '')); + end if; + end if; + end loop; + + if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then + raise exception 'Nichts geändert.'; + end if; + + update employee_history + set changes = v_neu, + event_date = v_datum, + description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum + where id = v_id; + + -- Für jedes betroffene Feld den jüngsten Eintrag suchen, der es trägt, und + -- dessen Nachher setzen. Das schliesst den eben berichtigten Eintrag ein + -- und berücksichtigt ein verschobenes Datum von selbst. + for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop + if not v_karte ? v_feld then + continue; + end if; + + select a->>'nachher' into v_gueltig + from employee_history h, + lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a + where h.employee_id = v_eintrag.employee_id + and a->>'feld' = v_feld + order by h.event_date desc, h.created_at desc + limit 1; + + v_spalte := v_karte->v_feld->>0; + v_typ := v_karte->v_feld->>1; + + if v_typ = 'liste' then + v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, '')); + else + v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ); + end if; + end loop; + + -- Alles in einem UPDATE: chk_weekly_hours koppelt Beschäftigungsausmaß und + -- Wochenstunden, und zwischen zwei getrennten Anweisungen stünde ein + -- Zwischenstand, den die Bedingung verbietet. + if array_length(v_setz, 1) > 0 then + begin + execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id); + exception when check_violation then + raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm; + end; + end if; + + 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(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' vom ' || v_eintrag.event_date || + case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end || + case when jsonb_array_length(v_korrektur) > 0 + then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end, + v_korrektur); +end; +$function$; + +comment on function update_history_entry(jsonb) is + 'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Der heutige Stand wird je Feld aus dem jüngsten Eintrag abgeleitet, der es trägt. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.'; + + +-- Selbstprüfung. +do $$ +declare + v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure); + v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure); +begin + if jsonb_typeof(app_feld_karte()) <> 'object' then + raise exception 'app_feld_karte liefert kein Objekt'; + end if; + if not (app_feld_karte() ? 'Adresse' and app_feld_karte() ? 'Wochenstunden') then + raise exception 'Die Feldtabelle ist unvollständig'; + end if; + if v_del not like '%app_feld_karte()%' then + raise exception 'delete_history_entry nutzt die gemeinsame Feldtabelle nicht'; + end if; + if v_del like '%jsonb_build_array(''first_name''%' then + raise exception 'delete_history_entry trägt noch eine eigene Kopie der Feldtabelle'; + end if; + if not (select prosecdef from pg_proc where oid = 'public.update_history_entry(jsonb)'::regprocedure) then + raise exception 'update_history_entry muss SECURITY DEFINER sein'; + end if; + if v_upd not like '%require_hr_admin%' then + raise exception 'update_history_entry prüft die Berechtigung nicht'; + end if; + -- Die Policies bleiben, wie sie sind. + if exists ( + select 1 from pg_policy p join pg_class c on c.oid = p.polrelid + where c.relname = 'employee_history' and p.polcmd in ('d', 'w') + ) then + raise exception 'employee_history hat eine update- oder delete-Policy bekommen'; + end if; +end +$$; diff --git a/supabase/migrations/20260813200000_history_links_to_pending.sql b/supabase/migrations/20260813200000_history_links_to_pending.sql new file mode 100644 index 0000000..7c834ae --- /dev/null +++ b/supabase/migrations/20260813200000_history_links_to_pending.sql @@ -0,0 +1,681 @@ +-- Auch geplante Änderungen lassen sich zurücknehmen und berichtigen. +-- +-- Bisher endete beides an der Gegenwart: was noch nicht wirksam war, blieb +-- stehen. Der Grund war kein Prinzip, sondern eine fehlende Verbindung — eine +-- noch nicht wirksame Änderung lebt als payload in pending_org_changes, und +-- zwischen ihr und der Historienzeile gab es keinen Schlüssel, nur Person und +-- Datum. Darüber zu raten hätte irgendwann die falsche Zeile getroffen: in +-- den Daten liegen bereits ein Eintritt und eine Vertragsänderung am selben +-- Tag. +-- +-- employee_history bekommt deshalb pending_id. change_employee_data setzt es, +-- wenn es eine geplante Änderung anlegt; für alles Wirksame bleibt es null. +-- +-- Eine geplante Änderung kann **zwei** Historienzeilen tragen — Stammdaten +-- und Vertrag werden getrennt geführt, hängen aber am selben Vorgang. Deshalb +-- entfernt das Zurücknehmen nur die Felder der betroffenen Gruppe aus dem +-- payload und bricht den Vorgang nur ab, wenn danach nichts übrig bleibt. +-- +-- Bestehende Zeilen werden verknüpft, wo es eindeutig ist: genau ein +-- laufender Vorgang der Person am selben Stichtag, den nicht schon eine +-- andere Zeile beansprucht. Alles andere bleibt ohne Bezug — und damit +-- weiterhin unantastbar, mit einer Meldung, die das sagt. + +alter table employee_history + add column if not exists pending_id uuid references pending_org_changes(id) on delete set null; + +comment on column employee_history.pending_id is + 'Der geplante Vorgang, zu dem diese Zeile gehört; null, sobald die Änderung wirksam ist oder es nie einen Vorgang gab. Ohne diesen Bezug lässt sich eine geplante Änderung nicht zurücknehmen — Person und Datum allein sind nicht eindeutig.'; + +create index if not exists idx_employee_history_pending on employee_history (pending_id) where pending_id is not null; + +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[]; + v_pending_id uuid; +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) + returning id into v_pending_id; + end if; + + if jsonb_array_length(v_person_changes) > 0 then + insert into employee_history (employee_id, event_date, event_type, description, changes, pending_id) + 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, v_pending_id); + 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, pending_id) + 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, v_pending_id); + 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$; + + +-- Die Feldtabelle: Beschriftung → Spalte, Typ und Gruppe. +-- +-- Die Gruppe ist neu. Eine noch nicht wirksame Änderung liegt als payload in +-- pending_org_changes, und dort sind die Felder nach person / contract / role +-- sortiert — so, wie change_employee_data sie entgegennimmt. Wer eine +-- geplante Änderung berichtigen oder zurücknehmen will, muss wissen, unter +-- welchem Schlüssel ein Feld dort steht. +create or replace function app_feld_karte() +returns jsonb +language sql +immutable +set search_path to 'public', 'pg_temp' +as $function$ + select jsonb_build_object( + 'Vorname', jsonb_build_array('first_name', 'text', 'person'), + 'Nachname', jsonb_build_array('last_name', 'text', 'person'), + 'Geschlecht', jsonb_build_array('gender', 'gender_type', 'person'), + 'Geburtsdatum', jsonb_build_array('birth_date', 'date', 'person'), + 'SV-Nummer', jsonb_build_array('sv_nummer', 'text', 'person'), + 'Staatsbürgerschaft', jsonb_build_array('nationality', 'text', 'person'), + 'Adresse', jsonb_build_array('address', 'text', 'person'), + 'Postleitzahl', jsonb_build_array('postal_code', 'text', 'person'), + 'Ort', jsonb_build_array('city', 'text', 'person'), + 'Land', jsonb_build_array('address_country', 'text', 'person'), + 'E-Mail', jsonb_build_array('email', 'text', 'person'), + 'Telefon', jsonb_build_array('phone', 'text', 'person'), + 'Notfallkontakt', jsonb_build_array('emergency_contact_name', 'text', 'person'), + 'Notfallkontakt Telefon', jsonb_build_array('emergency_contact_phone', 'text', 'person'), + 'Notfallkontakt Verhältnis', jsonb_build_array('emergency_contact_relation', 'text', 'person'), + 'Titel (vorangestellt)', jsonb_build_array('title_prefix', 'liste', 'person'), + 'Titel (nachgestellt)', jsonb_build_array('title_suffix', 'liste', 'person'), + 'Beschäftigungsausmaß', jsonb_build_array('employment_type', 'employment_type', 'contract'), + 'Wochenstunden', jsonb_build_array('weekly_hours', 'numeric', 'contract'), + 'Vertragsart', jsonb_build_array('contract_type', 'contract_type', 'contract'), + 'Befristet bis', jsonb_build_array('contract_end_date', 'date', 'contract'), + 'Angestellte:r/Arbeiter:in', jsonb_build_array('worker_type', 'worker_type', 'role'), + 'Kollektivvertrag', jsonb_build_array('collective_agreement', 'collective_agreement', 'role'), + 'Arbeitstage', jsonb_build_array('work_days', 'liste', 'role'), + 'Betriebsrat', jsonb_build_array('is_betriebsrat', 'boolean', 'role'), + 'Dienstwagen', jsonb_build_array('has_dienstwagen', 'boolean', 'role'), + 'Laterale Führung', jsonb_build_array('is_laterale_fuehrung', 'boolean', 'role'), + 'C-Level', jsonb_build_array('is_c_level', 'boolean', 'role'), + 'Dienstwagen Antrieb', jsonb_build_array('dienstwagen_art', 'text', 'role') + ); +$function$; + +comment on function app_feld_karte() is + 'Beschriftung eines Feldes → [Spalte, Typ, Gruppe im payload]. Quelle für das Zurücksetzen, Berichtigen und Abbrechen von Historieneinträgen.'; + + +-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung. +-- +-- Zwei Fälle, und sie sind grundverschieden: +-- +-- **Bereits wirksam.** Die Änderung steht in den Stammdaten. Je Feld wird auf +-- den Wert davor zurückgesetzt — aber nur, wenn kein späterer Eintrag +-- dasselbe Feld angefasst hat; sonst gilt der spätere weiter. Das ist „die +-- letztgültige Änderung ist die schlagende". +-- +-- **Noch nicht wirksam.** Es gibt nichts zurückzusetzen; die Änderung wartet +-- als payload in pending_org_changes. Zurückgenommen wird sie, indem ihre +-- Felder aus dem payload verschwinden. Bleibt danach nichts übrig, wird die +-- geplante Änderung abgebrochen — bleibt etwas, läuft sie mit dem Rest. +-- +-- Der zweite Fall braucht einen verlässlichen Bezug zwischen Historienzeile +-- und geplanter Änderung. Den gab es nicht, und über Person und Datum zu +-- raten hätte irgendwann die falsche Zeile getroffen: in den Daten liegen +-- bereits ein Eintritt und eine Vertragsänderung am selben Tag. Deshalb +-- trägt employee_history jetzt pending_id. +-- +-- Eine geplante Änderung kann **zwei** Historienzeilen haben — Stammdaten und +-- Vertrag werden getrennt geführt. Deshalb wird immer nur die Gruppe des +-- betroffenen Eintrags entfernt, nie der ganze payload. +create or replace function delete_history_entry(payload jsonb) +returns void +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + v_id uuid := (payload->>'history_id')::uuid; + v_eintrag employee_history%rowtype; + v_name text; + v_karte constant jsonb := app_feld_karte(); + v_aenderung jsonb; + v_feld text; + v_wert text; + v_spalte text; + v_typ text; + v_gruppe text; + v_spaeter boolean; + v_zurueckgesetzt jsonb := '[]'::jsonb; + v_setz text[] := '{}'; + v_plan pending_org_changes%rowtype; + v_neuer_payload jsonb; + v_leer boolean; +begin + perform require_hr_admin(); + + select * into v_eintrag from employee_history where id = v_id; + if not found then + raise exception 'Historieneintrag nicht gefunden.'; + end if; + + if v_eintrag.event_type = 'Eintritt' then + raise exception 'Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste.'; + end if; + + if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then + raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier zurücknehmen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type; + end if; + + if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then + raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.'; + end if; + + select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id; + + -- ── Noch nicht wirksam: die geplante Änderung entschärfen ────────── + if v_eintrag.event_date > current_date then + if v_eintrag.pending_id is null then + raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht abbrechen.'; + end if; + + select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update; + if not found or v_plan.status <> 'pending' then + raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.'; + end if; + + v_neuer_payload := v_plan.payload; + for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop + v_feld := v_aenderung->>'feld'; + if not v_karte ? v_feld then + continue; + end if; + v_spalte := v_karte->v_feld->>0; + v_gruppe := v_karte->v_feld->>2; + if v_neuer_payload ? v_gruppe then + v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], (v_neuer_payload->v_gruppe) - v_spalte); + end if; + end loop; + + v_leer := coalesce(jsonb_array_length( + (select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'person', '{}'::jsonb)) k)), 0) = 0 + and coalesce(jsonb_array_length( + (select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'contract', '{}'::jsonb)) k)), 0) = 0 + and coalesce(jsonb_array_length( + (select jsonb_agg(k) from jsonb_object_keys(coalesce(v_neuer_payload->'role', '{}'::jsonb)) k)), 0) = 0; + + if v_leer then + update pending_org_changes set status = 'cancelled' where id = v_plan.id; + else + update pending_org_changes set payload = v_neuer_payload where id = v_plan.id; + end if; + + delete from employee_history where id = v_id; + + 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(), 'Geplante Änderung abgebrochen', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' zum ' || v_eintrag.event_date || ' abgebrochen: ' || app_aenderungsfelder(v_eintrag.changes) || + case when v_leer then ' (der Vorgang entfällt ganz)' else ' (der Vorgang läuft mit den übrigen Feldern weiter)' end, + v_eintrag.changes); + return; + end if; + + -- ── Bereits wirksam: Feld für Feld zurücksetzen ──────────────────── + for v_aenderung in select * from jsonb_array_elements(v_eintrag.changes) loop + v_feld := v_aenderung->>'feld'; + + if not v_karte ? v_feld then + continue; + end if; + + select exists ( + select 1 + from employee_history h, + lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a + where h.employee_id = v_eintrag.employee_id + and h.id <> v_eintrag.id + and a->>'feld' = v_feld + and (h.event_date, h.created_at) > (v_eintrag.event_date, v_eintrag.created_at) + ) into v_spaeter; + + if v_spaeter then + continue; + end if; + + v_spalte := v_karte->v_feld->>0; + v_typ := v_karte->v_feld->>1; + v_wert := v_aenderung->>'vorher'; + + if v_typ = 'liste' then + v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_wert, '')); + else + v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_wert, ''), v_typ); + end if; + + v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object( + 'feld', v_feld, + 'vorher', v_aenderung->>'nachher', + 'nachher', v_wert + ); + end loop; + + -- Alles in einem UPDATE: chk_weekly_hours verknüpft Beschäftigungsausmaß + -- und Wochenstunden, und zwischen zwei getrennten Anweisungen stünde + -- zwangsläufig ein Zwischenstand, den die Bedingung verbietet. + if array_length(v_setz, 1) > 0 then + begin + execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id); + exception when check_violation then + raise exception 'Zurücksetzen nicht möglich: die Werte von damals passen nicht mehr zum heutigen Stand (%). Vermutlich wurde ein zusammengehörendes Feld später einzeln geändert.', sqlerrm; + end; + end if; + + delete from employee_history where id = v_id; + + 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(), 'Historieneintrag gelöscht', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' vom ' || v_eintrag.event_date || + case when jsonb_array_length(v_zurueckgesetzt) = 0 + then ' gelöscht; keine Werte zurückgesetzt (spätere Änderungen gelten)' + else ' gelöscht und zurückgesetzt: ' || app_aenderungsfelder(v_zurueckgesetzt) end, + v_zurueckgesetzt); +end; +$function$; + +comment on function delete_history_entry(jsonb) is + 'Nimmt eine Stammdaten- oder Vertragsänderung zurück. Bereits wirksam: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat. Noch nicht wirksam: entfernt die Felder aus dem geplanten Vorgang und bricht ihn ab, wenn nichts übrig bleibt. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.'; + + +-- Einen Historieneintrag berichtigen. +-- +-- Löschen nimmt einen Eintrag zurück, der nie hätte entstehen dürfen. Hier +-- geht es um den anderen Fall: der Vorgang stimmt, aber der erfasste Wert +-- oder das Datum nicht. Ohne diesen Weg bliebe nur „löschen und neu +-- erfassen" — und dann stünden in der Akte zwei Einträge für eine Änderung, +-- von denen der erste nie stattgefunden hat. +-- +-- Geändert wird das **Nachher** und das **Datum**. Das Vorher bleibt: es +-- beschreibt, was vor der Änderung galt, und das lässt sich nachträglich +-- nicht anders beschliessen. +-- +-- Bereits wirksam: die Stammdaten werden nachgezogen, wobei je Feld der +-- jüngste Eintrag gewinnt, der es trägt — dieselbe Regel wie beim Löschen, +-- von der anderen Seite gelesen, und sie trägt zusätzlich den Fall, dass ein +-- neues Datum die Reihenfolge verschiebt. +-- +-- Noch nicht wirksam: geändert wird der payload des geplanten Vorgangs und +-- sein Stichtag. An den Stammdaten passiert nichts — dort steht die Änderung +-- ja noch nicht. +create or replace function update_history_entry(payload jsonb) +returns void +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + v_id uuid := (payload->>'history_id')::uuid; + v_eintrag employee_history%rowtype; + v_datum date; + v_name text; + v_karte constant jsonb := app_feld_karte(); + v_alt jsonb; + v_feld text; + v_neuer_wert text; + v_neu jsonb := '[]'::jsonb; + v_korrektur jsonb := '[]'::jsonb; + v_setz text[] := '{}'; + v_spalte text; + v_typ text; + v_gruppe text; + v_gueltig text; + v_plan pending_org_changes%rowtype; + v_neuer_payload jsonb; + v_war_zukunft boolean; +begin + perform require_hr_admin(); + + select * into v_eintrag from employee_history where id = v_id; + if not found then + raise exception 'Historieneintrag nicht gefunden.'; + end if; + + if v_eintrag.event_type = 'Eintritt' then + raise exception 'Der Eintritt lässt sich hier nicht berichtigen.'; + end if; + + if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung') then + raise exception 'Nur Stammdaten- und Vertragsänderungen lassen sich hier berichtigen. Für % gibt es den passenden Vorgang.', v_eintrag.event_type; + end if; + + if v_eintrag.changes is null or jsonb_array_length(v_eintrag.changes) = 0 then + raise exception 'Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts zu berichtigen.'; + end if; + + v_war_zukunft := v_eintrag.event_date > current_date; + v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date); + + -- Ein Eintrag bleibt auf seiner Seite der Gegenwart. Beides zu erlauben + -- hiesse, eine gelaufene Änderung in eine geplante zu verwandeln (oder + -- umgekehrt) — dann müssten Stammdaten und payload gegenläufig angepasst + -- werden, und dafür gibt es die fachlichen Vorgänge. + if v_war_zukunft and v_datum <= current_date then + raise exception 'Eine geplante Änderung lässt sich hier nicht vorziehen. Dafür ist „Daten ändern" der richtige Weg.'; + end if; + if not v_war_zukunft and v_datum > current_date then + raise exception 'Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.'; + end if; + + select first_name || ' ' || last_name into v_name from employees where id = v_eintrag.employee_id; + + -- Neue Werteliste bauen: Vorher bleibt, Nachher darf ersetzt werden. + for v_alt in select * from jsonb_array_elements(v_eintrag.changes) loop + v_feld := v_alt->>'feld'; + select w->>'nachher' into v_neuer_wert + from jsonb_array_elements(coalesce(payload->'werte', '[]'::jsonb)) w + where w->>'feld' = v_feld; + + if v_neuer_wert is null then + v_neu := v_neu || v_alt; + else + v_neu := v_neu || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'vorher', 'nachher', nullif(v_neuer_wert, '')); + if coalesce(v_alt->>'nachher', '') is distinct from coalesce(nullif(v_neuer_wert, ''), '') then + v_korrektur := v_korrektur || jsonb_build_object('feld', v_feld, 'vorher', v_alt->>'nachher', 'nachher', nullif(v_neuer_wert, '')); + end if; + end if; + end loop; + + if jsonb_array_length(v_korrektur) = 0 and v_datum = v_eintrag.event_date then + raise exception 'Nichts geändert.'; + end if; + + update employee_history + set changes = v_neu, + event_date = v_datum, + description = 'Geänderte Felder: ' || app_aenderungsfelder(v_neu) || ', wirksam ab ' || v_datum + where id = v_id; + + -- ── Noch nicht wirksam: den geplanten Vorgang nachziehen ─────────── + if v_war_zukunft then + if v_eintrag.pending_id is null then + raise exception 'Zu dieser geplanten Änderung ist kein Vorgang hinterlegt. Sie stammt aus der Zeit vor dieser Verknüpfung und lässt sich hier nicht berichtigen.'; + end if; + + select * into v_plan from pending_org_changes where id = v_eintrag.pending_id for update; + if not found or v_plan.status <> 'pending' then + raise exception 'Der geplante Vorgang läuft nicht mehr — er wurde bereits angewendet oder abgebrochen.'; + end if; + + v_neuer_payload := jsonb_set(v_plan.payload, '{effective_date}', to_jsonb(v_datum::text)); + for v_alt in select * from jsonb_array_elements(v_neu) loop + v_feld := v_alt->>'feld'; + if not v_karte ? v_feld then + continue; + end if; + v_spalte := v_karte->v_feld->>0; + v_typ := v_karte->v_feld->>1; + v_gruppe := v_karte->v_feld->>2; + if not v_neuer_payload ? v_gruppe then + v_neuer_payload := jsonb_set(v_neuer_payload, array[v_gruppe], '{}'::jsonb); + end if; + v_neuer_payload := jsonb_set( + v_neuer_payload, + array[v_gruppe, v_spalte], + case + when v_alt->>'nachher' is null then 'null'::jsonb + when v_typ = 'liste' then to_jsonb(string_to_array(v_alt->>'nachher', ', ')) + when v_typ = 'boolean' then to_jsonb((v_alt->>'nachher')::boolean) + when v_typ = 'numeric' then to_jsonb((v_alt->>'nachher')::numeric) + else to_jsonb(v_alt->>'nachher') + end, + true); + end loop; + + update pending_org_changes + set payload = v_neuer_payload, effective_date = v_datum + where id = v_plan.id; + + 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(), 'Geplante Änderung berichtigt', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' zum ' || v_eintrag.event_date || + case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' verschoben' else '' end || + case when jsonb_array_length(v_korrektur) > 0 then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end, + v_korrektur); + return; + end if; + + -- ── Bereits wirksam: Stammdaten nachziehen ───────────────────────── + for v_feld in select distinct e->>'feld' from jsonb_array_elements(v_neu) e loop + if not v_karte ? v_feld then + continue; + end if; + + select a->>'nachher' into v_gueltig + from employee_history h, + lateral jsonb_array_elements(coalesce(h.changes, '[]'::jsonb)) a + where h.employee_id = v_eintrag.employee_id + and a->>'feld' = v_feld + and h.event_date <= current_date + order by h.event_date desc, h.created_at desc + limit 1; + + v_spalte := v_karte->v_feld->>0; + v_typ := v_karte->v_feld->>1; + + if v_typ = 'liste' then + v_setz := v_setz || format('%I = coalesce(string_to_array(%L, '', ''), ''{}'')', v_spalte, nullif(v_gueltig, '')); + else + v_setz := v_setz || format('%I = %L::%s', v_spalte, nullif(v_gueltig, ''), v_typ); + end if; + end loop; + + if array_length(v_setz, 1) > 0 then + begin + execute format('update employees set %s where id = %L', array_to_string(v_setz, ', '), v_eintrag.employee_id); + exception when check_violation then + raise exception 'Der berichtigte Wert passt nicht zum übrigen Stand (%). Zusammengehörende Felder — etwa Beschäftigungsausmaß und Wochenstunden — müssen gemeinsam stimmen.', sqlerrm; + end; + end if; + + 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(), 'Historieneintrag berichtigt', v_name, v_eintrag.employee_id, + v_eintrag.event_type || ' vom ' || v_eintrag.event_date || + case when v_datum <> v_eintrag.event_date then ' auf ' || v_datum || ' umdatiert' else '' end || + case when jsonb_array_length(v_korrektur) > 0 + then '; berichtigt: ' || app_aenderungsfelder(v_korrektur) else '' end, + v_korrektur); +end; +$function$; + +comment on function update_history_entry(jsonb) is + 'Berichtigt Wert und/oder Datum einer Stammdaten- oder Vertragsänderung. Bereits wirksam: die Stammdaten werden je Feld aus dem jüngsten wirksamen Eintrag abgeleitet. Noch nicht wirksam: payload und Stichtag des geplanten Vorgangs werden nachgezogen. SECURITY DEFINER, weil employee_history absichtlich keine update-Policy hat.'; + + +-- Bestehende Zeilen verknüpfen, wo genau ein Vorgang in Frage kommt. +with kandidat as ( + select h.id as history_id, + (select p.id + from pending_org_changes p + where p.employee_id = h.employee_id + and p.effective_date = h.event_date + and p.status = 'pending' + and p.change_type = 'contract_change' + and not exists ( + select 1 from employee_history x + where x.pending_id = p.id and x.event_type = h.event_type + ) + limit 2) as plan_id, + (select count(*) + from pending_org_changes p + where p.employee_id = h.employee_id + and p.effective_date = h.event_date + and p.status = 'pending' + and p.change_type = 'contract_change') as anzahl + from employee_history h + where h.event_date > current_date + and h.pending_id is null + and h.event_type in ('Stammdatenänderung', 'Vertragsänderung') +) +update employee_history h + set pending_id = k.plan_id + from kandidat k + where h.id = k.history_id + and k.anzahl = 1 + and k.plan_id is not null; + + +-- Selbstprüfung. +do $$ +declare + v_ced text := pg_get_functiondef('public.change_employee_data(jsonb)'::regprocedure); + v_del text := pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure); + v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure); +begin + if not exists ( + select 1 from information_schema.columns + where table_schema = 'public' and table_name = 'employee_history' and column_name = 'pending_id' + ) then + raise exception 'employee_history.pending_id fehlt'; + end if; + + if (length(v_ced) - length(replace(v_ced, 'v_pending_id)', ''))) / length('v_pending_id)') <> 2 then + raise exception 'change_employee_data schreibt pending_id nicht in beide Historien-Einträge'; + end if; + + if v_ced not like '%returning id into v_pending_id%' then + raise exception 'change_employee_data merkt sich den angelegten Vorgang nicht'; + end if; + + if jsonb_array_length(app_feld_karte()->'Adresse') <> 3 then + raise exception 'Die Feldtabelle nennt die Gruppe nicht'; + end if; + if app_feld_karte()->'Adresse'->>2 <> 'person' or app_feld_karte()->'Wochenstunden'->>2 <> 'contract' then + raise exception 'Die Gruppen in der Feldtabelle stimmen nicht'; + end if; + + if v_del not like '%pending_id is null%' or v_upd not like '%pending_id is null%' then + raise exception 'Der Zukunftsfall wird nicht behandelt'; + end if; + + -- Die Policies bleiben, wie sie sind. + if exists ( + select 1 from pg_policy p join pg_class c on c.oid = p.polrelid + where c.relname = 'employee_history' and p.polcmd in ('d', 'w') + ) then + raise exception 'employee_history hat eine update- oder delete-Policy bekommen'; + end if; +end +$$; diff --git a/tests/components/HistorieTab.test.tsx b/tests/components/HistorieTab.test.tsx index 83b3f97..4901be5 100644 --- a/tests/components/HistorieTab.test.tsx +++ b/tests/components/HistorieTab.test.tsx @@ -12,6 +12,7 @@ import type { Database } from "@/lib/supabase/types"; // tests/unit/history.test.ts und in der Migration selbst. vi.mock("@/actions/employees", () => ({ deleteHistoryEntry: vi.fn(async () => ({ success: true })), + updateHistoryEntry: vi.fn(async () => ({ success: true })), })); // Ausserhalb von Next gibt es keinen Router; die Komponente ruft nach dem @@ -40,6 +41,7 @@ function eintrag(teil: Partial): HistoryRow { event_type: "Stammdatenänderung", description: "Geänderte Felder: Adresse, Ort, wirksam ab 2026-03-02", changes: null, + pending_id: null, created_at: "2026-03-02T08:00:00.000Z", ...teil, }; @@ -168,6 +170,65 @@ describe("HistorieTab", () => { expect(deleteHistoryEntry).toHaveBeenCalledWith({ history_id: "h1", employee_id: "e1" }); }); + it("bietet neben dem Löschen auch das Berichtigen an", () => { + render(); + expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* bearbeiten/ })).toBeInTheDocument(); + }); + + it("bietet das Berichtigen dort nicht an, wo auch nicht gelöscht werden darf", () => { + render(); + expect(screen.queryByRole("button", { name: /bearbeiten/ })).not.toBeInTheDocument(); + }); + + it("zeigt beim Berichtigen das Vorher unveränderlich und das Nachher zum Tippen", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /bearbeiten/ })); + + const dialog = screen.getByRole("dialog"); + // Das Vorher ist Geschichte und steht nur da. + expect(within(dialog).getByText("Feldweg 66")).toBeInTheDocument(); + expect(within(dialog).queryByDisplayValue("Feldweg 66")).not.toBeInTheDocument(); + // Das Nachher lässt sich ändern. + expect(within(dialog).getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5"); + }); + + it("schickt nur ab, wenn wirklich etwas anders ist", async () => { + const user = userEvent.setup(); + const { updateHistoryEntry } = await import("@/actions/employees"); + render(); + await user.click(screen.getByRole("button", { name: /bearbeiten/ })); + + const knopf = screen.getByRole("button", { name: "Berichtigen" }); + expect(knopf).toBeDisabled(); + + const feld = screen.getByLabelText("Adresse — neuer Wert"); + await user.clear(feld); + await user.type(feld, "Bahnhofstraße 5"); + expect(knopf).toBeEnabled(); + + await user.click(knopf); + expect(updateHistoryEntry).toHaveBeenCalledWith( + expect.objectContaining({ + history_id: "h1", + employee_id: "e1", + werte: expect.arrayContaining([{ feld: "Adresse", nachher: "Bahnhofstraße 5" }]), + }) + ); + }); + + it("verwirft die Eingaben beim Abbrechen", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /bearbeiten/ })); + await user.clear(screen.getByLabelText("Adresse — neuer Wert")); + await user.type(screen.getByLabelText("Adresse — neuer Wert"), "Verworfen 1"); + await user.click(screen.getByRole("button", { name: "Abbrechen" })); + + await user.click(screen.getByRole("button", { name: /bearbeiten/ })); + expect(screen.getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5"); + }); + it("bleibt bei leerer Historie bei einem Satz", () => { render(); expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument(); diff --git a/tests/components/dialog-a11y.test.tsx b/tests/components/dialog-a11y.test.tsx index eeb6d7c..da0a982 100644 --- a/tests/components/dialog-a11y.test.tsx +++ b/tests/components/dialog-a11y.test.tsx @@ -25,7 +25,33 @@ function ModalHarness({ onClose = () => {} }: { onClose?: () => void }) { ); } +// Ein Dialog, dessen onClose bei jedem Rendern neu entsteht — und dessen +// Zustand im selben Bauteil liegt, sodass jeder Tastendruck neu rendert. Das +// ist der Normalfall für ein Formular im Dialog. +function DialogMitEigenemZustand() { + const [offen, setOffen] = useState(true); + const [wert, setWert] = useState(""); + if (!offen) return

    zu

    ; + return ( + setOffen(false)} title="Tippen" footer={}> + + + ); +} + describe("Modal focus management", () => { + it("lässt mehr als ein Zeichen tippen", async () => { + // Der Fokus-Wächter hing an der Identität von onClose. Die entsteht bei + // fast jedem Aufrufer bei jedem Rendern neu, also lief der Effekt nach + // jedem Tastendruck erneut — und sein Aufräumen holte den Fokus dorthin + // zurück, wo er vor dem Öffnen war. Nach dem ersten Zeichen ging die + // Eingabe ins Leere. + render(); + const feld = screen.getByLabelText("Feld"); + await userEvent.type(feld, "Bahnhofstrasse"); + expect(feld).toHaveValue("Bahnhofstrasse"); + }); + it("names the dialog from its visible heading", () => { render(); expect(screen.getByRole("dialog", { name: "Testdialog" })).toBeInTheDocument(); diff --git a/tests/unit/history.test.ts b/tests/unit/history.test.ts index 2f47f72..95467d5 100644 --- a/tests/unit/history.test.ts +++ b/tests/unit/history.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history"; +import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history"; import type { AuditChange, HistoryEventType } from "@/lib/supabase/types"; const HEUTE = "2026-08-13"; -function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null }> = {}) { +function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null; pending_id: string | null }> = {}) { return { id: "h1", event_type: "Stammdatenänderung" as HistoryEventType, @@ -15,17 +15,17 @@ function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event }; } -describe("darfGeloeschtWerden", () => { +describe("darfKorrigiertWerden", () => { it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => { - expect(darfGeloeschtWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true }); + expect(darfKorrigiertWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true }); }); it("lässt eine Vertragsänderung ebenso zu", () => { - expect(darfGeloeschtWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true); + expect(darfKorrigiertWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true); }); it("schützt den Eintritt", () => { - const u = darfGeloeschtWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE); + const u = darfKorrigiertWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE); expect(u.erlaubt).toBe(false); expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/); }); @@ -33,25 +33,31 @@ describe("darfGeloeschtWerden", () => { it.each(["Versetzung", "Beförderung", "Karenz", "Rückkehr", "Austritt", "Wiedereintritt", "Reorganisation"])( "verweist bei %s auf den fachlichen Vorgang", (typ) => { - const u = darfGeloeschtWerden(eintrag({ event_type: typ }), HEUTE); + const u = darfKorrigiertWerden(eintrag({ event_type: typ }), HEUTE); expect(u.erlaubt).toBe(false); expect(u.erlaubt === false && u.grund).toMatch(/passenden Vorgang|Planstellen oder den Status/); } ); - it("lässt Zukünftiges in Ruhe — daran hängt ein geplanter Vorgang", () => { - const u = darfGeloeschtWerden(eintrag({ event_date: "2026-09-01" }), HEUTE); + it("lässt Zukünftiges zu, sobald der geplante Vorgang bekannt ist", () => { + // Das ist der Sinn von pending_id: ohne Bezug liesse sich die geplante + // Änderung nur über Person und Datum finden, und das ist nicht eindeutig. + expect(darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: "p1" }), HEUTE).erlaubt).toBe(true); + }); + + it("weist Zukünftiges ohne Bezug zum Vorgang ab", () => { + const u = darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: null }), HEUTE); expect(u.erlaubt).toBe(false); - expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/); + expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/); }); it("zählt den heutigen Tag als wirksam", () => { - expect(darfGeloeschtWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true); + expect(darfKorrigiertWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true); }); it("weist Einträge ohne Feldwerte ab", () => { for (const ohne of [null, []]) { - const u = darfGeloeschtWerden(eintrag({ changes: ohne }), HEUTE); + const u = darfKorrigiertWerden(eintrag({ changes: ohne }), HEUTE); expect(u.erlaubt).toBe(false); expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/); }