Put the onboarding checklist where the file is
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 6m24s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m51s

The list existed on paper: one printed sheet per entry, twenty-five
boxes. What is on it is known only to whoever holds the sheet — it
cannot be searched, cannot be covered for while someone is away, and
says nothing about who ticked what.

Not every box on the sheet is a checkbox, and the differences carry
meaning, so the field kind is derived from the thing rather than
flattened:

  Haken   — the normal case. The Meldezettel is there or it is not.
  Ja/Nein — Prämienanspruch had *two* boxes on the sheet, and that is
            not decoration: "nein" is a finding, "not asked yet" is not.
            One checkbox cannot say both.
  Text    — shoe, shirt and trouser size. The value is the point;
            ticked off it would be worthless.

Every item takes a comment, and every item records who last touched it
and when — the part the sheet could never do.

Saved on click, not on submit. A checklist is worked through over days,
between other things; a save button at the end is where half a morning
goes missing.

The items live in lib/onboarding.ts, not in a table: a checklist is a
company process, not a master record. Stored per person is only the
answer, under the item's key — so an item dropped later leaves its old
answers standing instead of taking them along, and a file from back then
stays readable.

A list is created by hire and rehire, in the same transaction as the
hire itself: a hire without a checklist would be a half-recorded hire.
Rehire only adds what is missing and never clears an old tick — what
genuinely has to be redone is HR's call, and a program deciding it would
be guessing. People hired before this feature have no list and get a
button to start one.

Checked against the real database end to end: hire creates 25 open
items; checkbox, ja/nein, size and comment all land; a comment-only edit
leaves the tick alone; rehire tops the list up and keeps what was done.
The probe employee was removed afterwards — audit rows first, since the
log has no delete policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:40:02 +02:00
parent bd990b7f2c
commit 82d07f0d95
9 changed files with 800 additions and 8 deletions

View File

@@ -0,0 +1,243 @@
"use client";
import { MessageSquarePlus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { setOnboardingTask, startOnboarding } from "@/actions/employees";
import { Button } from "@/components/ui/Button";
import { useToast } from "@/components/ui/Toast";
import { fmtDate } from "@/lib/format";
import {
ONBOARDING_GRUPPEN,
fortschritt,
istErledigt,
type AufgabenPunkt,
type AufgabenStand,
} from "@/lib/onboarding";
// Die Checkliste, die bisher ein Blatt Papier war.
//
// Zwei Dinge macht sie anders als das Blatt, und beide sind der Grund, sie
// überhaupt zu bauen: sie sagt, **wer** wann abgehakt hat — auf dem Blatt
// stand nur ein Haken —, und sie lässt sich vertreten, weil sie nicht in
// einer Schublade liegt.
//
// 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 = {
employeeId: string;
staende: AufgabenStand[];
/** Ob überhaupt eine Liste existiert — sie entsteht mit dem Eintritt. */
vorhanden: boolean;
};
export function OnboardingTab({ employeeId, staende, vorhanden }: Props) {
const { showToast } = useToast();
const router = useRouter();
const [laeuft, setLaeuft] = useState<string | null>(null);
const [kommentarOffen, setKommentarOffen] = useState<Set<string>>(new Set());
const karte = new Map(staende.map((s) => [s.item_key, s]));
const stand = fortschritt(staende);
async function speichern(key: string, teil: { erledigt?: boolean; wert?: string | null; kommentar?: string | null }) {
setLaeuft(key);
const ergebnis = await setOnboardingTask({ employee_id: employeeId, item_key: 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 startOnboarding(employeeId);
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">
Für diese Person gibt es keine Onboarding-Checkliste. Sie entsteht mit einer Einstellung oder
Wiedereinstellung — wer davor eingetreten ist, hat keine.
</p>
<Button onClick={listeAnlegen} pending={laeuft === "neu"}>
Checkliste anlegen
</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>
{ONBOARDING_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} — Grösse`}
value={text}
disabled={laeuft}
onChange={(e) => setText(e.target.value)}
onBlur={() => text !== (stand?.wert ?? "") && onSpeichern({ wert: text || null })}
placeholder="Grösse"
className="w-24 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>
);
}