Files
alpenwerk-hr/components/employees/tabs/ChecklistPanel.tsx
Maximilian Stubhan f85731dde5
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m42s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m35s
Give exits their own checklist, next to the entry one
The offboarding list was a checkbox fieldset inside the exit panel — four
items, never sent anywhere. Nothing in terminateEmployee's payload
carried them; ticking a box there recorded exactly nothing.

It's replaced with the same kind of list the entries got: its own tab,
appearing the moment an exit is recorded, with one item per row, a
comment on each, and — unlike the fieldset — a record of who touched it
and when.

The eleven items come from the same printed sheet as the entry list.
Nine are plain checkboxes. Two are text fields under "Vermerke":
remaining vacation and the balance transferred for payout — the sheet
names "Überleitung Salden für Auszahlung" twice, once as a task to do
and once as the actual figure, and those are genuinely two different
questions, kept as two items. Where the sheet still says "GKK" rather
than today's "ÖGK", it's left as written — that's the name the process
runs under internally, not a typo.

No Show gets no list. Never having worked a single day, there's no IT
access to revoke, no GKK registration to undo, no Dienstzettel to
collect — an empty checklist there would be a label with nothing behind
it. Both the tab and the auto-creation on exit check for this
specifically, not just the "Ausgetreten" status that No Show shares with
a real exit. Rehiring the same person hides the tab again — the data
stays, since it happened, but a checklist for someone currently working
has nothing to point at.

The engine (what counts as done, how progress is computed) moved into
lib/checklist.ts so onboarding and offboarding can't drift into two
different ideas of "done" the way two independent copies eventually do;
lib/onboarding.ts and lib/offboarding.ts bind it to their own item list,
and the tab UI is a single ChecklistPanel bound the same way.

Checked against the real database: a real exit creates all eleven items
in the same transaction as the exit itself; a No Show creates none;
rehiring flips the tab off while the old answers stay queryable. One
false alarm during that check turned out to be the user's own clicks on
a real employee's onboarding list, made in the browser while trying the
earlier feature — left untouched, not test debris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:25:58 +02:00

250 lines
9.2 KiB
TypeScript

"use client";
import { MessageSquarePlus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } 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;
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, 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="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>
);
}