From 82d07f0d95ed279b725151a21176f35ab1d74f09 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 17 Aug 2026 15:40:02 +0200 Subject: [PATCH] Put the onboarding checklist where the file is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- actions/employees.ts | 51 +++- app/(app)/employees/[id]/page.tsx | 3 +- components/employees/EmployeeDetail.tsx | 16 +- components/employees/tabs/OnboardingTab.tsx | 243 ++++++++++++++++++ lib/employee-detail-data.ts | 8 + lib/onboarding.ts | 154 +++++++++++ lib/supabase/types.ts | 31 +++ .../20260817100000_onboarding_checkliste.sql | 171 ++++++++++++ tests/unit/onboarding.test.ts | 131 ++++++++++ 9 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 components/employees/tabs/OnboardingTab.tsx create mode 100644 lib/onboarding.ts create mode 100644 supabase/migrations/20260817100000_onboarding_checkliste.sql create mode 100644 tests/unit/onboarding.test.ts diff --git a/actions/employees.ts b/actions/employees.ts index 876298b..505b9f1 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { currentUserId } from "@/lib/auth/session"; import { withUser } from "@/lib/db"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; +import { ONBOARDING_PUNKTE } from "@/lib/onboarding"; import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; async function callRpc(fn: MutationFn, payload: Record, revalidate: string[]): Promise { @@ -67,9 +68,14 @@ export async function hireEmployee(payload: { // Einzige Mutation, deren Rückgabewert gebraucht wird: die neue // Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann. try { - const employeeId = await withUser(await currentUserId(), (tx) => - callFunction(tx, "hire_employee", payload as Record) - ); + const employeeId = await withUser(await currentUserId(), async (tx) => { + const id = (await callFunction(tx, "hire_employee", payload as Record)) as string; + // In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine + // halb erfasste Einstellung, und sie später nachzureichen hiesse, dass + // jemand daran denken muss. + await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) }); + return id; + }); revalidatePath("/employees"); revalidatePath("/"); revalidatePath("/positions"); @@ -160,7 +166,44 @@ export async function rehireEmployee(payload: { rehire_date: string; position_id: string; }): Promise { - return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); + try { + await withUser(await currentUserId(), async (tx) => { + await callFunction(tx, "rehire_employee", payload as unknown as Record); + // Auch bei der Wiedereinstellung: Dienstzettel, Bankverbindung und + // E-Card sind wieder zu erledigen. Die Punkte von damals stehen noch da + // und bleiben stehen — start_onboarding legt nur an, was fehlt, statt + // einen alten Haken zu löschen. Was wirklich neu zu tun ist, entscheidet + // HR an der Liste; ein Programm an ihrer Stelle würde raten. + await callFunction(tx, "start_onboarding", { + employee_id: payload.employee_id, + item_keys: ONBOARDING_PUNKTE.map((x) => x.key), + }); + }); + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." }; + } + for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path); + return { success: true }; +} + +/** Einen Punkt der Checkliste festhalten — Haken, Wert oder Kommentar. */ +export async function setOnboardingTask(payload: { + employee_id: string; + item_key: string; + erledigt?: boolean; + wert?: string | null; + kommentar?: string | null; +}): Promise { + return callRpc("set_onboarding_task", payload, [`/employees/${payload.employee_id}`]); +} + +/** Legt die Checkliste nachträglich an — für Personen von vor dieser Liste. */ +export async function startOnboarding(employeeId: string): Promise { + return callRpc( + "start_onboarding", + { employee_id: employeeId, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) }, + [`/employees/${employeeId}`] + ); } export async function addEmployeeDependent(payload: { diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index b11c166..d33ec98 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) { const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today)); if (!data) notFound(); - const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data; + const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, openPositions, byId } = data; return ( ("Stammdaten"); const [panel, setPanel] = useState(null); const location = locations.find((l) => l.id === employee.location_id); + // Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu + // erfahren, dass nichts zu tun ist. + const offeneAufgaben = onboarding.length > 0 ? fortschritt(onboarding).gesamt - fortschritt(onboarding).erledigt : 0; const isActive = employee.status === "Aktiv" || employee.status === "Karenz"; const canEditData = employee.status !== "Ausgetreten"; @@ -146,7 +153,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) { tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink" }`} > - {t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t} + {t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t === "Onboarding" && offeneAufgaben > 0 ? `Onboarding ${offeneAufgaben}` : t} ))} @@ -164,6 +171,9 @@ export function EmployeeDetail(props: EmployeeDetailProps) { kostenstelle={kostenstelle} /> )} + {tab === "Onboarding" && ( + 0} /> + )} {tab === "Historie" && } {tab === "HR-Notizen" && } diff --git a/components/employees/tabs/OnboardingTab.tsx b/components/employees/tabs/OnboardingTab.tsx new file mode 100644 index 0000000..ab31f9c --- /dev/null +++ b/components/employees/tabs/OnboardingTab.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { MessageSquarePlus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { setOnboardingTask, startOnboarding } from "@/actions/employees"; +import { Button } from "@/components/ui/Button"; +import { useToast } from "@/components/ui/Toast"; +import { fmtDate } from "@/lib/format"; +import { + ONBOARDING_GRUPPEN, + fortschritt, + istErledigt, + type AufgabenPunkt, + type AufgabenStand, +} from "@/lib/onboarding"; + +// Die Checkliste, die bisher ein Blatt Papier war. +// +// Zwei Dinge macht sie anders als das Blatt, und beide sind der Grund, sie +// überhaupt zu bauen: sie sagt, **wer** wann abgehakt hat — auf dem Blatt +// stand nur ein Haken —, und sie lässt sich vertreten, weil sie nicht in +// einer Schublade liegt. +// +// Gespeichert wird beim Klick, nicht beim Absenden. Eine Checkliste wird über +// Tage abgearbeitet, oft zwischen zwei anderen Dingen; ein „Speichern"-Knopf +// am Ende wäre die Stelle, an der ein halber Vormittag verlorengeht. + +type Props = { + employeeId: string; + staende: AufgabenStand[]; + /** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */ + vorhanden: boolean; +}; + +export function OnboardingTab({ employeeId, staende, vorhanden }: Props) { + const { showToast } = useToast(); + const router = useRouter(); + const [laeuft, setLaeuft] = useState(null); + const [kommentarOffen, setKommentarOffen] = useState>(new Set()); + + const karte = new Map(staende.map((s) => [s.item_key, s])); + const stand = fortschritt(staende); + + async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) { + setLaeuft(key); + const ergebnis = await setOnboardingTask({ employee_id: employeeId, item_key: key, ...teil }); + setLaeuft(null); + if (ergebnis.success) router.refresh(); + else showToast(ergebnis.error ?? "Konnte nicht gespeichert werden.", "error"); + } + + async function listeAnlegen() { + setLaeuft("neu"); + const ergebnis = await startOnboarding(employeeId); + setLaeuft(null); + if (ergebnis.success) { + showToast("Checkliste angelegt."); + router.refresh(); + } else { + showToast(ergebnis.error ?? "Konnte nicht angelegt werden.", "error"); + } + } + + if (!vorhanden) { + return ( +
+

+ Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder + Wiedereinstellung — wer davor eingetreten ist, hat keine. +

+ +
+ ); + } + + const anteil = Math.round((stand.erledigt / stand.gesamt) * 100); + + return ( +
+
+
+ + {stand.erledigt} von {stand.gesamt} erledigt + + + {stand.offen.length === 0 ? "Vollständig." : `Offen: ${stand.offen.slice(0, 3).join(", ")}`} + {stand.offen.length > 3 ? ` und ${stand.offen.length - 3} weitere` : ""} + +
+
+
+
+
+ + {ONBOARDING_GRUPPEN.map((gruppe) => ( +
+

{gruppe.label}

+
    + {gruppe.punkte.map((punkt) => ( + + setKommentarOffen((prev) => { + const next = new Set(prev); + next.add(punkt.key); + return next; + }) + } + onSpeichern={(teil) => speichern(punkt.key, teil)} + /> + ))} +
+
+ ))} +
+ ); +} + +function Zeile({ + punkt, + stand, + laeuft, + kommentarOffen, + onKommentarOeffnen, + onSpeichern, +}: { + punkt: AufgabenPunkt; + stand: AufgabenStand | undefined; + laeuft: boolean; + kommentarOffen: boolean; + onKommentarOeffnen: () => void; + onSpeichern: (teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) => void; +}) { + const erledigt = istErledigt(punkt, stand); + const [text, setText] = useState(stand?.wert ?? ""); + const [kommentar, setKommentar] = useState(stand?.kommentar ?? ""); + const zeigeKommentar = kommentarOffen || Boolean(stand?.kommentar); + + return ( +
  • +
    +
    + + {punkt.label} + + {punkt.hinweis && {punkt.hinweis}} +
    + + {punkt.art === "haken" && ( + onSpeichern({ erledigt: e.target.checked })} + className="h-4 w-4 shrink-0 rounded border-border text-brand-600 + focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500" + /> + )} + + {punkt.art === "janein" && ( + // Zwei Knöpfe statt eines Hakens: „nein" ist ein erhobener Befund, + // „noch nicht gefragt" nicht. Ein Haken könnte das nicht sagen. + + {(["ja", "nein"] as const).map((wert) => ( + + ))} + + )} + + {punkt.art === "text" && ( + setText(e.target.value)} + onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })} + placeholder="Grösse" + className="w-24 shrink-0 rounded border border-border px-2 py-1 text-sm text-ink + focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500" + /> + )} + + {!zeigeKommentar && ( + + )} +
    + + {zeigeKommentar && ( + setKommentar(e.target.value)} + onBlur={() => kommentar !== (stand?.kommentar ?? "") && onSpeichern({ kommentar: kommentar || null })} + placeholder="Kommentar …" + className="mt-1.5 w-full rounded border border-border bg-surface px-2 py-1 text-xs text-ink-body + focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500" + /> + )} + + {/* Wer und wann — der Unterschied zum Blatt Papier, auf dem nur der + Haken stand. */} + {stand?.updated_by_name && (erledigt || stand.kommentar) && ( +

    + {stand.updated_by_name} + {stand.updated_at ? ` · ${fmtDate(stand.updated_at)}` : ""} +

    + )} +
  • + ); +} diff --git a/lib/employee-detail-data.ts b/lib/employee-detail-data.ts index d88c9c1..3aeba6f 100644 --- a/lib/employee-detail-data.ts +++ b/lib/employee-detail-data.ts @@ -89,6 +89,13 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id) ) ).as("costRows"), + jsonArrayFrom( + eb + .selectFrom("onboarding_tasks") + .select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"]) + .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at")) + .where("employee_id", "=", id) + ).as("onboarding"), // Die offenen Planstellen bleiben bei heute: sie gehören zur // Organisation, nicht zu dieser Person. jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), @@ -131,6 +138,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod placement, // Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt. kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null, + onboarding: g.onboarding, openPositions, byId: new Map(relatedRows.map((e) => [e.id, e])), }; diff --git a/lib/onboarding.ts b/lib/onboarding.ts new file mode 100644 index 0000000..08418f5 --- /dev/null +++ b/lib/onboarding.ts @@ -0,0 +1,154 @@ +// Die Onboarding-Checkliste — ein Verzeichnis, das Oberfläche, Fortschritt +// und Auswertung gemeinsam lesen. +// +// Die Punkte stehen hier und nicht in der Datenbank. Das ist eine Entscheidung +// mit einer Kehrseite, also der Grund: eine Checkliste ist ein Firmenprozess, +// kein Stammdatum. Sie ändert sich selten, und wenn, dann soll die Änderung +// durch Prüfung und Protokoll gehen wie jede andere. Dafür lässt sie sich +// nicht ohne Aufspielen anpassen. +// +// Gespeichert wird je Person und Punkt **nur die Antwort**, unter dem +// Schlüssel des Punktes. Ein später entfernter Punkt lässt die alten Antworten +// deshalb stehen — sie verschwinden aus der Liste, aber nicht aus den Daten, +// und eine Akte von damals bleibt lesbar. + +/** + * Welche Eingabe ein Punkt braucht — aus der Sache abgeleitet, nicht geraten: + * + * • `haken` — erledigt oder nicht. Der Normalfall: „Meldezettel" ist da oder + * fehlt, ein Zwischenzustand ist nicht vorgesehen. + * • `janein` — eine Frage mit zwei Antworten, bei der **offen** etwas anderes + * heisst als **nein**. Beim Prämienanspruch stehen im Blatt zwei Kästchen + * nebeneinander, und das ist kein Zufall: „nein" ist ein erhobener Befund, + * „noch nicht gefragt" nicht. Ein einzelner Haken könnte das nicht sagen. + * • `text` — ein Wert, der festgehalten wird. Bei den Grössen ist die Angabe + * selbst der Zweck; abgehakt wäre sie wertlos. + */ +export type AufgabenArt = "haken" | "janein" | "text"; + +export type AufgabenPunkt = { + /** Bleibt stabil — er steht in den Daten. */ + key: string; + label: string; + art: AufgabenArt; + /** Zusatz unter der Beschriftung, wo die Bezeichnung allein nicht reicht. */ + hinweis?: string; +}; + +export type AufgabenGruppe = { + key: string; + label: string; + punkte: readonly AufgabenPunkt[]; +}; + +export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [ + { + key: "aufgaben", + label: "Zu erledigen", + punkte: [ + { key: "dienstzettel", label: "Dienstzettel / Dienstvertrag", art: "haken" }, + { key: "dienstzettel_retour", label: "Dienstzettel / Dienstvertrag retour", art: "haken" }, + { key: "onboarding_gestartet", label: "Onboarding gestartet", art: "haken" }, + { key: "lgv_e9", label: "LGV Anlage / E9", art: "haken" }, + { key: "interflex", label: "Interflex Anlage", art: "haken" }, + { key: "eurest", label: "Anmeldung EUREST", art: "haken" }, + { key: "oegk", label: "Anmeldung ÖGK", art: "haken" }, + { key: "webclient", label: "WebClient", art: "haken" }, + { key: "datensatzberechtigung", label: "Datensatzberechtigung Führungskraft", art: "haken" }, + { key: "praemienanspruch", label: "Prämienanspruch", art: "janein" }, + { + key: "webreise", + label: "Webreise Benutzer", + art: "haken", + hinweis: "HM 13, Seite 2 Punkt 15, „J“, ganz am Ende mit einfügen", + }, + ], + }, + { + key: "unterlagen", + label: "Unterlagen", + punkte: [ + { key: "passkopie", label: "Passkopie / Ausweis", art: "haken" }, + { + key: "arbeitsbewilligung", + label: "Arbeitsbewilligung", + art: "haken", + hinweis: "Nur bei Staatsbürgerschaft ausserhalb EU/EWR und Schweiz", + }, + { key: "meldezettel", label: "Meldezettel", art: "haken" }, + { key: "bankverbindung", label: "Bankverbindung", art: "haken" }, + { key: "ecard", label: "E-Card", art: "haken" }, + { key: "strafregisterauszug", label: "Strafregisterauszug", art: "haken" }, + { key: "salmonellenbefund", label: "Salmonellenbefund", art: "haken" }, + { key: "personaldatenblatt", label: "Personaldatenblatt", art: "haken" }, + { key: "cv", label: "CV", art: "haken" }, + { key: "goodies", label: "Goodies", art: "haken" }, + { key: "willkommensmappe", label: "Willkommensmappe", art: "haken" }, + ], + }, + { + key: "vermerke", + label: "Vermerke — Grösse", + punkte: [ + { key: "groesse_schuhe", label: "Schuhe", art: "text" }, + { key: "groesse_tshirt", label: "T-Shirt", art: "text" }, + { key: "groesse_hose", label: "Hose", art: "text" }, + ], + }, +] as const; + +export const ONBOARDING_PUNKTE: readonly AufgabenPunkt[] = ONBOARDING_GRUPPEN.flatMap((g) => g.punkte); + +const NACH_KEY = new Map(ONBOARDING_PUNKTE.map((p) => [p.key, p])); + +export function punktZu(key: string): AufgabenPunkt | undefined { + return NACH_KEY.get(key); +} + +/** Der gespeicherte Stand eines Punktes. */ +export type AufgabenStand = { + item_key: string; + erledigt: boolean; + /** Bei `janein` „ja"/„nein", bei `text` die Angabe, bei `haken` leer. */ + wert: string | null; + kommentar: string | null; + updated_at: string | null; + updated_by_name: string | null; +}; + +/** + * Ob ein Punkt als erledigt zählt — je Art verschieden, und deshalb an einer + * Stelle statt an dreien. + * + * Bei `janein` zählt auch „nein" als erledigt: die Frage ist beantwortet, und + * genau das ist die Aufgabe. Bei `text` ist die Angabe selbst die Erledigung; + * ein leerer Text ist keine. + */ +export function istErledigt(punkt: AufgabenPunkt, stand: AufgabenStand | undefined): boolean { + if (!stand) return false; + if (punkt.art === "haken") return stand.erledigt; + return (stand.wert ?? "").trim() !== ""; +} + +export type Fortschritt = { erledigt: number; gesamt: number; offen: string[] }; + +/** + * Wie weit die Liste ist. `offen` nennt die Beschriftungen, nicht die + * Schlüssel — die Zahl allein sagt nicht, worauf noch gewartet wird. + */ +export function fortschritt(staende: AufgabenStand[]): Fortschritt { + const karte = new Map(staende.map((s) => [s.item_key, s])); + const offen = ONBOARDING_PUNKTE.filter((p) => !istErledigt(p, karte.get(p.key))); + return { + erledigt: ONBOARDING_PUNKTE.length - offen.length, + gesamt: ONBOARDING_PUNKTE.length, + offen: offen.map((p) => p.label), + }; +} + +/** Was als Wert erlaubt ist — dieselbe Prüfung wie in der Datenbank. */ +export function wertIstGueltig(punkt: AufgabenPunkt, wert: string | null): boolean { + if (punkt.art === "janein") return wert === null || wert === "ja" || wert === "nein"; + if (punkt.art === "haken") return wert === null; + return true; +} diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index 68da0a2..ad7e148 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -479,6 +479,35 @@ export type Database = { }; Update: Partial; }; + // Die Onboarding-Checkliste: je Person und Punkt eine Zeile. Welche + // Punkte es gibt, steht in lib/onboarding.ts — nicht hier. + onboarding_tasks: NoRelationships & { + Row: { + id: string; + employee_id: string; + item_key: string; + erledigt: boolean; + wert: string | null; + kommentar: string | null; + created_at: string; + updated_at: string; + updated_by: string | null; + updated_by_name: string | null; + }; + Insert: { + id?: string; + employee_id: string; + item_key: string; + erledigt?: boolean; + wert?: string | null; + kommentar?: string | null; + created_at?: string; + updated_at?: string; + updated_by?: string | null; + updated_by_name?: string | null; + }; + Update: Partial; + }; // Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der // Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt. cost_centers: NoRelationships & { @@ -585,6 +614,8 @@ export type Database = { create_position: { Args: { payload: Record }; Returns: string }; update_position: { Args: { payload: Record }; Returns: void }; set_position_cost_center: { Args: { payload: Record }; Returns: void }; + set_onboarding_task: { Args: { payload: Record }; Returns: void }; + start_onboarding: { Args: { payload: Record }; Returns: number }; delete_position: { Args: { payload: Record }; Returns: void }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; apply_due_pending_changes: { Args: Record; Returns: number }; diff --git a/supabase/migrations/20260817100000_onboarding_checkliste.sql b/supabase/migrations/20260817100000_onboarding_checkliste.sql new file mode 100644 index 0000000..4c58b69 --- /dev/null +++ b/supabase/migrations/20260817100000_onboarding_checkliste.sql @@ -0,0 +1,171 @@ +-- Onboarding-Checkliste je Person +-- +-- Bisher lief das über ein Blatt neben der Anwendung: eine Tabelle mit +-- Kästchen, je Eintritt einmal ausgedruckt. Was darauf steht, weiss dann nur, +-- wer das Blatt hat — und ob der Meldezettel schon da ist, lässt sich weder +-- suchen noch auswerten noch vertreten. +-- +-- Gespeichert wird **je Person und Punkt eine Zeile**, unter dem Schlüssel des +-- Punktes. Die Punkte selbst stehen in lib/onboarding.ts, nicht hier: eine +-- Checkliste ist ein Firmenprozess und kein Stammdatum. Der Schlüssel als Text +-- statt als Fremdschlüssel hat einen Preis (die Datenbank kennt die gültigen +-- Werte nicht) und einen Grund: ein später gestrichener Punkt lässt die alten +-- Antworten stehen, statt sie mitzureissen. Eine Akte von damals bleibt so +-- lesbar, auch wenn die Liste heute anders aussieht. +-- +-- Drei Arten von Antwort in einer Tabelle, weil es dieselbe Sache ist: +-- • Haken → erledigt +-- • Ja/Nein → wert ('ja'/'nein'); offen ist etwas anderes als nein +-- • Text → wert (Grössen) +-- `erledigt` trägt bei allen dreien die Frage „abgehakt?", damit der +-- Fortschritt eine Spalte hat und keine Fallunterscheidung. + +create table if not exists onboarding_tasks ( + id uuid primary key default gen_random_uuid(), + employee_id uuid not null references employees(id) on delete cascade, + item_key text not null, + erledigt boolean not null default false, + wert text, + kommentar text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + updated_by uuid references profiles(id) on delete set null, + -- Der Name mitgeschrieben, nicht nur die Kennung: wer eine Checkliste von + -- vor zwei Jahren aufschlägt, will lesen, wer abgehakt hat, auch wenn die + -- Person längst nicht mehr im Verzeichnis steht. + updated_by_name text, + constraint onboarding_tasks_eine_zeile unique (employee_id, item_key) +); + +create index if not exists onboarding_tasks_employee_id_idx on onboarding_tasks (employee_id); +-- Für „was ist noch offen": der Teilindex liest nur die unerledigten. +create index if not exists onboarding_tasks_offen_idx on onboarding_tasks (employee_id) where not erledigt; + +drop policy if exists onboarding_tasks_hr_all on onboarding_tasks; +create policy onboarding_tasks_hr_all on onboarding_tasks + for all using (is_hr_user()) with check (is_hr_user()); + + +-- ── Einen Punkt festhalten ───────────────────────────────────────────── +-- +-- Ein Aufruf für alle drei Arten: gesetzt wird, was mitkommt. Wer nur den +-- Kommentar ändert, schickt nur den Kommentar — sonst würde ein Tippfehler im +-- Kommentarfeld den Haken mitlöschen. +create or replace function set_onboarding_task(payload jsonb) +returns void +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_item_key text := nullif(trim(payload->>'item_key'), ''); + v_name text; + v_vorher onboarding_tasks%rowtype; + v_erledigt boolean; + v_wert text; + v_kommentar text; +begin + perform require_hr_admin(); + + if v_item_key is null then + raise exception 'Es wurde kein Punkt angegeben.'; + end if; + + select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; + if v_name is null then + raise exception 'Die Person existiert nicht.'; + end if; + + select * into v_vorher from onboarding_tasks + where employee_id = v_employee_id and item_key = v_item_key for update; + + -- Weggelassen heisst „unverändert", nicht „leeren". Der Unterschied ist der + -- Grund, warum hier `payload ? 'feld'` steht und nicht coalesce: eine + -- ausdrückliche null muss löschen können. + v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean + else coalesce(v_vorher.erledigt, false) end; + v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end; + v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '') + else v_vorher.kommentar end; + + insert into onboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name) + values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar, + app_current_user_id(), current_actor_name()) + on conflict (employee_id, item_key) do update + set erledigt = excluded.erledigt, + wert = excluded.wert, + kommentar = excluded.kommentar, + updated_at = now(), + updated_by = excluded.updated_by, + updated_by_name = excluded.updated_by_name; + + -- Kein Eintrag in employee_history: das ist keine Änderung an der Person, + -- sondern der Stand einer Aufgabe. In der Historie stünden sonst + -- fünfundzwanzig Zeilen zwischen Eintritt und Versetzung. Ins Protokoll + -- gehört es trotzdem — es ist eine Handlung mit Urheber. + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Onboarding-Punkt', v_name, v_employee_id, + v_item_key || ': ' || + case when v_wert is not null then v_wert + when v_erledigt then 'erledigt' + else 'offen' end || + coalesce(' — ' || v_kommentar, '')); +end; +$function$; + +revoke all on function set_onboarding_task(jsonb) from public; +grant execute on function set_onboarding_task(jsonb) to public; + + +-- ── Die Liste anlegen ────────────────────────────────────────────────── +-- +-- Eine Checkliste entsteht mit dem Eintritt. Dass sie *existiert*, ist die +-- Aussage „diese Person ist im Onboarding" — deshalb wird sie angelegt und +-- nicht bloss beim ersten Klick nebenbei erzeugt: eine leere Liste, auf der +-- noch nichts steht, ist der eigentliche Anfangszustand und muss sichtbar +-- sein. +-- +-- Die Punkte kommen aus dem Aufruf, nicht aus der Datenbank: sie stehen in +-- lib/onboarding.ts, und zwei Listen nebeneinander liefen auseinander. +create or replace function start_onboarding(payload jsonb) +returns integer +language plpgsql +security definer +set search_path to 'public', 'pg_temp' +as $function$ +declare + v_employee_id uuid := (payload->>'employee_id')::uuid; + v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb); + v_name text; + v_angelegt integer; +begin + perform require_hr_admin(); + + select first_name || ' ' || last_name into v_name from employees where id = v_employee_id; + if v_name is null then + raise exception 'Die Person existiert nicht.'; + end if; + if jsonb_array_length(v_keys) = 0 then + raise exception 'Es wurden keine Punkte übergeben.'; + end if; + + insert into onboarding_tasks (employee_id, item_key, updated_by, updated_by_name) + select v_employee_id, k, app_current_user_id(), current_actor_name() + from jsonb_array_elements_text(v_keys) k + on conflict (employee_id, item_key) do nothing; + + get diagnostics v_angelegt = row_count; + + if v_angelegt > 0 then + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Onboarding-Checkliste angelegt', v_name, v_employee_id, + v_angelegt || ' Punkte'); + end if; + + return v_angelegt; +end; +$function$; + +revoke all on function start_onboarding(jsonb) from public; +grant execute on function start_onboarding(jsonb) to public; diff --git a/tests/unit/onboarding.test.ts b/tests/unit/onboarding.test.ts new file mode 100644 index 0000000..ad75421 --- /dev/null +++ b/tests/unit/onboarding.test.ts @@ -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 & { 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); + }); +});