import type { EmploymentStatus, HistoryEventType } from "./supabase/types"; // ── Bestand (point-in-time snapshot) ────────────────────────────── export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share"; export type GroupDimension = | "division" | "department" | "team" | "location" | "status" | "employment_type" | "contract_type" | "entry_year" | "source" | "paygrade"; export const MEASURE_LABELS: Record = { headcount: "Headcount", fte: "FTE", parttime_rate: "Teilzeitquote", avg_age: "Ø Alter", avg_tenure: "Ø Zugehörigkeit", female_share: "Frauenanteil", }; export const GROUP_LABELS: Record = { 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", }; export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"]; 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; }; export type OrgLookups = { divisionName: Map; departmentNameByTeam: Map; teamName: Map; locationName: Map; }; export function todayIso(): string { return new Date().toISOString().slice(0, 10); } // 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 ageAsOf(birthDate: string, asOf: string): number { const d = new Date(birthDate); const ref = new Date(asOf); let age = ref.getFullYear() - d.getFullYear(); if (ref.getMonth() < d.getMonth() || (ref.getMonth() === d.getMonth() && ref.getDate() < d.getDate())) age -= 1; return age; } function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number { const start = new Date(entryDate); const end = exitDate && exitDate <= asOf ? new Date(exitDate) : new Date(asOf); return Math.max(0, (end.getTime() - start.getTime()) / (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 String(new Date(e.entry_date).getFullYear()); case "source": return e.source; case "paygrade": return e.paygrade; default: return "Unbekannt"; } } 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 + ageAsOf(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; 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(); for (const e of employees) { const key = groupKeyFor(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(); for (const e of rowsForGroup) { const sKey = groupKeyFor(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: measureValue(sRows, measure, asOf), count: sRows.length, })); } rows.push(row); } return 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" }, ]; // ── 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 = { event_type: "Ereignistyp", division: "Bereich", department: "Abteilung", team: "Team", location: "Standort", event_year: "Jahr", }; export const EVENT_TYPE_LABELS: Record = { 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 String(new Date(e.event_date).getFullYear()); default: return "Unbekannt"; } } export function aggregateEvents( events: ReportEvent[], group: EventGroupDimension, split: EventGroupDimension | null, lookups: OrgLookups ): ReportRow[] { const byGroup = new Map(); 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(); 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" }, ];