import type { SupabaseClient } from "@supabase/supabase-js"; import type { OrgEmployee } from "@/components/orgchart/types"; import { todayIso } from "./format"; import { deriveStatusAsOf } from "./reports"; import { fetchAllRows } from "./supabase/query"; import type { Database } from "./supabase/types"; // The Organigramm as it stood (or will stand) on a given date. Three sources // have to be reconciled, because no single one covers the whole timeline: // // past/today employee_assignments — the interval covering `asOf` // future pending_org_changes — effective-dated moves not yet applied // membership entry/exit/karenz — who counted as staff on that date // // See supabase/migrations/*_employee_assignment_history.sql for why the // placement timeline is captured by a trigger rather than per-RPC. const ORG_COLUMNS = "id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date"; /** Change types that move someone in the org; the rest only affect status or contract. */ const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const; export type OrgAsOfResult = { employees: OrgEmployee[]; /** How many placements were projected from not-yet-applied changes. */ projectedCount: number; /** Earliest date the assignment history actually covers. */ historyStartsAt: string | null; }; type EmployeeRow = { id: string; personnel_number: number; first_name: string; last_name: string; job_title: string; manager_id: string | null; team_id: string | null; division_id: string; is_lead: boolean; org_level: number; entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null; }; type AssignmentRow = { employee_id: string; manager_id: string | null; team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number; valid_from: string; }; type PendingRow = { employee_id: string; effective_date: string; payload: Record }; type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number }; // Mirrors resolve_manager_for() in supabase/migrations: an IC reports to // their team's lead, a team lead to the division head, and anyone without a // team to the CEO. Only used for employees a pending change actually moves — // everyone else keeps the manager recorded on their assignment, so existing // data that deviates from the rule is never silently "corrected". function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null { if (placement.team_id && !placement.is_lead) { return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null; } if (placement.team_id && placement.is_lead) { return ( all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ?? null ); } return all.find((e) => e.placement.org_level === 0)?.id ?? null; } export async function loadOrgAsOf(supabase: SupabaseClient, asOf: string): Promise { const today = todayIso(); const [allEmployees, assignments, teams, departments, pending] = await Promise.all([ fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")), fetchAllRows(() => supabase .from("employee_assignments") .select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from") .lte("valid_from", asOf) .or(`valid_to.is.null,valid_to.gt.${asOf}`) .order("employee_id") ), fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")), fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")), asOf > today ? fetchAllRows(() => supabase .from("pending_org_changes") .select("employee_id, change_type, effective_date, payload") .eq("status", "pending") .lte("effective_date", asOf) .in("change_type", [...PLACEMENT_CHANGES]) .order("effective_date") ) : Promise.resolve([]), ]); return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending }); } // The pure half of the above: everything that turns the four row sets into a // snapshot, with no Supabase client in sight, so the reconciliation rules can // be tested directly. export function resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending, }: { asOf: string; employees: EmployeeRow[]; assignments: AssignmentRow[]; teams: { id: string; department_id: string }[]; departments: { id: string; division_id: string }[]; pending: PendingRow[]; }): OrgAsOfResult { const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a])); // A projected move names only the target team; its division follows from // the team's department, the same way the DB trigger derives it. const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id])); const teamDivision = new Map( teams.flatMap((t) => { const divisionId = departmentDivision.get(t.department_id); return divisionId ? [[t.id, divisionId] as const] : []; }) ); // Employed (or on leave) on that date — the same derivation the Berichte // page uses, so the two can never disagree on who counted when. const staff = allEmployees.filter((e) => { const status = deriveStatusAsOf(e, asOf); return status === "Aktiv" || status === "Karenz"; }); const resolved = staff.map((e) => { const a = assignmentByEmployee.get(e.id); return { employee: e, managerId: a ? a.manager_id : e.manager_id, placement: { team_id: a ? a.team_id : e.team_id, division_id: a ? a.division_id : e.division_id, job_title: a ? a.job_title : e.job_title, is_lead: a ? a.is_lead : e.is_lead, org_level: a ? a.org_level : e.org_level, } satisfies Placement, }; }); // Project the future. Ordered by effective_date, so a later move wins. const byId = new Map(resolved.map((r) => [r.employee.id, r])); const moved = new Set(); for (const change of pending) { const target = byId.get(change.employee_id); if (!target) continue; const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string }; const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null; if (newTeamId) { target.placement.team_id = newTeamId; const divisionId = teamDivision.get(newTeamId); if (divisionId) target.placement.division_id = divisionId; moved.add(target.employee.id); } if (payload.new_title) target.placement.job_title = payload.new_title; } // Second pass: a moved employee's manager follows from the *projected* // org, not the one they left — and the lead of their new team may itself // have moved in this same batch. for (const r of resolved) { if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement }))); } // A manager who had not joined yet, or had already left, is not in this // set — without re-rooting, their whole reporting line would silently // vanish from the chart rather than showing up one level higher. const presentIds = new Set(resolved.map((r) => r.employee.id)); const employees: OrgEmployee[] = resolved.map((r) => ({ id: r.employee.id, personnel_number: r.employee.personnel_number, first_name: r.employee.first_name, last_name: r.employee.last_name, job_title: r.placement.job_title, manager_id: r.managerId && presentIds.has(r.managerId) ? r.managerId : null, team_id: r.placement.team_id, division_id: r.placement.division_id, is_lead: r.placement.is_lead, org_level: r.placement.org_level, })); const historyStartsAt = assignments.reduce( (min, a) => (min === null || a.valid_from < min ? a.valid_from : min), null ); return { employees, projectedCount: moved.size, historyStartsAt }; }