Files
alpenwerk-hr/lib/history.ts
Maximilian Stubhan 08d2740690 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>
2026-08-13 21:35:51 +02:00

87 lines
3.4 KiB
TypeScript

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 KorrekturUrteil = { erlaubt: true } | { erlaubt: false; grund: string };
type Eintrag = {
event_type: HistoryEventType;
event_date: string;
changes: AuditChange[] | null;
/** Der geplante Vorgang, falls die Änderung noch nicht wirksam ist. */
pending_id?: string | null;
};
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string): KorrekturUrteil {
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.",
};
}
// Noch nicht wirksam: das geht, aber nur mit Bezug auf den geplanten
// Vorgang. Zeilen aus der Zeit vor dieser Verknüpfung haben keinen — sie
// liessen sich nur über Person und Datum zuordnen, und das ist nicht
// eindeutig genug, um daraufhin eine geplante Änderung abzubrechen.
if (eintrag.event_date > heute && !eintrag.pending_id) {
return {
erlaubt: false,
grund:
"Zu dieser geplanten Änderung ist kein Vorgang hinterlegt — sie stammt aus der Zeit vor dieser Verknüpfung. " +
"Zurücknehmen lässt sie sich nur, indem der Vorgang selbst abgebrochen wird.",
};
}
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 };
});
}