Files
alpenwerk-hr/components/employees/tabs/ChecklistPanel.tsx
Maximilian Stubhan 19e3170b00
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m6s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m22s
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>
2026-08-18 20:19:58 +02:00

271 lines
9.7 KiB
TypeScript

"use client";
import { MessageSquarePlus, Printer } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, LINK_BUTTON_CLASS } 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;
/** Ziel für den Ausdruck — eine eigene Seite, siehe PrintChecklist. */
druckHref: string;
onSpeichern: (
itemKey: string,
teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }
) => Promise<ActionResult>;
onAnlegen: () => Promise<ActionResult>;
};
export function ChecklistPanel({
gruppen,
staende,
vorhanden,
leerText,
anlegenLabel,
druckHref,
onSpeichern,
onAnlegen,
}: Props) {
const { showToast } = useToast();
const router = useRouter();
const [laeuft, setLaeuft] = useState<string | null>(null);
const [kommentarOffen, setKommentarOffen] = useState<Set<string>>(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 (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-ink-body">{leerText}</p>
<Button onClick={listeAnlegen} pending={laeuft === "neu"}>
{anlegenLabel}
</Button>
</div>
);
}
const anteil = Math.round((stand.erledigt / stand.gesamt) * 100);
return (
<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="flex flex-wrap items-baseline justify-between gap-2">
<span className="text-sm font-semibold text-ink">
{stand.erledigt} von {stand.gesamt} erledigt
</span>
<span className="text-xs text-ink-muted">
{stand.offen.length === 0 ? "Vollständig." : `Offen: ${stand.offen.slice(0, 3).join(", ")}`}
{stand.offen.length > 3 ? ` und ${stand.offen.length - 3} weitere` : ""}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-white">
<div
className={`h-full rounded-full ${anteil === 100 ? "bg-success-text" : "bg-brand-500"}`}
style={{ width: `${Math.max(2, anteil)}%` }}
/>
</div>
</div>
{gruppen.map((gruppe) => (
<div key={gruppe.key}>
<h3 className="text-xs font-bold uppercase tracking-wide text-ink-muted">{gruppe.label}</h3>
<ul className="mt-2 flex flex-col divide-y divide-border-subtle">
{gruppe.punkte.map((punkt) => (
<Zeile
key={punkt.key}
punkt={punkt}
stand={karte.get(punkt.key)}
laeuft={laeuft === punkt.key}
kommentarOffen={kommentarOffen.has(punkt.key)}
onKommentarOeffnen={() =>
setKommentarOffen((prev) => {
const next = new Set(prev);
next.add(punkt.key);
return next;
})
}
onSpeichern={(teil) => speichern(punkt.key, teil)}
/>
))}
</ul>
</div>
))}
</div>
);
}
function Zeile({
punkt,
stand,
laeuft,
kommentarOffen,
onKommentarOeffnen,
onSpeichern,
}: {
punkt: AufgabenPunkt;
stand: AufgabenStand | undefined;
laeuft: boolean;
kommentarOffen: boolean;
onKommentarOeffnen: () => void;
onSpeichern: (teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) => void;
}) {
const erledigt = istErledigt(punkt, stand);
const [text, setText] = useState(stand?.wert ?? "");
const [kommentar, setKommentar] = useState(stand?.kommentar ?? "");
const zeigeKommentar = kommentarOffen || Boolean(stand?.kommentar);
return (
<li className="py-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<div className="min-w-0 flex-1">
<span className={`text-sm ${erledigt ? "text-ink-muted line-through decoration-ink-muted/40" : "text-ink"}`}>
{punkt.label}
</span>
{punkt.hinweis && <span className="block text-xs text-ink-muted">{punkt.hinweis}</span>}
</div>
{punkt.art === "haken" && (
<input
type="checkbox"
aria-label={punkt.label}
checked={stand?.erledigt ?? false}
disabled={laeuft}
onChange={(e) => onSpeichern({ erledigt: e.target.checked })}
className="h-4 w-4 shrink-0 rounded border-border text-brand-600
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
/>
)}
{punkt.art === "janein" && (
// Zwei Knöpfe statt eines Hakens: „nein" ist ein erhobener Befund,
// „noch nicht gefragt" nicht. Ein Haken könnte das nicht sagen.
<span className="flex shrink-0 gap-1">
{(["ja", "nein"] as const).map((wert) => (
<button
key={wert}
type="button"
disabled={laeuft}
aria-pressed={stand?.wert === wert}
onClick={() => onSpeichern({ wert: stand?.wert === wert ? null : wert })}
className={`rounded-full px-2.5 py-0.5 text-xs font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
stand?.wert === wert ? "bg-brand-500 text-white" : "bg-surface text-ink-muted hover:text-ink"
}`}
>
{wert === "ja" ? "Ja" : "Nein"}
</button>
))}
</span>
)}
{punkt.art === "text" && (
<input
type="text"
aria-label={`${punkt.label} — Wert`}
value={text}
disabled={laeuft}
onChange={(e) => setText(e.target.value)}
onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })}
placeholder="Wert"
className="w-28 shrink-0 rounded border border-border px-2 py-1 text-sm text-ink
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
{!zeigeKommentar && (
<button
type="button"
onClick={onKommentarOeffnen}
aria-label={`Kommentar zu ${punkt.label}`}
className="shrink-0 rounded p-1 text-ink-muted hover:bg-brand-50 hover:text-brand-700
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
)}
</div>
{zeigeKommentar && (
<input
type="text"
aria-label={`Kommentar zu ${punkt.label}`}
value={kommentar}
disabled={laeuft}
onChange={(e) => setKommentar(e.target.value)}
onBlur={() => kommentar !== (stand?.kommentar ?? "") && onSpeichern({ kommentar: kommentar || null })}
placeholder="Kommentar …"
className="mt-1.5 w-full rounded border border-border bg-surface px-2 py-1 text-xs text-ink-body
focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
/>
)}
{/* Wer und wann — der Unterschied zum Blatt Papier, auf dem nur der
Haken stand. */}
{stand?.updated_by_name && (erledigt || stand.kommentar) && (
<p className="mt-1 text-[11px] text-ink-muted">
{stand.updated_by_name}
{stand.updated_at ? ` · ${fmtDate(stand.updated_at)}` : ""}
</p>
)}
</li>
);
}