diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 8e2425b..807ab9f 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -23,6 +23,9 @@ const TONE: Record = { danger: { text: "text-danger-text", bar: "bg-danger-text" }, warning: { text: "text-warning-text", bar: "bg-warning-text" }, brand: { text: "text-brand-700", bar: "bg-brand-500" }, + // Geplante Eintritte: eine Ankündigung, kein Befund. „info" ist der Ton, + // den die Statuschips für „Geplant" ohnehin tragen. + info: { text: "text-info-text", bar: "bg-info-text" }, }; // Die Tabelle, die hier stand, ist nach lib/colors.ts gewandert: Punkt und @@ -169,13 +172,39 @@ export default async function DashboardPage({ href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`, }, { label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" }, + // ── Zwei Kacheln aus dem Workshop (Anforderung 10) ────────────── + // + // „Aktives Dienstverhältnis" ist nicht dasselbe wie „Aktive + // Mitarbeiter:innen" ganz links. Dort steht, wer heute arbeitet; hier, + // mit wem ein Vertrag läuft — Langzeitabwesende eingeschlossen. Genau + // diese Zahl ist die Bezugsgrösse fast jeder Personalkennzahl, und sie + // war bisher nirgends zu sehen: sichtbar waren 806 und 10, addieren + // musste man selbst. + { + label: "Aktives Dienstverhältnis", + value: activeCount + karenzCount, + tone: "default", + href: "/employees?status=Aktiv&status=Karenz", + }, + // Geplante Eintritte ohne Zeitgrenze — anders als die Karte „Anstehend" + // darunter, die nur den eingestellten Vorschauzeitraum zeigt. Ein + // Eintritt in vier Monaten ist vereinbart und zählt, auch wenn er in der + // Vorschau noch nicht auftaucht. + { + label: "Geplante Eintritte", + value: staffRows.filter((row) => statusOf(row) === "Geplant").length, + tone: "info", + href: "/employees?status=Geplant", + }, { label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" }, ]; return (
{drafts.length > 0 && } -
+ {/* Acht Kacheln: vier je Zeile ab der grossen Breite, damit keine + Zeile mit zwei Resten endet. */} +
{kpis.map((kpi) => ( & { status?: string; asOf?: string; eventType?: string; + eventSubtype?: string; from?: string; to?: string; }; @@ -48,6 +50,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom const eventGroup = parseEventGroupDimension(params.group); const eventSplit = parseEventSplitDimension(params.split); const eventType = parseEventType(params.eventType); + const eventSubtype = parseEventSubtype(params.eventSubtype, eventType); const from = parseEventDateParam(params.from); const to = parseEventDateParam(params.to); const measure = parseMeasure(params.measure); @@ -71,6 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom mode === "events" ? loadEventHistory(tx, { eventType: eventType ?? undefined, + eventSubtype: eventSubtype ?? undefined, division: params.division, location: params.location, from, @@ -108,6 +112,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom eventGroup={eventGroup} eventSplit={eventSplit ?? ""} eventType={eventType ?? ""} + eventSubtype={eventSubtype ?? ""} eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }} rows={rows} total={sumValues(rows)} diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 2459670..e51ae1c 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -1,6 +1,13 @@ import { NextResponse, type NextRequest } from "next/server"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; -import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports"; +import { + EVENT_TYPE_LABELS, + parseEventDateParam, + parseEventSubtype, + parseEventType, + type OrgLookups, + type ReportEvent, +} from "@/lib/reports"; import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data"; import { requireHrUser } from "@/lib/auth/require-hr"; import { withUser } from "@/lib/db"; @@ -22,6 +29,10 @@ export async function GET(request: NextRequest) { loadOrgLookups(tx), loadEventHistory(tx, { eventType: eventType ?? undefined, + // Ohne diese Zeile exportierte die Schaltfläche neben der Auswertung + // etwas anderes, als auf dem Bildschirm steht — die Einschränkung auf + // die Beendigungsart fiele beim Export still weg. + eventSubtype: parseEventSubtype(params.get("eventSubtype"), eventType) ?? undefined, division: params.get("division") ?? undefined, location: params.get("location") ?? undefined, from: parseEventDateParam(params.get("from")), @@ -43,6 +54,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn[] { return [ { header: "Datum", get: (e) => e.event_date, kind: "date" }, { header: "Ereignistyp", get: (e) => EVENT_TYPE_LABELS[e.event_type] ?? e.event_type }, + // Beendigungsart bzw. Art der Langzeitabwesenheit. Leer bei allen + // Ereignissen, die keinen Untertyp kennen — eine Versetzung hat keinen. + { header: "Untertyp", get: (e) => e.subtype ?? "" }, { header: "Vorname", get: (e) => e.first_name }, { header: "Nachname", get: (e) => e.last_name }, { header: "Position", get: (e) => e.job_title }, diff --git a/components/dashboard/AnstehendListe.tsx b/components/dashboard/AnstehendListe.tsx index ee701f5..03a145c 100644 --- a/components/dashboard/AnstehendListe.tsx +++ b/components/dashboard/AnstehendListe.tsx @@ -47,11 +47,40 @@ export function AnstehendListe({ leerText: string; }) { const [alle, setAlle] = useState(false); - const sichtbar = alle ? eintraege : eintraege.slice(0, VORSCHAU); - const weitere = eintraege.length - sichtbar.length; + // Namensfilter aus dem Workshop (Anforderung 2). Im Browser und nicht über + // die Adresse, aus demselben Grund wie das Aufklappen: die Einträge sind + // vollständig geladen, es gibt nichts nachzuholen. + const [suche, setSuche] = useState(""); + const begriff = suche.trim().toLowerCase(); + // Wortweise und am Wortanfang — dieselbe Regel wie in der Mitarbeiterliste, + // damit „gru" Gruber findet und nicht auch jede Notiz mit „Arbeitsgruppe". + // Der Hinweis wird bewusst nicht durchsucht: gesucht wird nach Personen. + const gefiltert = begriff + ? eintraege.filter((e) => + e.label + .toLowerCase() + .split(/[\s,;/-]+/) + .some((wort) => wort.startsWith(begriff)) + ) + : eintraege; + const sichtbar = alle ? gefiltert : gefiltert.slice(0, VORSCHAU); + const weitere = gefiltert.length - sichtbar.length; return ( <> + {/* Erst ab neun Einträgen: darunter sieht man die ganze Liste ohnehin, + und ein Suchfeld über acht Zeilen ist mehr Bedienelement als Hilfe. */} + {eintraege.length > VORSCHAU && ( + setSuche(e.target.value)} + placeholder="Name filtern…" + aria-label="Anstehende Ereignisse nach Name filtern" + className="mb-2 w-full rounded border border-border-strong bg-white px-2.5 py-1.5 text-sm text-ink + placeholder:text-ink-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500" + /> + )}
    {sichtbar.map((item) => { // Überfällig gibt es nur bei Wiedervorlagen: die anderen Arten @@ -83,6 +112,14 @@ export function AnstehendListe({ ); })} {eintraege.length === 0 &&

    {leerText}

    } + {/* Ein leeres Ergebnis mit Suchbegriff ist etwas anderes als eine + leere Karte: der Zeitraum stimmt, nur der Name passt nicht. */} + {eintraege.length > 0 && gefiltert.length === 0 && ( +

    + Kein Name passt zu „{suche.trim()}“. {eintraege.length}{" "} + {eintraege.length === 1 ? "Ereignis steht" : "Ereignisse stehen"} an. +

    + )}
{/* Der Knopf steht nur da, wenn es etwas zu holen gibt. Ist die Liste diff --git a/components/employees/PrintChecklist.tsx b/components/employees/PrintChecklist.tsx index a1851f0..503d28a 100644 --- a/components/employees/PrintChecklist.tsx +++ b/components/employees/PrintChecklist.tsx @@ -183,11 +183,12 @@ export function PrintChecklist({ ))}
- {/* ── Fuss: Platz für Unterschriften ─────────────────── */} -
-
Datum, Unterschrift Mitarbeiter:in
-
Datum, Unterschrift Personalabteilung
-
+ {/* Hier standen zwei Unterschriftenfelder — „Datum, Unterschrift + Mitarbeiter:in" und „… Personalabteilung". Der Kunde hat sie + gestrichen (Anforderung 3 aus dem Workshop): das Blatt ist eine + Arbeitsliste für die Personalabteilung und kein Dokument, das + jemand gegenzeichnet. Wer eine Aufgabe abgehakt hat, steht ohnehin + an der Aufgabe selbst. */}
); diff --git a/components/employees/panels/TerminatePanel.tsx b/components/employees/panels/TerminatePanel.tsx index de17afd..7517048 100644 --- a/components/employees/panels/TerminatePanel.tsx +++ b/components/employees/panels/TerminatePanel.tsx @@ -7,23 +7,23 @@ import { Button } from "@/components/ui/Button"; import { SelectField, TextField, TextareaField } from "@/components/ui/Field"; import { SlideOver } from "@/components/ui/SlideOver"; import { useToast } from "@/components/ui/Toast"; +import { + AUSTRITTSART_LABELS, + BEENDIGUNG_NO_SHOW, + BEENDIGUNGSART_WERTE, + austrittsartVon, + beendigungsartenZu, + type Austrittsart, +} from "@/lib/beendigung"; import type { Database } from "@/lib/types"; import { fmtDate, fmtName } from "@/lib/format"; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; -// „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten -// Sinn, sondern der Fall, dass jemand nie angetreten ist. -const NO_SHOW = "No Show"; -const EXIT_REASONS = [ - "Einvernehmliche Auflösung", - "Kündigung AN", - "Kündigung AG", - "Befristungsablauf", - "Pensionierung", - "Entlassung", - NO_SHOW, -]; +// Die Liste steht in lib/beendigung.ts, nicht mehr hier: der Berichtemanager +// braucht sie ebenso, und zwei Listen liefen auseinander. +const NO_SHOW = BEENDIGUNG_NO_SHOW; + type TerminatePanelProps = { open: boolean; onClose: () => void; @@ -38,7 +38,12 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T // Wer noch gar nicht angefangen hat, tritt fast nie aus einem anderen Grund // aus. Die Vorbelegung nimmt den wahrscheinlichen Fall vorweg, ohne die // übrigen zu verstellen. - const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : EXIT_REASONS[0]); + const [reason, setReason] = useState(employee.status === "Geplant" ? NO_SHOW : BEENDIGUNGSART_WERTE[0]); + // „Alle" statt einer Vorauswahl: die Einschränkung ist eine Hilfe beim + // Suchen, keine Pflichtangabe. Wer die gewünschte Art auswendig kennt, + // soll sie unmittelbar wählen können. + const [austrittsart, setAustrittsart] = useState(""); + const waehlbar = beendigungsartenZu(austrittsart || null); const [note, setNote] = useState(""); const [pending, setPending] = useState(false); @@ -113,11 +118,36 @@ export function TerminatePanel({ open, onClose, employee, directReportCount }: T {directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet. )} + {/* Die Austrittsart wählt nicht mit, sie engt ein. Gespeichert wird + allein die Beendigungsart; auf wessen Betreiben beendet wurde, + leitet lib/beendigung.ts daraus ab. Als zweites freies Feld liesse + sich „Entlassung, freiwillig" erfassen, und zwei Auswertungen über + dieselbe Person kämen zu verschiedenen Ergebnissen. */} + { + const neu = (v || "") as Austrittsart | ""; + setAustrittsart(neu); + // Passt der bisher gewählte Grund nicht mehr zur Einschränkung, + // wandert die Auswahl auf den ersten passenden. Stehenzulassen, + // was in der Liste nicht mehr sichtbar ist, wäre ein Formular, + // das etwas anderes absendet, als es zeigt. + const erlaubt = beendigungsartenZu(neu || null); + if (!erlaubt.includes(reason)) setReason(erlaubt[0]); + }} + options={[ + { value: "", label: "Alle Beendigungsarten" }, + ...(Object.keys(AUSTRITTSART_LABELS) as Austrittsart[]).map((a) => ({ value: a, label: AUSTRITTSART_LABELS[a] })), + ]} + hint="Schränkt die Auswahl darunter ein. Gespeichert wird die Beendigungsart." + /> ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))} + options={waehlbar.map((r) => ({ value: r, label: r === NO_SHOW ? "No Show (nicht angetreten)" : r }))} + hint={austrittsartVon(reason) ? AUSTRITTSART_LABELS[austrittsartVon(reason)!] : undefined} /> }; type OrgOption = { id: string; name: string }; @@ -69,6 +75,8 @@ type EventsProps = CommonProps & { eventGroup: EventGroupDimension; eventSplit: EventGroupDimension | ""; eventType: string; + /** Beendigungsart bzw. Art der Langzeitabwesenheit; leer heisst „alle". */ + eventSubtype: string; eventFilters: { division: string; location: string; from: string; to: string }; }; @@ -227,6 +235,10 @@ export function ReportsPageClient(props: ReportsPageClientProps) { const searchParams = useSearchParams(); const { showToast } = useToast(); const [expandedKey, setExpandedKey] = useState(null); + // Welche aufgeklappte Zeile ihre Namen **vollständig** zeigt. Vorher stand + // dort „+794 weitere" als blosser Text: die Auskunft, dass es mehr gibt, + // ohne einen Weg dorthin. Wer die Liste brauchte, musste sie exportieren. + const [volleListeKey, setVolleListeKey] = useState(null); const [saveModalOpen, setSaveModalOpen] = useState(false); const [newReportName, setNewReportName] = useState(""); const [savingReport, setSavingReport] = useState(false); @@ -439,12 +451,35 @@ export function ReportsPageClient(props: ReportsPageClientProps) { label="Ereignistyp" dense value={props.eventType} - onChange={(v) => updateParams({ eventType: v || undefined })} + // Der Untertyp gehört zum Ereignistyp und fällt mit ihm weg. + // Bliebe er in der Adresse stehen, filterte eine Auswahl + // weiter, die auf dem Bildschirm nicht mehr zu sehen ist. + onChange={(v) => updateParams({ eventType: v || undefined, eventSubtype: undefined })} options={[ { value: "", label: "Alle Ereignistypen" }, ...EVENT_TYPES.map((t) => ({ value: t, label: EVENT_TYPE_LABELS[t] })), ]} /> + {/* Nur dort, wo es einen gibt: eine Versetzung hat keine + Beendigungsart, und ein leeres zweites Feld wäre eine Frage + ohne Antwortmöglichkeit. */} + {(() => { + const typ = props.eventType ? (props.eventType as HistoryEventType) : null; + const untertyp = typ ? EREIGNIS_UNTERTYP[typ] : undefined; + if (!untertyp) return null; + return ( + updateParams({ eventSubtype: v || undefined })} + options={[ + { value: "", label: untertyp.alle }, + ...untertypOptionen(typ).map((o) => ({ value: o, label: o })), + ]} + /> + ); + })()} - - {isExpanded && ( -
-
    - {row.people.slice(0, 12).map((p, i) => ( -
  • - - {p.name} - - - {p.title} · {p.team} · {fmtDate(p.entry_date)} - -
  • - ))} -
- {row.people.length > 12 &&

+{row.people.length - 12} weitere

} -
- )} + {isExpanded && + (() => { + const alle = volleListeKey === row.key; + const sichtbar = alle ? row.people : row.people.slice(0, VORSCHAU_NAMEN); + const rest = row.people.length - VORSCHAU_NAMEN; + return ( +
+ {/* Bei achthundert Namen scrollt die Liste in sich, + statt die Auswertung darüber aus dem Bild zu + schieben. */} +
    + {sichtbar.map((p, i) => ( +
  • + + {p.name} + + + {p.title} · {p.team} · {fmtDate(p.entry_date)} + +
  • + ))} +
+ {rest > 0 && ( + + )} +
+ ); + })()} ); })} diff --git a/lib/beendigung.ts b/lib/beendigung.ts new file mode 100644 index 0000000..2b2f172 --- /dev/null +++ b/lib/beendigung.ts @@ -0,0 +1,86 @@ +// Wie ein Dienstverhältnis endet — und auf wessen Betreiben. +// +// Die Liste stand bisher in components/employees/panels/TerminatePanel.tsx und +// damit an genau einer Stelle: dem Formular. Der Berichtemanager konnte +// deshalb nach dem Ereignis „Austritt" fragen, aber nicht danach, *warum* — +// und die Frage, die im Personalwesen zuerst kommt, ist die nach der +// Fluktuation: wie viele sind gegangen, wie vielen wurde gekündigt. +// +// ── Warum die Zuordnung abgeleitet und nicht gespeichert wird ──────── +// +// Aus dem Workshop kam „ein weiteres Dropdown: freiwilliger / unfreiwilliger +// Austritt". Als zweites, frei wählbares Feld liesse es sich dem Grund +// widersprechen — „Entlassung, freiwillig" wäre eine erfassbare Eingabe, und +// zwei Auswertungen über dieselbe Person kämen zu verschiedenen Ergebnissen. +// Auf wessen Betreiben beendet wird, steckt bereits in der Beendigungsart; +// das Dropdown schränkt deshalb die Auswahl ein, statt eine zweite Wahrheit +// daneben zu stellen. Gespeichert wird weiterhin nur `exit_reason`. +// +// ── Warum es drei Gruppen sind und nicht zwei ──────────────────────── +// +// Zwei Gruppen decken die Liste nicht ab. Ein Befristungsablauf geschieht auf +// niemandes Betreiben — er war von Anfang an vereinbart. Ein Nichtantritt ist +// kein Austritt, sondern ein Verhältnis, das nie begonnen hat. Beide einer der +// beiden Seiten zuzuschlagen würde jede Fluktuationsquote verfälschen, und +// zwar in eine Richtung, die niemand mehr nachvollziehen kann. Sie stehen +// deshalb als „Sonstige" für sich. +// +// Offen und im Begleitschreiben an den Kunden gestellt: ob die +// einvernehmliche Auflösung — hier als freiwillig geführt, weil sie die +// Zustimmung der Person voraussetzt — dort richtig steht. In der Praxis geht +// ihr oft ein Wunsch des Dienstgebers voraus. + +export const BEENDIGUNG_NO_SHOW = "No Show"; + +export type Austrittsart = "freiwillig" | "unfreiwillig" | "sonstige"; + +export const AUSTRITTSART_LABELS: Record = { + freiwillig: "Freiwilliger Austritt", + unfreiwillig: "Unfreiwilliger Austritt", + sonstige: "Sonstige Beendigung", +}; + +export type Beendigungsart = { + /** Der gespeicherte Wert in `employees.exit_reason`. */ + wert: string; + art: Austrittsart; +}; + +export const BEENDIGUNGSARTEN: readonly Beendigungsart[] = [ + { wert: "Einvernehmliche Auflösung", art: "freiwillig" }, + { wert: "Kündigung AN", art: "freiwillig" }, + { wert: "Pensionierung", art: "freiwillig" }, + // Aus dem Workshop nachgetragen (Anforderung 1). In der Probezeit kann jede + // Seite ohne Frist und ohne Grund beenden; welche es war, ist genau die + // Unterscheidung, die sonst verlorenginge. + { wert: "Dienstnehmerseitige Beendigung in Probezeit", art: "freiwillig" }, + { wert: "Kündigung AG", art: "unfreiwillig" }, + { wert: "Entlassung", art: "unfreiwillig" }, + { wert: "Dienstgeberseitige Beendigung in Probezeit", art: "unfreiwillig" }, + { wert: "Befristungsablauf", art: "sonstige" }, + // „No Show" steht am Ende und getrennt: es ist kein Austritt im gewohnten + // Sinn, sondern der Fall, dass jemand nie angetreten ist. + { wert: BEENDIGUNG_NO_SHOW, art: "sonstige" }, +] as const; + +export const BEENDIGUNGSART_WERTE: readonly string[] = BEENDIGUNGSARTEN.map((b) => b.wert); + +const ART_ZU_WERT = new Map(BEENDIGUNGSARTEN.map((b) => [b.wert, b.art] as const)); + +/** + * Auf wessen Betreiben beendet wurde — oder null, wenn der gespeicherte Grund + * in keiner der Gruppen steht. + * + * Null ist kein Sonderfall, sondern der Normalfall für Altdaten: `exit_reason` + * ist eine Textspalte ohne Prüfung, und der Massenimport schreibt hinein, was + * in der Datei stand. Wer hier auf eine Gruppe rät, ordnet fremde Schreibweisen + * still der falschen zu. + */ +export function austrittsartVon(grund: string | null | undefined): Austrittsart | null { + return grund ? (ART_ZU_WERT.get(grund) ?? null) : null; +} + +/** Die Beendigungsarten einer Gruppe — für die abhängige Auswahl im Formular. */ +export function beendigungsartenZu(art: Austrittsart | null): readonly string[] { + return art ? BEENDIGUNGSART_WERTE.filter((w) => ART_ZU_WERT.get(w) === art) : BEENDIGUNGSART_WERTE; +} diff --git a/lib/offboarding.ts b/lib/offboarding.ts index 669e869..89ed956 100644 --- a/lib/offboarding.ts +++ b/lib/offboarding.ts @@ -40,6 +40,9 @@ export const OFFBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [ { 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" }, + // Gegenstück zum gleichnamigen Punkt beim Onboarding: dort die Übergabe, + // hier die Rückgabe. Aus dem Workshop nachgetragen. + { key: "firmenfahrzeug", label: "Firmenfahrzeug", art: "haken" }, { key: "offboarding_gestartet", label: "Offboarding gestartet", art: "haken" }, ], }, diff --git a/lib/onboarding.ts b/lib/onboarding.ts index bf215cc..39e3e73 100644 --- a/lib/onboarding.ts +++ b/lib/onboarding.ts @@ -45,6 +45,11 @@ export const ONBOARDING_GRUPPEN: readonly AufgabenGruppe[] = [ { key: "eurest", label: "Anmeldung EUREST", art: "haken" }, { key: "oegk", label: "Anmeldung ÖGK", art: "haken" }, { key: "webclient", label: "WebClient", art: "haken" }, + // Aus dem Workshop nachgetragen: die Übergabe eines Firmenfahrzeugs ist + // ein eigener Vorgang mit Papieren und Schlüsseln und stand bisher + // nirgends. `employees.has_dienstwagen` sagt, ob eines zusteht — nicht, + // ob es übergeben wurde. Das sind zwei verschiedene Fragen. + { key: "firmenfahrzeug", label: "Firmenfahrzeug", art: "haken" }, { key: "datensatzberechtigung", label: "Datensatzberechtigung Führungskraft", art: "haken" }, { key: "praemienanspruch", label: "Prämienanspruch", art: "janein" }, { diff --git a/lib/reports-data.ts b/lib/reports-data.ts index f9e8e46..1b106c3 100644 --- a/lib/reports-data.ts +++ b/lib/reports-data.ts @@ -6,6 +6,7 @@ import { loadPlacements } from "./placement"; import { LEERE_CRITERIA, passtImSpeicher, type Criteria } from "./report-criteria"; import { deriveStatusAsOf, + EREIGNIS_UNTERTYP, EVENT_DATE_OPEN, parseStatuses, todayIso, @@ -102,7 +103,15 @@ export function applyCriteria( } export type SnapshotFilters = ReportFilters & { asOf?: string }; -export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; +export type EventFilters = { + eventType?: HistoryEventType; + /** Beendigungsart bzw. Art der Langzeitabwesenheit — nur mit eventType sinnvoll. */ + eventSubtype?: string; + division?: string; + location?: string; + from?: string; + to?: string; +}; /** * Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und @@ -281,7 +290,14 @@ export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null; if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; + const subtype = untertypVon(h.event_type, emp); + // Der Untertyp schränkt nur innerhalb seines Ereignistyps ein. Ohne die + // erste Hälfte fielen bei „Alle Ereignistypen" sämtliche Zeilen ohne + // Untertyp heraus — eine Versetzung hat keine Beendigungsart. + if (filters.eventSubtype && EREIGNIS_UNTERTYP[h.event_type] && subtype !== filters.eventSubtype) continue; + events.push({ employee_id: emp.id, first_name: emp.first_name, @@ -321,7 +343,24 @@ export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise = { Rückkehr: "Rückkehr aus Langzeitabwesenheit", }; +// ── Was im Berichtemanager zur Auswahl steht ──────────────────────── +// +// Nicht alle elf. EVENT_TYPE_LABELS bleibt vollständig — die Historie einer +// Person zeigt jedes Ereignis, und jedes braucht seine Beschriftung und seine +// Farbe. Die *Auswertung* fragt aber nach Bewegungen im Bestand, und zwei +// Typen sind dort nur Rauschen: +// +// * „Stammdatenänderung" entsteht bei jeder geänderten Telefonnummer. +// * „Gehaltsanpassung" ist ein totes Ereignis: das Gehalt liegt in Loga, +// keine SQL-Funktion schreibt diesen Typ mehr. +// +// Beide standen in der Liste und lieferten Auswertungen, die niemand wollte. +// Aus dem Workshop gestrichen (Anforderung 4). +// +// „Wiedereintritt" stand dagegen zu Recht schon hier und bekommt in +// lib/colors.ts dieselbe grüne Kategorie wie der Eintritt — es ist einer. +const NICHT_AUSWERTBAR: readonly HistoryEventType[] = ["Stammdatenänderung", "Gehaltsanpassung"]; + +export const EREIGNIS_AUSWAHL: readonly HistoryEventType[] = ( + Object.keys(EVENT_TYPE_LABELS) as HistoryEventType[] +).filter((t) => !NICHT_AUSWERTBAR.includes(t)); + export type ReportEvent = { employee_id: string; first_name: string; @@ -403,6 +427,25 @@ export type ReportEvent = { event_date: string; event_type: HistoryEventType; description: string; + /** + * Der Untertyp des Ereignisses — die Beendigungsart bei einem Austritt, die + * Art der Abwesenheit bei einer Langzeitabwesenheit. Beides steht auf der + * Person und nicht am Ereignis: `employee_history` hält den Verlauf, die + * Einzelheiten stehen in `employees`. + * + * Daraus folgt die Grenze, und sie steht auch in der Oberfläche: nach einer + * Wiedereinstellung setzt rehire_employee `exit_reason` auf null zurück, und + * der Austritt von damals hat dann keinen Grund mehr. Für die Frage, die + * gestellt wird — „wie viele sind dieses Jahr gegangen, und warum" —, ist + * das ohne Belang; für eine Auswertung über fünf Jahre wäre es eine Lücke. + */ + subtype: string | null; +}; + +/** Zu welchen Ereignistypen es überhaupt einen Untertyp gibt. */ +export const EREIGNIS_UNTERTYP: Partial> = { + Austritt: { label: "Beendigungsart", alle: "Alle Beendigungsarten" }, + Karenz: { label: "Art der Langzeitabwesenheit", alle: "Alle Arten" }, }; function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: OrgLookups): string { @@ -518,6 +561,26 @@ export function parseEventType(value: string | null | undefined): HistoryEventTy return value && Object.hasOwn(EVENT_TYPE_LABELS, value) ? (value as HistoryEventType) : null; } +/** + * Der Untertyp aus der Adresszeile — geprüft gegen die Liste, die zum + * gewählten Ereignistyp gehört. + * + * Ohne den Ereignistyp gibt es keinen gültigen Untertyp: „Wochenhilfe" zu + * einem Austritt ist keine Einschränkung, sondern ein leeres Ergebnis mit + * unklarer Ursache. + */ +export function parseEventSubtype(value: string | null | undefined, typ: HistoryEventType | null): string | null { + if (!value || !typ || !EREIGNIS_UNTERTYP[typ]) return null; + return untertypOptionen(typ).includes(value) ? value : null; +} + +/** Die erlaubten Untertypen eines Ereignistyps. */ +export function untertypOptionen(typ: HistoryEventType | null): readonly string[] { + if (typ === "Austritt") return BEENDIGUNGSART_WERTE; + if (typ === "Karenz") return ABSENCE_TYPES; + return []; +} + // Rejects anything that is not a real calendar date, so a Stichtag from the // URL can never reach a date comparison (or a column header) as free text. export function parseIsoDateParam(value: string | null | undefined): string | undefined { diff --git a/tests/unit/offboarding.test.ts b/tests/unit/offboarding.test.ts index 4383960..dad335f 100644 --- a/tests/unit/offboarding.test.ts +++ b/tests/unit/offboarding.test.ts @@ -25,8 +25,10 @@ describe("Das Verzeichnis", () => { }); it("bildet jeden Punkt aus dem Blatt ab", () => { - expect(OFFBOARDING_PUNKTE).toHaveLength(11); + // 11 vom Blatt, dazu „Firmenfahrzeug" aus dem Workshop. + expect(OFFBOARDING_PUNKTE).toHaveLength(12); for (const key of [ + "firmenfahrzeug", "br_verstaendigung", "kuendigungsschreiben", "bestaetigung_kdn_bei", diff --git a/tests/unit/onboarding.test.ts b/tests/unit/onboarding.test.ts index ad75421..fb33b65 100644 --- a/tests/unit/onboarding.test.ts +++ b/tests/unit/onboarding.test.ts @@ -25,8 +25,9 @@ describe("Das Verzeichnis", () => { }); it("bildet jeden Punkt aus dem Blatt ab", () => { - expect(ONBOARDING_PUNKTE).toHaveLength(25); - for (const key of ["dienstzettel", "meldezettel", "salmonellenbefund", "willkommensmappe", "webreise"]) { + // 25 vom Blatt, dazu „Firmenfahrzeug" aus dem Workshop. + expect(ONBOARDING_PUNKTE).toHaveLength(26); + for (const key of ["dienstzettel", "meldezettel", "salmonellenbefund", "willkommensmappe", "webreise", "firmenfahrzeug"]) { expect(punktZu(key), key).toBeDefined(); } }); diff --git a/tests/unit/reports.test.ts b/tests/unit/reports.test.ts index 6b4fb6c..722524f 100644 --- a/tests/unit/reports.test.ts +++ b/tests/unit/reports.test.ts @@ -314,6 +314,7 @@ describe("aggregateEvents", () => { event_date: "2026-03-01", event_type: "Eintritt", description: "Eintritt als Maschinenbediener:in", + subtype: null, ...overrides, }; }