Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an honest thing to do — and deleting it also undoes it. The rule they asked for is the interesting part: the last valid change wins. Deleting an entry walks its fields one at a time. If a later entry touched the same field, the current value stays — that later change is the one in force. Otherwise the field goes back to what the deleted entry recorded as its "before". So the middle of three entries can be removed without an old value overwriting a newer one. Four kinds of entry refuse to be deleted, each saying why in the place the button would have been. Eintritt anchors the timeline. Transfers, promotions, absences and exits moved positions and status — they have proper operations for that, and guessing backwards is how you corrupt an org chart. Anything not yet effective hangs off a planned change, and that link is not trustworthy: there is no key between a history row and its pending row, only a person and a date, and the data already has an Eintritt and a Vertragsänderung sharing one. Matching on the date would eventually cancel a change nobody meant. And entries from before the history carried values have nothing to fall back to. Confirmation is not "are you sure" — that question gets a reflex yes by the third time. The dialog says what will be different afterwards: which field goes back to which value, and which one stays because something later claimed it. employee_history keeps its append-only policies; delete_history_entry is SECURITY DEFINER and checks the permission itself in its first line. The audit log keeps the deletion with the values that were removed, and the audit log genuinely cannot be edited. The rule lives twice — in SQL and in lib/history.ts. The database is the authority; the copy exists so the UI can hide a button that would fail and print the reason instead. Rehearsed against real data in a rolled-back transaction first: the later change held, the untouched field reverted, all four refusals fired. Also corrected in the data catalogue: I had written that require_hr_admin was called by nothing. It guards all sixteen mutating functions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,21 @@ export async function deleteEmployeeDependent(payload: {
|
||||
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine irrtümlich erfasste Stammdaten- oder Vertragsänderung zurück.
|
||||
*
|
||||
* Was zurückgesetzt wird und was stehen bleibt, entscheidet die Datenbank —
|
||||
* sie prüft dabei erneut, ob der Eintrag überhaupt gelöscht werden darf. Die
|
||||
* Oberfläche zeigt den Knopf nur dort, wo es geht (siehe lib/history.ts);
|
||||
* kommt trotzdem eine Ablehnung zurück, wird deren Begründung angezeigt.
|
||||
*/
|
||||
export async function deleteHistoryEntry(payload: {
|
||||
history_id: string;
|
||||
employee_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("delete_history_entry", { history_id: payload.history_id }, [`/employees/${payload.employee_id}`, "/audit"]);
|
||||
}
|
||||
|
||||
export async function addEmployeeNote(payload: {
|
||||
employee_id: string;
|
||||
category: NoteCategory;
|
||||
|
||||
@@ -155,7 +155,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
breadcrumb={breadcrumb}
|
||||
/>
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
|
||||
|
||||
122
components/employees/HistorieLoeschen.tsx
Normal file
122
components/employees/HistorieLoeschen.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { deleteHistoryEntry } from "@/actions/employees";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Vorschau } from "@/lib/history";
|
||||
|
||||
// Löschen mit Ansage.
|
||||
//
|
||||
// Bestätigen heisst hier nicht „Wirklich?" — das beantwortet jede Person nach
|
||||
// dem dritten Mal blind mit Ja. Der Dialog sagt stattdessen, **was danach
|
||||
// anders ist**: welches Feld auf welchen Wert zurückgeht, und welches nicht,
|
||||
// weil eine spätere Änderung es erneut angefasst hat. Wer das liest, merkt
|
||||
// selbst, ob er den richtigen Eintrag erwischt hat.
|
||||
|
||||
export function HistorieLoeschen({
|
||||
historyId,
|
||||
employeeId,
|
||||
bezeichnung,
|
||||
datum,
|
||||
vorschau,
|
||||
}: {
|
||||
historyId: string;
|
||||
employeeId: string;
|
||||
bezeichnung: string;
|
||||
datum: string;
|
||||
vorschau: Vorschau[];
|
||||
}) {
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const zurueck = vorschau.filter((v) => !v.bleibt);
|
||||
const bleibt = vorschau.filter((v) => v.bleibt);
|
||||
|
||||
async function loeschen() {
|
||||
setLaeuft(true);
|
||||
const ergebnis = await deleteHistoryEntry({ history_id: historyId, employee_id: employeeId });
|
||||
setLaeuft(false);
|
||||
if (ergebnis.success) {
|
||||
showToast("Eintrag gelöscht.");
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(ergebnis.error ?? "Löschen fehlgeschlagen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen(true)}
|
||||
aria-label={`${bezeichnung} vom ${fmtDate(datum)} löschen`}
|
||||
className="rounded p-1 text-ink-muted hover:bg-danger-bg hover:text-danger-text
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={offen}
|
||||
onClose={() => setOffen(false)}
|
||||
title="Eintrag löschen"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={loeschen} pending={laeuft} className="!bg-danger-solid text-white hover:brightness-110">
|
||||
Löschen und zurücksetzen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-body">
|
||||
<strong className="text-ink">{bezeichnung}</strong> vom {fmtDate(datum)} wird aus der Historie entfernt.
|
||||
</p>
|
||||
|
||||
{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">
|
||||
{zurueck.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 line-through decoration-ink-muted/40">{v.von || "leer"}</span>{" "}
|
||||
<span aria-hidden="true">→</span> <span className="text-ink">{v.auf || "leer"}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
{bleibt.map((v) => (
|
||||
<li key={v.feld} className="text-sm text-ink-muted">
|
||||
<span className="font-semibold">{v.feld}</span> — eine spätere Änderung hat dieses Feld erneut
|
||||
gesetzt, und die gilt weiter.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
|
||||
Der Vorgang wird im Protokoll festgehalten — mit Zeitpunkt, Person und den Werten der gelöschten Zeile. Die
|
||||
Zeile selbst lässt sich nicht wiederherstellen.
|
||||
</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
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 type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
|
||||
// Die Geschichte einer Person — und zwar aufklappbar bis auf die Werte.
|
||||
// Die Geschichte einer Person — aufklappbar bis auf die Werte, und dort, wo
|
||||
// ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
|
||||
//
|
||||
// Vorher stand hier nur „Geänderte Felder: Adresse, Ort". Damit liess sich
|
||||
// zwar sehen, *dass* jemand die Anschrift angefasst hat, aber nicht, was
|
||||
@@ -16,8 +19,13 @@ type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
// Aufgeklappt wird mit <details>, nicht mit einem Zustand im Browser: die
|
||||
// Werte stehen dann schon in der Seite, sind durchsuchbar (Strg+F) und im
|
||||
// Ausdruck sichtbar, und es braucht kein Skript dafür.
|
||||
//
|
||||
// Der Löschknopf erscheint nur an Einträgen, die sich zurücknehmen lassen.
|
||||
// An allen anderen steht stattdessen der Grund — leise, aber lesbar. Ein
|
||||
// Knopf, der erst nach dem Klick sagt „geht nicht", wäre eine Falle; ein
|
||||
// fehlender Knopf ohne Erklärung wäre ein Rätsel.
|
||||
|
||||
export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
|
||||
const today = todayIso();
|
||||
|
||||
if (history.length === 0) {
|
||||
@@ -29,6 +37,7 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
{history.map((h) => {
|
||||
const isFuture = h.event_date > today;
|
||||
const changes = h.changes ?? [];
|
||||
const urteil = darfGeloeschtWerden(h, today);
|
||||
return (
|
||||
<li key={h.id} className="py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -39,6 +48,17 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||||
</span>
|
||||
)}
|
||||
{urteil.erlaubt && (
|
||||
<span className="ml-auto">
|
||||
<HistorieLoeschen
|
||||
historyId={h.id}
|
||||
employeeId={employeeId}
|
||||
bezeichnung={h.event_type}
|
||||
datum={h.event_date}
|
||||
vorschau={loeschVorschau(h, history)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
||||
|
||||
@@ -56,6 +76,7 @@ export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
|
||||
<AenderungsTabelle changes={changes} />
|
||||
</div>
|
||||
{!urteil.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{urteil.grund}</p>}
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -21,7 +21,7 @@ kommt.
|
||||
| Spalten | 142 |
|
||||
| Aufzählungstypen | 10 |
|
||||
| Sichten (Views) | 0 |
|
||||
| Eigene SQL-Funktionen | 35 (plus 31 aus der Erweiterung `pg_trgm`) |
|
||||
| Eigene SQL-Funktionen | 36 (plus 31 aus der Erweiterung `pg_trgm`) |
|
||||
| RLS-Policies | 21, auf jeder Tabelle mindestens eine |
|
||||
|
||||
---
|
||||
@@ -51,6 +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
|
||||
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.
|
||||
|
||||
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
|
||||
`position_assignments` ist die Verknüpfung A008 („Inhaber ist"). Das Flag
|
||||
@@ -311,6 +317,12 @@ 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.
|
||||
|
||||
**Umfeld:** `add_employee_dependent`, `delete_employee_dependent`,
|
||||
`add_employee_note`, `complete_employee_note`
|
||||
|
||||
@@ -326,19 +338,25 @@ 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`
|
||||
|
||||
Vier Funktionen laufen als `SECURITY DEFINER`, also mit den Rechten ihrer
|
||||
Fünf 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`. 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.
|
||||
`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.
|
||||
|
||||
**Übrig geblieben:** `generate_company_email` und `is_hr_admin` /
|
||||
`require_hr_admin` stehen noch in der Datenbank, werden aber von nichts mehr
|
||||
gerufen. Die E-Mail-Erzeugung stammt aus der Zeit, als eine Firmenadresse
|
||||
automatisch vergeben wurde; heute ist `employees.email` die private Adresse
|
||||
und freiwillig. Die Admin-Prüfungen stammen aus einem Rollenmodell, das es
|
||||
nicht mehr gibt.
|
||||
**Der Türsteher:** `require_hr_admin()` steht am Anfang von **16**
|
||||
Funktionen — jeder ändernden. Es wirft, wenn `is_hr_user()` falsch ist, und
|
||||
liefert damit eine lesbare Meldung statt einer nackten RLS-Verletzung. Der
|
||||
Name täuscht: ein Admin-Rollenmodell gibt es nicht, `is_hr_admin()` ruft
|
||||
schlicht `is_hr_user()` auf. Die Schranke selbst bleiben die Policies.
|
||||
|
||||
**Übrig geblieben:** `generate_company_email` steht noch in der Datenbank,
|
||||
wird aber von nichts mehr gerufen — sie stammt aus der Zeit, als eine
|
||||
Firmenadresse automatisch vergeben wurde; heute ist `employees.email` die
|
||||
private Adresse und freiwillig.
|
||||
|
||||
---
|
||||
|
||||
|
||||
78
lib/history.ts
Normal file
78
lib/history.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { AuditChange, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Welche Historieneinträge sich zurücknehmen lassen — und warum die übrigen
|
||||
// nicht.
|
||||
//
|
||||
// Dieselbe Regel steht in der Datenbank (delete_history_entry). Das ist eine
|
||||
// Doppelung, und zwar mit Absicht: die Datenbank ist die verbindliche Stelle,
|
||||
// weil sie die einzige ist, an der niemand vorbeikommt. Hier steht sie
|
||||
// nochmal, damit die Oberfläche einen Knopf nur dort zeigt, wo er auch
|
||||
// funktioniert, und daneben schreiben kann, woran es sonst liegt. Ein Knopf,
|
||||
// der erst nach dem Klick sagt „geht nicht", ist eine Falle.
|
||||
//
|
||||
// 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 };
|
||||
|
||||
type Eintrag = {
|
||||
event_type: HistoryEventType;
|
||||
event_date: string;
|
||||
changes: AuditChange[] | null;
|
||||
};
|
||||
|
||||
export function darfGeloeschtWerden(eintrag: Eintrag, heute: string): LoeschUrteil {
|
||||
if (eintrag.event_type === "Eintritt") {
|
||||
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
|
||||
}
|
||||
|
||||
if (eintrag.event_type !== "Stammdatenänderung" && eintrag.event_type !== "Vertragsänderung") {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund:
|
||||
"Dieser Vorgang hat Planstellen oder den Status bewegt. Zurücknehmen lässt er sich nur über den passenden " +
|
||||
"Vorgang, nicht durch Löschen der Zeile.",
|
||||
};
|
||||
}
|
||||
|
||||
if (eintrag.event_date > heute) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Diese Änderung ist noch nicht wirksam und hängt an einem geplanten Vorgang. Sie muss dort abgebrochen werden.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!eintrag.changes || eintrag.changes.length === 0) {
|
||||
return {
|
||||
erlaubt: false,
|
||||
grund: "Zu diesem Eintrag sind keine Feldwerte erfasst — es gibt nichts, worauf zurückgesetzt werden könnte.",
|
||||
};
|
||||
}
|
||||
|
||||
return { erlaubt: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Was das Löschen bewirken würde: je Feld entweder Zurücksetzen oder nicht,
|
||||
* weil ein späterer Eintrag dasselbe Feld angefasst hat.
|
||||
*
|
||||
* Dient allein der Ankündigung im Bestätigungsdialog — entschieden wird es
|
||||
* in der Datenbank, an denselben Daten, im selben Augenblick.
|
||||
*/
|
||||
export type Vorschau = { feld: string; von: string | null; auf: string | null; bleibt: boolean };
|
||||
|
||||
export function loeschVorschau(
|
||||
eintrag: Eintrag & { id: string; created_at: string },
|
||||
alle: (Eintrag & { id: string; created_at: string })[]
|
||||
): Vorschau[] {
|
||||
return (eintrag.changes ?? []).map((c) => {
|
||||
const spaeter = alle.some(
|
||||
(h) =>
|
||||
h.id !== eintrag.id &&
|
||||
(h.changes ?? []).some((a) => a.feld === c.feld) &&
|
||||
(h.event_date > eintrag.event_date ||
|
||||
(h.event_date === eintrag.event_date && h.created_at > eintrag.created_at))
|
||||
);
|
||||
return { feld: c.feld, von: c.nachher, auf: c.vorher, bleibt: spaeter };
|
||||
});
|
||||
}
|
||||
@@ -502,6 +502,9 @@ export type Database = {
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// 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 };
|
||||
// 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 };
|
||||
|
||||
209
supabase/migrations/20260813140000_delete_history_entry.sql
Normal file
209
supabase/migrations/20260813140000_delete_history_entry.sql
Normal file
@@ -0,0 +1,209 @@
|
||||
-- Einen Historieneintrag zurücknehmen — samt seiner Wirkung.
|
||||
--
|
||||
-- Die Historie ist bewusst fortschreibend: employee_history hat nur Policies
|
||||
-- für select und insert, es gibt kein update und kein delete. Das bleibt so.
|
||||
-- Was hier entsteht, ist ein einzelner kontrollierter Weg daran vorbei, und
|
||||
-- er ist eng: nur eine irrtümlich erfasste Stammdaten- oder Vertragsänderung,
|
||||
-- nur mit Feldwerten, nur wenn sie bereits wirksam ist.
|
||||
--
|
||||
-- ═══ Warum überhaupt löschen ═══
|
||||
--
|
||||
-- Eine Adresse, die versehentlich geändert wurde, steht sonst für immer als
|
||||
-- Änderung in der Akte — und die Person wohnt an der falschen Anschrift, bis
|
||||
-- jemand sie von Hand zurücksetzt. Dieses Zurücksetzen erzeugt dann eine
|
||||
-- *zweite* Änderung, und in der Historie stehen zwei Einträge, von denen
|
||||
-- keiner je stattgefunden hat. Genau das soll der Vorgang hier ersparen.
|
||||
--
|
||||
-- ═══ „Die letztgültige Änderung ist die schlagende" ═══
|
||||
--
|
||||
-- Beim Zurücknehmen wird je Feld einzeln entschieden:
|
||||
--
|
||||
-- * Hat ein **späterer** Eintrag dasselbe Feld angefasst, bleibt der
|
||||
-- heutige Wert stehen — die spätere Änderung ist die gültige.
|
||||
-- * Sonst wird der Wert auf das „vorher" des gelöschten Eintrags gesetzt.
|
||||
--
|
||||
-- Deshalb lässt sich auch der mittlere von drei Einträgen entfernen, ohne
|
||||
-- dass ein alter Wert einen neueren überschreibt.
|
||||
--
|
||||
-- ═══ Was nicht geht, und warum ═══
|
||||
--
|
||||
-- * **Eintritt** — der Anker der Zeitleiste. Ohne ihn hat die Person keinen
|
||||
-- Anfang, und ein Trigger verbietet ohnehin Ereignisse davor.
|
||||
-- * **Zukünftiges** — dazu gehört eine Zeile in pending_org_changes, und
|
||||
-- die lässt sich einem Historieneintrag nicht zuverlässig zuordnen: es
|
||||
-- gibt keinen Schlüssel zwischen beiden, nur Person und Datum. In den
|
||||
-- Daten hängt bereits ein „Eintritt" und eine Vertragsänderung am selben
|
||||
-- Tag. Eine Zuordnung über das Datum träfe irgendwann die falsche Zeile,
|
||||
-- und dann verschwände eine geplante Änderung, die niemand gemeint hat.
|
||||
-- * **Versetzung, Beförderung, Karenz, Rückkehr, Austritt, Wiedereintritt,
|
||||
-- Reorganisation** — die haben Planstellen und Zuordnungen bewegt.
|
||||
-- Dafür gibt es die fachlichen Vorgänge, die das sauber fortschreiben,
|
||||
-- statt rückwärts zu raten.
|
||||
-- * **Einträge ohne Feldwerte** — alles vor der Erweiterung der Historie.
|
||||
-- Es gibt nichts, worauf zurückgesetzt werden könnte.
|
||||
--
|
||||
-- ═══ Der Nachweis bleibt ═══
|
||||
--
|
||||
-- Gelöscht wird die Historienzeile, nicht die Spur: das Audit-Log bekommt
|
||||
-- einen Eintrag mit den Werten der gelöschten Zeile. Das Protokoll ist selbst
|
||||
-- fortschreibend, dort kann nichts verschwinden.
|
||||
|
||||
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_aenderung jsonb;
|
||||
v_feld text;
|
||||
v_wert text;
|
||||
v_spalte text;
|
||||
v_typ text;
|
||||
v_spaeter boolean;
|
||||
v_zurueckgesetzt jsonb := '[]'::jsonb;
|
||||
-- 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 := 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')
|
||||
);
|
||||
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
|
||||
execute format('update employees set %I = coalesce(string_to_array(%L, '', ''), ''{}'') where id = %L',
|
||||
v_spalte, nullif(v_wert, ''), v_eintrag.employee_id);
|
||||
else
|
||||
execute format('update employees set %I = %L::%s where id = %L',
|
||||
v_spalte, nullif(v_wert, ''), v_typ, v_eintrag.employee_id);
|
||||
end if;
|
||||
|
||||
v_zurueckgesetzt := v_zurueckgesetzt || jsonb_build_object(
|
||||
'feld', v_feld,
|
||||
'vorher', v_aenderung->>'nachher',
|
||||
'nachher', v_wert
|
||||
);
|
||||
end loop;
|
||||
|
||||
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 irrtümliche Stammdaten- oder Vertragsänderung zurück: setzt je Feld auf den Wert davor, sofern kein späterer Eintrag dasselbe Feld geändert hat, und entfernt die Historienzeile. Der Vorgang selbst wird im Audit-Log festgehalten. SECURITY DEFINER, weil employee_history absichtlich keine delete-Policy hat.';
|
||||
|
||||
-- Selbstprüfung: lieber laut scheitern als still nichts tun.
|
||||
do $$
|
||||
declare
|
||||
v_def text;
|
||||
begin
|
||||
select pg_get_functiondef('public.delete_history_entry(jsonb)'::regprocedure) into v_def;
|
||||
|
||||
if not (select prosecdef from pg_proc where oid = 'public.delete_history_entry(jsonb)'::regprocedure) then
|
||||
raise exception 'delete_history_entry muss SECURITY DEFINER sein, sonst greift die fehlende delete-Policy';
|
||||
end if;
|
||||
|
||||
if v_def not like '%require_hr_admin%' then
|
||||
raise exception 'delete_history_entry prüft die Berechtigung nicht';
|
||||
end if;
|
||||
|
||||
-- Die delete-Policy darf es weiterhin nicht geben: der Weg hier ist der
|
||||
-- einzige, und er ist geprüft.
|
||||
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 = 'd'
|
||||
) then
|
||||
raise exception 'employee_history hat eine delete-Policy bekommen — das war nicht beabsichtigt';
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
@@ -1,8 +1,29 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render as rtlRender, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HistorieTab } from "@/components/employees/tabs/HistorieTab";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Der Löschknopf hängt an einer Server-Action, und die zieht über lib/db das
|
||||
// Paket `server-only` nach — im Test ein Fehler beim Import. Ersetzt wird
|
||||
// deshalb das Aktionsmodul, nicht die Komponente: geprüft wird hier, was die
|
||||
// Historie *anzeigt*, nicht was die Datenbank tut. Das steht in
|
||||
// tests/unit/history.test.ts und in der Migration selbst.
|
||||
vi.mock("@/actions/employees", () => ({
|
||||
deleteHistoryEntry: vi.fn(async () => ({ success: true })),
|
||||
}));
|
||||
|
||||
// Ausserhalb von Next gibt es keinen Router; die Komponente ruft nach dem
|
||||
// Löschen refresh() auf.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }),
|
||||
}));
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
return rtlRender(<ToastProvider>{ui}</ToastProvider>);
|
||||
}
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
|
||||
// Der Anlass: eine Adressänderung war in der Historie der Person nur als
|
||||
@@ -33,13 +54,13 @@ const ADRESSWECHSEL = eintrag({
|
||||
|
||||
describe("HistorieTab", () => {
|
||||
it("zeigt die alte Anschrift, nicht nur die neue", () => {
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} />);
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
expect(screen.getByText("Feldweg 66")).toBeInTheDocument();
|
||||
expect(screen.getByText("Bahnhofstrasse 5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stellt Vorher und Nachher je Feld gegenüber", () => {
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} />);
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
const zeile = screen.getByText("Adresse").closest("tr")!;
|
||||
const zellen = within(zeile).getAllByRole("cell");
|
||||
expect(zellen[1]).toHaveTextContent("Feldweg 66");
|
||||
@@ -47,17 +68,17 @@ describe("HistorieTab", () => {
|
||||
});
|
||||
|
||||
it("nennt die Zahl der Felder am Aufklapper", () => {
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} />);
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
expect(screen.getByText(/2 Felder im Detail/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("benennt ein einzelnes Feld im Singular", () => {
|
||||
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Ort", vorher: "Brno", nachher: "Linz" }] })]} />);
|
||||
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Ort", vorher: "Brno", nachher: "Linz" }] })]} employeeId="e1" />);
|
||||
expect(screen.getByText(/1 Feld im Detail/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("macht aus einem leeren Vorher-Wert eine Aussage statt einer Lücke", () => {
|
||||
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Telefon", vorher: null, nachher: "0664 1234567" }] })]} />);
|
||||
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Telefon", vorher: null, nachher: "0664 1234567" }] })]} employeeId="e1" />);
|
||||
const zeile = screen.getByText("Telefon").closest("tr")!;
|
||||
expect(within(zeile).getAllByRole("cell")[1]).toHaveTextContent("leer");
|
||||
});
|
||||
@@ -65,14 +86,90 @@ describe("HistorieTab", () => {
|
||||
it("hängt keinen Aufklapper an Ereignisse ohne Feldwerte", () => {
|
||||
// Eintritt, Austritt und die Zeilen von vor dieser Erweiterung haben
|
||||
// keine Werte. Ein leerer Aufklapper würde einen Fehler vermuten lassen.
|
||||
render(<HistorieTab history={[eintrag({ event_type: "Eintritt", description: "Eintritt als Elektrotechniker:in" })]} />);
|
||||
render(<HistorieTab history={[eintrag({ event_type: "Eintritt", description: "Eintritt als Elektrotechniker:in" })]} employeeId="e1" />);
|
||||
expect(screen.getByText("Eintritt als Elektrotechniker:in")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/im Detail/)).not.toBeInTheDocument();
|
||||
expect(document.querySelector("details")).toBeNull();
|
||||
});
|
||||
|
||||
it("bietet das Löschen an einer irrtümlichen Änderung an", () => {
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* löschen/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet es am Eintritt nicht an und sagt, warum", () => {
|
||||
render(
|
||||
<HistorieTab
|
||||
history={[eintrag({ event_type: "Eintritt", changes: [{ feld: "Adresse", vorher: "a", nachher: "b" }] })]}
|
||||
employeeId="e1"
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Anfang der Zeitleiste/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet es an einer Versetzung nicht an", () => {
|
||||
render(
|
||||
<HistorieTab
|
||||
history={[eintrag({ event_type: "Versetzung", changes: [{ feld: "Adresse", vorher: "a", nachher: "b" }] })]}
|
||||
employeeId="e1"
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Planstellen oder den Status/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("bietet es an einer noch nicht wirksamen Änderung nicht an", () => {
|
||||
render(<HistorieTab history={[eintrag({ event_date: "2099-01-01" })]} employeeId="e1" />);
|
||||
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sagt vor dem Löschen, was zurückgesetzt wird und was bleibt", async () => {
|
||||
const user = userEvent.setup();
|
||||
const alt = eintrag({
|
||||
id: "alt",
|
||||
event_date: "2026-08-01",
|
||||
created_at: "2026-08-01T09:00:00.000Z",
|
||||
changes: [
|
||||
{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" },
|
||||
{ feld: "Telefon", vorher: "0664 000", nachher: "0664 111" },
|
||||
],
|
||||
});
|
||||
const neu = eintrag({
|
||||
id: "neu",
|
||||
event_date: "2026-08-10",
|
||||
created_at: "2026-08-10T09:00:00.000Z",
|
||||
changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }],
|
||||
});
|
||||
render(<HistorieTab history={[neu, alt]} employeeId="e1" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Stammdatenänderung vom 01\.08\.2026 löschen/ }));
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
// Das Telefon geht zurück, die Adresse nicht — sie wurde später erneut
|
||||
// geändert, und diese Änderung ist die schlagende.
|
||||
const zurueck = within(dialog).getByRole("heading", { name: "Wird zurückgesetzt" }).parentElement!;
|
||||
expect(within(zurueck).getByText("Telefon")).toBeInTheDocument();
|
||||
expect(within(zurueck).queryByText("Adresse")).not.toBeInTheDocument();
|
||||
|
||||
const bleibt = within(dialog).getByRole("heading", { name: "Bleibt unverändert" }).parentElement!;
|
||||
expect(within(bleibt).getByText("Adresse")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("löscht erst nach ausdrücklicher Bestätigung", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { deleteHistoryEntry } = await import("@/actions/employees");
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /löschen$/ }));
|
||||
expect(deleteHistoryEntry).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Löschen und zurücksetzen" }));
|
||||
expect(deleteHistoryEntry).toHaveBeenCalledWith({ history_id: "h1", employee_id: "e1" });
|
||||
});
|
||||
|
||||
it("bleibt bei leerer Historie bei einem Satz", () => {
|
||||
render(<HistorieTab history={[]} />);
|
||||
render(<HistorieTab history={[]} employeeId="e1" />);
|
||||
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
109
tests/unit/history.test.ts
Normal file
109
tests/unit/history.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { darfGeloeschtWerden, 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 }> = {}) {
|
||||
return {
|
||||
id: "h1",
|
||||
event_type: "Stammdatenänderung" as HistoryEventType,
|
||||
event_date: "2026-08-10",
|
||||
created_at: "2026-08-10T09:00:00.000Z",
|
||||
changes: [{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" }] as AuditChange[] | null,
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
describe("darfGeloeschtWerden", () => {
|
||||
it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => {
|
||||
expect(darfGeloeschtWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true });
|
||||
});
|
||||
|
||||
it("lässt eine Vertragsänderung ebenso zu", () => {
|
||||
expect(darfGeloeschtWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true);
|
||||
});
|
||||
|
||||
it("schützt den Eintritt", () => {
|
||||
const u = darfGeloeschtWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/);
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/);
|
||||
});
|
||||
|
||||
it("zählt den heutigen Tag als wirksam", () => {
|
||||
expect(darfGeloeschtWerden(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);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("loeschVorschau", () => {
|
||||
const alt = eintrag({
|
||||
id: "alt",
|
||||
event_date: "2026-08-01",
|
||||
created_at: "2026-08-01T09:00:00.000Z",
|
||||
changes: [
|
||||
{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" },
|
||||
{ feld: "Telefon", vorher: "0664 000", nachher: "0664 111" },
|
||||
],
|
||||
});
|
||||
const neu = eintrag({
|
||||
id: "neu",
|
||||
event_date: "2026-08-10",
|
||||
created_at: "2026-08-10T09:00:00.000Z",
|
||||
changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }],
|
||||
});
|
||||
|
||||
it("lässt ein Feld stehen, das später erneut geändert wurde", () => {
|
||||
// Das ist die Regel, um die es geht: die letztgültige Änderung schlägt.
|
||||
const v = loeschVorschau(alt, [alt, neu]);
|
||||
expect(v.find((x) => x.feld === "Adresse")!.bleibt).toBe(true);
|
||||
});
|
||||
|
||||
it("setzt ein Feld zurück, das seither niemand angefasst hat", () => {
|
||||
const v = loeschVorschau(alt, [alt, neu]);
|
||||
const tel = v.find((x) => x.feld === "Telefon")!;
|
||||
expect(tel.bleibt).toBe(false);
|
||||
expect(tel.auf).toBe("0664 000");
|
||||
});
|
||||
|
||||
it("setzt beim neuesten Eintrag alles zurück", () => {
|
||||
const v = loeschVorschau(neu, [alt, neu]);
|
||||
expect(v.every((x) => !x.bleibt)).toBe(true);
|
||||
expect(v[0].auf).toBe("Zwischenweg 1");
|
||||
});
|
||||
|
||||
it("unterscheidet zwei Einträge am selben Tag über die Erfassungszeit", () => {
|
||||
const frueh = eintrag({ id: "a", event_date: HEUTE, created_at: `${HEUTE}T08:00:00.000Z` });
|
||||
const spaet = eintrag({ id: "b", event_date: HEUTE, created_at: `${HEUTE}T16:00:00.000Z` });
|
||||
expect(loeschVorschau(frueh, [frueh, spaet])[0].bleibt).toBe(true);
|
||||
expect(loeschVorschau(spaet, [frueh, spaet])[0].bleibt).toBe(false);
|
||||
});
|
||||
|
||||
it("nennt die Richtung so, wie sie im Dialog steht", () => {
|
||||
// von = was jetzt drinsteht, auf = worauf zurückgesetzt wird.
|
||||
const [v] = loeschVorschau(neu, [neu]);
|
||||
expect(v).toMatchObject({ feld: "Adresse", von: "Endstrasse 9", auf: "Zwischenweg 1" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user