Print a checklist without printing the application around it
The checklist gets a print view: one A4 page, header carrying personnel number, name, date of extract, entry date and position, the items in two flowing columns so twenty-five fit, and two signature lines at the foot. It prints the *state*, dated — not a blank form. The default filename in the save dialog comes from the document title, which is set to the agreed convention: 20260818_2884_Aigner-Manuel_Onboarding-Checklist Surname first, like everywhere else in the application, so a folder of these sorts by person and within a person by date. Umlauts are resolved rather than stripped: the naive route (NFKD, then every non-ASCII to a dash) turns "Müller" into "Mu-ller", because decomposition splits the umlaut and the diaeresis becomes the dash. "Weiß" needs its own rule — it has no decomposition and would otherwise vanish. The reported defect: the printout carried the application's own top bar — hamburger, bell, "Neueinstellung", sign-out. Those are controls; on paper they are decoration, and on a checklist filed in a personnel record, misleading. The rule now sits on AppShell rather than on this one page, so the org-chart print view — which had the same problem — gets it too, along with anything printed later. The shell's padding goes with it: the type area is set by @page on the print page itself, and the shell's would have been added on top. The browser's own header line (date, title, URL) is separate — that is a checkbox in the print dialog, not something CSS can reach. 440 tests pass, including 13 new ones pinning the filename convention. Not yet seen in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
54
app/(app)/employees/[id]/checkliste/page.tsx
Normal file
54
app/(app)/employees/[id]/checkliste/page.tsx
Normal file
@@ -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 (
|
||||||
|
<PrintChecklist
|
||||||
|
art={art}
|
||||||
|
kopf={daten.kopf}
|
||||||
|
gruppen={art === "offboarding" ? OFFBOARDING_GRUPPEN : ONBOARDING_GRUPPEN}
|
||||||
|
staende={daten.staende}
|
||||||
|
today={today}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
components/employees/PrintChecklist.tsx
Normal file
194
components/employees/PrintChecklist.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<style>{`
|
||||||
|
@page { size: A4 portrait; margin: 14mm 14mm 12mm; }
|
||||||
|
@media print {
|
||||||
|
html, body { background: #fff; }
|
||||||
|
.nur-bildschirm { display: none !important; }
|
||||||
|
.blatt {
|
||||||
|
box-shadow: none !important; border: 0 !important; border-radius: 0 !important;
|
||||||
|
margin: 0 !important; padding: 0 !important; width: auto !important; min-height: 0 !important;
|
||||||
|
}
|
||||||
|
/* Eine Gruppe soll nicht mitten in der Überschrift umbrechen, und
|
||||||
|
eine Zeile nicht zwischen Haken und Kommentar. */
|
||||||
|
.gruppe { break-inside: avoid; }
|
||||||
|
.punkt { break-inside: avoid; }
|
||||||
|
}
|
||||||
|
/* Zweispaltig, damit 25 Punkte auf ein Blatt gehen. Als Spaltensatz und
|
||||||
|
nicht als Gitter: die Punkte fliessen dann von der linken in die
|
||||||
|
rechte Spalte, statt dass eine Spalte halb leer bleibt. */
|
||||||
|
.spalten { column-count: 2; column-gap: 10mm; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div className="nur-bildschirm flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-ink-muted">
|
||||||
|
So kommt es aus dem Drucker. Vorgeschlagener Dateiname:{" "}
|
||||||
|
<span className="font-semibold text-ink">{dateiname}.pdf</span>
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.print()}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Als PDF speichern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="nur-bildschirm max-w-prose text-xs leading-relaxed text-ink-muted">
|
||||||
|
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“.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="blatt mx-auto w-[210mm] rounded border border-border bg-white p-[14mm] shadow-[var(--shadow-card)]">
|
||||||
|
{/* ── Kopf ───────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-start justify-between gap-6 border-b-2 border-ink pb-2">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-[15pt] font-extrabold leading-tight text-ink">{artTitel(art)}</h1>
|
||||||
|
<p className="text-[9pt] text-ink-muted">Alpenwerk Industrie GmbH</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-right text-[8pt] leading-snug text-ink-muted">
|
||||||
|
Auszug vom
|
||||||
|
<span className="block text-[10pt] font-semibold tabular-nums text-ink">{fmtDate(today)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-3 grid grid-cols-[auto_1fr_auto_1fr] gap-x-4 gap-y-1 text-[9pt]">
|
||||||
|
<dt className="font-semibold text-ink-muted">Pers.-Nr.</dt>
|
||||||
|
<dd className="tabular-nums text-ink">{kopf.personnel_number}</dd>
|
||||||
|
<dt className="font-semibold text-ink-muted">Eintritt</dt>
|
||||||
|
<dd className="tabular-nums text-ink">{fmtDate(kopf.entry_date)}</dd>
|
||||||
|
|
||||||
|
<dt className="font-semibold text-ink-muted">Name</dt>
|
||||||
|
<dd className="font-semibold text-ink">{fmtName(kopf.first_name, kopf.last_name)}</dd>
|
||||||
|
<dt className="font-semibold text-ink-muted">Position</dt>
|
||||||
|
<dd className="text-ink">
|
||||||
|
{kopf.position_title ?? kopf.job_title}
|
||||||
|
{kopf.position_number && <span className="text-ink-muted"> · Planstelle {kopf.position_number}</span>}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{/* ── Fortschritt ────────────────────────────────────── */}
|
||||||
|
<div className="mt-3 flex items-baseline justify-between border-t border-border pt-2 text-[9pt]">
|
||||||
|
<span className="font-semibold text-ink">
|
||||||
|
{stand.erledigt} von {stand.gesamt} erledigt
|
||||||
|
</span>
|
||||||
|
{stand.offen.length > 0 && (
|
||||||
|
<span className="text-ink-muted">
|
||||||
|
{stand.offen.length} offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Die Punkte ─────────────────────────────────────── */}
|
||||||
|
<div className="spalten mt-3">
|
||||||
|
{gruppen.map((gruppe) => (
|
||||||
|
<div key={gruppe.key} className="gruppe mb-3">
|
||||||
|
<h2 className="mb-1 bg-surface px-1.5 py-0.5 text-[8pt] font-bold uppercase tracking-wide text-ink-body">
|
||||||
|
{gruppe.label}
|
||||||
|
</h2>
|
||||||
|
<ul>
|
||||||
|
{gruppe.punkte.map((punkt) => {
|
||||||
|
const s = karte.get(punkt.key);
|
||||||
|
const fertig = istErledigt(punkt, s);
|
||||||
|
return (
|
||||||
|
<li key={punkt.key} className="punkt flex gap-1.5 py-[2pt] text-[9pt] leading-snug">
|
||||||
|
<span aria-hidden className="shrink-0 text-[10pt] leading-none text-ink">
|
||||||
|
{fertig ? GLYPH_AN : GLYPH_AUS}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className={fertig ? "text-ink" : "text-ink-body"}>{punkt.label}</span>
|
||||||
|
{/* Bei Ja/Nein und Text steht der Wert daneben — ein
|
||||||
|
Haken allein sagte dort nicht, was erfasst wurde. */}
|
||||||
|
{punkt.art !== "haken" && (
|
||||||
|
<span className="font-semibold text-ink">
|
||||||
|
{": "}
|
||||||
|
{s?.wert ?? "—"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s?.kommentar && (
|
||||||
|
<span className="block text-[8pt] italic text-ink-body">{s.kommentar}</span>
|
||||||
|
)}
|
||||||
|
{s?.updated_by_name && fertig && (
|
||||||
|
<span className="block text-[7pt] text-ink-muted">
|
||||||
|
{s.updated_by_name}
|
||||||
|
{s.updated_at ? ` · ${fmtDate(s.updated_at)}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Fuss: Platz für Unterschriften ─────────────────── */}
|
||||||
|
<div className="mt-6 flex gap-10 text-[8pt] text-ink-muted">
|
||||||
|
<div className="flex-1 border-t border-ink pt-1">Datum, Unterschrift Mitarbeiter:in</div>
|
||||||
|
<div className="flex-1 border-t border-ink pt-1">Datum, Unterschrift Personalabteilung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageSquarePlus } from "lucide-react";
|
import { MessageSquarePlus, Printer } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
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 { useToast } from "@/components/ui/Toast";
|
||||||
import {
|
import {
|
||||||
alleReichungen,
|
alleReichungen,
|
||||||
@@ -34,6 +35,8 @@ type Props = {
|
|||||||
/** Text, solange keine Liste existiert. */
|
/** Text, solange keine Liste existiert. */
|
||||||
leerText: string;
|
leerText: string;
|
||||||
anlegenLabel: string;
|
anlegenLabel: string;
|
||||||
|
/** Ziel für den Ausdruck — eine eigene Seite, siehe PrintChecklist. */
|
||||||
|
druckHref: string;
|
||||||
onSpeichern: (
|
onSpeichern: (
|
||||||
itemKey: string,
|
itemKey: string,
|
||||||
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
|
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
|
||||||
@@ -41,7 +44,16 @@ type Props = {
|
|||||||
onAnlegen: () => Promise<ActionResult>;
|
onAnlegen: () => Promise<ActionResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [laeuft, setLaeuft] = useState<string | null>(null);
|
const [laeuft, setLaeuft] = useState<string | null>(null);
|
||||||
@@ -86,6 +98,15 @@ export function ChecklistPanel({ gruppen, staende, vorhanden, leerText, anlegenL
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{/* Neuer Tab: der Ausdruck ist eine eigene Seite, und wer ihn schliesst,
|
||||||
|
steht wieder in der Akte statt auf einer leeren Historie. */}
|
||||||
|
<Link href={druckHref} target="_blank" className={LINK_BUTTON_CLASS}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Als PDF speichern
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded border border-border bg-surface px-3 py-2.5">
|
<div className="rounded border border-border bg-surface px-3 py-2.5">
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<span className="text-sm font-semibold text-ink">
|
<span className="text-sm font-semibold text-ink">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function OffboardingTab({
|
|||||||
vorhanden={vorhanden}
|
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."
|
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"
|
anlegenLabel="Checkliste anlegen"
|
||||||
|
druckHref={`/employees/${employeeId}/checkliste?art=offboarding`}
|
||||||
onSpeichern={(itemKey, teil) => setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
onSpeichern={(itemKey, teil) => setOffboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||||
onAnlegen={() => startOffboarding(employeeId)}
|
onAnlegen={() => startOffboarding(employeeId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function OnboardingTab({
|
|||||||
vorhanden={vorhanden}
|
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."
|
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"
|
anlegenLabel="Checkliste anlegen"
|
||||||
|
druckHref={`/employees/${employeeId}/checkliste?art=onboarding`}
|
||||||
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
onSpeichern={(itemKey, teil) => setOnboardingTask({ employee_id: employeeId, item_key: itemKey, ...teil })}
|
||||||
onAnlegen={() => startOnboarding(employeeId)}
|
onAnlegen={() => startOnboarding(employeeId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,13 +12,25 @@ export function AppShell({ userLabel, openNotes, children }: { userLabel: string
|
|||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const closeNav = useCallback(() => setNavOpen(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 (
|
return (
|
||||||
<div className="min-h-dvh">
|
<div className="min-h-dvh print:min-h-0">
|
||||||
|
<div className="print:hidden">
|
||||||
<Sidebar open={navOpen} onClose={closeNav} />
|
<Sidebar open={navOpen} onClose={closeNav} />
|
||||||
<div className="flex min-h-dvh flex-col lg:ml-[236px]">
|
</div>
|
||||||
|
<div className="flex min-h-dvh flex-col lg:ml-[236px] print:ml-0 print:block print:min-h-0">
|
||||||
|
<div className="print:hidden">
|
||||||
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
|
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
|
||||||
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
|
</div>
|
||||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6 print:p-0">
|
||||||
|
<div className="mx-auto w-full max-w-[1280px] print:max-w-none">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
79
lib/checklist-print-data.ts
Normal file
79
lib/checklist-print-data.ts
Normal file
@@ -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<ChecklistDruckDaten | null> {
|
||||||
|
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[],
|
||||||
|
};
|
||||||
|
}
|
||||||
65
lib/checklist-print.ts
Normal file
65
lib/checklist-print.ts
Normal file
@@ -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)}`;
|
||||||
|
}
|
||||||
92
tests/unit/checklist-print.test.ts
Normal file
92
tests/unit/checklist-print.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user