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 (
+
+ );
+}
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.
-
+ 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);
+ });
+});