Roll reporting up past an absent manager, and say so on both sides
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:
62
lib/acting-manager.ts
Normal file
62
lib/acting-manager.ts
Normal 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)]));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user