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>
);
}