Give exits their own checklist, next to the entry one
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m42s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m35s

The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.

It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.

The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.

No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.

The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.

Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 16:25:58 +02:00
parent 82d07f0d95
commit f85731dde5
15 changed files with 894 additions and 339 deletions

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import {
OFFBOARDING_GRUPPEN,
OFFBOARDING_PUNKTE,
fortschritt,
gehoertOffboarding,
istErledigt,
punktZu,
type AufgabenStand,
} from "@/lib/offboarding";
// Vom selben Blatt Papier wie die Onboarding-Liste, für den anderen Weg. Die
// Mechanik selbst (was als erledigt zählt, wie der Fortschritt entsteht) ist
// mit lib/onboarding.ts geteilt (lib/checklist.ts) und dort schon geprüft —
// hier geht es um das, was an dieser Liste besonders ist.
function stand(teil: Partial<AufgabenStand> & { item_key: string }): AufgabenStand {
return { erledigt: false, wert: null, kommentar: null, updated_at: null, updated_by_name: null, ...teil };
}
describe("Das Verzeichnis", () => {
it("hat eindeutige Schlüssel — sie stehen in den Daten", () => {
const keys = OFFBOARDING_PUNKTE.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
});
it("bildet jeden Punkt aus dem Blatt ab", () => {
expect(OFFBOARDING_PUNKTE).toHaveLength(11);
for (const key of [
"br_verstaendigung",
"kuendigungsschreiben",
"bestaetigung_kdn_bei",
"lgv_austritt",
"gkk_abmeldung",
"if6020_austritt",
"endabrechnung",
"offboarding_gestartet",
]) {
expect(punktZu(key), key).toBeDefined();
}
});
it("führt die beiden Vermerke als Text, nicht als Haken", () => {
// Ein Resturlaubssaldo abgehakt wäre wertlos — die Zahl selbst ist der Zweck.
expect(punktZu("resturlaub")?.art).toBe("text");
expect(punktZu("ueberleitung_salden_betrag")?.art).toBe("text");
});
it("unterscheidet die Aufgabe von ihrem Betrag", () => {
// Das Blatt nennt „Überleitung Salden für Auszahlung" zweimal: einmal als
// abzuhakende Aufgabe, einmal als Feld für den tatsächlichen Betrag. Zwei
// verschiedene Fragen, zwei verschiedene Punkte.
expect(punktZu("ueberleitung_salden")?.art).toBe("haken");
expect(punktZu("ueberleitung_salden_betrag")?.art).toBe("text");
expect(punktZu("ueberleitung_salden")?.key).not.toBe(punktZu("ueberleitung_salden_betrag")?.key);
});
it("führt keinen Ja/Nein-Punkt — anders als beim Onboarding", () => {
// Auf diesem Blatt gibt es keine Stelle mit zwei Kästchen nebeneinander.
expect(OFFBOARDING_PUNKTE.some((p) => p.art === "janein")).toBe(false);
});
it("gruppiert vollständig — kein Punkt fällt zwischen die Gruppen", () => {
const ausGruppen = OFFBOARDING_GRUPPEN.flatMap((g) => g.punkte.map((p) => p.key));
expect(new Set(ausGruppen)).toEqual(new Set(OFFBOARDING_PUNKTE.map((p) => p.key)));
});
});
describe("fortschritt", () => {
it("ist auf einer leeren Liste vollständig offen", () => {
const f = fortschritt([]);
expect(f.erledigt).toBe(0);
expect(f.gesamt).toBe(OFFBOARDING_PUNKTE.length);
});
it("zählt Haken und Text zusammen", () => {
const f = fortschritt([
stand({ item_key: "endabrechnung", erledigt: true }),
stand({ item_key: "resturlaub", wert: "12 Tage" }),
]);
expect(f.erledigt).toBe(2);
expect(f.offen).not.toContain("Endabrechnung");
});
});
describe("istErledigt", () => {
it("verlangt bei einem Haken die ausdrückliche Markierung", () => {
const punkt = punktZu("gkk_abmeldung")!;
expect(istErledigt(punkt, stand({ item_key: punkt.key, erledigt: true }))).toBe(true);
expect(istErledigt(punkt, stand({ item_key: punkt.key }))).toBe(false);
expect(istErledigt(punkt, undefined)).toBe(false);
});
});
describe("gehoertOffboarding", () => {
// Die Regel hinter dem Reiter: er erscheint bei einem echten Austritt,
// nicht bei jedem, der denselben Status „Ausgetreten" trägt.
it("bejaht bei einem echten Austritt", () => {
expect(gehoertOffboarding({ status: "Ausgetreten", exit_reason: "Kündigung AN" })).toBe(true);
});
it("verneint bei No Show, obwohl der Status derselbe ist", () => {
// Nie einen Tag gearbeitet: kein IT-Zugang, keine GKK-Anmeldung, kein
// Dienstzettel. Der Reiter wäre ein Etikett ohne Inhalt.
expect(gehoertOffboarding({ status: "Ausgetreten", exit_reason: "No Show" })).toBe(false);
});
it("verneint für eine aktive Person", () => {
expect(gehoertOffboarding({ status: "Aktiv", exit_reason: null })).toBe(false);
});
it("verneint für eine geplante Person", () => {
expect(gehoertOffboarding({ status: "Geplant", exit_reason: null })).toBe(false);
});
});