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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user