diff --git a/actions/employees.ts b/actions/employees.ts index 505b9f1..628acbe 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 { OFFBOARDING_PUNKTE } from "@/lib/offboarding"; import { ONBOARDING_PUNKTE } from "@/lib/onboarding"; import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; @@ -91,7 +92,50 @@ export async function terminateEmployee(payload: { exit_reason: string; note?: string; }): Promise { - return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); + // No Show ist kein Austritt im gewohnten Sinn — die Person hat nie + // angefangen. Dafür gibt es nichts offzuboarden: keinen IT-Zugang, der + // eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Die Liste + // entstünde leer und wäre ein Etikett ohne Inhalt. + if (payload.exit_reason === "No Show") { + return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); + } + + try { + await withUser(await currentUserId(), async (tx) => { + await callFunction(tx, "terminate_employee", payload as unknown as Record); + // In derselben Transaktion: ein Austritt ohne Checkliste wäre ein + // halb erfasster Austritt, und sie später anzulegen hiesse, dass + // jemand daran denken muss. + await callFunction(tx, "start_offboarding", { + employee_id: payload.employee_id, + item_keys: OFFBOARDING_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 Offboarding-Checkliste festhalten — Haken, Wert oder Kommentar. */ +export async function setOffboardingTask(payload: { + employee_id: string; + item_key: string; + erledigt?: boolean; + wert?: string | null; + kommentar?: string | null; +}): Promise { + return callRpc("set_offboarding_task", payload, [`/employees/${payload.employee_id}`]); +} + +/** Legt die Offboarding-Checkliste nachträglich an — für Austritte von vor dieser Liste. */ +export async function startOffboarding(employeeId: string): Promise { + return callRpc( + "start_offboarding", + { employee_id: employeeId, item_keys: OFFBOARDING_PUNKTE.map((x) => x.key) }, + [`/employees/${employeeId}`] + ); } export async function transferEmployee(payload: { diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index d33ec98..1f1201a 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, onboarding, openPositions, byId } = data; + const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, offboarding, openPositions, byId } = data; return ( ("Stammdaten"); + const { employee, placement, breadcrumb, kostenstelle, onboarding, offboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props; + const [tab, setTab] = useState("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 offeneOnboarding = onboarding.length > 0 ? fortschrittOnboarding(onboarding).gesamt - fortschrittOnboarding(onboarding).erledigt : 0; + const offeneOffboarding = offboarding.length > 0 ? fortschrittOffboarding(offboarding).gesamt - fortschrittOffboarding(offboarding).erledigt : 0; + + const zeigtOffboarding = gehoertOffboarding(employee); + const tabs = ALLE_TABS.filter((t) => t !== "Offboarding" || zeigtOffboarding); const isActive = employee.status === "Aktiv" || employee.status === "Karenz"; const canEditData = employee.status !== "Ausgetreten"; @@ -143,7 +153,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) { {/* Tabs, so the list gets the tablist role and each button says whether it is the selected one. */}
- {TABS.map((t) => ( + {tabs.map((t) => ( ))}
@@ -174,6 +190,9 @@ export function EmployeeDetail(props: EmployeeDetailProps) { {tab === "Onboarding" && ( 0} /> )} + {tab === "Offboarding" && zeigtOffboarding && ( + 0} /> + )} {tab === "Historie" && } {tab === "HR-Notizen" && } diff --git a/components/employees/panels/TerminatePanel.tsx b/components/employees/panels/TerminatePanel.tsx index fb6822c..17c90a6 100644 --- a/components/employees/panels/TerminatePanel.tsx +++ b/components/employees/panels/TerminatePanel.tsx @@ -24,8 +24,6 @@ const EXIT_REASONS = [ "Entlassung", NO_SHOW, ]; -const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"]; - type TerminatePanelProps = { open: boolean; onClose: () => void; @@ -42,7 +40,6 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T // übrigen zu verstellen. const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]); const [note, setNote] = useState(""); - const [checked, setChecked] = useState(CHECKLIST_ITEMS.map(() => false)); const [pending, setPending] = useState(false); // Bei einem Nichtantritt ist das Datum nicht frei wählbar: es ist der Tag, @@ -142,22 +139,16 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T Stichtag als beschäftigt.

)} - {/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme. */} - + {/* Bei einem Nichtantritt wurde nichts ausgegeben, was zurückkäme — + deshalb entsteht dort auch keine Offboarding-Checkliste. Für einen + echten Austritt legt terminateEmployee sie in derselben + Transaktion an; hier steht nur die Ankündigung, damit niemand sie + hier sucht und nichts findet. */} + {!istNoShow && ( +

+ Nach der Bestätigung steht im Reiter „Offboarding“ eine Checkliste zur Verfügung. +

+ )} ); diff --git a/components/employees/tabs/ChecklistPanel.tsx b/components/employees/tabs/ChecklistPanel.tsx new file mode 100644 index 0000000..5ae2f30 --- /dev/null +++ b/components/employees/tabs/ChecklistPanel.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { MessageSquarePlus } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/Button"; +import { useToast } from "@/components/ui/Toast"; +import { + alleReichungen, + fortschrittVon, + istErledigt, + type AufgabenGruppe, + type AufgabenPunkt, + type AufgabenStand, +} from "@/lib/checklist"; +import { fmtDate } from "@/lib/format"; +import type { ActionResult } from "@/lib/db/rpc"; + +// Die Checkliste, die bisher ein Blatt Papier war — geteilt zwischen +// Onboarding und Offboarding. Beide sind dieselbe Sache mit anderen Punkten: +// eine Liste, die sich abhaken, mit Ja/Nein beantworten oder mit einem Wert +// füllen lässt, mit Kommentar, und mit einem Vermerk, **wer** wann etwas +// eingetragen hat — auf dem Blatt stand nur ein Haken. +// +// 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 = { + gruppen: readonly AufgabenGruppe[]; + staende: AufgabenStand[]; + /** Ob überhaupt eine Liste existiert. */ + vorhanden: boolean; + /** Text, solange keine Liste existiert. */ + leerText: string; + anlegenLabel: string; + onSpeichern: ( + itemKey: string, + teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null } + ) => Promise; + onAnlegen: () => Promise; +}; + +export function ChecklistPanel({ gruppen, staende, vorhanden, leerText, anlegenLabel, onSpeichern, onAnlegen }: Props) { + const { showToast } = useToast(); + const router = useRouter(); + const [laeuft, setLaeuft] = useState(null); + const [kommentarOffen, setKommentarOffen] = useState>(new Set()); + + const punkte = alleReichungen(gruppen); + const karte = new Map(staende.map((s) => [s.item_key, s])); + const stand = fortschrittVon(punkte, staende); + + async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) { + setLaeuft(key); + const ergebnis = await onSpeichern(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 onAnlegen(); + setLaeuft(null); + if (ergebnis.success) { + showToast("Checkliste angelegt."); + router.refresh(); + } else { + showToast(ergebnis.error ?? "Konnte nicht angelegt werden.", "error"); + } + } + + if (!vorhanden) { + return ( +
+

{leerText}

+ +
+ ); + } + + 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` : ""} + +
+
+
+
+
+ + {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="Wert" + className="w-28 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/components/employees/tabs/OffboardingTab.tsx b/components/employees/tabs/OffboardingTab.tsx new file mode 100644 index 0000000..eb7225c --- /dev/null +++ b/components/employees/tabs/OffboardingTab.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { setOffboardingTask, startOffboarding } from "@/actions/employees"; +import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding"; +import type { AufgabenStand } from "@/lib/checklist"; +import { ChecklistPanel } from "./ChecklistPanel"; + +// Dünne Bindung — siehe OnboardingTab.tsx, dieselbe Mechanik für den anderen +// Weg. + +export function OffboardingTab({ + employeeId, + staende, + vorhanden, +}: { + employeeId: string; + staende: AufgabenStand[]; + /** Ob überhaupt eine Liste existiert — sie entsteht mit dem Austritt. */ + vorhanden: boolean; +}) { + return ( + setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })} + onAnlegen={() => startOffboarding(employeeId)} + /> + ); +} diff --git a/components/employees/tabs/OnboardingTab.tsx b/components/employees/tabs/OnboardingTab.tsx index ab31f9c..7573ed4 100644 --- a/components/employees/tabs/OnboardingTab.tsx +++ b/components/employees/tabs/OnboardingTab.tsx @@ -1,243 +1,33 @@ "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"; +import { ONBOARDING_GRUPPEN } from "@/lib/onboarding"; +import type { AufgabenStand } from "@/lib/checklist"; +import { ChecklistPanel } from "./ChecklistPanel"; -// 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. +// Dünne Bindung: die Mechanik der Checkliste steht in ChecklistPanel, geteilt +// mit OffboardingTab. Hier steht nur, welches Verzeichnis gilt und welche +// Server-Aktionen es speichern. -type Props = { +export function OnboardingTab({ + employeeId, + staende, + vorhanden, +}: { 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)}` : ""} -

    - )} -
  • + setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })} + onAnlegen={() => startOnboarding(employeeId)} + /> ); } diff --git a/lib/checklist.ts b/lib/checklist.ts new file mode 100644 index 0000000..62988a1 --- /dev/null +++ b/lib/checklist.ts @@ -0,0 +1,98 @@ +// Der gemeinsame Kern einer personenbezogenen Checkliste. +// +// Onboarding und Offboarding sind zwei Listen mit demselben Verhalten: Punkte +// mit einer Art, ein gespeicherter Stand je Person und Punkt, ein Fortschritt +// daraus. Dieselbe Logik zweimal abzuschreiben wäre die Art Duplikat, die +// nicht laut scheitert, sondern leise auseinanderläuft — eine Änderung an der +// einen Stelle, die die andere nie erreicht. Deshalb steht sie hier einmal, +// und lib/onboarding.ts wie lib/offboarding.ts binden sie nur an ihr eigenes +// Verzeichnis. + +/** + * Welche Eingabe ein Punkt braucht — aus der Sache abgeleitet, nicht geraten: + * + * • `haken` — erledigt oder nicht. Der Normalfall: ein Beleg ist da oder + * fehlt, ein Zwischenzustand ist nicht vorgesehen. + * • `janein` — eine Frage mit zwei Antworten, bei der **offen** etwas anderes + * heisst als **nein**. „nein" ist ein erhobener Befund, „noch nicht + * gefragt" nicht — ein einzelner Haken könnte das nicht sagen. + * • `text` — ein Wert, der festgehalten wird. Bei einer Grösse oder einem + * Resturlaubssaldo 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[]; +}; + +/** 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; +}; + +export type Fortschritt = { erledigt: number; gesamt: number; offen: string[] }; + +/** Aus einer flachen Liste von Gruppen die Punkte allein — mehrfach gebraucht. */ +export function alleReichungen(gruppen: readonly AufgabenGruppe[]): readonly AufgabenPunkt[] { + return gruppen.flatMap((g) => g.punkte); +} + +export function punktZuVon(punkte: readonly AufgabenPunkt[], key: string): AufgabenPunkt | undefined { + // Keine Karte vorab: die Verzeichnisse sind klein (ein bis zwei Dutzend + // Punkte), und eine Suche bei jedem Aufruf ist hier billiger als der + // Aufwand, zwei Module je eine eigene, versteckte Karte pflegen zu lassen. + return punkte.find((p) => p.key === key); +} + +/** + * Ob ein Punkt als erledigt zählt — je Art verschieden, und deshalb an einer + * Stelle statt an mehreren. + * + * 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() !== ""; +} + +/** + * Wie weit eine Liste ist. `offen` nennt die Beschriftungen, nicht die + * Schlüssel — die Zahl allein sagt nicht, worauf noch gewartet wird. + */ +export function fortschrittVon(punkte: readonly AufgabenPunkt[], staende: AufgabenStand[]): Fortschritt { + const karte = new Map(staende.map((s) => [s.item_key, s])); + const offen = punkte.filter((p) => !istErledigt(p, karte.get(p.key))); + return { + erledigt: punkte.length - offen.length, + gesamt: 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/employee-detail-data.ts b/lib/employee-detail-data.ts index 3aeba6f..c7cf5cf 100644 --- a/lib/employee-detail-data.ts +++ b/lib/employee-detail-data.ts @@ -96,6 +96,13 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at")) .where("employee_id", "=", id) ).as("onboarding"), + jsonArrayFrom( + eb + .selectFrom("offboarding_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("offboarding"), // Die offenen Planstellen bleiben bei heute: sie gehören zur // Organisation, nicht zu dieser Person. jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), @@ -139,6 +146,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod // 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, + offboarding: g.offboarding, openPositions, byId: new Map(relatedRows.map((e) => [e.id, e])), }; diff --git a/lib/offboarding.ts b/lib/offboarding.ts new file mode 100644 index 0000000..1465c85 --- /dev/null +++ b/lib/offboarding.ts @@ -0,0 +1,80 @@ +import { + alleReichungen, + fortschrittVon, + punktZuVon, + type AufgabenGruppe, + type AufgabenPunkt, + type AufgabenStand, + type Fortschritt, +} from "./checklist"; + +export type { AufgabenArt, AufgabenGruppe, AufgabenPunkt, AufgabenStand, Fortschritt } from "./checklist"; +export { istErledigt, wertIstGueltig } from "./checklist"; + +// Die Offboarding-Checkliste — vom selben Blatt Papier wie die Onboarding- +// Liste, nur für den anderen Weg. Was dort steht, ist hier eins zu eins +// übernommen; wo die Bezeichnung „GKK" auf dem Blatt steht statt des heute +// gebräuchlichen „ÖGK", ist sie unverändert stehengeblieben — das ist der +// Name, unter dem der Vorgang im Haus läuft, nicht ein Tippfehler. +// +// Zwei Punkte auf dem Blatt tragen denselben Namen zweimal: „Überleitung +// Salden für Auszahlung" steht einmal als abzuhakende Aufgabe und daneben als +// Feld für den tatsächlichen Betrag. Das ist kein Versehen im Blatt, sondern +// zwei verschiedene Fragen — „ist es veranlasst?" und „wie viel ist es?" —, +// die hier auch zwei verschiedene Punkte sind. +// +// Warum kein Ja/Nein-Punkt hier steht (anders als Prämienanspruch beim +// Onboarding): auf diesem Blatt gibt es keine Stelle mit zwei Kästchen +// nebeneinander. Taucht künftig eine auf, ist `art: "janein"` bereit. + +export const OFFBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [ + { + key: "aufgaben", + label: "Zu erledigende Aufgaben", + punkte: [ + { key: "br_verstaendigung", label: "BR Verständigung bei KDG", art: "haken" }, + { key: "kuendigungsschreiben", label: "Kündigungsschreiben (KDG)", art: "haken" }, + { key: "bestaetigung_kdn_bei", label: "Bestätigung KDN / BEI", art: "haken" }, + { key: "lgv_austritt", label: "LGV Austritt", art: "haken" }, + { key: "gkk_abmeldung", label: "GKK-Abmeldung", art: "haken" }, + { key: "if6020_austritt", label: "IF 6020 Austritt", art: "haken" }, + { key: "ueberleitung_salden", label: "Überleitung Salden für Auszahlung", art: "haken" }, + { key: "endabrechnung", label: "Endabrechnung", art: "haken" }, + { key: "offboarding_gestartet", label: "Offboarding gestartet", art: "haken" }, + ], + }, + { + key: "vermerke", + label: "Vermerke", + punkte: [ + { key: "resturlaub", label: "Resturlaub", art: "text" }, + { key: "ueberleitung_salden_betrag", label: "Überleitung Salden für Auszahlung — Betrag", art: "text" }, + ], + }, +] as const; + +export const OFFBOARDING_PUNKTE: readonly AufgabenPunkt[] = alleReichungen(OFFBOARDING_GRUPPEN); + +export function punktZu(key: string): AufgabenPunkt | undefined { + return punktZuVon(OFFBOARDING_PUNKTE, key); +} + +export function fortschritt(staende: AufgabenStand[]): Fortschritt { + return fortschrittVon(OFFBOARDING_PUNKTE, staende); +} + +/** + * Ob eine Person offzuboarden ist — die Regel hinter dem Reiter. + * + * Nur ein echter Austritt zählt. No Show scheidet zwar mit demselben Status + * „Ausgetreten" aus, hat aber nie einen Tag gearbeitet: keinen IT-Zugang, der + * eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Ein Reiter mit + * einer leeren Liste wäre dort ein Etikett ohne Inhalt. + * + * Wird die Person wiedereingestellt, wechselt der Status weg von + * „Ausgetreten", und die Regel verneint wieder — die Checkliste bleibt in den + * Daten stehen, nur ihr Reiter verschwindet, solange sie ohne Gegenstand ist. + */ +export function gehoertOffboarding(employee: { status: string; exit_reason: string | null }): boolean { + return employee.status === "Ausgetreten" && employee.exit_reason !== "No Show"; +} diff --git a/lib/onboarding.ts b/lib/onboarding.ts index 08418f5..bf215cc 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -1,3 +1,19 @@ +import { + alleReichungen, + fortschrittVon, + punktZuVon, + type AufgabenGruppe, + type AufgabenPunkt, + type AufgabenStand, + type Fortschritt, +} from "./checklist"; + +export type { AufgabenArt, AufgabenGruppe, AufgabenPunkt, AufgabenStand, Fortschritt } from "./checklist"; +// istErledigt/wertIstGueltig nehmen bereits einen Punkt entgegen, nicht den +// Schlüssel — sie brauchen keine Bindung an dieses Verzeichnis und werden +// unverändert weitergereicht. +export { istErledigt, wertIstGueltig } from "./checklist"; + // Die Onboarding-Checkliste — ein Verzeichnis, das Oberfläche, Fortschritt // und Auswertung gemeinsam lesen. // @@ -11,35 +27,10 @@ // 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[]; -}; +// +// Die Mechanik selbst (Fortschritt, was als erledigt zählt) steht in +// lib/checklist.ts — geteilt mit lib/offboarding.ts, damit beide Listen +// dasselbe „erledigt" meinen und nicht mit der Zeit auseinanderlaufen. export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [ { @@ -97,58 +88,12 @@ export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [ }, ] 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 const ONBOARDING_PUNKTE: readonly AufgabenPunkt[] = alleReichungen(ONBOARDING_GRUPPEN); export function punktZu(key: string): AufgabenPunkt | undefined { - return NACH_KEY.get(key); + return punktZuVon(ONBOARDING_PUNKTE, 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; + return fortschrittVon(ONBOARDING_PUNKTE, staende); } diff --git a/lib/supabase/types.ts b/lib/supabase/types.ts index ad7e148..5d0e3b8 100644 --- a/lib/supabase/types.ts +++ b/lib/supabase/types.ts @@ -508,6 +508,35 @@ export type Database = { }; Update: Partial; }; + // Die Offboarding-Checkliste — dieselbe Form wie onboarding_tasks, für + // den anderen Weg. Punkte in lib/offboarding.ts. + offboarding_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 & { @@ -616,6 +645,8 @@ export type Database = { set_position_cost_center: { Args: { payload: Record }; Returns: void }; set_onboarding_task: { Args: { payload: Record }; Returns: void }; start_onboarding: { Args: { payload: Record }; Returns: number }; + set_offboarding_task: { Args: { payload: Record }; Returns: void }; + start_offboarding: { 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/20260818100000_offboarding_checkliste.sql b/supabase/migrations/20260818100000_offboarding_checkliste.sql new file mode 100644 index 0000000..889736e --- /dev/null +++ b/supabase/migrations/20260818100000_offboarding_checkliste.sql @@ -0,0 +1,148 @@ +-- Offboarding-Checkliste je Person +-- +-- Dieselbe Sache wie onboarding_tasks (20260817100000), für den anderen Weg: +-- je Person und Punkt eine Zeile, der Schlüssel als Text statt als Fremd- +-- schlüssel, damit ein später gestrichener Punkt seine alten Antworten +-- stehen lässt statt sie mitzureissen. Die Begründung im Einzelnen steht bei +-- der Onboarding-Migration; hier nur, was abweicht. +-- +-- Eine eigene Tabelle statt einer gemeinsamen mit Spalte „Richtung": beide +-- Listen haben eigene Punkte (lib/onboarding.ts, lib/offboarding.ts), einen +-- eigenen Anlass (Ein-, Aus- oder Wiedereintritt) und eigene Protokollzeilen. +-- Eine gemeinsame Tabelle würde das mit einer Fallunterscheidung nachbauen, +-- die schon in zwei Tabellen und zwei Funktionen so nicht existiert. + +create table if not exists offboarding_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, + updated_by_name text, + constraint offboarding_tasks_eine_zeile unique (employee_id, item_key) +); + +create index if not exists offboarding_tasks_employee_id_idx on offboarding_tasks (employee_id); +create index if not exists offboarding_tasks_offen_idx on offboarding_tasks (employee_id) where not erledigt; + +drop policy if exists offboarding_tasks_hr_all on offboarding_tasks; +create policy offboarding_tasks_hr_all on offboarding_tasks + for all using (is_hr_user()) with check (is_hr_user()); + + +-- ── Einen Punkt festhalten ───────────────────────────────────────────── +-- Wortgleich mit set_onboarding_task, nur auf die andere Tabelle bezogen. +create or replace function set_offboarding_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 offboarding_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 offboarding_tasks + where employee_id = v_employee_id and item_key = v_item_key for update; + + 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 offboarding_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; + + insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details) + values (app_current_user_id(), current_actor_name(), 'Offboarding-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_offboarding_task(jsonb) from public; +grant execute on function set_offboarding_task(jsonb) to public; + + +-- ── Die Liste anlegen ────────────────────────────────────────────────── +-- +-- Anders als beim Onboarding steht dieser Aufruf nicht für jeden Austritt in +-- derselben Transaktion, die den Status setzt: bei einem No Show — wer nie +-- angetreten ist — gibt es nichts offzuboarden, es war nie ein IT-Zugang +-- eingerichtet, keine GKK-Anmeldung, kein Dienstzettel. Diese Funktion prüft +-- das nicht selbst; die aufrufende Seite (actions/employees.ts) entscheidet, +-- wann sie gerufen wird. Sie existiert trotzdem als eigene, aufrufbare +-- Funktion — für den Nachtrag bei einem Austritt von vor dieser Liste. +create or replace function start_offboarding(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 offboarding_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(), 'Offboarding-Checkliste angelegt', v_name, v_employee_id, + v_angelegt || ' Punkte'); + end if; + + return v_angelegt; +end; +$function$; + +revoke all on function start_offboarding(jsonb) from public; +grant execute on function start_offboarding(jsonb) to public; diff --git a/tests/components/TerminatePanel.test.tsx b/tests/components/TerminatePanel.test.tsx index 6592d1c..5d9fa0c 100644 --- a/tests/components/TerminatePanel.test.tsx +++ b/tests/components/TerminatePanel.test.tsx @@ -122,12 +122,16 @@ describe("TerminatePanel — Nichtantritt", () => { ); }); - it("blendet die Offboarding-Checkliste aus — ausgegeben wurde nie etwas", async () => { + it("kündigt die Offboarding-Checkliste nur für einen echten Austritt an", async () => { + // Die Liste selbst entsteht jetzt in derselben Transaktion wie der + // Austritt (actions/employees.ts) und lebt im Reiter „Offboarding" — hier + // steht nur die Ankündigung. Bei No Show fehlt sie: die Person hat nie + // angefangen, es gibt nichts offzuboarden. const user = userEvent.setup(); zeige({ status: "Aktiv" }); - expect(screen.getByText("Offboarding-Checkliste")).toBeVisible(); + expect(screen.getByText(/Reiter „Offboarding“/)).toBeVisible(); await user.selectOptions(screen.getByLabelText("Beendigungsart"), "No Show"); - expect(screen.getByText("Offboarding-Checkliste")).not.toBeVisible(); + expect(screen.queryByText(/Reiter „Offboarding“/)).not.toBeInTheDocument(); }); it("sagt, was mit der Planstelle passiert", async () => { diff --git a/tests/unit/offboarding.test.ts b/tests/unit/offboarding.test.ts new file mode 100644 index 0000000..38186a3 --- /dev/null +++ b/tests/unit/offboarding.test.ts @@ -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 & { 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); + }); +});