Files
alpenwerk-hr/lib/reports.ts
Maximilian Stubhan 79f0e19bf8 Org assignment history, mobile support, and a correctness pass
Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
2026-07-24 23:38:10 +02:00

481 lines
19 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 { todayIso, yearsBetweenIso } from "./format";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/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"
| "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",
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;
division_id: string;
team_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;
dependents_count: number;
};
export type OrgLookups = {
divisionName: Map<string, string>;
departmentNameByTeam: 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*. Division/team/location still reflect the
// employee's *current* assignment — the schema has no history of org-unit
// changes over time, only free-text employee_history descriptions — so a
// stichtag report groups by today's org placement, not the placement as of
// that date. Documented in the UI rather than silently wrong.
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 lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department":
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team":
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
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 "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: `${e.first_name} ${e.last_name}`,
title: e.job_title,
team: e.team_id ? (lookups.teamName.get(e.team_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",
Karenz: "Karenz",
Vertragsänderung: "Vertragsänderung",
Stammdatenänderung: "Stammdatenänderung",
Austritt: "Austritt",
Wiedereintritt: "Wiedereintritt",
Reorganisation: "Reorganisation",
Gehaltsanpassung: "Gehaltsanpassung",
Rückkehr: "Rückkehr (Karenz)",
};
export type ReportEvent = {
employee_id: string;
first_name: string;
last_name: string;
job_title: string;
division_id: string;
team_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 lookups.divisionName.get(e.division_id) ?? "Unbekannt";
case "department":
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "";
case "team":
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "";
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: `${e.first_name} ${e.last_name}`,
title: e.description,
team: e.team_id ? (lookups.teamName.get(e.team_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);
}