import type { SupabaseClient } from "@supabase/supabase-js"; import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types"; import { todayIso } from "./format"; import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting"; import { fetchAllRows } from "./supabase/query"; import type { Database } from "./supabase/types"; // Das Organigramm, wie es an einem Stichtag stand oder stehen wird. // // Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den // ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die // Vergangenheit, vorgemerkte Änderungen für die Zukunft und die // Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte. // // Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also // beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche // Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem // Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite // Zugehörigkeitsregel braucht es nicht mehr. // // Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht // in position_assignments, weil sie erst am Stichtag geschrieben werden. /** Änderungsarten, die jemanden in der Organisation verschieben. */ const PLACEMENT_CHANGES = ["transfer"] as const; export type OrgAsOfResult = { employees: OrgEmployee[]; vacancies: OrgVacancy[]; /** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */ projectedCount: number; /** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */ historyStartsAt: string | null; }; type EmployeeRow = { id: string; personnel_number: number; first_name: string; last_name: string; job_title: string; karenz_start_date: string | null; karenz_return_date: string | null; absence_type: string | null; }; type PositionRow = { id: string; position_number: string; org_unit_id: string; is_chief: boolean; jobs: { title: string }; }; type AssignmentRow = { employee_id: string; position_id: string }; type PendingRow = { employee_id: string; effective_date: string; payload: Record }; export async function loadOrgAsOf(supabase: SupabaseClient, asOf: string): Promise { const today = todayIso(); const [units, positions, assignments, employees, pending, earliest] = await Promise.all([ fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")), fetchAllRows(() => supabase .from("om_positions") .select("id, position_number, org_unit_id, is_chief, jobs!inner(title)") .lte("valid_from", asOf) .or(`valid_to.is.null,valid_to.gt.${asOf}`) .order("id") ), fetchAllRows(() => supabase .from("position_assignments") .select("employee_id, position_id") .lte("valid_from", asOf) .or(`valid_to.is.null,valid_to.gt.${asOf}`) .order("employee_id") ), fetchAllRows(() => supabase .from("employees") .select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type") .order("id") ), asOf > today ? fetchAllRows(() => supabase .from("pending_org_changes") .select("employee_id, effective_date, payload") .eq("status", "pending") .lte("effective_date", asOf) .in("change_type", [...PLACEMENT_CHANGES]) .order("effective_date") ) : Promise.resolve([]), supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(), ]); return resolveOrgSnapshot({ asOf, units: units.map((u) => ({ id: u.id, parentId: u.parent_id })), positions: positions as unknown as PositionRow[], assignments: assignments as AssignmentRow[], employees: employees as EmployeeRow[], pending: pending as PendingRow[], historyStartsAt: earliest.data?.valid_from ?? null, }); } /** * Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die * Regeln direkt prüfbar sind. */ export function resolveOrgSnapshot({ asOf, units, positions, assignments, employees: allEmployees, pending, historyStartsAt, }: { asOf: string; units: OmUnit[]; positions: PositionRow[]; assignments: AssignmentRow[]; employees: EmployeeRow[]; pending: PendingRow[]; historyStartsAt: string | null; }): OrgAsOfResult { const positionById = new Map(positions.map((p) => [p.id, p])); const employeeById = new Map(allEmployees.map((e) => [e.id, e])); // Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(), // damit die drei nie auseinanderlaufen können. const isAbsent = (e: EmployeeRow) => e.karenz_start_date !== null && e.karenz_start_date <= asOf && (e.karenz_return_date === null || asOf < e.karenz_return_date); const positionOf = new Map(); for (const a of assignments) { if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id); } // Die Zukunft projizieren: nach effective_date sortiert, eine spätere // Versetzung gewinnt. const moved = new Set(); for (const change of pending) { if (!positionOf.has(change.employee_id)) continue; const targetId = (change.payload as { target_position_id?: string }).target_position_id; if (!targetId || !positionById.has(targetId)) continue; positionOf.set(change.employee_id, targetId); moved.add(change.employee_id); } const holders: OmHolder[] = []; for (const [employeeId, positionId] of positionOf) { const position = positionById.get(positionId)!; holders.push({ employeeId, positionId, orgUnitId: position.org_unit_id, isChief: position.is_chief, absent: isAbsent(employeeById.get(employeeId)!), }); } const lines = resolveReportingLines(units, holders); const employees: OrgEmployee[] = lines.map((l) => { const e = employeeById.get(l.employeeId)!; const position = positionById.get(l.positionId)!; return { id: e.id, personnel_number: e.personnel_number, first_name: e.first_name, last_name: e.last_name, // Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person: // bei einer projizierten Versetzung ist nur die erste schon richtig. job_title: position.jobs.title, manager_id: l.actingManagerId, // Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die // Oberfläche zweimal dieselbe Person an. formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId, absent: isAbsent(e), absence_type: e.absence_type, org_unit_id: l.orgUnitId, is_chief: l.isChief, position_id: l.positionId, position_number: position.position_number, }; }); // Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene // Tabelle neben der Organisation; hier sind sie schlicht das Komplement. const besetzt = new Set(positionOf.values()); const vacancies: OrgVacancy[] = positions .filter((p) => !besetzt.has(p.id)) .map((p) => ({ position_id: p.id, position_number: p.position_number, job_title: p.jobs.title, org_unit_id: p.org_unit_id, is_chief: p.is_chief, })); return { employees, vacancies, projectedCount: moved.size, historyStartsAt }; }