Put the onboarding checklist where the file is
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:
243
components/employees/tabs/OnboardingTab.tsx
Normal file
243
components/employees/tabs/OnboardingTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user