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>
134 lines
5.5 KiB
TypeScript
134 lines
5.5 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;
|
|
/** Für die Reihenfolge bei gleichem Datum. */
|
|
created_at?: string;
|
|
};
|
|
|
|
/** Die Vorgänge, die sich zurücknehmen und berichtigen lassen. */
|
|
const KORRIGIERBAR: HistoryEventType[] = ["Stammdatenänderung", "Vertragsänderung", "Karenz", "Rückkehr"];
|
|
|
|
/** Abwesenheit und Rückkehr haben eigene Vorgänge, auch für die Zukunft. */
|
|
const NUR_WIRKSAM: HistoryEventType[] = ["Karenz", "Rückkehr"];
|
|
|
|
export function darfKorrigiertWerden(eintrag: Eintrag, heute: string, alle: Eintrag[] = []): KorrekturUrteil {
|
|
if (eintrag.event_type === "Eintritt") {
|
|
return { erlaubt: false, grund: "Der Eintritt ist der Anfang der Zeitleiste und bleibt." };
|
|
}
|
|
|
|
if (!KORRIGIERBAR.includes(eintrag.event_type)) {
|
|
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.",
|
|
};
|
|
}
|
|
|
|
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus.
|
|
// Bliebe sie stehen, während die Abwesenheit verschwindet, stünde in der
|
|
// Akte eine Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem
|
|
// Eintrag, dessen Ausgangslage gelöscht ist.
|
|
if (eintrag.event_type === "Karenz" && alle.some((h) => h.event_type === "Rückkehr" && spaeter(h, eintrag))) {
|
|
return {
|
|
erlaubt: false,
|
|
grund: "Zu dieser Abwesenheit gibt es eine Rückkehr. Sie muss zuerst gelöscht werden.",
|
|
};
|
|
}
|
|
|
|
if (NUR_WIRKSAM.includes(eintrag.event_type) && eintrag.event_date > heute) {
|
|
return {
|
|
erlaubt: false,
|
|
grund: "Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden.",
|
|
};
|
|
}
|
|
|
|
// 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 };
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
return (a.created_at ?? "") > (b.created_at ?? "");
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
});
|
|
}
|