Correct an entry date, and stop returns without an absence

Three things, all from the same screenshot.

The entry date can now be corrected. The Eintritt entry gets an edit
button — date only, no delete, because it is the start of the timeline
and a person without one has no beginning. Unlike every other entry it
needs no recorded before-values: the old date is on the employee row, so
this works on rows written long before any of this existed, which is
exactly the case that matters.

What hangs off that date is checked: no other event may precede it, exit
and absence start may not fall before it, and the first position
assignment moves with it — left behind it would leave days of employment
with no post, or a post with nobody in it. Someone already working
cannot be given a future entry date either; without that check a person
who has been here for years could be turned into a planned entry, and
the status derivation would agree.

That last rule came out of the rehearsal finding a hole: my first probe
picked a person with no other history rows, so the "nothing may precede
it" check had nothing to compare against and a date in 2099 sailed
through.

Second, the screenshot showed two returns from one absence, and the data
confirmed it: one person with two Rückkehr entries and a third still
scheduled, recorded while they were long since active. record_karenz_
return never checked that there was an absence to return from. Now it
does, and it refuses a second scheduled return — which would have
silently overwritten the first on its effective date.

Third, the history is filterable: upcoming versus done, a date range,
and the event types that actually occur in that file. The count of
upcoming items shows without filtering, because "what is coming" is the
usual reason to open the tab at all.

Still not deletable: Versetzung, Beförderung, Austritt, Wiedereintritt,
Reorganisation. Undoing those means restoring position assignments, and
that deserves its own step rather than being tacked onto this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 11:31:41 +02:00
parent d2f4a7aab7
commit 861c47b757
5 changed files with 707 additions and 79 deletions

View File

@@ -31,6 +31,7 @@ export function HistorieBearbeiten({
changes,
istZukunft,
heute,
nurDatum = false,
}: {
historyId: string;
employeeId: string;
@@ -43,6 +44,14 @@ export function HistorieBearbeiten({
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
* verloren. */
heute: string;
/**
* Der Eintritt hat keine Felder, die sich zurücknehmen liessen — nur ein
* Datum, das falsch erfasst sein kann. Daran hängt trotzdem einiges: die
* erste Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das und weist
* verständlich ab.
*/
nurDatum?: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
@@ -72,11 +81,14 @@ export function HistorieBearbeiten({
// 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) {
// Beim Eintritt gilt das nicht: er darf in der Vergangenheit *und* in der
// Zukunft liegen — ein geplanter Eintritt ist ein gewöhnlicher Fall. Was
// dort zusammenpassen muss, prüft die Datenbank und sagt es verständlich.
if (!nurDatum && istZukunft && neuesDatum <= heute) {
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
return;
}
if (!istZukunft && neuesDatum > heute) {
if (!nurDatum && !istZukunft && neuesDatum > heute) {
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
return;
}
@@ -127,23 +139,32 @@ export function HistorieBearbeiten({
>
<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."}
{nurDatum
? " — der Eintritt selbst bleibt; berichtigt wird nur sein Datum."
: 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"
label={nurDatum ? "Eintrittsdatum" : "Wirksam ab"}
type="date"
min={istZukunft ? morgen : undefined}
max={istZukunft ? undefined : heute}
min={!nurDatum && istZukunft ? morgen : undefined}
max={!nurDatum && !istZukunft ? heute : undefined}
value={neuesDatum}
onChange={setNeuesDatum}
/>
</div>
<div className="mt-5 overflow-x-auto">
{nurDatum && (
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
Daran hängt mehr als eine Zahl: die erste Planstellenbesetzung wandert mit, und kein anderes Ereignis darf
vor dem Eintritt liegen. Passt das neue Datum nicht dazu, wird die Änderung mit dem Grund abgewiesen.
</p>
)}
<div className={`mt-5 overflow-x-auto ${nurDatum ? "hidden" : ""}`}>
<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">
@@ -176,9 +197,11 @@ export function HistorieBearbeiten({
</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."}{" "}
{nurDatum
? "Stammdaten, Historie und Planstellenbesetzung werden gemeinsam nachgezogen."
: 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>

View File

@@ -1,15 +1,20 @@
"use client";
import { useState } from "react";
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { TextField } from "@/components/ui/Field";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate, todayIso } from "@/lib/format";
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import { darfBearbeitetWerden, darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
// Die Geschichte einer Person — aufklappbar bis auf die Werte, und dort, wo
// ein Eintrag irrtümlich entstanden ist, auch zurücknehmbar.
// Die Geschichte einer Person — aufklappbar bis auf die Werte, filterbar, 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
@@ -21,80 +26,183 @@ type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
// 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.
// Die Knöpfe erscheinen nur an Einträgen, wo sie etwas bewirken können. 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.
type Sicht = "alle" | "anstehend" | "erledigt";
const SICHTEN: { value: Sicht; label: string }[] = [
{ value: "alle", label: "Alle" },
{ value: "anstehend", label: "Anstehend" },
{ value: "erledigt", label: "Gelaufen" },
];
export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; employeeId: string }) {
const today = todayIso();
const [sicht, setSicht] = useState<Sicht>("alle");
const [von, setVon] = useState("");
const [bis, setBis] = useState("");
// Ereignistypen, die in dieser Akte überhaupt vorkommen — eine Auswahl aus
// elf Typen, von denen zehn nie auftauchen, wäre nur Suchaufwand.
const vorhandeneTypen = [...new Set(history.map((h) => h.event_type))];
const [typen, setTypen] = useState<Set<string>>(new Set());
const gefiltert = history.filter((h) => {
if (sicht === "anstehend" && h.event_date <= today) return false;
if (sicht === "erledigt" && h.event_date > today) return false;
if (von && h.event_date < von) return false;
if (bis && h.event_date > bis) return false;
if (typen.size > 0 && !typen.has(h.event_type)) return false;
return true;
});
const anstehend = history.filter((h) => h.event_date > today).length;
const eingeschraenkt = sicht !== "alle" || Boolean(von) || Boolean(bis) || typen.size > 0;
function typUmschalten(typ: string) {
setTypen((prev) => {
const next = new Set(prev);
if (next.has(typ)) next.delete(typ);
else next.add(typ);
return next;
});
}
function zuruecksetzen() {
setSicht("alle");
setVon("");
setBis("");
setTypen(new Set());
}
if (history.length === 0) {
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
}
return (
<ul className="flex flex-col divide-y divide-border">
{history.map((h) => {
const isFuture = h.event_date > today;
const changes = h.changes ?? [];
// Die ganze Historie mitgeben: ob eine Abwesenheit gelöscht werden
// darf, hängt daran, ob es eine spätere Rückkehr gibt.
const urteil = darfKorrigiertWerden(h, today, history);
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>{h.event_type}</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
{isFuture && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
</span>
)}
{urteil.erlaubt && (
<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>
)}
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 rounded border border-border bg-surface px-3 py-2.5">
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl<Sicht> value={sicht} onChange={setSicht} options={SICHTEN} />
{/* Was noch kommt, ist der häufigste Grund, hier hereinzusehen —
deshalb steht die Zahl da, auch ohne dass jemand filtert. */}
{anstehend > 0 && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
{anstehend} anstehend
</span>
)}
<div className="ml-auto flex items-end gap-2">
<TextField label="Von" dense type="date" value={von} onChange={setVon} className="w-40" />
<TextField label="Bis" dense type="date" value={bis} onChange={setBis} className="w-40" />
</div>
</div>
{changes.length > 0 && (
<details className="group mt-1.5">
<summary
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
›
</span>
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
</summary>
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
<AenderungsTabelle changes={changes} />
</div>
{!urteil.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{urteil.grund}</p>}
</details>
{vorhandeneTypen.length > 1 && (
<div className="flex flex-wrap items-center gap-1.5">
{vorhandeneTypen.map((typ) => (
<button
key={typ}
type="button"
onClick={() => typUmschalten(typ)}
aria-pressed={typen.has(typ)}
className={`rounded-full px-2 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
typen.has(typ) ? actionBadgeStyle(typ) : "bg-white text-ink-muted hover:text-ink"
}`}
>
{typ}
</button>
))}
{eingeschraenkt && (
<button
type="button"
onClick={zuruecksetzen}
className="ml-auto rounded text-xs text-ink-muted hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
Filter zurücksetzen
</button>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
{gefiltert.length === 0 ? (
<p className="text-sm text-ink-muted">
Kein Eintrag passt zu dieser Auswahl. {history.length} {history.length === 1 ? "Eintrag" : "Einträge"} sind
vorhanden.
</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{gefiltert.map((h) => {
const isFuture = h.event_date > today;
const changes = h.changes ?? [];
// Die ganze Historie mitgeben, nicht die gefilterte: ob eine
// Abwesenheit gelöscht werden darf, hängt an einer späteren
// Rückkehr — auch wenn die gerade ausgeblendet ist.
const loeschbar = darfKorrigiertWerden(h, today, history);
const bearbeitbar = darfBearbeitetWerden(h, today, history);
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
{h.event_type}
</span>
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
{isFuture && (
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
</span>
)}
<span className="ml-auto flex items-center gap-0.5">
{bearbeitbar.erlaubt && (
<HistorieBearbeiten
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
changes={changes}
istZukunft={isFuture}
heute={today}
nurDatum={h.event_type === "Eintritt"}
/>
)}
{loeschbar.erlaubt && (
<HistorieLoeschen
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
vorschau={loeschVorschau(h, history)}
istZukunft={isFuture}
/>
)}
</span>
</div>
<p className="mt-1 text-sm text-ink">{h.description}</p>
{changes.length > 0 && (
<details className="group mt-1.5">
<summary
className="inline-flex cursor-pointer list-none items-center gap-1 rounded text-xs font-semibold text-brand-700
hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<span className="transition-transform group-open:rotate-90" aria-hidden="true">
›
</span>
{changes.length} {changes.length === 1 ? "Feld" : "Felder"} im Detail
</summary>
<div className="mt-2 rounded border border-border bg-surface px-3 py-2">
<AenderungsTabelle changes={changes} />
</div>
{!loeschbar.erlaubt && <p className="mt-1.5 text-xs text-ink-muted">{loeschbar.grund}</p>}
</details>
)}
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -86,6 +86,21 @@ export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eint
return { erlaubt: true };
}
/**
* Bearbeiten ist weiter gefasst als Löschen.
*
* Der Eintritt lässt sich nicht löschen — er ist der Anfang der Zeitleiste,
* und ohne ihn hätte die Person keinen. Sein **Datum** kann aber falsch
* erfasst sein, und dann hängt daran mehr als eine Zahl: die erste
* Planstellenbesetzung, der frühestmögliche Zeitpunkt jedes weiteren
* Ereignisses, die Zugehörigkeit. Die Datenbank prüft das alles beim Ändern;
* hier geht es nur darum, den Knopf überhaupt anzubieten.
*/
export function darfBearbeitetWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
if (eintrag.event_type === "Eintritt") return { erlaubt: true };
return darfKorrigiertWerden(eintrag, heute, alle);
}
/** Später im Sinne der Anzeige: erst das Datum, dann die Erfassungszeit. */
function spaeter(a: Eintrag, b: Eintrag): boolean {
if (a.event_date !== b.event_date) return a.event_date > b.event_date;

View File

@@ -0,0 +1,413 @@
-- Das Eintrittsdatum berichtigen — und eine Rückkehr nur aus einer Abwesenheit.
--
-- ═══ Eine Rückkehr setzt eine Abwesenheit voraus ═══
--
-- Diese Prüfung fehlte. In den Daten steht eine Person mit **zwei** Rückkehren
-- zu einer Abwesenheit: die zweite wurde erfasst, als sie längst wieder aktiv
-- war, und eine dritte war noch geplant. Der Status ergäbe sich danach aus
-- einem Ereignis, das nie stattgefunden hat.
--
-- Zwei Bedingungen also: die Person muss abwesend sein, und es darf nicht
-- schon eine Rückkehr geplant sein — eine zweite würde die erste am Stichtag
-- stillschweigend überschreiben.
--
-- ═══ Das Eintrittsdatum ═══
--
-- Der Eintritt ist der Anfang der Zeitleiste und lässt sich nicht löschen.
-- Sein Datum kann aber falsch erfasst sein, und dann hängt daran mehr als
-- eine Zahl. Beim Ändern wird deshalb geprüft:
--
-- * Kein anderes Ereignis darf davor liegen — ein Trigger verbietet es
-- ohnehin, hier steht der Grund lesbar statt als Auslösermeldung.
-- * Austritt und Abwesenheitsbeginn dürfen nicht davor rutschen.
-- * Die erste Planstellenbesetzung wandert mit. Bliebe sie stehen, gäbe es
-- Tage mit Beschäftigung ohne Stelle oder umgekehrt.
--
-- Anders als die übrigen Einträge braucht der Eintritt dafür keine in
-- `changes` hinterlegten Vorher-Werte: der alte Wert steht in employees.
-- Damit funktioniert das auch für Zeilen, die lange vor dieser Erweiterung
-- entstanden sind — und das ist der Fall, um den es geht.
CREATE OR REPLACE FUNCTION public.record_karenz_return(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_return_date date := (payload->>'return_date')::date;
v_name text;
v_employment_type employment_type;
v_weekly_hours numeric;
v_karenz_start date;
v_absence_type text;
v_old employees%rowtype;
-- Wie bei der Abwesenheit: ohne Vorher-Werte liesse sich eine
-- irrtümlich erfasste Rückkehr nicht zurücknehmen.
v_changes jsonb := '[]'::jsonb;
-- Warum jemand mit weniger Stunden zurückkommt: Wiedereingliederungs-
-- oder Elternteilzeit. Nur bedeutsam, wenn überhaupt reduziert wird.
v_grund text := nullif(payload->>'reduction_reason', '');
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_karenz_start := v_old.karenz_start_date;
v_absence_type := v_old.absence_type;
-- Ohne Abwesenheit keine Rückkehr. Die Prüfung fehlte, und in den Daten
-- steht eine Person mit zwei Rückkehren zu einer Abwesenheit: die zweite
-- wurde erfasst, als sie längst wieder aktiv war. Der Status wäre danach
-- aus einem Ereignis abgeleitet, das nie stattgefunden hat.
if v_old.status <> 'Karenz' and v_old.karenz_start_date is null then
raise exception 'Diese Person ist nicht abwesend — eine Rückkehr gibt es nur aus einer Abwesenheit.';
end if;
-- Und nur eine: eine zweite geplante Rückkehr würde die erste am
-- Stichtag stillschweigend überschreiben.
if exists (
select 1 from pending_org_changes p
where p.employee_id = v_employee_id and p.change_type = 'karenz_return' and p.status = 'pending'
) then
raise exception 'Für diese Person ist bereits eine Rückkehr geplant. Sie muss zuerst zurückgenommen werden.';
end if;
if v_karenz_start is not null and v_return_date <= v_karenz_start then
raise exception 'Das Rückkehrdatum muss nach dem Beginn der Langzeitabwesenheit (%) liegen.', v_karenz_start;
end if;
if payload->>'employment_mode' = 'Vollzeit' then
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
elsif payload->>'employment_mode' = 'Teilzeit' then
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
end if;
if v_return_date <= current_date then
-- Keine Manager-Nachführung mehr nötig: wer aus der Abwesenheit
-- zurückkehrt, ist wieder anwesend, und die abgeleitete Berichtslinie
-- fällt automatisch von der Vertretung auf ihn zurück.
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
absence_type = null,
employment_type = coalesce(v_employment_type, employment_type),
weekly_hours = coalesce(v_weekly_hours, weekly_hours),
-- Kehrt jemand reduziert zurück, ist der Grund dafür ein Zustand,
-- kein Einmalereignis: danach lässt sich auswerten, wer gerade in
-- Eltern- oder Wiedereingliederungsteilzeit ist.
teilzeit_art = case when payload->>'employment_mode' = 'Teilzeit' then v_grund else teilzeit_art end,
teilzeit_bis = case
when payload->>'employment_mode' = 'Teilzeit' and v_grund is not null
then nullif(payload->>'teilzeit_bis', '')::date
when payload->>'employment_mode' = 'Teilzeit' then null
else teilzeit_bis end
where id = v_employee_id;
else
update employees set karenz_return_date = v_return_date where id = v_employee_id;
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'karenz_return', v_return_date,
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours,
'teilzeit_art', v_grund, 'teilzeit_bis', nullif(payload->>'teilzeit_bis', '')));
end if;
if v_return_date <= current_date then
v_changes := app_aenderung(v_changes, 'Status', v_old.status::text, 'Aktiv');
v_changes := app_aenderung(v_changes, 'Art der Abwesenheit', v_old.absence_type, null);
v_changes := app_aenderung(v_changes, 'Abwesend ab', v_old.karenz_start_date::text, null);
v_changes := app_aenderung(v_changes, 'Geplante Rückkehr', v_old.karenz_return_date::text, null);
if v_employment_type is not null then
v_changes := app_aenderung(v_changes, 'Beschäftigungsausmaß', v_old.employment_type::text, v_employment_type::text);
v_changes := app_aenderung(v_changes, 'Wochenstunden', v_old.weekly_hours::text, v_weekly_hours::text);
end if;
if payload->>'employment_mode' = 'Teilzeit' then
v_changes := app_aenderung(v_changes, 'Teilzeitvariante', v_old.teilzeit_art, v_grund);
v_changes := app_aenderung(v_changes, 'Teilzeit bis', v_old.teilzeit_bis::text,
case when v_grund is not null then nullif(payload->>'teilzeit_bis', '') else null end);
end if;
end if;
insert into employee_history (employee_id, event_date, event_type, description, changes)
values (v_employee_id, v_return_date, 'Rückkehr',
'Rückkehr aus ' || coalesce(v_absence_type, 'Langzeitabwesenheit') || ' am ' || v_return_date
|| case when payload->>'employment_mode' = 'Teilzeit'
then ', reduziert auf ' || (payload->>'weekly_hours') || ' h'
|| coalesce(' (' || v_grund || ')', '')
else '' end,
v_changes);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date || coalesce(' — ' || v_grund, ''));
end;
$function$;
CREATE OR REPLACE FUNCTION public.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;
v_person employees%rowtype;
v_fruehestes date;
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;
-- ── Der Eintritt: nur das Datum, dafür ohne Vorher-Werte ────────
--
-- Er ist der Anfang der Zeitleiste und hat keine Felder, die sich
-- zurücknehmen liessen — wohl aber ein Datum, das falsch erfasst sein
-- kann. Anders als die übrigen Einträge braucht er dafür keine in
-- changes hinterlegten Werte: der alte Wert steht in employees.
-- Deshalb funktioniert das auch für Zeilen, die lange vor dieser
-- Erweiterung entstanden sind.
if v_eintrag.event_type = 'Eintritt' then
v_datum := coalesce(nullif(payload->>'event_date', '')::date, v_eintrag.event_date);
select * into v_person from employees where id = v_eintrag.employee_id;
if v_datum = v_person.entry_date then
raise exception 'Nichts geändert.';
end if;
-- Nichts darf vor dem Eintritt liegen. Ein Trigger verbietet es
-- ohnehin; hier steht der Grund lesbar statt als Auslösermeldung.
select min(h.event_date) into v_fruehestes
from employee_history h
where h.employee_id = v_eintrag.employee_id and h.id <> v_eintrag.id;
if v_fruehestes is not null and v_datum > v_fruehestes then
raise exception 'Am % steht bereits ein Ereignis. Der Eintritt kann nicht danach liegen.', v_fruehestes;
end if;
-- Wer schon angefangen hat, kann nicht künftig anfangen. Ohne diese
-- Prüfung liesse sich eine aktive Person durch ein Datum in der Zukunft
-- rückwirkend in einen geplanten Eintritt verwandeln — die Ableitung
-- sagt dann „Geplant", obwohl die Person seit Jahren da ist. Bei jemandem
-- ohne weitere Ereignisse greift sonst überhaupt nichts.
if v_person.status in ('Aktiv', 'Karenz') and v_datum > current_date then
raise exception 'Diese Person arbeitet bereits. Der Eintritt kann nicht in der Zukunft liegen.';
end if;
if v_person.exit_date is not null and v_datum > v_person.exit_date then
raise exception 'Der Austritt am % läge dann vor dem Eintritt.', v_person.exit_date;
end if;
if v_person.karenz_start_date is not null and v_datum > v_person.karenz_start_date then
raise exception 'Die Abwesenheit ab % läge dann vor dem Eintritt.', v_person.karenz_start_date;
end if;
-- Die erste Planstellenbesetzung beginnt mit dem Eintritt und wandert
-- mit. Bliebe sie stehen, gäbe es Tage mit Beschäftigung ohne Stelle
-- oder umgekehrt.
update position_assignments
set valid_from = v_datum
where employee_id = v_eintrag.employee_id
and valid_from = v_person.entry_date
and (valid_to is null or valid_to > v_datum);
update employees set entry_date = v_datum where id = v_eintrag.employee_id;
update employee_history
set event_date = v_datum,
description = regexp_replace(description, '^Eintritt', 'Eintritt')
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(), 'Eintrittsdatum berichtigt',
v_person.first_name || ' ' || v_person.last_name, v_eintrag.employee_id,
'Eintritt vom ' || v_eintrag.event_date || ' auf ' || v_datum || ' berichtigt',
jsonb_build_array(jsonb_build_object('feld', 'Eintrittsdatum',
'vorher', v_eintrag.event_date::text, 'nachher', v_datum::text)));
return;
end if;
if v_eintrag.event_type not in ('Stammdatenänderung', 'Vertragsänderung', 'Karenz', 'Rückkehr') then
raise exception 'Dieser Vorgang lässt sich hier nicht berichtigen. Für % gibt es den passenden Weg.', v_eintrag.event_type;
end if;
if v_eintrag.event_type in ('Karenz', 'Rückkehr') and v_eintrag.event_date > current_date then
raise exception 'Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst 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_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$;
-- Selbstprüfung.
do $$
declare
v_ret text := pg_get_functiondef('public.record_karenz_return(jsonb)'::regprocedure);
v_upd text := pg_get_functiondef('public.update_history_entry(jsonb)'::regprocedure);
begin
if v_ret not like '%nicht abwesend%' then
raise exception 'record_karenz_return prüft die Abwesenheit nicht';
end if;
if v_ret not like '%bereits eine Rückkehr geplant%' then
raise exception 'record_karenz_return lässt zwei geplante Rückkehren zu';
end if;
if v_upd not like '%set entry_date = v_datum%' then
raise exception 'update_history_entry ändert das Eintrittsdatum nicht';
end if;
if v_upd not like '%Der Eintritt kann nicht danach liegen%' then
raise exception 'Die Abhängigkeitsprüfung zum Eintritt fehlt';
end if;
end
$$;

View File

@@ -234,3 +234,72 @@ describe("HistorieTab", () => {
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
});
});
// Eine Akte wird meist aus einem von zwei Gründen aufgeschlagen: „was steht
// an" oder „was war damals". Ohne Filter muss man dafür durch alles scrollen,
// und je länger jemand da ist, desto weniger taugt das.
describe("HistorieTab — Filter", () => {
const VERGANGEN = eintrag({ id: "v", event_type: "Versetzung", event_date: "2020-05-01", description: "Versetzung damals" });
const HEUTE_EINTRAG = eintrag({ id: "h", event_type: "Stammdatenänderung", event_date: "2026-03-02", description: "Heute-Eintrag" });
const KUENFTIG = eintrag({ id: "z", event_type: "Versetzung", event_date: "2099-01-01", description: "Versetzung künftig" });
const ALLE = [KUENFTIG, HEUTE_EINTRAG, VERGANGEN];
it("zeigt ohne Filter alles", () => {
render(<HistorieTab history={ALLE} employeeId="e1" />);
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
expect(screen.getByText("Versetzung künftig")).toBeInTheDocument();
});
it("nennt die Zahl der anstehenden Punkte, auch ohne dass jemand filtert", () => {
render(<HistorieTab history={ALLE} employeeId="e1" />);
expect(screen.getByText("1 anstehend")).toBeInTheDocument();
});
it("blendet mit „Anstehend“ alles Gelaufene aus", async () => {
const user = userEvent.setup();
render(<HistorieTab history={ALLE} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: "Anstehend" }));
expect(screen.getByText("Versetzung künftig")).toBeInTheDocument();
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
});
it("blendet mit „Gelaufen“ das Anstehende aus", async () => {
const user = userEvent.setup();
render(<HistorieTab history={ALLE} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: "Gelaufen" }));
expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument();
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
});
it("grenzt über den Zeitraum ein", async () => {
const user = userEvent.setup();
render(<HistorieTab history={ALLE} employeeId="e1" />);
await user.type(screen.getByLabelText("Von"), "2026-01-01");
expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument();
expect(screen.getByText("Heute-Eintrag")).toBeInTheDocument();
});
it("filtert nach Ereignisart", async () => {
const user = userEvent.setup();
render(<HistorieTab history={ALLE} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: "Versetzung", pressed: false }));
expect(screen.getByText("Versetzung damals")).toBeInTheDocument();
expect(screen.queryByText("Heute-Eintrag")).not.toBeInTheDocument();
});
it("sagt bei leerem Ergebnis, wie viele Einträge es überhaupt gibt", async () => {
const user = userEvent.setup();
render(<HistorieTab history={[VERGANGEN]} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: "Anstehend" }));
expect(screen.getByText(/Kein Eintrag passt zu dieser Auswahl/)).toBeInTheDocument();
expect(screen.getByText(/1 Eintrag sind vorhanden|1 Eintrag/)).toBeInTheDocument();
});
it("bietet das Eintrittsdatum zum Bearbeiten an, aber nicht zum Löschen", () => {
// Der Eintritt ist der Anfang der Zeitleiste und bleibt — sein Datum kann
// trotzdem falsch erfasst sein.
render(<HistorieTab history={[eintrag({ event_type: "Eintritt", changes: null })]} employeeId="e1" />);
expect(screen.getByRole("button", { name: /Eintritt vom .* bearbeiten/ })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
});
});