Let planned changes be taken back and corrected too

Deleting and correcting a history entry stopped at the present: anything
not yet effective stayed put. That was not a principle, it was a missing
link. A planned change lives as a payload in pending_org_changes, and
nothing tied it to the history row — only a person and a date, and the
data already holds an Eintritt and a Vertragsänderung sharing one. So
employee_history now carries pending_id, set by change_employee_data
when it schedules something.

One planned change can carry two history rows: Stammdaten and Vertrag
are kept apart but scheduled together. Taking one back therefore strips
only that group's fields from the payload, and cancels the operation
only when nothing is left. Correcting one rewrites its group and the
effective date, and touches no employee data — the change has not
happened yet.

An entry stays on its side of the present. Pulling a planned change into
today, or pushing an effective one into the future, would mean adjusting
the employee record and the pending payload in opposite directions;
that is what the real operations are for.

Existing rows were linked where exactly one running operation matched
the person and date and no other row had claimed it. All five of them
matched. Anything ambiguous would have kept the old refusal, which now
says the actual reason.

The edit dialog surfaced a bug in useDialogFocus that predates it: the
effect depended on the identity of onClose, which almost every caller
rebuilds on render, so it re-ran after each keystroke and its cleanup
pulled focus back to whatever opened the dialog. Any dialog with a text
field would have accepted one character. It never showed because until
now no dialog kept its own state next to its own onClose.

Rehearsed against real data: a two-row planned change corrected, one row
taken back with the operation continuing on the rest, the second taken
back with the operation cancelled, and both refusals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 21:35:51 +02:00
parent 9308096754
commit 08d2740690
13 changed files with 1453 additions and 41 deletions

View File

@@ -0,0 +1,187 @@
"use client";
import { Pencil } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { updateHistoryEntry } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { TextField } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import type { AuditChange } from "@/lib/supabase/types";
// Berichtigen, nicht neu erfassen.
//
// „Daten ändern" schreibt eine *neue* Änderung — richtig, wenn sich etwas
// wirklich geändert hat. Hier geht es um den anderen Fall: der Vorgang
// stimmt, aber der erfasste Wert oder das Datum nicht. Ohne diesen Weg
// stünden in der Akte zwei Einträge für eine Änderung, von denen der erste
// nie stattgefunden hat.
//
// Bearbeitet wird nur das **Nachher**. Das Vorher steht daneben, unveränder-
// lich: es beschreibt, was vor der Änderung galt, und das lässt sich
// nachträglich nicht anders beschliessen.
export function HistorieBearbeiten({
historyId,
employeeId,
bezeichnung,
datum,
changes,
istZukunft,
heute,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
changes: AuditChange[];
/** Noch nicht wirksam — dann wird der geplante Vorgang berichtigt, nicht der Stand. */
istZukunft: boolean;
/** Vom Server, nicht aus new Date(): sonst rechnet der Browser mit seiner
* eigenen Zeitzone, und in einer Renderfunktion hat die Uhr ohnehin nichts
* verloren. */
heute: string;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
const [neuesDatum, setNeuesDatum] = useState(datum);
const [werte, setWerte] = useState<Record<string, string>>(() =>
Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""]))
);
const { showToast } = useToast();
const router = useRouter();
// Morgen aus dem Serverdatum, nicht aus der Uhr des Browsers.
const morgen = new Date(Date.parse(heute + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
const etwasGeaendert =
neuesDatum !== datum || changes.some((c) => (werte[c.feld] ?? "") !== (c.nachher ?? ""));
function abbrechen() {
// Beim Schliessen zurück auf den gespeicherten Stand, damit ein zweites
// Öffnen nicht die verworfenen Eingaben zeigt.
setNeuesDatum(datum);
setWerte(Object.fromEntries(changes.map((c) => [c.feld, c.nachher ?? ""])));
setOffen(false);
}
async function speichern() {
// Ein Eintrag bleibt auf seiner Seite der Gegenwart. Eine gelaufene
// Änderung in eine geplante zu verwandeln (oder umgekehrt) hiesse,
// Stammdaten und Vorgang gegenläufig anzupassen — dafür gibt es die
// fachlichen Vorgänge. Die Datenbank weist es ohnehin ab; hier steht es
// nur früher und freundlicher.
if (istZukunft && neuesDatum <= heute) {
showToast("Eine geplante Änderung lässt sich hier nicht vorziehen.", "error");
return;
}
if (!istZukunft && neuesDatum > heute) {
showToast("Eine bereits wirksame Änderung lässt sich nicht in die Zukunft verschieben.", "error");
return;
}
setLaeuft(true);
const ergebnis = await updateHistoryEntry({
history_id: historyId,
employee_id: employeeId,
event_date: neuesDatum,
werte: changes.map((c) => ({ feld: c.feld, nachher: werte[c.feld]?.trim() || null })),
});
setLaeuft(false);
if (ergebnis.success) {
showToast("Eintrag berichtigt.");
setOffen(false);
router.refresh();
} else {
showToast(ergebnis.error ?? "Berichtigen fehlgeschlagen.", "error");
}
}
return (
<>
<button
type="button"
onClick={() => setOffen(true)}
aria-label={`${bezeichnung} vom ${fmtDate(datum)} bearbeiten`}
className="rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Modal
open={offen}
onClose={abbrechen}
title="Eintrag berichtigen"
widthClassName="max-w-2xl"
footer={
<>
<Button variant="ghost" onClick={abbrechen}>
Abbrechen
</Button>
<Button onClick={speichern} pending={laeuft} disabled={!etwasGeaendert}>
Berichtigen
</Button>
</>
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong>
{istZukunft
? " — diese Änderung ist noch nicht wirksam. Berichtigt wird, was am Stichtag passieren soll."
: " — was hier stand, war falsch erfasst. Für eine tatsächliche Änderung ist „Daten ändern“ der richtige Weg."}
</p>
<div className="mt-4 max-w-xs">
<TextField
label="Wirksam ab"
type="date"
min={istZukunft ? morgen : undefined}
max={istZukunft ? undefined : heute}
value={neuesDatum}
onChange={setNeuesDatum}
/>
</div>
<div className="mt-5 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
<th className="py-2 pr-4">Feld</th>
<th className="py-2 pr-4">Vorher</th>
<th className="py-2">Nachher</th>
</tr>
</thead>
<tbody>
{changes.map((c) => (
<tr key={c.feld} className="border-b border-border-subtle align-middle last:border-0">
<td className="py-2 pr-4 font-semibold text-ink-body">{c.feld}</td>
<td className="py-2 pr-4 text-ink-muted">
{c.vorher === null || c.vorher === "" ? <span className="italic">leer</span> : c.vorher}
</td>
<td className="py-2">
<input
type="text"
aria-label={`${c.feld} — neuer Wert`}
value={werte[c.feld] ?? ""}
onChange={(e) => setWerte((v) => ({ ...v, [c.feld]: e.target.value }))}
className="w-full rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-4 rounded bg-surface px-3 py-2 text-xs text-ink-muted">
{istZukunft
? "An den Stammdaten ändert sich jetzt nichts — die Änderung greift erst am Stichtag. Berichtigt wird der geplante Vorgang selbst."
: "Die Stammdaten werden nachgezogen — je Feld gilt dann der jüngste Eintrag, der es trägt. Hat eine spätere Änderung dasselbe Feld erneut gesetzt, bleibt deren Wert stehen."}{" "}
Die Berichtigung selbst steht im Protokoll.
</p>
</Modal>
</>
);
}

View File

@@ -24,12 +24,15 @@ export function HistorieLoeschen({
bezeichnung,
datum,
vorschau,
istZukunft,
}: {
historyId: string;
employeeId: string;
bezeichnung: string;
datum: string;
vorschau: Vorschau[];
/** Noch nicht wirksam — dann wird der geplante Vorgang entschärft. */
istZukunft: boolean;
}) {
const [offen, setOffen] = useState(false);
const [laeuft, setLaeuft] = useState(false);
@@ -80,10 +83,30 @@ export function HistorieLoeschen({
}
>
<p className="text-sm text-ink-body">
<strong className="text-ink">{bezeichnung}</strong> vom {fmtDate(datum)} wird aus der Historie entfernt.
<strong className="text-ink">{bezeichnung}</strong> {istZukunft ? "zum" : "vom"} {fmtDate(datum)} wird aus der
Historie entfernt.
</p>
{zurueck.length > 0 && (
{istZukunft && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird nicht mehr passieren</h3>
<ul className="mt-1.5 flex flex-col gap-1">
{vorschau.map((v) => (
<li key={v.feld} className="text-sm text-ink-body">
<span className="font-semibold text-ink">{v.feld}</span>{" "}
<span className="text-ink-muted">sollte auf</span> <span className="text-ink">{v.von || "leer"}</span>{" "}
<span className="text-ink-muted">gesetzt werden</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-ink-muted">
An den Stammdaten ändert sich nichts — die Änderung war noch nicht wirksam. Betrifft der geplante Vorgang
noch weitere Felder, läuft er mit diesen weiter.
</p>
</div>
)}
{!istZukunft && zurueck.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Wird zurückgesetzt</h3>
<ul className="mt-1.5 flex flex-col gap-1">
@@ -98,7 +121,7 @@ export function HistorieLoeschen({
</div>
)}
{bleibt.length > 0 && (
{!istZukunft && bleibt.length > 0 && (
<div className="mt-4">
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">Bleibt unverändert</h3>
<ul className="mt-1.5 flex flex-col gap-1">

View File

@@ -1,8 +1,9 @@
import { HistorieBearbeiten } from "@/components/employees/HistorieBearbeiten";
import { HistorieLoeschen } from "@/components/employees/HistorieLoeschen";
import { AenderungsTabelle } from "@/components/ui/AenderungsTabelle";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate, todayIso } from "@/lib/format";
import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history";
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
@@ -37,7 +38,7 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
{history.map((h) => {
const isFuture = h.event_date > today;
const changes = h.changes ?? [];
const urteil = darfGeloeschtWerden(h, today);
const urteil = darfKorrigiertWerden(h, today);
return (
<li key={h.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
@@ -49,13 +50,23 @@ export function HistorieTab({ history, employeeId }: { history: HistoryRow[]; em
</span>
)}
{urteil.erlaubt && (
<span className="ml-auto">
<span className="ml-auto flex items-center gap-0.5">
<HistorieBearbeiten
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
changes={changes}
istZukunft={isFuture}
heute={today}
/>
<HistorieLoeschen
historyId={h.id}
employeeId={employeeId}
bezeichnung={h.event_type}
datum={h.event_date}
vorschau={loeschVorschau(h, history)}
istZukunft={isFuture}
/>
</span>
)}