The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden." There was no such way. The row sat in the file, the scheduled change kept running toward its date, and nothing could stop either one. That is not hypothetical. One person went absent in July, came back in August, and still has a second return booked for the first of September — recorded while they were already working again. The guard added yesterday stops a third from being written; it does not remove the one that exists. Absences are called off whole, not field by field. For a planned contract change the scheduled payload gets the affected fields lifted out of it and runs on with the rest; an absence has no fields in that map, and half an absence is not a thing anyone means. So the whole scheduled change is cancelled, and what it had already noted on the person goes with it: the date they were to be away from, the date they were to come back on. Left behind, the profile would show an absence with no event behind it. If the absence is still running, the return date planned when it began applies again. The link between the row and the scheduled change had to exist first — start_karenz and record_karenz_return now record it. Existing rows get it backfilled, but only where one running change of that kind falls on that person and that day. Where two would match, the row keeps refusing: guessing which process to cancel is worse than refusing to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
5.6 KiB
TypeScript
137 lines
5.6 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 werden anders zurückgenommen als der Rest: aus
|
|
* einer geplanten Vertragsänderung nimmt man einzelne Felder heraus, eine
|
|
* geplante Abwesenheit fällt ganz. Beides steht in delete_history_entry;
|
|
* hier zählt nur, dass für diese beiden keine Feldwerte nötig sind.
|
|
*/
|
|
const OHNE_FELDWERTE: 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.",
|
|
};
|
|
}
|
|
|
|
// 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.",
|
|
};
|
|
}
|
|
|
|
// Eine geplante Abwesenheit hat keine Feldwerte — sie fällt als Ganzes.
|
|
if (eintrag.event_date > heute && OHNE_FELDWERTE.includes(eintrag.event_type)) {
|
|
return { erlaubt: true };
|
|
}
|
|
|
|
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 };
|
|
});
|
|
}
|