While somebody is on a long-term absence their reports report to the next
management level, and it keeps rolling up until it reaches somebody present.
Derived at read time in lib/acting-manager.ts rather than written to
employees.manager_id: the absent person stays formally in charge, so the
stand-in has to be visible as a stand-in rather than quietly replacing them.
Both ids therefore travel to the UI, and both sides carry a badge — the
absent person ("Abwesend · Vertretung: X") and anyone now reporting
elsewhere ("Vertretung für Y").
Three cases the walk has to survive, all covered by tests:
- Several absent levels in a row — it keeps climbing, and still names the
*recorded* manager as the one being covered for, not the level skipped.
- Everyone above absent — it stops and keeps the recorded manager. Re-rooting
a team to the top of the chart would distort more than showing an absent
manager whose absence is labelled anyway.
- A manager_id cycle, which nothing in the schema forbids.
An absent lead needs no separate deputy field: their stand-in is simply
their own acting manager, the same one their reports moved to.
232 lines
9.3 KiB
TypeScript
232 lines
9.3 KiB
TypeScript
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
import type { OrgEmployee } from "@/components/orgchart/types";
|
|
import { resolveActingManagers } from "./acting-manager";
|
|
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, absence_type";
|
|
|
|
/** 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;
|
|
absence_type: 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<string, unknown> };
|
|
|
|
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<Database>, asOf: string): Promise<OrgAsOfResult> {
|
|
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<string>();
|
|
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 recordedManagerOf = (r: (typeof resolved)[number]) =>
|
|
r.managerId && presentIds.has(r.managerId) ? r.managerId : null;
|
|
|
|
// While somebody is on a long-term absence their reports roll up to the
|
|
// next present level. Derived here rather than written to the database:
|
|
// the absent person stays formally in charge, and the stand-in is only a
|
|
// stand-in — which is why both ids travel to the UI.
|
|
const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id));
|
|
const acting = resolveActingManagers(
|
|
resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) }))
|
|
);
|
|
|
|
const employees: OrgEmployee[] = resolved.map((r) => {
|
|
const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null };
|
|
return {
|
|
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: actingManagerId,
|
|
formal_manager_id: coveredForId,
|
|
absent: absentIds.has(r.employee.id),
|
|
absence_type: r.employee.absence_type,
|
|
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<string | null>(
|
|
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
|
|
null
|
|
);
|
|
|
|
return { employees, projectedCount: moved.size, historyStartsAt };
|
|
}
|