import type { SupabaseClient } from "@supabase/supabase-js"; import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org"; import { loadPlacements } from "./placement"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; import { fetchAllRows } from "./supabase/query"; import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; // Shared by the Berichte page and /api/export/* so they can never drift on // what "the current view" means — same filters, same stichtag/event-window // rules. export type ReportFilters = { /** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */ division?: string; location?: string; status?: string; employment?: string; }; export type SnapshotFilters = ReportFilters & { asOf?: string }; export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string }; /** * Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und * welches Team über ihr liegen. Ein Bericht gruppiert dann über einen * Kartenzugriff statt über einen Aufstieg im Baum je Zeile. */ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups { const divisionName = new Map(); const departmentName = new Map(); const teamName = new Map(); for (const unit of orgMaps.unitList) { for (const a of ancestorsOf(orgMaps, unit.id)) { if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name); else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name); else if (a.unit_type === "Team") teamName.set(unit.id, a.name); } } return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) }; } export async function loadOrgLookups(supabase: SupabaseClient): Promise<{ lookups: OrgLookups; orgMaps: OrgMaps; divisions: { id: string; name: string }[]; locations: { id: string; name: string }[]; }> { const orgMaps = await loadOrgMaps(supabase); const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name })); return { lookups: lookupsFromOrgMaps(orgMaps, locations), orgMaps, // Als Filter angeboten wird die oberste Ebene unter der Gesellschaft — // das, was im Altmodell „Bereich" hiess. Der Filter greift auf den // ganzen Teilbaum. divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })), locations, }; } const SNAPSHOT_EMPLOYEE_COLUMNS = "id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level"; // employee_id -> number of employee_dependents rows. Selects only the FK // column (no dependent PII needed) since only per-employee counts feed the // has_dependents/avg_dependents report dimensions; counted client-side // since the Supabase JS client has no `count(*) group by employee_id` // shorthand. Shared by the Bestand pivot and the full employees export. export async function loadDependentsCounts(supabase: SupabaseClient): Promise> { const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id")); const counts = new Map(); for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1); return counts; } // Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst. export async function loadSnapshotEmployees(supabase: SupabaseClient, filters: SnapshotFilters): Promise { const asOf = filters.asOf || todayIso(); function snapshotQuery() { let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id"); if (filters.location) query = query.eq("location_id", filters.location); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); return query; } const [data, dependentsCounts, placements, orgMaps] = await Promise.all([ fetchAllRows(snapshotQuery), loadDependentsCounts(supabase), loadPlacements(supabase, { asOf }), filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), ]); // Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst // deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem // Bereich selbst nur die Bereichsleitung sitzt. const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const withDerivedStatus: ReportEmployee[] = []; for (const e of data) { const placement = placements.get(e.id); // Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand, // sitzt aber auf keiner Planstelle mehr. const orgUnitId = placement?.current ? placement.orgUnitId : null; if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue; withDerivedStatus.push({ id: e.id, first_name: e.first_name, last_name: e.last_name, job_title: placement?.jobTitle ?? e.job_title, org_unit_id: orgUnitId, location_id: e.location_id, status: deriveStatusAsOf(e, asOf), employment_type: e.employment_type, contract_type: e.contract_type, entry_date: e.entry_date, exit_date: e.exit_date, weekly_hours: e.weekly_hours, source: e.source, paygrade: e.paygrade, birth_date: e.birth_date, gender: e.gender, worker_type: e.worker_type, collective_agreement: e.collective_agreement, work_days: e.work_days, is_betriebsrat: e.is_betriebsrat, has_dienstwagen: e.has_dienstwagen, is_laterale_fuehrung: e.is_laterale_fuehrung, is_c_level: e.is_c_level, dependents_count: dependentsCounts.get(e.id) ?? 0, }); } const statuses = parseStatuses(filters.status); return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number])); } // Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie // kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte. // Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem // Team stand, in das die Person nie versetzt worden war. // // from/to: "" (unset) falls back to the current calendar year; the literal // sentinel EVENT_DATE_OPEN means that side of the interval is intentionally // unbounded (e.g. "alle Ereignisse bis heute", no start date). export async function loadEventHistory(supabase: SupabaseClient, filters: EventFilters): Promise { const currentYear = new Date().getFullYear(); const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`; function historyQuery() { let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id"); if (from) query = query.gte("event_date", from); if (to) query = query.lte("event_date", to); if (filters.eventType) query = query.eq("event_type", filters.eventType); return query; } const [history, employees, assignments, orgMaps] = await Promise.all([ fetchAllRows(historyQuery), fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")), fetchAllRows(() => supabase .from("position_assignments") .select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)") .order("employee_id") ), filters.division ? loadOrgMaps(supabase) : Promise.resolve(null), ]); const spans = new Map(); for (const a of assignments as unknown as { employee_id: string; valid_from: string; valid_to: string | null; om_positions: { org_unit_id: string }; }[]) { const list = spans.get(a.employee_id) ?? []; list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id }); spans.set(a.employee_id, list); } const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null; const employeeById = new Map(employees.map((e) => [e.id, e])); const events: ReportEvent[] = []; for (const h of history) { const emp = employeeById.get(h.employee_id); if (!emp) continue; if (filters.location && emp.location_id !== filters.location) continue; const unitId = spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null; if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue; events.push({ employee_id: emp.id, first_name: emp.first_name, last_name: emp.last_name, job_title: emp.job_title, org_unit_id: unitId, location_id: emp.location_id, event_date: h.event_date, event_type: h.event_type, description: h.description, }); } return events; }