Let planned changes be taken back and corrected too
Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ActionResult> {
|
||||
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;
|
||||
|
||||
187
components/employees/HistorieBearbeiten.tsx
Normal file
187
components/employees/HistorieBearbeiten.tsx
Normal file
@@ -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<Record<string, string>>(() =>
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
|
||||
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={offen}
|
||||
onClose={abbrechen}
|
||||
title="Eintrag berichtigen"
|
||||
widthClassName="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={abbrechen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
|
||||
Berichtigen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong>
|
||||
{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."}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 max-w-xs">
|
||||
<TextField
|
||||
label="Wirksam ab"
|
||||
type="date"
|
||||
min={istZukunft ? morgen : undefined}
|
||||
max={istZukunft ? undefined : heute}
|
||||
value={neuesDatum}
|
||||
onChange={setNeuesDatum}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="py-2 pr-4">Feld</th>
|
||||
<th className="py-2 pr-4">Vorher</th>
|
||||
<th className="py-2">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((c) => (
|
||||
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
|
||||
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
|
||||
<td className="py-2 pr-4 text-ink-muted">
|
||||
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`${c.feld} — neuer Wert`}
|
||||
value={werte[c.feld] ?? ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
{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.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong> vom {fmtDate(datum)} wird aus der Historie entfernt.
|
||||
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
|
||||
Historie entfernt.
|
||||
</p>
|
||||
|
||||
{zurueck.length > 0 && (
|
||||
{istZukunft && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{vorschau.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-body">
|
||||
<span className="font-semibold text-ink">{v.feld}</span>{" "}
|
||||
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
|
||||
<span className="text-ink-muted">gesetzt werden</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!istZukunft && zurueck.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
@@ -98,7 +121,7 @@ export function HistorieLoeschen({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bleibt.length > 0 && (
|
||||
{!istZukunft && bleibt.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
|
||||
@@ -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 (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -49,13 +50,23 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
|
||||
</span>
|
||||
)}
|
||||
{urteil.erlaubt && (
|
||||
<span className="ml-auto">
|
||||
<span className="ml-auto flex items-center gap-0.5">
|
||||
<HistorieBearbeiten
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
changes={changes}
|
||||
istZukunft={isFuture}
|
||||
heute={today}
|
||||
/>
|
||||
<HistorieLoeschen
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
vorschau={loeschVorschau(h, history)}
|
||||
istZukunft={isFuture}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,18 @@ function focusableWithin(container: HTMLElement): HTMLElement[] {
|
||||
export function useDialogFocus(open: boolean, onClose: () => void, containerRef: RefObject<HTMLElement | null>) {
|
||||
const restoreToRef = useRef<HTMLElement | null>(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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
@@ -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<string, unknown> }; Returns: void };
|
||||
// Berichtigt Wert und/oder Datum eines Historieneintrags.
|
||||
update_history_entry: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
|
||||
366
supabase/migrations/20260813180000_update_history_entry.sql
Normal file
366
supabase/migrations/20260813180000_update_history_entry.sql
Normal file
@@ -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
|
||||
$$;
|
||||
681
supabase/migrations/20260813200000_history_links_to_pending.sql
Normal file
681
supabase/migrations/20260813200000_history_links_to_pending.sql
Normal file
@@ -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
|
||||
$$;
|
||||
@@ -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>): 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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
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(<HistorieTab history={[eintrag({ event_type: "Versetzung" })]} employeeId="e1" />);
|
||||
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
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(<HistorieTab history={[]} employeeId="e1" />);
|
||||
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
|
||||
|
||||
@@ -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 <p>zu</p>;
|
||||
return (
|
||||
<Modal open onClose={() => setOffen(false)} title="Tippen" footer={<Button>Speichern</Button>}>
|
||||
<TextField label="Feld" value={wert} onChange={setWert} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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(<DialogMitEigenemZustand />);
|
||||
const feld = screen.getByLabelText("Feld");
|
||||
await userEvent.type(feld, "Bahnhofstrasse");
|
||||
expect(feld).toHaveValue("Bahnhofstrasse");
|
||||
});
|
||||
|
||||
it("names the dialog from its visible heading", () => {
|
||||
render(<ModalHarness />);
|
||||
expect(screen.getByRole("dialog", { name: "Testdialog" })).toBeInTheDocument();
|
||||
|
||||
@@ -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<HistoryEventType>(["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/);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user