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)]));
}