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

@@ -12,6 +12,7 @@ import type { Database } from "@/lib/supabase/types";
// tests/unit/history.test.ts und in der Migration selbst.
vi.mock("@/actions/employees", () => ({
deleteHistoryEntry: vi.fn(async () => ({ success: true })),
updateHistoryEntry: vi.fn(async () => ({ success: true })),
}));
// Ausserhalb von Next gibt es keinen Router; die Komponente ruft nach dem
@@ -40,6 +41,7 @@ function eintrag(teil: Partial<HistoryRow>): HistoryRow {
event_type: "Stammdatenänderung",
description: "Geänderte Felder: Adresse, Ort, wirksam ab 2026-03-02",
changes: null,
pending_id: null,
created_at: "2026-03-02T08:00:00.000Z",
...teil,
};
@@ -168,6 +170,65 @@ describe("HistorieTab", () => {
expect(deleteHistoryEntry).toHaveBeenCalledWith({ history_id: "h1", employee_id: "e1" });
});
it("bietet neben dem Löschen auch das Berichtigen an", () => {
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* bearbeiten/ })).toBeInTheDocument();
});
it("bietet das Berichtigen dort nicht an, wo auch nicht gelöscht werden darf", () => {
render(<HistorieTab history={[eintrag({ event_type: "Versetzung" })]} employeeId="e1" />);
expect(screen.queryByRole("button", { name: /bearbeiten/ })).not.toBeInTheDocument();
});
it("zeigt beim Berichtigen das Vorher unveränderlich und das Nachher zum Tippen", async () => {
const user = userEvent.setup();
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
const dialog = screen.getByRole("dialog");
// Das Vorher ist Geschichte und steht nur da.
expect(within(dialog).getByText("Feldweg 66")).toBeInTheDocument();
expect(within(dialog).queryByDisplayValue("Feldweg 66")).not.toBeInTheDocument();
// Das Nachher lässt sich ändern.
expect(within(dialog).getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5");
});
it("schickt nur ab, wenn wirklich etwas anders ist", async () => {
const user = userEvent.setup();
const { updateHistoryEntry } = await import("@/actions/employees");
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
const knopf = screen.getByRole("button", { name: "Berichtigen" });
expect(knopf).toBeDisabled();
const feld = screen.getByLabelText("Adresse — neuer Wert");
await user.clear(feld);
await user.type(feld, "Bahnhofstraße 5");
expect(knopf).toBeEnabled();
await user.click(knopf);
expect(updateHistoryEntry).toHaveBeenCalledWith(
expect.objectContaining({
history_id: "h1",
employee_id: "e1",
werte: expect.arrayContaining([{ feld: "Adresse", nachher: "Bahnhofstraße 5" }]),
})
);
});
it("verwirft die Eingaben beim Abbrechen", async () => {
const user = userEvent.setup();
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
await user.clear(screen.getByLabelText("Adresse — neuer Wert"));
await user.type(screen.getByLabelText("Adresse — neuer Wert"), "Verworfen 1");
await user.click(screen.getByRole("button", { name: "Abbrechen" }));
await user.click(screen.getByRole("button", { name: /bearbeiten/ }));
expect(screen.getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5");
});
it("bleibt bei leerer Historie bei einem Satz", () => {
render(<HistorieTab history={[]} employeeId="e1" />);
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();

View File

@@ -25,7 +25,33 @@ function ModalHarness({ onClose = () => {} }: { onClose?: () => void }) {
);
}
// Ein Dialog, dessen onClose bei jedem Rendern neu entsteht — und dessen
// Zustand im selben Bauteil liegt, sodass jeder Tastendruck neu rendert. Das
// ist der Normalfall für ein Formular im Dialog.
function DialogMitEigenemZustand() {
const [offen, setOffen] = useState(true);
const [wert, setWert] = useState("");
if (!offen) return <p>zu</p>;
return (
<Modal open onClose={() => setOffen(false)} title="Tippen" footer={<Button>Speichern</Button>}>
<TextField label="Feld" value={wert} onChange={setWert} />
</Modal>
);
}
describe("Modal focus management", () => {
it("lässt mehr als ein Zeichen tippen", async () => {
// Der Fokus-Wächter hing an der Identität von onClose. Die entsteht bei
// fast jedem Aufrufer bei jedem Rendern neu, also lief der Effekt nach
// jedem Tastendruck erneut — und sein Aufräumen holte den Fokus dorthin
// zurück, wo er vor dem Öffnen war. Nach dem ersten Zeichen ging die
// Eingabe ins Leere.
render(<DialogMitEigenemZustand />);
const feld = screen.getByLabelText("Feld");
await userEvent.type(feld, "Bahnhofstrasse");
expect(feld).toHaveValue("Bahnhofstrasse");
});
it("names the dialog from its visible heading", () => {
render(<ModalHarness />);
expect(screen.getByRole("dialog", { name: "Testdialog" })).toBeInTheDocument();

View File

@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history";
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { AuditChange, HistoryEventType } from "@/lib/supabase/types";
const HEUTE = "2026-08-13";
function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null }> = {}) {
function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null; pending_id: string | null }> = {}) {
return {
id: "h1",
event_type: "Stammdatenänderung" as HistoryEventType,
@@ -15,17 +15,17 @@ function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event
};
}
describe("darfGeloeschtWerden", () => {
describe("darfKorrigiertWerden", () => {
it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => {
expect(darfGeloeschtWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true });
expect(darfKorrigiertWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true });
});
it("lässt eine Vertragsänderung ebenso zu", () => {
expect(darfGeloeschtWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true);
expect(darfKorrigiertWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true);
});
it("schützt den Eintritt", () => {
const u = darfGeloeschtWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE);
const u = darfKorrigiertWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/);
});
@@ -33,25 +33,31 @@ describe("darfGeloeschtWerden", () => {
it.each<HistoryEventType>(["Versetzung", "Beförderung", "Karenz", "Rückkehr", "Austritt", "Wiedereintritt", "Reorganisation"])(
"verweist bei %s auf den fachlichen Vorgang",
(typ) => {
const u = darfGeloeschtWerden(eintrag({ event_type: typ }), HEUTE);
const u = darfKorrigiertWerden(eintrag({ event_type: typ }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/passenden Vorgang|Planstellen oder den Status/);
}
);
it("lässt Zukünftiges in Ruhe — daran hängt ein geplanter Vorgang", () => {
const u = darfGeloeschtWerden(eintrag({ event_date: "2026-09-01" }), HEUTE);
it("lässt Zukünftiges zu, sobald der geplante Vorgang bekannt ist", () => {
// Das ist der Sinn von pending_id: ohne Bezug liesse sich die geplante
// Änderung nur über Person und Datum finden, und das ist nicht eindeutig.
expect(darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: "p1" }), HEUTE).erlaubt).toBe(true);
});
it("weist Zukünftiges ohne Bezug zum Vorgang ab", () => {
const u = darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: null }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/);
expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/);
});
it("zählt den heutigen Tag als wirksam", () => {
expect(darfGeloeschtWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true);
expect(darfKorrigiertWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true);
});
it("weist Einträge ohne Feldwerte ab", () => {
for (const ohne of [null, []]) {
const u = darfGeloeschtWerden(eintrag({ changes: ohne }), HEUTE);
const u = darfKorrigiertWerden(eintrag({ changes: ohne }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
}