Put the onboarding checklist where the file is
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m24s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m51s

The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.

Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:

  Haken   — the normal case. The Meldezettel is there or it is not.
  Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
            not decoration: "nein" is a finding, "not asked yet" is not.
            One checkbox cannot say both.
  Text    — shoe, shirt and trouser size. The value is the point;
            ticked off it would be worthless.

Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.

Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.

The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.

A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.

Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:40:02 +02:00
parent bd990b7f2c
commit 82d07f0d95
9 changed files with 800 additions and 8 deletions

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
ONBOARDING_GRUPPEN,
ONBOARDING_PUNKTE,
fortschritt,
istErledigt,
punktZu,
wertIstGueltig,
type AufgabenStand,
} from "@/lib/onboarding";
// Die Checkliste kommt von einem Blatt Papier. Was dort ein Kästchen war, ist
// hier nicht überall eines: „Prämienanspruch" hatte zwei Kästchen (ja | nein),
// und die Grössen waren Felder zum Hineinschreiben. Die Feldart trägt also
// eine fachliche Aussage — deshalb wird sie hier festgehalten.
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 = ONBOARDING_PUNKTE.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
});
it("bildet jeden Punkt aus dem Blatt ab", () => {
expect(ONBOARDING_PUNKTE).toHaveLength(25);
for (const key of ["dienstzettel", "meldezettel", "salmonellenbefund", "willkommensmappe", "webreise"]) {
expect(punktZu(key), key).toBeDefined();
}
});
it("führt Prämienanspruch als Ja/Nein, nicht als Haken", () => {
// Zwei Kästchen im Blatt, und das ist kein Zufall: „nein" ist ein
// erhobener Befund, „noch nicht gefragt" nicht.
expect(punktZu("praemienanspruch")?.art).toBe("janein");
});
it("führt die Grössen als Text", () => {
// Abgehakt wäre eine Schuhgrösse wertlos.
for (const key of ["groesse_schuhe", "groesse_tshirt", "groesse_hose"]) {
expect(punktZu(key)?.art, key).toBe("text");
}
});
it("gruppiert vollständig — kein Punkt fällt zwischen die Gruppen", () => {
const ausGruppen = ONBOARDING_GRUPPEN.flatMap((g) => g.punkte.map((p) => p.key));
expect(new Set(ausGruppen)).toEqual(new Set(ONBOARDING_PUNKTE.map((p) => p.key)));
});
});
describe("istErledigt", () => {
const haken = punktZu("meldezettel")!;
const janein = punktZu("praemienanspruch")!;
const text = punktZu("groesse_schuhe")!;
it("zählt einen gesetzten Haken", () => {
expect(istErledigt(haken, stand({ item_key: haken.key, erledigt: true }))).toBe(true);
expect(istErledigt(haken, stand({ item_key: haken.key }))).toBe(false);
});
it("zählt bei Ja/Nein auch ein Nein als erledigt", () => {
// Die Aufgabe war, die Frage zu beantworten — nicht, sie zu bejahen.
expect(istErledigt(janein, stand({ item_key: janein.key, wert: "nein" }))).toBe(true);
expect(istErledigt(janein, stand({ item_key: janein.key, wert: "ja" }))).toBe(true);
expect(istErledigt(janein, stand({ item_key: janein.key }))).toBe(false);
});
it("zählt bei Text die Angabe selbst, nicht den Haken", () => {
expect(istErledigt(text, stand({ item_key: text.key, wert: "43" }))).toBe(true);
expect(istErledigt(text, stand({ item_key: text.key, wert: " " }))).toBe(false);
// Ein Haken ohne Angabe wäre eine leere Behauptung.
expect(istErledigt(text, stand({ item_key: text.key, erledigt: true }))).toBe(false);
});
it("hält eine fehlende Zeile für offen", () => {
expect(istErledigt(haken, undefined)).toBe(false);
});
});
describe("fortschritt", () => {
it("ist auf einer leeren Liste vollständig offen", () => {
const f = fortschritt([]);
expect(f.erledigt).toBe(0);
expect(f.gesamt).toBe(ONBOARDING_PUNKTE.length);
expect(f.offen).toHaveLength(ONBOARDING_PUNKTE.length);
});
it("nennt die offenen Punkte beim Namen, nicht beim Schlüssel", () => {
// Die Zahl allein sagt nicht, worauf noch gewartet wird.
expect(fortschritt([]).offen).toContain("Meldezettel");
expect(fortschritt([]).offen).not.toContain("meldezettel");
});
it("zählt gemischte Arten zusammen", () => {
const f = fortschritt([
stand({ item_key: "meldezettel", erledigt: true }),
stand({ item_key: "praemienanspruch", wert: "nein" }),
stand({ item_key: "groesse_schuhe", wert: "43" }),
]);
expect(f.erledigt).toBe(3);
expect(f.offen).not.toContain("Meldezettel");
});
it("stört sich nicht an Zeilen zu Punkten, die es nicht mehr gibt", () => {
// Ein gestrichener Punkt lässt seine Antworten stehen — sie sollen die
// Zählung weder erhöhen noch zum Absturz führen.
const f = fortschritt([stand({ item_key: "gibt_es_nicht_mehr", erledigt: true })]);
expect(f.erledigt).toBe(0);
expect(f.gesamt).toBe(ONBOARDING_PUNKTE.length);
});
});
describe("wertIstGueltig", () => {
it("lässt bei Ja/Nein nur ja, nein oder nichts zu", () => {
const janein = punktZu("praemienanspruch")!;
expect(wertIstGueltig(janein, "ja")).toBe(true);
expect(wertIstGueltig(janein, "nein")).toBe(true);
expect(wertIstGueltig(janein, null)).toBe(true);
expect(wertIstGueltig(janein, "vielleicht")).toBe(false);
});
it("lässt bei einem Haken keinen Wert zu", () => {
expect(wertIstGueltig(punktZu("meldezettel")!, "irgendwas")).toBe(false);
});
it("lässt bei Text alles zu — eine Grösse ist keine Auswahlliste", () => {
expect(wertIstGueltig(punktZu("groesse_hose")!, "W32/L34")).toBe(true);
});
});