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>
195 lines
8.6 KiB
TypeScript
195 lines
8.6 KiB
TypeScript
"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>
|
|
);
|
|
}
|