Files
alpenwerk-hr/lib/reports.ts
Andrei Laas 0039ce5069
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m36s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m12s
Headcount heisst ueberall dasselbe
"Headcount" stand auf der Uebersicht fuer die Aktiven und im Bericht fuer eine
andere Zahl -- 785 gegen 788, dasselbe Wort fuer Verschiedenes (N.02). Max hat
die Begriffe festgelegt: Headcount Aktiv fuer die Aktiven, Headcount Aktives
Dienstverhaeltnis fuer Aktive plus Langzeitabwesende.

Die beiden Kacheln auf der Uebersicht tragen jetzt genau diese Namen. Beide
Zahlen gab es dort schon, nur hiess die eine "Aktives Dienstverhaeltnis" und
die andere "Aktive Mitarbeiter:innen (HC)".

Im Bericht ginge ein fester Name nicht: die Kennzahl zaehlt, was gerade
ausgewaehlt ist, auch Geplante oder Ausgetretene. Trifft die Auswahl eine der
beiden Groessen, steht ihr Name in der Ueberschrift; sonst steht dabei, welche
Status gezaehlt wurden -- mit den Anzeigenamen, also "Langzeitabwesenheit" und
nicht "Karenz".
2026-09-29 20:36:22 +02:00

648 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ABSENCE_TYPES, statusLabel } from "./absence";
import { BEENDIGUNGSART_WERTE } from "./beendigung";
import { fmtName, todayIso, yearsBetweenIso } from "./format";
import { HAY_GRADE_WERTE } from "./hay-grade";
import { WOCHENTAGE } from "./wochentage";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./types";
export { todayIso };
// ── Bestand (point-in-time snapshot) ──────────────────────────────
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share" | "avg_dependents";
export type GroupDimension =
| "division"
| "department"
| "team"
| "location"
| "status"
| "employment_type"
| "contract_type"
| "entry_year"
| "source"
| "paygrade"
| "worker_type"
| "collective_agreement"
| "betriebsrat"
| "dienstwagen"
| "laterale_fuehrung"
| "c_level"
| "has_dependents"
| "teilzeit_art"
| "weekday";
/**
* Die Kennzahl mit dem, was sie tatsächlich zählt.
*
* „Headcount" stand auf der Übersicht für die Aktiven und im Bericht für eine
* andere Zahl — dasselbe Wort für Verschiedenes, gemeldet als N.02. Der Kunde
* führt zwei Grössen mit festen Namen (Max, 30.09.): **Headcount Aktiv** für
* die Aktiven und **Headcount Aktives Dienstverhältnis** für Aktive plus
* Langzeitabwesende.
*
* Die Kennzahl im Bericht zählt aber, was gerade ausgewählt ist — auch
* Geplante oder Ausgetretene. Ein fester Name wäre dort also schlicht falsch.
* Deshalb: trifft die Auswahl eine der beiden Grössen, steht ihr Name da;
* sonst steht dabei, welche Status gezählt wurden.
*/
export function messgroesseLabel(measure: Measure, statuses: readonly string[]): string {
const basis = MEASURE_LABELS[measure];
if (measure !== "headcount") return basis;
const gewaehlt = new Set(statuses);
if (gewaehlt.size === 1 && gewaehlt.has("Aktiv")) return "Headcount Aktiv";
if (gewaehlt.size === 2 && gewaehlt.has("Aktiv") && gewaehlt.has("Karenz")) {
return "Headcount Aktives Dienstverhältnis";
}
if (gewaehlt.size === 0) return basis;
return `${basis} (${statuses.map((s) => statusLabel(s as EmploymentStatus)).join(", ")})`;
}
export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount",
fte: "FTE",
parttime_rate: "Teilzeitquote",
avg_age: "Ø Alter",
avg_tenure: "Ø Zugehörigkeit",
female_share: "Frauenanteil",
avg_dependents: "Ø Angehörige",
};
export const GROUP_LABELS: Record<GroupDimension, string> = {
division: "Bereich",
department: "Abteilung",
team: "Team",
location: "Standort",
status: "Status",
employment_type: "Beschäftigung",
contract_type: "Vertragsart",
entry_year: "Eintrittsjahr",
source: "Intern/Extern",
paygrade: "Hay-Grade",
worker_type: "Beschäftigtengruppe",
collective_agreement: "Kollektivvertrag",
betriebsrat: "Betriebsrat",
dienstwagen: "Dienstwagen",
laterale_fuehrung: "Laterale Führung",
c_level: "C-Level",
has_dependents: "Hat Angehörige",
teilzeit_art: "Teilzeitvariante",
weekday: "Wochentag",
};
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share", "avg_dependents"];
const SUM_MEASURES: Measure[] = ["headcount", "fte"];
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
export const DEFAULT_STATUSES: EmploymentStatus[] = ["Aktiv", "Karenz"];
// `status` filters travel through the URL/exports as a comma-joined list
// (e.g. "Aktiv,Karenz"); this is the one place that turns that string back
// into a validated status set, defaulting to Aktiv+Karenz when unset — used
// by both the Bestand pivot and the full data export so they can never
// silently disagree on which statuses "no filter" means.
export function parseStatuses(status: string | undefined): EmploymentStatus[] {
if (!status) return DEFAULT_STATUSES;
const requested = status.split(",");
const valid = STATUS_OPTIONS.filter((s) => requested.includes(s));
return valid.length > 0 ? valid : DEFAULT_STATUSES;
}
export type ReportEmployee = {
id: string;
first_name: string;
last_name: string;
job_title: string;
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
org_unit_id: string | null;
location_id: string;
status: string;
employment_type: string;
contract_type: string;
entry_date: string;
exit_date: string | null;
weekly_hours: number;
source: string;
paygrade: string;
birth_date: string;
gender: string;
worker_type: string;
collective_agreement: string;
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
teilzeit_art: string | null;
dependents_count: number;
};
// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über
// drei verschiedene Fremdschlüssel: welcher Bereich, welche Abteilung und
// welches Team zu einer Einheit gehören, ergibt sich aus ihrer Vorfahrenkette
// und wird einmal vorberechnet.
/**
* Eine Organisationseinheit als Filterwert — jede Ebene, nicht nur die
* Bereiche. `depth` dient der Einrückung in der Auswahlliste.
*/
export type UnitOption = { id: string; name: string; depth: number; unitType: string };
export type OrgLookups = {
divisionName: Map<string, string>;
departmentName: Map<string, string>;
teamName: Map<string, string>;
locationName: Map<string, string>;
};
// Reconstructs status as of any date from the columns that actually carry a
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
// which only ever reflects *today*.
//
// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit
// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie
// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung —
// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein.
// ── Warum der Austritt zuerst geprüft wird ──────────────────────────
//
// Umgekehrt herum stand hier „Geplant" vor „Ausgetreten", und wer einen
// Eintritt in der Zukunft hatte, galt als geplant — auch dann noch, wenn der
// Austritt längst erfasst war. Genau das trifft den No-Show (Migration
// 20260814100000): jemand wird eingestellt, erscheint nie, und der Austritt
// wird noch vor dem Eintrittstag verbucht. Im Bestand sind das Zeilen mit
// Eintritt 01.10.2026 und einem Austritt, die der Filter „Geplant" mitzählte,
// während die Liste daneben „Ausgetreten" anzeigte — employees.status, das
// die SQL-Funktion beim Austritt gesetzt hat, sagte von Anfang an das
// Richtige.
//
// Ein abgeschlossener Austritt ist der stärkere Befund: er beendet das
// Verhältnis, gleichgültig ob der Eintritt schon war oder noch kommt.
// „Geplant" heißt danach genau das, was es heißen soll — ein Eintritt, der
// noch bevorsteht und nicht zurückgenommen wurde.
//
// ── Warum ein Austritt am Eintrittstag *jeden* Stichtag schlägt ──────
//
// Die erste Fassung dieser Regel prüfte nur `exit_date <= asOf`, und das
// reichte für den Nichtantritt nicht: Migration 20260814100000 setzt bei
// „No Show" das Austrittsdatum **auf den Eintrittstag**. Liegt der noch in
// der Zukunft, ist auch der Austritt in der Zukunft — die Regel fiel durch
// auf „Geplant", und die Liste zeigte jemanden als anstehenden Eintritt, von
// dem längst feststand, dass er nicht kommt.
//
// Die Migration begründet ihr Vorgehen damit, „nie aktiv" folge aus dem
// Datum von selbst. In SQL stimmt das; hier stand der Satz nur als Absicht
// und nicht als Klausel. Er steht jetzt da: endet das Verhältnis nicht
// später, als es beginnt, gab es keinen einzigen Tag Beschäftigung — zu
// keinem Stichtag, auch zu keinem vor dem Eintritt.
//
// lib/employee-status-filter.ts bildet dieselbe Reihenfolge in SQL ab; die
// beiden müssen Klausel für Klausel zusammenpassen.
export function deriveStatusAsOf(
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
asOf: string
): EmploymentStatus {
if (e.exit_date && (e.exit_date <= asOf || e.exit_date <= e.entry_date)) return "Ausgetreten";
if (e.entry_date > asOf) return "Geplant";
if (e.karenz_start_date && e.karenz_start_date <= asOf && (!e.karenz_return_date || asOf < e.karenz_return_date)) return "Karenz";
return "Aktiv";
}
function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number {
const start = Date.parse(`${entryDate}T00:00:00Z`);
const end = Date.parse(`${exitDate && exitDate <= asOf ? exitDate : asOf}T00:00:00Z`);
return Math.max(0, (end - start) / (1000 * 60 * 60 * 24 * 365.25));
}
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
switch (dim) {
case "division":
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
case "department":
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
case "team":
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "status":
return e.status;
case "employment_type":
return e.employment_type;
case "contract_type":
return e.contract_type;
case "entry_year":
return e.entry_date.slice(0, 4);
case "source":
return e.source;
case "paygrade":
return e.paygrade;
case "worker_type":
return e.worker_type;
case "collective_agreement":
return e.collective_agreement;
case "betriebsrat":
return e.is_betriebsrat ? "Ja" : "Nein";
case "dienstwagen":
return e.has_dienstwagen ? "Ja" : "Nein";
case "laterale_fuehrung":
return e.is_laterale_fuehrung ? "Ja" : "Nein";
case "c_level":
return e.is_c_level ? "Ja" : "Nein";
case "has_dependents":
return e.dependents_count > 0 ? "Ja" : "Nein";
case "teilzeit_art":
// „Keine" statt „–": die Antwort ist hier eine Aussage, kein Fehlen.
return e.teilzeit_art ?? "Keine";
case "weekday":
// Not a strict partition — see groupKeysFor, which aggregateReport
// actually uses. This single-key fallback only covers a direct
// groupKeyFor("weekday", ...) call from outside aggregateReport.
return e.work_days[0] ?? "–";
default:
return "Unbekannt";
}
}
/**
* Dimensionen, deren Werte eine eigene Reihenfolge haben.
*
* Alles andere wird nach der Kennzahl sortiert, gross zuerst — bei „Bereich"
* oder „Standort" ist das die Antwort auf die Frage, die der Bericht stellt.
* Bei einer Leiter ist es keine: Montag vor Dienstag und HG09 vor HG10 sind
* die Reihenfolge, in der die Werte *sind*, und eine nach Häufigkeit
* umgestellte Leiter liest sich als Zufall.
*
* HG09 ist die unterste Stufe, HG20 die oberste. Der „Generic Grade" (`-`)
* steht vor allen: er ist keine Stufe, sondern ihr Fehlen, und vor der
* niedrigsten ist der Platz, an dem das am wenigsten nach einer Aussage
* aussieht.
*/
const EIGENE_REIHENFOLGE: Partial<Record<GroupDimension, readonly string[]>> = {
weekday: WOCHENTAGE,
paygrade: HAY_GRADE_WERTE,
};
function rang(dim: GroupDimension, key: string): number {
const liste = EIGENE_REIHENFOLGE[dim];
if (!liste) return 0;
const i = liste.indexOf(key);
// Unbekanntes hinten, nicht vorn: ein Wert, den die Liste nicht kennt, soll
// auffallen und nicht die Leiter anführen.
return i === -1 ? liste.length : i;
}
function sortiereNachReihenfolge<T extends { key: string }>(items: T[], dim: GroupDimension): T[] {
return [...items].sort((a, b) => rang(dim, a.key) - rang(dim, b.key));
}
// Reused by ReportsPageClient (split legend) and the report export route
// (split columns) to render a split in the order its values have rather than
// in first-encountered order; a no-op for every other dimension.
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
return EIGENE_REIHENFOLGE[dim] ? [...keys].sort((a, b) => rang(dim, a) - rang(dim, b)) : keys;
}
// Every dimension other than `weekday` is a strict single-key partition
// (delegates to groupKeyFor); `weekday` returns one key per work day, so an
// employee is counted in every day they work — deliberately not a
// partition, since that's the whole point of the dimension.
export function groupKeysFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string[] {
if (dim === "weekday") return e.work_days.length > 0 ? e.work_days : ["–"];
return [groupKeyFor(e, dim, lookups)];
}
export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number {
if (rows.length === 0) return 0;
switch (measure) {
case "headcount":
return rows.length;
case "fte":
return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0);
case "parttime_rate":
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
case "avg_age":
return rows.reduce((s, e) => s + yearsBetweenIso(e.birth_date, asOf), 0) / rows.length;
case "avg_tenure":
return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length;
case "female_share":
return (rows.filter((e) => e.gender === "w").length / rows.length) * 100;
case "avg_dependents":
return rows.reduce((s, e) => s + e.dependents_count, 0) / rows.length;
default:
return 0;
}
}
export type ReportPerson = { id: string; name: string; title: string; team: string; entry_date: string };
export type ReportSplitRow = { key: string; value: number; count: number };
export type ReportRow = { key: string; value: number; count: number; people: ReportPerson[]; split?: ReportSplitRow[] };
export function aggregateReport(
employees: ReportEmployee[],
measure: Measure,
group: GroupDimension,
split: GroupDimension | null,
lookups: OrgLookups,
asOf: string = todayIso()
): ReportRow[] {
const byGroup = new Map<string, ReportEmployee[]>();
for (const e of employees) {
for (const key of groupKeysFor(e, group, lookups)) {
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e);
}
}
const rows: ReportRow[] = [];
for (const [key, rowsForGroup] of byGroup) {
const value = measureValue(rowsForGroup, measure, asOf);
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.id,
name: fmtName(e.first_name, e.last_name),
title: e.job_title,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
entry_date: e.entry_date,
}));
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
if (split) {
const bySplit = new Map<string, ReportEmployee[]>();
for (const e of rowsForGroup) {
for (const sKey of groupKeysFor(e, split, lookups)) {
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e);
}
}
const splitRows = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
key: sKey,
value: measureValue(sRows, measure, asOf),
count: sRows.length,
}));
row.split = EIGENE_REIHENFOLGE[split] ? sortiereNachReihenfolge(splitRows, split) : splitRows;
}
rows.push(row);
}
return EIGENE_REIHENFOLGE[group] ? sortiereNachReihenfolge(rows, group) : rows.sort((a, b) => b.value - a.value);
}
export function sumValues(rows: { value: number }[]): number {
return rows.reduce((s, r) => s + r.value, 0);
}
// headcount/fte sum across groups; averages/ratios are weighted by each
// group's underlying record count for a sensible overall figure.
export function totalForRows(rows: { value: number; count: number }[], measure: Measure): number {
if (SUM_MEASURES.includes(measure)) return sumValues(rows);
const totalCount = rows.reduce((s, r) => s + r.count, 0);
if (totalCount === 0) return 0;
return rows.reduce((s, r) => s + r.value * r.count, 0) / totalCount;
}
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Headcount nach Hay-Grade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },
{ name: "Ø Angehörige nach Bereich", measure: "avg_dependents", group: "division" },
];
// ── Ereignisse (events over a period) ─────────────────────────────
// Backed by employee_history, the append-only log — unlike Bestand, this
// covers every event type (not just Eintritt/Austritt), survives an
// employee's entry_date being overwritten by a later rehire, and each event
// keeps its own date/description regardless of the employee's current state.
// Sentinel for "von"/"bis" — distinct from "" (unset, falls back to the
// current-year default) or a real date. Written to the URL/exports as the
// literal string "open".
export const EVENT_DATE_OPEN = "open";
export type EventGroupDimension = "event_type" | "division" | "department" | "team" | "location" | "event_year";
export const EVENT_GROUP_LABELS: Record<EventGroupDimension, string> = {
event_type: "Ereignistyp",
division: "Bereich",
department: "Abteilung",
team: "Team",
location: "Standort",
event_year: "Jahr",
};
export const EVENT_TYPE_LABELS: Record<HistoryEventType, string> = {
Eintritt: "Eintritt",
Beförderung: "Beförderung",
Versetzung: "Versetzung",
// Stored value stays 'Karenz'; the label follows the renamed concept.
Karenz: "Langzeitabwesenheit",
Vertragsänderung: "Vertragsänderung",
Stammdatenänderung: "Stammdatenänderung",
Austritt: "Austritt",
Wiedereintritt: "Wiedereintritt",
Reorganisation: "Reorganisation",
Gehaltsanpassung: "Gehaltsanpassung",
Rückkehr: "Rückkehr aus Langzeitabwesenheit",
Übernahme: "Übernahme (extern → intern)",
};
// ── 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;
last_name: string;
job_title: string;
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
org_unit_id: string | null;
location_id: string;
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<Record<HistoryEventType, { label: string; alle: string }>> = {
Austritt: { label: "Beendigungsart", alle: "Alle Beendigungsarten" },
Karenz: { label: "Art der Langzeitabwesenheit", alle: "Alle Arten" },
};
function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: OrgLookups): string {
switch (dim) {
case "event_type":
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
case "division":
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
case "department":
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
case "team":
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "event_year":
return e.event_date.slice(0, 4);
default:
return "Unbekannt";
}
}
export function aggregateEvents(
events: ReportEvent[],
group: EventGroupDimension,
split: EventGroupDimension | null,
lookups: OrgLookups
): ReportRow[] {
const byGroup = new Map<string, ReportEvent[]>();
for (const e of events) {
const key = eventGroupKeyFor(e, group, lookups);
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e);
}
const rows: ReportRow[] = [];
for (const [key, rowsForGroup] of byGroup) {
// Repurposes ReportPerson for events: title -> event description,
// entry_date -> event_date. Keeps the existing drill-down UI/export
// code working unchanged for both report modes.
const people: ReportPerson[] = rowsForGroup.map((e) => ({
id: e.employee_id,
name: fmtName(e.first_name, e.last_name),
title: e.description,
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
entry_date: e.event_date,
}));
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
if (split) {
const bySplit = new Map<string, ReportEvent[]>();
for (const e of rowsForGroup) {
const sKey = eventGroupKeyFor(e, split, lookups);
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e);
}
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ key: sKey, value: sRows.length, count: sRows.length }));
}
rows.push(row);
}
return rows.sort((a, b) => b.value - a.value);
}
export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; split?: EventGroupDimension; eventType?: HistoryEventType }[] = [
{ name: "Ereignisse nach Typ", group: "event_type" },
{ name: "Eintritte nach Bereich", group: "division", eventType: "Eintritt" },
{ name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" },
{ name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" },
];
// ── Query-string parsing ──────────────────────────────────────────
// The Berichte page and every /api/export/* route read the same handful of
// dimension/measure names out of a URL the user fully controls. These used
// to be unchecked `as` casts, which let an unknown value through as a real
// enum value: it reached GROUP_LABELS[group] as undefined (a literal
// "undefined" column header in the export) and was interpolated into the
// download filename, i.e. into a Content-Disposition header. Parsing against
// the label maps — the same objects that define the legal values — keeps the
// two in step by construction.
function parseKeyOf<T extends string>(labels: Record<T, string>, value: string | null | undefined, fallback: T): T {
return value && Object.hasOwn(labels, value) ? (value as T) : fallback;
}
export function parseMode(value: string | null | undefined): "snapshot" | "events" {
return value === "events" ? "events" : "snapshot";
}
export function parseMeasure(value: string | null | undefined): Measure {
return parseKeyOf(MEASURE_LABELS, value, "headcount");
}
export function parseGroupDimension(value: string | null | undefined, fallback: GroupDimension = "division"): GroupDimension {
return parseKeyOf(GROUP_LABELS, value, fallback);
}
export function parseEventGroupDimension(
value: string | null | undefined,
fallback: EventGroupDimension = "event_type"
): EventGroupDimension {
return parseKeyOf(EVENT_GROUP_LABELS, value, fallback);
}
// Unlike the dimensions above, "no split" and "all event types" are legal —
// hence null rather than a fallback value for an unrecognized input.
export function parseSplitDimension(value: string | null | undefined): GroupDimension | null {
return value && Object.hasOwn(GROUP_LABELS, value) ? (value as GroupDimension) : null;
}
export function parseEventSplitDimension(value: string | null | undefined): EventGroupDimension | null {
return value && Object.hasOwn(EVENT_GROUP_LABELS, value) ? (value as EventGroupDimension) : null;
}
export function parseEventType(value: string | null | undefined): HistoryEventType | null {
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 {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const d = new Date(`${value}T00:00:00Z`);
return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value ? undefined : value;
}
// from/to additionally accept the EVENT_DATE_OPEN sentinel ("this side of
// the interval is intentionally unbounded"), which is not a date.
export function parseEventDateParam(value: string | null | undefined): string | undefined {
return value === EVENT_DATE_OPEN ? EVENT_DATE_OPEN : parseIsoDateParam(value);
}