Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".
Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.
Zwei Anmerkungen von Max:
* Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
naechsten Speichern von selbst.
* "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
jede Korrektur am Personenkreis scheitern, solange der Grad noch
dransteht.
596 lines
25 KiB
TypeScript
596 lines
25 KiB
TypeScript
import { ABSENCE_TYPES } from "./absence";
|
||
import { BEENDIGUNGSART_WERTE } from "./beendigung";
|
||
import { fmtName, todayIso, yearsBetweenIso } from "./format";
|
||
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";
|
||
|
||
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: "Paygrade",
|
||
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";
|
||
}
|
||
}
|
||
|
||
function weekdayRank(key: string): number {
|
||
const i = WOCHENTAGE.indexOf(key as Weekday);
|
||
return i === -1 ? WOCHENTAGE.length : i;
|
||
}
|
||
|
||
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
|
||
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
|
||
}
|
||
|
||
// Reused by ReportsPageClient (split legend) and the report export route
|
||
// (split columns) to render a `weekday` split chronologically rather than
|
||
// in first-encountered order; a no-op for every other dimension.
|
||
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
|
||
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(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 = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
|
||
}
|
||
rows.push(row);
|
||
}
|
||
return group === "weekday" ? sortByWeekday(rows) : 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 Paygrade", 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",
|
||
};
|
||
|
||
// ── 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);
|
||
}
|