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:
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
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";
|
||||
|
||||
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
|
||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||
try {
|
||||
const employeeId = await withUser(await currentUserId(), (tx) =>
|
||||
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
||||
);
|
||||
const employeeId = await withUser(await currentUserId(), async (tx) => {
|
||||
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("/");
|
||||
revalidatePath("/positions");
|
||||
@@ -160,7 +166,44 @@ export async function rehireEmployee(payload: {
|
||||
rehire_date: string;
|
||||
position_id: string;
|
||||
}): 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: {
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const data = await withUser(await currentUserId(), (tx) => loadEmployeeDetail(tx, id, today));
|
||||
|
||||
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 (
|
||||
<EmployeeDetail
|
||||
@@ -32,6 +32,7 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
}
|
||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||
kostenstelle={kostenstelle}
|
||||
onboarding={onboarding}
|
||||
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
|
||||
// zweimal da.
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
).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
|
||||
// Organisation, nicht zu dieser Person.
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
@@ -131,6 +138,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
||||
placement,
|
||||
// Die Kostenstelle des Sitzes, auf dem die Person am Stichtag sitzt.
|
||||
kostenstelle: placement ? (kontierungZum(g.costRows as KontierungsZeile[], asOf).get(placement.positionId) ?? null) : null,
|
||||
onboarding: g.onboarding,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
|
||||
154
lib/onboarding.ts
Normal file
154
lib/onboarding.ts
Normal 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;
|
||||
}
|
||||
@@ -479,6 +479,35 @@ export type Database = {
|
||||
};
|
||||
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
|
||||
// Person: der Sitz kostet Geld, auch wenn niemand darauf sitzt.
|
||||
cost_centers: NoRelationships & {
|
||||
@@ -585,6 +614,8 @@ export type Database = {
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
update_position: { 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 };
|
||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
|
||||
171
supabase/migrations/20260817100000_onboarding_checkliste.sql
Normal file
171
supabase/migrations/20260817100000_onboarding_checkliste.sql
Normal 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;
|
||||
131
tests/unit/onboarding.test.ts
Normal file
131
tests/unit/onboarding.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user