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

@@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip";
import { fmtFullName, tenure } from "@/lib/format";
import { fortschritt, type AufgabenStand } from "@/lib/onboarding";
import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
@@ -17,6 +18,7 @@ import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab";
import { OnboardingTab } from "./tabs/OnboardingTab";
import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab";
@@ -36,6 +38,8 @@ type EmployeeDetailProps = {
breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null;
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
onboarding: AufgabenStand[];
manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null;
@@ -48,14 +52,17 @@ type EmployeeDetailProps = {
};
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Onboarding", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, placement, breadcrumb, kostenstelle, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const { employee, placement, breadcrumb, kostenstelle, onboarding, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null);
const location = locations.find((l) => l.id === employee.location_id);
// Am Reiter steht, was noch aussteht — sonst müsste man hineinsehen, um zu
// erfahren, dass nichts zu tun ist.
const offeneAufgaben = onboarding.length > 0 ? fortschritt(onboarding).gesamt - fortschritt(onboarding).erledigt : 0;
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
const canEditData = employee.status !== "Ausgetreten";
@@ -146,7 +153,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
}`}
>
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t === "Onboarding" && offeneAufgaben > 0 ? `Onboarding ${offeneAufgaben}` : t}
</button>
))}
</div>
@@ -164,6 +171,9 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
kostenstelle={kostenstelle}
/>
)}
{tab === "Onboarding" && (
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
)}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div>