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

@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { currentUserId } from "@/lib/auth/session"; import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db"; import { withUser } from "@/lib/db";
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc"; import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
import { ONBOARDING_PUNKTE } from "@/lib/onboarding";
import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types"; import type { CollectiveAgreement, DienstwagenArt, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> { async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
@@ -67,9 +68,14 @@ export async function hireEmployee(payload: {
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue // Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann. // Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
try { try {
const employeeId = await withUser(await currentUserId(), (tx) => const employeeId = await withUser(await currentUserId(), async (tx) => {
callFunction(tx, "hire_employee", payload as Record<string, unknown>) const id = (await callFunction(tx, "hire_employee", payload as Record<string, unknown>)) as string;
); // In derselben Transaktion: eine Einstellung ohne Checkliste wäre eine
// halb erfasste Einstellung, und sie später nachzureichen hiesse, dass
// jemand daran denken muss.
await callFunction(tx, "start_onboarding", { employee_id: id, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) });
return id;
});
revalidatePath("/employees"); revalidatePath("/employees");
revalidatePath("/"); revalidatePath("/");
revalidatePath("/positions"); revalidatePath("/positions");
@@ -160,7 +166,44 @@ export async function rehireEmployee(payload: {
rehire_date: string; rehire_date: string;
position_id: string; position_id: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); try {
await withUser(await currentUserId(), async (tx) => {
await callFunction(tx, "rehire_employee", payload as unknown as Record<string, unknown>);
// Auch bei der Wiedereinstellung: Dienstzettel, Bankverbindung und
// E-Card sind wieder zu erledigen. Die Punkte von damals stehen noch da
// und bleiben stehen — start_onboarding legt nur an, was fehlt, statt
// einen alten Haken zu löschen. Was wirklich neu zu tun ist, entscheidet
// HR an der Liste; ein Programm an ihrer Stelle würde raten.
await callFunction(tx, "start_onboarding", {
employee_id: payload.employee_id,
item_keys: ONBOARDING_PUNKTE.map((x) => x.key),
});
});
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
}
for (const path of [`/employees/${payload.employee_id}`, "/employees", "/"]) revalidatePath(path);
return { success: true };
}
/** Einen Punkt der Checkliste festhalten — Haken, Wert oder Kommentar. */
export async function setOnboardingTask(payload: {
employee_id: string;
item_key: string;
erledigt?: boolean;
wert?: string | null;
kommentar?: string | null;
}): Promise<ActionResult> {
return callRpc("set_onboarding_task", payload, [`/employees/${payload.employee_id}`]);
}
/** Legt die Checkliste nachträglich an — für Personen von vor dieser Liste. */
export async function startOnboarding(employeeId: string): Promise<ActionResult> {
return callRpc(
"start_onboarding",
{ employee_id: employeeId, item_keys: ONBOARDING_PUNKTE.map((x) => x.key) },
[`/employees/${employeeId}`]
);
} }
export async function addEmployeeDependent(payload: { export async function addEmployeeDependent(payload: {

View File

@@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today)); const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
if (!data) notFound(); if (!data) notFound();
const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, openPositions, byId } = data; const { employee, line, reports, history, dependents, notes, orgMaps, placement, kostenstelle, onboarding, openPositions, byId } = data;
return ( return (
<EmployeeDetail <EmployeeDetail
@@ -32,6 +32,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
} }
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)} breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
kostenstelle={kostenstelle} kostenstelle={kostenstelle}
onboarding={onboarding}
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null} manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person // Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
// zweimal da. // zweimal da.

View File

@@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/Avatar";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { fmtFullName, tenure } from "@/lib/format"; import { fmtFullName, tenure } from "@/lib/format";
import { fortschritt, type AufgabenStand } from "@/lib/onboarding";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { DatenAendernPanel } from "./panels/DatenAendernPanel";
@@ -17,6 +18,7 @@ import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel"; import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab"; import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab"; import { NotizenTab } from "./tabs/NotizenTab";
import { OnboardingTab } from "./tabs/OnboardingTab";
import { OrganisationTab } from "./tabs/OrganisationTab"; import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab"; import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab"; import { VertragTab } from "./tabs/VertragTab";
@@ -36,6 +38,8 @@ type EmployeeDetailProps = {
breadcrumb: string; breadcrumb: string;
/** Die Kostenstelle der laufenden Planstellenbesetzung. */ /** Die Kostenstelle der laufenden Planstellenbesetzung. */
kostenstelle: { code: string; name: string } | null; kostenstelle: { code: string; name: string } | null;
/** Der Stand der Onboarding-Checkliste; leer heisst: es gibt keine. */
onboarding: AufgabenStand[];
manager: MiniEmployee | null; manager: MiniEmployee | null;
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */ /** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
formalManager: MiniEmployee | null; formalManager: MiniEmployee | null;
@@ -48,14 +52,17 @@ type EmployeeDetailProps = {
}; };
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null; 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) { 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 [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null); const [panel, setPanel] = useState<PanelType>(null);
const location = locations.find((l) => l.id === employee.location_id); 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 isActive = employee.status === "Aktiv" || employee.status === "Karenz";
const canEditData = employee.status !== "Ausgetreten"; 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" 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> </button>
))} ))}
</div> </div>
@@ -164,6 +171,9 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
kostenstelle={kostenstelle} kostenstelle={kostenstelle}
/> />
)} )}
{tab === "Onboarding" && (
<OnboardingTab employeeId={employee.id} staende={onboarding} vorhanden={onboarding.length > 0} />
)}
{tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />} {tab === "Historie" && <HistorieTab history={history} employeeId={employee.id} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />} {tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div> </div>

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>
);
}

View File

@@ -89,6 +89,13 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id) e.selectFrom("position_assignments as pa").select("pa.position_id").where("pa.employee_id", "=", id)
) )
).as("costRows"), ).as("costRows"),
jsonArrayFrom(
eb
.selectFrom("onboarding_tasks")
.select(["item_key", "erledigt", "wert", "kommentar", "updated_by_name"])
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
.where("employee_id", "=", id)
).as("onboarding"),
// Die offenen Planstellen bleiben bei heute: sie gehören zur // Die offenen Planstellen bleiben bei heute: sie gehören zur
// Organisation, nicht zu dieser Person. // Organisation, nicht zu dieser Person.
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"), jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
@@ -131,6 +138,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
placement, placement,
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt. // Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null, kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
onboarding: g.onboarding,
openPositions, openPositions,
byId: new Map(relatedRows.map((e) => [e.id, e])), byId: new Map(relatedRows.map((e) => [e.id, e])),
}; };

154
lib/onboarding.ts Normal file
View File

@@ -0,0 +1,154 @@
// Die Onboarding-Checkliste — ein Verzeichnis, das Oberfläche, Fortschritt
// und Auswertung gemeinsam lesen.
//
// Die Punkte stehen hier und nicht in der Datenbank. Das ist eine Entscheidung
// mit einer Kehrseite, also der Grund: eine Checkliste ist ein Firmenprozess,
// kein Stammdatum. Sie ändert sich selten, und wenn, dann soll die Änderung
// durch Prüfung und Protokoll gehen wie jede andere. Dafür lässt sie sich
// nicht ohne Aufspielen anpassen.
//
// Gespeichert wird je Person und Punkt **nur die Antwort**, unter dem
// Schlüssel des Punktes. Ein später entfernter Punkt lässt die alten Antworten
// deshalb stehen — sie verschwinden aus der Liste, aber nicht aus den Daten,
// und eine Akte von damals bleibt lesbar.
/**
* Welche Eingabe ein Punkt braucht — aus der Sache abgeleitet, nicht geraten:
*
* • `haken` — erledigt oder nicht. Der Normalfall: „Meldezettel" ist da oder
* fehlt, ein Zwischenzustand ist nicht vorgesehen.
* • `janein` — eine Frage mit zwei Antworten, bei der **offen** etwas anderes
* heisst als **nein**. Beim Prämienanspruch stehen im Blatt zwei Kästchen
* nebeneinander, und das ist kein Zufall: „nein" ist ein erhobener Befund,
* „noch nicht gefragt" nicht. Ein einzelner Haken könnte das nicht sagen.
* • `text` — ein Wert, der festgehalten wird. Bei den Grössen ist die Angabe
* selbst der Zweck; abgehakt wäre sie wertlos.
*/
export type AufgabenArt = "haken" | "janein" | "text";
export type AufgabenPunkt = {
/** Bleibt stabil — er steht in den Daten. */
key: string;
label: string;
art: AufgabenArt;
/** Zusatz unter der Beschriftung, wo die Bezeichnung allein nicht reicht. */
hinweis?: string;
};
export type AufgabenGruppe = {
key: string;
label: string;
punkte: readonly AufgabenPunkt[];
};
export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
{
key: "aufgaben",
label: "Zu erledigen",
punkte: [
{ key: "dienstzettel", label: "Dienstzettel / Dienstvertrag", art: "haken" },
{ key: "dienstzettel_retour", label: "Dienstzettel / Dienstvertrag retour", art: "haken" },
{ key: "onboarding_gestartet", label: "Onboarding gestartet", art: "haken" },
{ key: "lgv_e9", label: "LGV Anlage / E9", art: "haken" },
{ key: "interflex", label: "Interflex Anlage", art: "haken" },
{ key: "eurest", label: "Anmeldung EUREST", art: "haken" },
{ key: "oegk", label: "Anmeldung ÖGK", art: "haken" },
{ key: "webclient", label: "WebClient", art: "haken" },
{ key: "datensatzberechtigung", label: "Datensatzberechtigung Führungskraft", art: "haken" },
{ key: "praemienanspruch", label: "Prämienanspruch", art: "janein" },
{
key: "webreise",
label: "Webreise Benutzer",
art: "haken",
hinweis: "HM 13, Seite 2 Punkt 15, „J“, ganz am Ende mit einfügen",
},
],
},
{
key: "unterlagen",
label: "Unterlagen",
punkte: [
{ key: "passkopie", label: "Passkopie / Ausweis", art: "haken" },
{
key: "arbeitsbewilligung",
label: "Arbeitsbewilligung",
art: "haken",
hinweis: "Nur bei Staatsbürgerschaft ausserhalb EU/EWR und Schweiz",
},
{ key: "meldezettel", label: "Meldezettel", art: "haken" },
{ key: "bankverbindung", label: "Bankverbindung", art: "haken" },
{ key: "ecard", label: "E-Card", art: "haken" },
{ key: "strafregisterauszug", label: "Strafregisterauszug", art: "haken" },
{ key: "salmonellenbefund", label: "Salmonellenbefund", art: "haken" },
{ key: "personaldatenblatt", label: "Personaldatenblatt", art: "haken" },
{ key: "cv", label: "CV", art: "haken" },
{ key: "goodies", label: "Goodies", art: "haken" },
{ key: "willkommensmappe", label: "Willkommensmappe", art: "haken" },
],
},
{
key: "vermerke",
label: "Vermerke — Grösse",
punkte: [
{ key: "groesse_schuhe", label: "Schuhe", art: "text" },
{ key: "groesse_tshirt", label: "T-Shirt", art: "text" },
{ key: "groesse_hose", label: "Hose", art: "text" },
],
},
] as const;
export const ONBOARDING_PUNKTE: readonly AufgabenPunkt[] = ONBOARDING_GRUPPEN.flatMap((g) => g.punkte);
const NACH_KEY = new Map(ONBOARDING_PUNKTE.map((p) => [p.key, p]));
export function punktZu(key: string): AufgabenPunkt | undefined {
return NACH_KEY.get(key);
}
/** Der gespeicherte Stand eines Punktes. */
export type AufgabenStand = {
item_key: string;
erledigt: boolean;
/** Bei `janein` „ja"/„nein", bei `text` die Angabe, bei `haken` leer. */
wert: string | null;
kommentar: string | null;
updated_at: string | null;
updated_by_name: string | null;
};
/**
* Ob ein Punkt als erledigt zählt — je Art verschieden, und deshalb an einer
* Stelle statt an dreien.
*
* Bei `janein` zählt auch „nein" als erledigt: die Frage ist beantwortet, und
* genau das ist die Aufgabe. Bei `text` ist die Angabe selbst die Erledigung;
* ein leerer Text ist keine.
*/
export function istErledigt(punkt: AufgabenPunkt, stand: AufgabenStand | undefined): boolean {
if (!stand) return false;
if (punkt.art === "haken") return stand.erledigt;
return (stand.wert ?? "").trim() !== "";
}
export type Fortschritt = { erledigt: number; gesamt: number; offen: string[] };
/**
* Wie weit die Liste ist. `offen` nennt die Beschriftungen, nicht die
* Schlüssel — die Zahl allein sagt nicht, worauf noch gewartet wird.
*/
export function fortschritt(staende: AufgabenStand[]): Fortschritt {
const karte = new Map(staende.map((s) => [s.item_key, s]));
const offen = ONBOARDING_PUNKTE.filter((p) => !istErledigt(p, karte.get(p.key)));
return {
erledigt: ONBOARDING_PUNKTE.length - offen.length,
gesamt: ONBOARDING_PUNKTE.length,
offen: offen.map((p) => p.label),
};
}
/** Was als Wert erlaubt ist — dieselbe Prüfung wie in der Datenbank. */
export function wertIstGueltig(punkt: AufgabenPunkt, wert: string | null): boolean {
if (punkt.art === "janein") return wert === null || wert === "ja" || wert === "nein";
if (punkt.art === "haken") return wert === null;
return true;
}

View File

@@ -479,6 +479,35 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
}; };
// Die Onboarding-Checkliste: je Person und Punkt eine Zeile. Welche
// Punkte es gibt, steht in lib/onboarding.ts — nicht hier.
onboarding_tasks: NoRelationships & {
Row: {
id: string;
employee_id: string;
item_key: string;
erledigt: boolean;
wert: string | null;
kommentar: string | null;
created_at: string;
updated_at: string;
updated_by: string | null;
updated_by_name: string | null;
};
Insert: {
id?: string;
employee_id: string;
item_key: string;
erledigt?: boolean;
wert?: string | null;
kommentar?: string | null;
created_at?: string;
updated_at?: string;
updated_by?: string | null;
updated_by_name?: string | null;
};
Update: Partial<Database["public"]["Tables"]["onboarding_tasks"]["Insert"]>;
};
// Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der // Kostenstellen. Die Zuordnung hängt an der Planstelle, nicht an der
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt. // Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
cost_centers: NoRelationships & { cost_centers: NoRelationships & {
@@ -585,6 +614,8 @@ export type Database = {
create_position: { Args: { payload: Record<string, unknown> }; Returns: string }; create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
update_position: { Args: { payload: Record<string, unknown> }; Returns: void }; update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void }; set_position_cost_center: { Args: { payload: Record<string, unknown> }; Returns: void };
set_onboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
start_onboarding: { Args: { payload: Record<string, unknown> }; Returns: number };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void }; delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean }; is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
apply_due_pending_changes: { Args: Record<string, never>; Returns: number }; apply_due_pending_changes: { Args: Record<string, never>; Returns: number };

View File

@@ -0,0 +1,171 @@
-- Onboarding-Checkliste je Person
--
-- Bisher lief das über ein Blatt neben der Anwendung: eine Tabelle mit
-- Kästchen, je Eintritt einmal ausgedruckt. Was darauf steht, weiss dann nur,
-- wer das Blatt hat — und ob der Meldezettel schon da ist, lässt sich weder
-- suchen noch auswerten noch vertreten.
--
-- Gespeichert wird **je Person und Punkt eine Zeile**, unter dem Schlüssel des
-- Punktes. Die Punkte selbst stehen in lib/onboarding.ts, nicht hier: eine
-- Checkliste ist ein Firmenprozess und kein Stammdatum. Der Schlüssel als Text
-- statt als Fremdschlüssel hat einen Preis (die Datenbank kennt die gültigen
-- Werte nicht) und einen Grund: ein später gestrichener Punkt lässt die alten
-- Antworten stehen, statt sie mitzureissen. Eine Akte von damals bleibt so
-- lesbar, auch wenn die Liste heute anders aussieht.
--
-- Drei Arten von Antwort in einer Tabelle, weil es dieselbe Sache ist:
-- • Haken → erledigt
-- • Ja/Nein → wert ('ja'/'nein'); offen ist etwas anderes als nein
-- • Text → wert (Grössen)
-- `erledigt` trägt bei allen dreien die Frage „abgehakt?", damit der
-- Fortschritt eine Spalte hat und keine Fallunterscheidung.
create table if not exists onboarding_tasks (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
item_key text not null,
erledigt boolean not null default false,
wert text,
kommentar text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
updated_by uuid references profiles(id) on delete set null,
-- Der Name mitgeschrieben, nicht nur die Kennung: wer eine Checkliste von
-- vor zwei Jahren aufschlägt, will lesen, wer abgehakt hat, auch wenn die
-- Person längst nicht mehr im Verzeichnis steht.
updated_by_name text,
constraint onboarding_tasks_eine_zeile unique (employee_id, item_key)
);
create index if not exists onboarding_tasks_employee_id_idx on onboarding_tasks (employee_id);
-- Für „was ist noch offen": der Teilindex liest nur die unerledigten.
create index if not exists onboarding_tasks_offen_idx on onboarding_tasks (employee_id) where not erledigt;
drop policy if exists onboarding_tasks_hr_all on onboarding_tasks;
create policy onboarding_tasks_hr_all on onboarding_tasks
for all using (is_hr_user()) with check (is_hr_user());
-- ── Einen Punkt festhalten ─────────────────────────────────────────────
--
-- Ein Aufruf für alle drei Arten: gesetzt wird, was mitkommt. Wer nur den
-- Kommentar ändert, schickt nur den Kommentar — sonst würde ein Tippfehler im
-- Kommentarfeld den Haken mitlöschen.
create or replace function set_onboarding_task(payload jsonb)
returns void
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_item_key text := nullif(trim(payload->>'item_key'), '');
v_name text;
v_vorher onboarding_tasks%rowtype;
v_erledigt boolean;
v_wert text;
v_kommentar text;
begin
perform require_hr_admin();
if v_item_key is null then
raise exception 'Es wurde kein Punkt angegeben.';
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
select * into v_vorher from onboarding_tasks
where employee_id = v_employee_id and item_key = v_item_key for update;
-- Weggelassen heisst „unverändert", nicht „leeren". Der Unterschied ist der
-- Grund, warum hier `payload ? 'feld'` steht und nicht coalesce: eine
-- ausdrückliche null muss löschen können.
v_erledigt := case when payload ? 'erledigt' then (payload->>'erledigt')::boolean
else coalesce(v_vorher.erledigt, false) end;
v_wert := case when payload ? 'wert' then nullif(trim(payload->>'wert'), '') else v_vorher.wert end;
v_kommentar := case when payload ? 'kommentar' then nullif(trim(payload->>'kommentar'), '')
else v_vorher.kommentar end;
insert into onboarding_tasks (employee_id, item_key, erledigt, wert, kommentar, updated_by, updated_by_name)
values (v_employee_id, v_item_key, v_erledigt, v_wert, v_kommentar,
app_current_user_id(), current_actor_name())
on conflict (employee_id, item_key) do update
set erledigt = excluded.erledigt,
wert = excluded.wert,
kommentar = excluded.kommentar,
updated_at = now(),
updated_by = excluded.updated_by,
updated_by_name = excluded.updated_by_name;
-- Kein Eintrag in employee_history: das ist keine Änderung an der Person,
-- sondern der Stand einer Aufgabe. In der Historie stünden sonst
-- fünfundzwanzig Zeilen zwischen Eintritt und Versetzung. Ins Protokoll
-- gehört es trotzdem — es ist eine Handlung mit Urheber.
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Onboarding-Punkt', v_name, v_employee_id,
v_item_key || ': ' ||
case when v_wert is not null then v_wert
when v_erledigt then 'erledigt'
else 'offen' end ||
coalesce(' — ' || v_kommentar, ''));
end;
$function$;
revoke all on function set_onboarding_task(jsonb) from public;
grant execute on function set_onboarding_task(jsonb) to public;
-- ── Die Liste anlegen ──────────────────────────────────────────────────
--
-- Eine Checkliste entsteht mit dem Eintritt. Dass sie *existiert*, ist die
-- Aussage „diese Person ist im Onboarding" — deshalb wird sie angelegt und
-- nicht bloss beim ersten Klick nebenbei erzeugt: eine leere Liste, auf der
-- noch nichts steht, ist der eigentliche Anfangszustand und muss sichtbar
-- sein.
--
-- Die Punkte kommen aus dem Aufruf, nicht aus der Datenbank: sie stehen in
-- lib/onboarding.ts, und zwei Listen nebeneinander liefen auseinander.
create or replace function start_onboarding(payload jsonb)
returns integer
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_keys jsonb := coalesce(payload->'item_keys', '[]'::jsonb);
v_name text;
v_angelegt integer;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
if v_name is null then
raise exception 'Die Person existiert nicht.';
end if;
if jsonb_array_length(v_keys) = 0 then
raise exception 'Es wurden keine Punkte übergeben.';
end if;
insert into onboarding_tasks (employee_id, item_key, updated_by, updated_by_name)
select v_employee_id, k, app_current_user_id(), current_actor_name()
from jsonb_array_elements_text(v_keys) k
on conflict (employee_id, item_key) do nothing;
get diagnostics v_angelegt = row_count;
if v_angelegt > 0 then
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (app_current_user_id(), current_actor_name(), 'Onboarding-Checkliste angelegt', v_name, v_employee_id,
v_angelegt || ' Punkte');
end if;
return v_angelegt;
end;
$function$;
revoke all on function start_onboarding(jsonb) from public;
grant execute on function start_onboarding(jsonb) to public;

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import {
ONBOARDING_GRUPPEN,
ONBOARDING_PUNKTE,
fortschritt,
istErledigt,
punktZu,
wertIstGueltig,
type AufgabenStand,
} from "@/lib/onboarding";
// Die Checkliste kommt von einem Blatt Papier. Was dort ein Kästchen war, ist
// hier nicht überall eines: „Prämienanspruch" hatte zwei Kästchen (ja | nein),
// und die Grössen waren Felder zum Hineinschreiben. Die Feldart trägt also
// eine fachliche Aussage — deshalb wird sie hier festgehalten.
function stand(teil: Partial<AufgabenStand> & { item_key: string }): AufgabenStand {
return { erledigt: false, wert: null, kommentar: null, updated_at: null, updated_by_name: null, ...teil };
}
describe("Das Verzeichnis", () => {
it("hat eindeutige Schlüssel — sie stehen in den Daten", () => {
const keys = ONBOARDING_PUNKTE.map((p) => p.key);
expect(new Set(keys).size).toBe(keys.length);
});
it("bildet jeden Punkt aus dem Blatt ab", () => {
expect(ONBOARDING_PUNKTE).toHaveLength(25);
for (const key of ["dienstzettel", "meldezettel", "salmonellenbefund", "willkommensmappe", "webreise"]) {
expect(punktZu(key), key).toBeDefined();
}
});
it("führt Prämienanspruch als Ja/Nein, nicht als Haken", () => {
// Zwei Kästchen im Blatt, und das ist kein Zufall: „nein" ist ein
// erhobener Befund, „noch nicht gefragt" nicht.
expect(punktZu("praemienanspruch")?.art).toBe("janein");
});
it("führt die Grössen als Text", () => {
// Abgehakt wäre eine Schuhgrösse wertlos.
for (const key of ["groesse_schuhe", "groesse_tshirt", "groesse_hose"]) {
expect(punktZu(key)?.art, key).toBe("text");
}
});
it("gruppiert vollständig — kein Punkt fällt zwischen die Gruppen", () => {
const ausGruppen = ONBOARDING_GRUPPEN.flatMap((g) => g.punkte.map((p) => p.key));
expect(new Set(ausGruppen)).toEqual(new Set(ONBOARDING_PUNKTE.map((p) => p.key)));
});
});
describe("istErledigt", () => {
const haken = punktZu("meldezettel")!;
const janein = punktZu("praemienanspruch")!;
const text = punktZu("groesse_schuhe")!;
it("zählt einen gesetzten Haken", () => {
expect(istErledigt(haken, stand({ item_key: haken.key, erledigt: true }))).toBe(true);
expect(istErledigt(haken, stand({ item_key: haken.key }))).toBe(false);
});
it("zählt bei Ja/Nein auch ein Nein als erledigt", () => {
// Die Aufgabe war, die Frage zu beantworten — nicht, sie zu bejahen.
expect(istErledigt(janein, stand({ item_key: janein.key, wert: "nein" }))).toBe(true);
expect(istErledigt(janein, stand({ item_key: janein.key, wert: "ja" }))).toBe(true);
expect(istErledigt(janein, stand({ item_key: janein.key }))).toBe(false);
});
it("zählt bei Text die Angabe selbst, nicht den Haken", () => {
expect(istErledigt(text, stand({ item_key: text.key, wert: "43" }))).toBe(true);
expect(istErledigt(text, stand({ item_key: text.key, wert: " " }))).toBe(false);
// Ein Haken ohne Angabe wäre eine leere Behauptung.
expect(istErledigt(text, stand({ item_key: text.key, erledigt: true }))).toBe(false);
});
it("hält eine fehlende Zeile für offen", () => {
expect(istErledigt(haken, undefined)).toBe(false);
});
});
describe("fortschritt", () => {
it("ist auf einer leeren Liste vollständig offen", () => {
const f = fortschritt([]);
expect(f.erledigt).toBe(0);
expect(f.gesamt).toBe(ONBOARDING_PUNKTE.length);
expect(f.offen).toHaveLength(ONBOARDING_PUNKTE.length);
});
it("nennt die offenen Punkte beim Namen, nicht beim Schlüssel", () => {
// Die Zahl allein sagt nicht, worauf noch gewartet wird.
expect(fortschritt([]).offen).toContain("Meldezettel");
expect(fortschritt([]).offen).not.toContain("meldezettel");
});
it("zählt gemischte Arten zusammen", () => {
const f = fortschritt([
stand({ item_key: "meldezettel", erledigt: true }),
stand({ item_key: "praemienanspruch", wert: "nein" }),
stand({ item_key: "groesse_schuhe", wert: "43" }),
]);
expect(f.erledigt).toBe(3);
expect(f.offen).not.toContain("Meldezettel");
});
it("stört sich nicht an Zeilen zu Punkten, die es nicht mehr gibt", () => {
// Ein gestrichener Punkt lässt seine Antworten stehen — sie sollen die
// Zählung weder erhöhen noch zum Absturz führen.
const f = fortschritt([stand({ item_key: "gibt_es_nicht_mehr", erledigt: true })]);
expect(f.erledigt).toBe(0);
expect(f.gesamt).toBe(ONBOARDING_PUNKTE.length);
});
});
describe("wertIstGueltig", () => {
it("lässt bei Ja/Nein nur ja, nein oder nichts zu", () => {
const janein = punktZu("praemienanspruch")!;
expect(wertIstGueltig(janein, "ja")).toBe(true);
expect(wertIstGueltig(janein, "nein")).toBe(true);
expect(wertIstGueltig(janein, null)).toBe(true);
expect(wertIstGueltig(janein, "vielleicht")).toBe(false);
});
it("lässt bei einem Haken keinen Wert zu", () => {
expect(wertIstGueltig(punktZu("meldezettel")!, "irgendwas")).toBe(false);
});
it("lässt bei Text alles zu — eine Grösse ist keine Auswahlliste", () => {
expect(wertIstGueltig(punktZu("groesse_hose")!, "W32/L34")).toBe(true);
});
});