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>
271 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|