Roll reporting up past an absent manager, and say so on both sides
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m18s

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.
This commit is contained in:
2026-07-27 12:03:34 +02:00
parent a0973cff66
commit 2776c33d08
7 changed files with 246 additions and 19 deletions

62
lib/acting-manager.ts Normal file
View File

@@ -0,0 +1,62 @@
// Who actually leads a team while its lead is on a long-term absence.
//
// The rule: reporting rolls up to the next management level, and keeps
// rolling up until it reaches somebody who is present. Someone on
// Langzeitabwesenheit stays formally in charge — nothing is written to
// employees.manager_id — so this is derived at read time and the stand-in is
// always shown as such rather than silently replacing the real manager.
export type ChainNode = {
id: string;
/** The recorded manager, absent or not. */
managerId: string | null;
absent: boolean;
};
export type ActingManagerResult = {
/** Who to report to in practice — the nearest present manager. */
actingManagerId: string | null;
/**
* The recorded manager, when they differ from the acting one. Null when
* nobody is standing in, so the UI can show the arrangement only when
* there is one.
*/
coveredForId: string | null;
};
/**
* Walks up from `node`'s recorded manager until it finds one who is present.
*
* Falls back to the recorded manager when the whole chain above is absent
* (or leads nowhere): re-rooting a team to the top of the chart would be a
* bigger lie than showing the absent manager, and the absence is labelled
* either way.
*/
export function resolveActingManager(node: ChainNode, byId: Map<string, ChainNode>): ActingManagerResult {
const formalId = node.managerId;
if (!formalId) return { actingManagerId: null, coveredForId: null };
const formal = byId.get(formalId);
if (!formal || !formal.absent) return { actingManagerId: formalId, coveredForId: null };
// Nothing in the schema forbids a manager_id cycle, so the walk has to
// terminate on its own rather than trusting the data.
const seen = new Set<string>([node.id, formalId]);
let current: ChainNode | undefined = formal;
while (current?.managerId && !seen.has(current.managerId)) {
seen.add(current.managerId);
const next = byId.get(current.managerId);
if (!next) break;
if (!next.absent) return { actingManagerId: next.id, coveredForId: formalId };
current = next;
}
return { actingManagerId: formalId, coveredForId: null };
}
/** Applies the walk to a whole set, returning one result per node id. */
export function resolveActingManagers(nodes: ChainNode[]): Map<string, ActingManagerResult> {
const byId = new Map(nodes.map((n) => [n.id, n]));
return new Map(nodes.map((n) => [n.id, resolveActingManager(n, byId)]));
}

View File

@@ -1,5 +1,6 @@
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";
@@ -16,7 +17,7 @@ import type { Database } from "./supabase/types";
// 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";
"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;
@@ -44,6 +45,7 @@ type EmployeeRow = {
exit_date: string | null;
karenz_start_date: string | null;
karenz_return_date: string | null;
absence_type: string | null;
};
type AssignmentRow = {
@@ -189,19 +191,36 @@ export function resolveOrgSnapshot({
// 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;
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,
}));
// 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),