Give exits their own checklist, next to the entry one
The offboarding list was a checkbox fieldset inside the exit panel — four items, never sent anywhere. Nothing in terminateEmployee's payload carried them; ticking a box there recorded exactly nothing. It's replaced with the same kind of list the entries got: its own tab, appearing the moment an exit is recorded, with one item per row, a comment on each, and — unlike the fieldset — a record of who touched it and when. The eleven items come from the same printed sheet as the entry list. Nine are plain checkboxes. Two are text fields under "Vermerke": remaining vacation and the balance transferred for payout — the sheet names "Überleitung Salden für Auszahlung" twice, once as a task to do and once as the actual figure, and those are genuinely two different questions, kept as two items. Where the sheet still says "GKK" rather than today's "ÖGK", it's left as written — that's the name the process runs under internally, not a typo. No Show gets no list. Never having worked a single day, there's no IT access to revoke, no GKK registration to undo, no Dienstzettel to collect — an empty checklist there would be a label with nothing behind it. Both the tab and the auto-creation on exit check for this specifically, not just the "Ausgetreten" status that No Show shares with a real exit. Rehiring the same person hides the tab again — the data stays, since it happened, but a checklist for someone currently working has nothing to point at. The engine (what counts as done, how progress is computed) moved into lib/checklist.ts so onboarding and offboarding can't drift into two different ideas of "done" the way two independent copies eventually do; lib/onboarding.ts and lib/offboarding.ts bind it to their own item list, and the tab UI is a single ChecklistPanel bound the same way. Checked against the real database: a real exit creates all eleven items in the same transaction as the exit itself; a No Show creates none; rehiring flips the tab off while the old answers stay queryable. One false alarm during that check turned out to be the user's own clicks on a real employee's onboarding list, made in the browser while trying the earlier feature — left untouched, not test debris. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
98
lib/checklist.ts
Normal file
98
lib/checklist.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Der gemeinsame Kern einer personenbezogenen Checkliste.
|
||||
//
|
||||
// Onboarding und Offboarding sind zwei Listen mit demselben Verhalten: Punkte
|
||||
// mit einer Art, ein gespeicherter Stand je Person und Punkt, ein Fortschritt
|
||||
// daraus. Dieselbe Logik zweimal abzuschreiben wäre die Art Duplikat, die
|
||||
// nicht laut scheitert, sondern leise auseinanderläuft — eine Änderung an der
|
||||
// einen Stelle, die die andere nie erreicht. Deshalb steht sie hier einmal,
|
||||
// und lib/onboarding.ts wie lib/offboarding.ts binden sie nur an ihr eigenes
|
||||
// Verzeichnis.
|
||||
|
||||
/**
|
||||
* Welche Eingabe ein Punkt braucht — aus der Sache abgeleitet, nicht geraten:
|
||||
*
|
||||
* • `haken` — erledigt oder nicht. Der Normalfall: ein Beleg ist da oder
|
||||
* fehlt, ein Zwischenzustand ist nicht vorgesehen.
|
||||
* • `janein` — eine Frage mit zwei Antworten, bei der **offen** etwas anderes
|
||||
* heisst als **nein**. „nein" ist ein erhobener Befund, „noch nicht
|
||||
* gefragt" nicht — ein einzelner Haken könnte das nicht sagen.
|
||||
* • `text` — ein Wert, der festgehalten wird. Bei einer Grösse oder einem
|
||||
* Resturlaubssaldo 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[];
|
||||
};
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
export type Fortschritt = { erledigt: number; gesamt: number; offen: string[] };
|
||||
|
||||
/** Aus einer flachen Liste von Gruppen die Punkte allein — mehrfach gebraucht. */
|
||||
export function alleReichungen(gruppen: readonly AufgabenGruppe[]): readonly AufgabenPunkt[] {
|
||||
return gruppen.flatMap((g) => g.punkte);
|
||||
}
|
||||
|
||||
export function punktZuVon(punkte: readonly AufgabenPunkt[], key: string): AufgabenPunkt | undefined {
|
||||
// Keine Karte vorab: die Verzeichnisse sind klein (ein bis zwei Dutzend
|
||||
// Punkte), und eine Suche bei jedem Aufruf ist hier billiger als der
|
||||
// Aufwand, zwei Module je eine eigene, versteckte Karte pflegen zu lassen.
|
||||
return punkte.find((p) => p.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob ein Punkt als erledigt zählt — je Art verschieden, und deshalb an einer
|
||||
* Stelle statt an mehreren.
|
||||
*
|
||||
* 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() !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie weit eine Liste ist. `offen` nennt die Beschriftungen, nicht die
|
||||
* Schlüssel — die Zahl allein sagt nicht, worauf noch gewartet wird.
|
||||
*/
|
||||
export function fortschrittVon(punkte: readonly AufgabenPunkt[], staende: AufgabenStand[]): Fortschritt {
|
||||
const karte = new Map(staende.map((s) => [s.item_key, s]));
|
||||
const offen = punkte.filter((p) => !istErledigt(p, karte.get(p.key)));
|
||||
return {
|
||||
erledigt: punkte.length - offen.length,
|
||||
gesamt: 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;
|
||||
}
|
||||
@@ -96,6 +96,13 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
||||
.select((x) => zeitstempel(x.ref("updated_at")).as("updated_at"))
|
||||
.where("employee_id", "=", id)
|
||||
).as("onboarding"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("offboarding_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("offboarding"),
|
||||
// Die offenen Planstellen bleiben bei heute: sie gehören zur
|
||||
// Organisation, nicht zu dieser Person.
|
||||
jsonArrayFrom(offeneStellenAbfrage(eb, today)).as("open"),
|
||||
@@ -139,6 +146,7 @@ export async function loadEmployeeDetail(tx: Tx, id: string, today: string = tod
|
||||
// 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,
|
||||
offboarding: g.offboarding,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
|
||||
80
lib/offboarding.ts
Normal file
80
lib/offboarding.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
alleReichungen,
|
||||
fortschrittVon,
|
||||
punktZuVon,
|
||||
type AufgabenGruppe,
|
||||
type AufgabenPunkt,
|
||||
type AufgabenStand,
|
||||
type Fortschritt,
|
||||
} from "./checklist";
|
||||
|
||||
export type { AufgabenArt, AufgabenGruppe, AufgabenPunkt, AufgabenStand, Fortschritt } from "./checklist";
|
||||
export { istErledigt, wertIstGueltig } from "./checklist";
|
||||
|
||||
// Die Offboarding-Checkliste — vom selben Blatt Papier wie die Onboarding-
|
||||
// Liste, nur für den anderen Weg. Was dort steht, ist hier eins zu eins
|
||||
// übernommen; wo die Bezeichnung „GKK" auf dem Blatt steht statt des heute
|
||||
// gebräuchlichen „ÖGK", ist sie unverändert stehengeblieben — das ist der
|
||||
// Name, unter dem der Vorgang im Haus läuft, nicht ein Tippfehler.
|
||||
//
|
||||
// Zwei Punkte auf dem Blatt tragen denselben Namen zweimal: „Überleitung
|
||||
// Salden für Auszahlung" steht einmal als abzuhakende Aufgabe und daneben als
|
||||
// Feld für den tatsächlichen Betrag. Das ist kein Versehen im Blatt, sondern
|
||||
// zwei verschiedene Fragen — „ist es veranlasst?" und „wie viel ist es?" —,
|
||||
// die hier auch zwei verschiedene Punkte sind.
|
||||
//
|
||||
// Warum kein Ja/Nein-Punkt hier steht (anders als Prämienanspruch beim
|
||||
// Onboarding): auf diesem Blatt gibt es keine Stelle mit zwei Kästchen
|
||||
// nebeneinander. Taucht künftig eine auf, ist `art: "janein"` bereit.
|
||||
|
||||
export const OFFBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||
{
|
||||
key: "aufgaben",
|
||||
label: "Zu erledigende Aufgaben",
|
||||
punkte: [
|
||||
{ key: "br_verstaendigung", label: "BR Verständigung bei KDG", art: "haken" },
|
||||
{ key: "kuendigungsschreiben", label: "Kündigungsschreiben (KDG)", art: "haken" },
|
||||
{ key: "bestaetigung_kdn_bei", label: "Bestätigung KDN / BEI", art: "haken" },
|
||||
{ key: "lgv_austritt", label: "LGV Austritt", art: "haken" },
|
||||
{ key: "gkk_abmeldung", label: "GKK-Abmeldung", art: "haken" },
|
||||
{ key: "if6020_austritt", label: "IF 6020 Austritt", art: "haken" },
|
||||
{ key: "ueberleitung_salden", label: "Überleitung Salden für Auszahlung", art: "haken" },
|
||||
{ key: "endabrechnung", label: "Endabrechnung", art: "haken" },
|
||||
{ key: "offboarding_gestartet", label: "Offboarding gestartet", art: "haken" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vermerke",
|
||||
label: "Vermerke",
|
||||
punkte: [
|
||||
{ key: "resturlaub", label: "Resturlaub", art: "text" },
|
||||
{ key: "ueberleitung_salden_betrag", label: "Überleitung Salden für Auszahlung — Betrag", art: "text" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const OFFBOARDING_PUNKTE: readonly AufgabenPunkt[] = alleReichungen(OFFBOARDING_GRUPPEN);
|
||||
|
||||
export function punktZu(key: string): AufgabenPunkt | undefined {
|
||||
return punktZuVon(OFFBOARDING_PUNKTE, key);
|
||||
}
|
||||
|
||||
export function fortschritt(staende: AufgabenStand[]): Fortschritt {
|
||||
return fortschrittVon(OFFBOARDING_PUNKTE, staende);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ob eine Person offzuboarden ist — die Regel hinter dem Reiter.
|
||||
*
|
||||
* Nur ein echter Austritt zählt. No Show scheidet zwar mit demselben Status
|
||||
* „Ausgetreten" aus, hat aber nie einen Tag gearbeitet: keinen IT-Zugang, der
|
||||
* eingerichtet wurde, keine GKK-Anmeldung, kein Dienstzettel. Ein Reiter mit
|
||||
* einer leeren Liste wäre dort ein Etikett ohne Inhalt.
|
||||
*
|
||||
* Wird die Person wiedereingestellt, wechselt der Status weg von
|
||||
* „Ausgetreten", und die Regel verneint wieder — die Checkliste bleibt in den
|
||||
* Daten stehen, nur ihr Reiter verschwindet, solange sie ohne Gegenstand ist.
|
||||
*/
|
||||
export function gehoertOffboarding(employee: { status: string; exit_reason: string | null }): boolean {
|
||||
return employee.status === "Ausgetreten" && employee.exit_reason !== "No Show";
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
import {
|
||||
alleReichungen,
|
||||
fortschrittVon,
|
||||
punktZuVon,
|
||||
type AufgabenGruppe,
|
||||
type AufgabenPunkt,
|
||||
type AufgabenStand,
|
||||
type Fortschritt,
|
||||
} from "./checklist";
|
||||
|
||||
export type { AufgabenArt, AufgabenGruppe, AufgabenPunkt, AufgabenStand, Fortschritt } from "./checklist";
|
||||
// istErledigt/wertIstGueltig nehmen bereits einen Punkt entgegen, nicht den
|
||||
// Schlüssel — sie brauchen keine Bindung an dieses Verzeichnis und werden
|
||||
// unverändert weitergereicht.
|
||||
export { istErledigt, wertIstGueltig } from "./checklist";
|
||||
|
||||
// Die Onboarding-Checkliste — ein Verzeichnis, das Oberfläche, Fortschritt
|
||||
// und Auswertung gemeinsam lesen.
|
||||
//
|
||||
@@ -11,35 +27,10 @@
|
||||
// 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[];
|
||||
};
|
||||
//
|
||||
// Die Mechanik selbst (Fortschritt, was als erledigt zählt) steht in
|
||||
// lib/checklist.ts — geteilt mit lib/offboarding.ts, damit beide Listen
|
||||
// dasselbe „erledigt" meinen und nicht mit der Zeit auseinanderlaufen.
|
||||
|
||||
export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||
{
|
||||
@@ -97,58 +88,12 @@ export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [
|
||||
},
|
||||
] 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 const ONBOARDING_PUNKTE: readonly AufgabenPunkt[] = alleReichungen(ONBOARDING_GRUPPEN);
|
||||
|
||||
export function punktZu(key: string): AufgabenPunkt | undefined {
|
||||
return NACH_KEY.get(key);
|
||||
return punktZuVon(ONBOARDING_PUNKTE, 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;
|
||||
return fortschrittVon(ONBOARDING_PUNKTE, staende);
|
||||
}
|
||||
|
||||
@@ -508,6 +508,35 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["onboarding_tasks"]["Insert"]>;
|
||||
};
|
||||
// Die Offboarding-Checkliste — dieselbe Form wie onboarding_tasks, für
|
||||
// den anderen Weg. Punkte in lib/offboarding.ts.
|
||||
offboarding_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"]["offboarding_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 & {
|
||||
@@ -616,6 +645,8 @@ export type Database = {
|
||||
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 };
|
||||
set_offboarding_task: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
start_offboarding: { 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