diff --git a/app/(app)/employees/[id]/checkliste/page.tsx b/app/(app)/employees/[id]/checkliste/page.tsx
new file mode 100644
index 0000000..5dc6334
--- /dev/null
+++ b/app/(app)/employees/[id]/checkliste/page.tsx
@@ -0,0 +1,54 @@
+import { notFound } from "next/navigation";
+import { PrintChecklist } from "@/components/employees/PrintChecklist";
+import { currentUserId } from "@/lib/auth/session";
+import { artTitel, checklistDateiname, parseArt } from "@/lib/checklist-print";
+import { loadChecklistPrint } from "@/lib/checklist-print-data";
+import { withUser } from "@/lib/db";
+import { todayIso } from "@/lib/format";
+import { OFFBOARDING_GRUPPEN } from "@/lib/offboarding";
+import { ONBOARDING_GRUPPEN } from "@/lib/onboarding";
+
+// Eigene Route statt eines Druckstils auf dem Reiter.
+//
+// Der Reiter ist zum Arbeiten gebaut: Haken zum Anklicken, Eingabefelder,
+// Kommentarknöpfe. Auf Papier ist davon nichts nützlich. Hier entsteht aus
+// denselben Daten ein Blatt, das den Stand festhält — mit Kopf, Fortschritt
+// und Platz für zwei Unterschriften.
+
+type Props = {
+ params: Promise<{ id: string }>;
+ searchParams: Promise<{ art?: string }>;
+};
+
+/**
+ * Der Titel ist zugleich der Dateivorschlag im Druckdialog. Er wird hier
+ * gesetzt *und* in der Komponente noch einmal — hier für den Tab und den Fall,
+ * dass jemand die Seite direkt druckt, dort, weil der Titel aus den geladenen
+ * Daten kommt und die Metadaten sonst ein zweites Mal laden müssten.
+ */
+export async function generateMetadata({ params, searchParams }: Props) {
+ const [{ id }, sp] = await Promise.all([params, searchParams]);
+ const art = parseArt(sp.art);
+ const daten = await withUser(await currentUserId(), (tx) => loadChecklistPrint(tx, id, art));
+ if (!daten) return { title: artTitel(art) };
+ return { title: checklistDateiname(art, daten.kopf, todayIso()) };
+}
+
+export default async function ChecklistPrintPage({ params, searchParams }: Props) {
+ const [{ id }, sp] = await Promise.all([params, searchParams]);
+ const art = parseArt(sp.art);
+ const today = todayIso();
+
+ const daten = await withUser(await currentUserId(), (tx) => loadChecklistPrint(tx, id, art, today));
+ if (!daten) notFound();
+
+ return (
+
+ );
+}
diff --git a/components/employees/PrintChecklist.tsx b/components/employees/PrintChecklist.tsx
new file mode 100644
index 0000000..a1851f0
--- /dev/null
+++ b/components/employees/PrintChecklist.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { Printer } from "lucide-react";
+import { useEffect } from "react";
+import { Button } from "@/components/ui/Button";
+import {
+ alleReichungen,
+ fortschrittVon,
+ istErledigt,
+ type AufgabenGruppe,
+ type AufgabenStand,
+} from "@/lib/checklist";
+import { artTitel, checklistDateiname, type ChecklistArt } from "@/lib/checklist-print";
+import type { ChecklistKopf } from "@/lib/checklist-print-data";
+import { fmtDate, fmtName } from "@/lib/format";
+
+// Der Ausdruck einer Checkliste — eine Seite, zum Abheften oder Mitnehmen.
+//
+// Warum überhaupt Papier, wenn die Liste in der Anwendung steht: sie wird
+// besprochen. Beim Eintrittsgespräch liegt sie auf dem Tisch, beim Austritt
+// geht sie an die Lohnverrechnung, und in einer Personalakte auf Papier fehlt
+// sie sonst. Gedruckt wird deshalb der **Stand**, mit Datum des Auszugs — kein
+// Formular zum Ausfüllen.
+//
+// Erzeugt wird kein PDF im Server: gedruckt wird über den Browser, wie beim
+// Organigramm. Der Grund ist derselbe — eine PDF-Bibliothek müsste Schriften,
+// Umbrüche und Seitenränder selbst nachbauen, und das Ergebnis wäre ein
+// zweites Aussehen neben dem der Anwendung. Der Dateiname im Speichern-Dialog
+// kommt aus dem Dokumenttitel; den setzt diese Seite auf die Konvention.
+
+const GLYPH_AN = "☒";
+const GLYPH_AUS = "☐";
+
+export function PrintChecklist({
+ art,
+ kopf,
+ gruppen,
+ staende,
+ today,
+}: {
+ art: ChecklistArt;
+ kopf: ChecklistKopf;
+ gruppen: readonly AufgabenGruppe[];
+ staende: AufgabenStand[];
+ /** Vom Server: das Auszugsdatum gehört nicht an die Uhr des Browsers. */
+ today: string;
+}) {
+ const dateiname = checklistDateiname(art, kopf, today);
+
+ // Der Titel ist der Vorschlag im Druckdialog. Ohne ihn hiesse die Datei nach
+ // der Adresse der Seite, also etwa „employees-…-checkliste".
+ useEffect(() => {
+ const vorher = document.title;
+ document.title = dateiname;
+ return () => {
+ document.title = vorher;
+ };
+ }, [dateiname]);
+
+ const punkte = alleReichungen(gruppen);
+ const karte = new Map(staende.map((s) => [s.item_key, s]));
+ const stand = fortschrittVon(punkte, staende);
+
+ return (
+
+
+
+
+
+ So kommt es aus dem Drucker. Vorgeschlagener Dateiname:{" "}
+ {dateiname}.pdf
+
+
+
+
+
+ Im Druckdialog „Als PDF speichern“ wählen. Hintergrundgrafiken müssen dort eingeschaltet sein, sonst
+ fehlen die Flächen hinter den Gruppentiteln — in Chrome unter „Weitere Einstellungen“.
+
+
+ {fertig ? GLYPH_AN : GLYPH_AUS}
+
+
+ {punkt.label}
+ {/* Bei Ja/Nein und Text steht der Wert daneben — ein
+ Haken allein sagte dort nicht, was erfasst wurde. */}
+ {punkt.art !== "haken" && (
+
+ {": "}
+ {s?.wert ?? "—"}
+
+ )}
+ {s?.kommentar && (
+ {s.kommentar}
+ )}
+ {s?.updated_by_name && fertig && (
+
+ {s.updated_by_name}
+ {s.updated_at ? ` · ${fmtDate(s.updated_at)}` : ""}
+
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+ {/* ── Fuss: Platz für Unterschriften ─────────────────── */}
+
+
Datum, Unterschrift Mitarbeiter:in
+
Datum, Unterschrift Personalabteilung
+
+
+
+ );
+}
diff --git a/components/employees/tabs/ChecklistPanel.tsx b/components/employees/tabs/ChecklistPanel.tsx
index 5ae2f30..388d8d3 100644
--- a/components/employees/tabs/ChecklistPanel.tsx
+++ b/components/employees/tabs/ChecklistPanel.tsx
@@ -1,9 +1,10 @@
"use client";
-import { MessageSquarePlus } from "lucide-react";
+import { MessageSquarePlus, Printer } from "lucide-react";
+import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
-import { Button } from "@/components/ui/Button";
+import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import {
alleReichungen,
@@ -34,6 +35,8 @@ type Props = {
/** Text, solange keine Liste existiert. */
leerText: string;
anlegenLabel: string;
+ /** Ziel für den Ausdruck — eine eigene Seite, siehe PrintChecklist. */
+ druckHref: string;
onSpeichern: (
itemKey: string,
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
@@ -41,7 +44,16 @@ type Props = {
onAnlegen: () => Promise;
};
-export function ChecklistPanel({ gruppen, staende, vorhanden, leerText, anlegenLabel, onSpeichern, onAnlegen }: Props) {
+export function ChecklistPanel({
+ gruppen,
+ staende,
+ vorhanden,
+ leerText,
+ anlegenLabel,
+ druckHref,
+ onSpeichern,
+ onAnlegen,
+}: Props) {
const { showToast } = useToast();
const router = useRouter();
const [laeuft, setLaeuft] = useState(null);
@@ -86,6 +98,15 @@ export function ChecklistPanel({ gruppen, staende, vorhanden, leerText, anlegenL
return (
+
+ {/* Neuer Tab: der Ausdruck ist eine eigene Seite, und wer ihn schliesst,
+ steht wieder in der Akte statt auf einer leeren Historie. */}
+
+
+ Als PDF speichern
+
+
+
diff --git a/components/employees/tabs/OffboardingTab.tsx b/components/employees/tabs/OffboardingTab.tsx
index eb7225c..6c94395 100644
--- a/components/employees/tabs/OffboardingTab.tsx
+++ b/components/employees/tabs/OffboardingTab.tsx
@@ -25,6 +25,7 @@ export function OffboardingTab({
vorhanden={vorhanden}
leerText="Für diese Person gibt es keine Offboarding-Checkliste. Für einen No Show wird keine angelegt — wer nie angetreten ist, hat nichts offzuboarden."
anlegenLabel="Checkliste anlegen"
+ druckHref={`/employees/${employeeId}/checkliste?art=offboarding`}
onSpeichern={(itemKey, teil) => 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 7573ed4..ff1da23 100644
--- a/components/employees/tabs/OnboardingTab.tsx
+++ b/components/employees/tabs/OnboardingTab.tsx
@@ -26,6 +26,7 @@ export function OnboardingTab({
vorhanden={vorhanden}
leerText="Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder Wiedereinstellung — wer davor eingetreten ist, hat keine."
anlegenLabel="Checkliste anlegen"
+ druckHref={`/employees/${employeeId}/checkliste?art=onboarding`}
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
onAnlegen={() => startOnboarding(employeeId)}
/>
diff --git a/components/shell/AppShell.tsx b/components/shell/AppShell.tsx
index c3f9a57..8f4f1f9 100644
--- a/components/shell/AppShell.tsx
+++ b/components/shell/AppShell.tsx
@@ -12,13 +12,25 @@ export function AppShell({ userLabel, openNotes, children }: { userLabel: string
const [navOpen, setNavOpen] = useState(false);
const closeNav = useCallback(() => setNavOpen(false), []);
+ // Auf Papier gehört von der Hülle nichts hin: Navigation, Glocke und
+ // Abmelde-Knopf sind Bedienelemente, und gedruckt sind sie bestenfalls
+ // Zierrat — auf einer Checkliste, die in eine Personalakte geht, sogar
+ // irreführend. Die Regel steht hier und nicht auf der einzelnen Druckseite,
+ // damit jede weitere sie mitbekommt, statt sie zu wiederholen.
+ //
+ // Auch die Ränder fallen weg: die Satzfläche bestimmt @page auf der
+ // Druckseite selbst, und die Polsterung der Hülle käme obendrauf.
return (
-
-
-
- setNavOpen(true)} />
-
-
{children}
+
+
+
+
+
+
+ setNavOpen(true)} />
+
+
+
{children}
diff --git a/lib/checklist-print-data.ts b/lib/checklist-print-data.ts
new file mode 100644
index 0000000..47ab95e
--- /dev/null
+++ b/lib/checklist-print-data.ts
@@ -0,0 +1,79 @@
+import type { AufgabenStand } from "./checklist";
+import type { ChecklistArt } from "./checklist-print";
+import type { Tx } from "./db";
+import { jsonArrayFrom, jsonObjectFrom, zeitstempel } from "./db/json";
+import { todayIso } from "./format";
+import { besetzungenAbfrage, orgAsOf, pickPlacements } from "./placement";
+
+// Was der Ausdruck einer Checkliste braucht — und nur das.
+//
+// Nicht loadEmployeeDetail: das holt offene Planstellen, Berichtslinien, den
+// Organisationsbaum und die Historie, wovon der Ausdruck nichts zeigt. Eine
+// eigene, schmale Abfrage in einer Rundreise (lib/db/json.ts).
+
+export type ChecklistKopf = {
+ personnel_number: number;
+ first_name: string;
+ last_name: string;
+ entry_date: string;
+ /** Die Bezeichnung der Person; kann von der Planstelle abweichen. */
+ job_title: string;
+ /** Aus der laufenden Besetzung — bei einer Person ohne Planstelle leer. */
+ position_number: string | null;
+ position_title: string | null;
+};
+
+export type ChecklistDruckDaten = {
+ kopf: ChecklistKopf;
+ staende: AufgabenStand[];
+};
+
+export async function loadChecklistPrint(
+ tx: Tx,
+ employeeId: string,
+ art: ChecklistArt,
+ today: string = todayIso()
+): Promise {
+ const tabelle = art === "offboarding" ? "offboarding_tasks" : "onboarding_tasks";
+
+ const gelesen = await tx
+ .selectNoFrom((eb) => [
+ jsonObjectFrom(
+ eb
+ .selectFrom("employees")
+ .select(["personnel_number", "first_name", "last_name", "entry_date", "exit_date", "job_title"])
+ .where("id", "=", employeeId)
+ ).as("person"),
+ jsonArrayFrom(besetzungenAbfrage(eb, [employeeId])).as("placementRows"),
+ jsonArrayFrom(
+ eb
+ .selectFrom(tabelle)
+ .select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"])
+ .select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
+ .where("employee_id", "=", employeeId)
+ ).as("staende"),
+ ])
+ .executeTakeFirstOrThrow();
+
+ const person = gelesen.person;
+ if (!person) return null;
+
+ // Derselbe Stichtag wie in der Akte: bei einer ausgetretenen Person ist die
+ // Besetzung am Austrittstag beendet, und zu heute gefragt käme keine heraus
+ // — der Kopf stünde dann ohne Planstelle da, obwohl es eine gab.
+ const asOf = orgAsOf(person, today);
+ const placement = pickPlacements(gelesen.placementRows as never, asOf).get(employeeId) ?? null;
+
+ return {
+ kopf: {
+ personnel_number: person.personnel_number,
+ first_name: person.first_name,
+ last_name: person.last_name,
+ entry_date: person.entry_date,
+ job_title: person.job_title,
+ position_number: placement?.positionNumber ?? null,
+ position_title: placement?.jobTitle ?? null,
+ },
+ staende: gelesen.staende as AufgabenStand[],
+ };
+}
diff --git a/lib/checklist-print.ts b/lib/checklist-print.ts
new file mode 100644
index 0000000..2fcb92a
--- /dev/null
+++ b/lib/checklist-print.ts
@@ -0,0 +1,65 @@
+// Namensgebung und Kopfdaten für den Ausdruck einer Checkliste.
+//
+// Reine Funktionen, ohne Datenbank und ohne React — damit die Konvention
+// geprüft werden kann, ohne eine Seite zu bauen.
+
+export type ChecklistArt = "onboarding" | "offboarding";
+
+export const CHECKLIST_ARTEN: readonly ChecklistArt[] = ["onboarding", "offboarding"];
+
+export function parseArt(wert: string | undefined): ChecklistArt {
+ return wert === "offboarding" ? "offboarding" : "onboarding";
+}
+
+/** Wie die Liste im Titel und im Dateinamen heisst. */
+export function artLabel(art: ChecklistArt): string {
+ return art === "offboarding" ? "Offboarding-Checklist" : "Onboarding-Checklist";
+}
+
+export function artTitel(art: ChecklistArt): string {
+ return art === "offboarding" ? "Offboarding-Checkliste" : "Onboarding-Checkliste";
+}
+
+/**
+ * Umlaute und Sonderzeichen in etwas, das in jedem Dateisystem und in jedem
+ * Skript überlebt.
+ *
+ * Ein blosses NFKD mit anschliessendem Ersetzen aller Nicht-ASCII-Zeichen
+ * würde aus „Müller" ein „Mu-ller" machen: die Zerlegung trennt den Umlaut in
+ * `u` + kombinierenden Trema, und der Trema wird dann zum Trennstrich. Deshalb
+ * werden die kombinierenden Zeichen ausdrücklich entfernt, nicht ersetzt — und
+ * das scharfe S vorher zu „ss", weil es keine Zerlegung hat.
+ */
+export function dateiSicher(text: string): string {
+ return text
+ .replace(/ß/g, "ss")
+ .normalize("NFKD")
+ // \p{M} statt eines Zeichenbereichs: die kombinierenden Zeichen sind
+ // unsichtbar, und ein als Literal geschriebener Bereich könnte bei einem
+ // Kodierungswechsel stillschweigend kaputtgehen — dann fiele die Zeile aus
+ // und die nächste machte aus „Müller" ein „Mu-ller", ohne zu scheitern.
+ .replace(/\p{M}/gu, "")
+ .replace(/[^a-zA-Z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+}
+
+/**
+ * `YYYYMMDD_PersNummer_Name_Onboarding-Checklist`
+ *
+ * Der Name steht als `Nachname-Vorname` darin, in derselben Reihenfolge, in
+ * der die Anwendung ihn überall anzeigt — so sortiert sich ein Ordner voller
+ * solcher Dateien von selbst nach Person, und innerhalb einer Person nach
+ * Datum, weil das Datum vorne steht.
+ *
+ * Das Datum ist das des Auszugs, nicht des Eintritts: der Dateiname sagt,
+ * wann dieser Stand gezogen wurde.
+ */
+export function checklistDateiname(
+ art: ChecklistArt,
+ person: { personnel_number: number; first_name: string; last_name: string },
+ auszugsdatum: string
+): string {
+ const datum = auszugsdatum.slice(0, 10).replace(/-/g, "");
+ const name = dateiSicher(`${person.last_name}-${person.first_name}`);
+ return `${datum}_${person.personnel_number}_${name}_${artLabel(art)}`;
+}
diff --git a/tests/unit/checklist-print.test.ts b/tests/unit/checklist-print.test.ts
new file mode 100644
index 0000000..db02724
--- /dev/null
+++ b/tests/unit/checklist-print.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from "vitest";
+import { artLabel, artTitel, checklistDateiname, dateiSicher, parseArt } from "@/lib/checklist-print";
+
+// Der Dateiname ist eine Absprache, nicht eine Laune: die Dateien landen in
+// einer Personalakte, und dort sortiert und findet man sie nur, wenn alle
+// gleich heissen. Deshalb steht die Konvention hier fest und wird geprüft.
+
+const PERSON = { personnel_number: 2884, first_name: "Manuel", last_name: "Aigner" };
+
+describe("checklistDateiname", () => {
+ it("folgt der Konvention YYYYMMDD_PersNr_Name_Liste", () => {
+ expect(checklistDateiname("onboarding", PERSON, "2026-08-18")).toBe("20260818_2884_Aigner-Manuel_Onboarding-Checklist");
+ });
+
+ it("unterscheidet Ein- und Ausstieg", () => {
+ expect(checklistDateiname("offboarding", PERSON, "2026-08-18")).toBe(
+ "20260818_2884_Aigner-Manuel_Offboarding-Checklist"
+ );
+ });
+
+ it("stellt den Nachnamen voran — wie überall in der Anwendung", () => {
+ // So sortiert sich ein Ordner voller solcher Dateien nach Person, und
+ // innerhalb einer Person nach Datum, weil das Datum vorne steht.
+ const name = checklistDateiname("onboarding", PERSON, "2026-08-18");
+ expect(name.indexOf("Aigner")).toBeLessThan(name.indexOf("Manuel"));
+ });
+
+ it("nimmt das Auszugsdatum, nicht das Eintrittsdatum", () => {
+ expect(checklistDateiname("onboarding", PERSON, "2012-08-07")).toContain("20120807");
+ });
+
+ it("verträgt einen Zeitstempel als Datum", () => {
+ expect(checklistDateiname("onboarding", PERSON, "2026-08-18T09:31:00.000Z")).toContain("20260818");
+ });
+});
+
+describe("dateiSicher", () => {
+ it("löst Umlaute auf, ohne einen Trennstrich hineinzuschmuggeln", () => {
+ // Der naive Weg (NFKD, dann alles Nicht-ASCII zu „-") macht aus „Müller"
+ // ein „Mu-ller": die Zerlegung trennt den Umlaut in u + Trema, und der
+ // Trema wird zum Strich.
+ expect(dateiSicher("Müller")).toBe("Muller");
+ expect(dateiSicher("Öztürk")).toBe("Ozturk");
+ expect(dateiSicher("Šimić")).toBe("Simic");
+ });
+
+ it("schreibt das scharfe S aus", () => {
+ // Es hat keine Zerlegung — ohne eigene Regel fiele es ganz weg.
+ expect(dateiSicher("Weiß")).toBe("Weiss");
+ });
+
+ it("ersetzt Zeichen, die in Dateinamen Ärger machen", () => {
+ expect(dateiSicher("O'Brien")).toBe("O-Brien");
+ expect(dateiSicher("van der Berg")).toBe("van-der-Berg");
+ expect(dateiSicher("Meier/Huber")).toBe("Meier-Huber");
+ });
+
+ it("lässt keine Striche am Rand stehen", () => {
+ expect(dateiSicher(" Aigner ")).toBe("Aigner");
+ expect(dateiSicher("-Aigner-")).toBe("Aigner");
+ });
+
+ it("zieht mehrere Sonderzeichen zu einem Strich zusammen", () => {
+ expect(dateiSicher("Aigner Maier")).toBe("Aigner-Maier");
+ });
+});
+
+describe("parseArt", () => {
+ it("nimmt offboarding, wenn es dasteht", () => {
+ expect(parseArt("offboarding")).toBe("offboarding");
+ });
+
+ it("fällt auf onboarding zurück — auch bei Unsinn in der Adresse", () => {
+ // Der Wert landet in einem Tabellennamen und im Dateinamen; was nicht in
+ // der Liste steht, kommt nicht durch.
+ expect(parseArt(undefined)).toBe("onboarding");
+ expect(parseArt("")).toBe("onboarding");
+ expect(parseArt("offboarding_tasks; drop")).toBe("onboarding");
+ expect(parseArt("ONBOARDING")).toBe("onboarding");
+ });
+});
+
+describe("Beschriftungen", () => {
+ it("nennt die Liste im Dateinamen englisch, im Titel deutsch", () => {
+ // Der Dateiname folgt der Vorgabe („Onboarding-Checklist"), die Seite
+ // spricht die Sprache der Anwendung.
+ expect(artLabel("onboarding")).toBe("Onboarding-Checklist");
+ expect(artTitel("onboarding")).toBe("Onboarding-Checkliste");
+ expect(artLabel("offboarding")).toBe("Offboarding-Checklist");
+ expect(artTitel("offboarding")).toBe("Offboarding-Checkliste");
+ });
+});