Files
alpenwerk-hr/lib/reports.ts
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

500 lines
20 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 { fmtName, todayIso, yearsBetweenIso } from "./format";
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: "Angestellte:r / Arbeiter:in",
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.
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.entry_date > asOf) return "Geplant";
if (e.exit_date && e.exit_date <= asOf) return "Ausgetreten";
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";
}
}
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function weekdayRank(key: string): number {
const i = WEEKDAY_ORDER.indexOf(key as Weekday);
return i === -1 ? WEEKDAY_ORDER.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",
};
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;
};
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;
}
// 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);
}