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:
93
tests/unit/acting-manager.test.ts
Normal file
93
tests/unit/acting-manager.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveActingManager, resolveActingManagers, type ChainNode } from "@/lib/acting-manager";
|
||||
|
||||
// Nobody's manager_id is rewritten for an absence — the stand-in is derived
|
||||
// here, at read time, so the absent person stays formally in charge and the
|
||||
// arrangement can be shown as temporary.
|
||||
function node(id: string, managerId: string | null, absent = false): ChainNode {
|
||||
return { id, managerId, absent };
|
||||
}
|
||||
|
||||
function chain(...nodes: ChainNode[]) {
|
||||
return new Map(nodes.map((n) => [n.id, n]));
|
||||
}
|
||||
|
||||
describe("resolveActingManager", () => {
|
||||
it("leaves a present manager alone", () => {
|
||||
const ic = node("ic", "lead");
|
||||
const map = chain(ic, node("lead", "head"));
|
||||
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "lead", coveredForId: null });
|
||||
});
|
||||
|
||||
it("rolls up one level when the manager is absent", () => {
|
||||
const ic = node("ic", "lead");
|
||||
const map = chain(ic, node("lead", "head", true), node("head", "ceo"));
|
||||
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "head", coveredForId: "lead" });
|
||||
});
|
||||
|
||||
it("keeps rolling up while each level is absent", () => {
|
||||
const ic = node("ic", "lead");
|
||||
const map = chain(ic, node("lead", "head", true), node("head", "ceo", true), node("ceo", null));
|
||||
// coveredForId names the *recorded* manager, which is what the employee
|
||||
// and the org chart know about — not the intermediate level skipped.
|
||||
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "ceo", coveredForId: "lead" });
|
||||
});
|
||||
|
||||
it("gives an absent lead the same stand-in as their own reports", () => {
|
||||
// The deputy for an absent person is simply their own acting manager,
|
||||
// which is why no separate deputy field is needed anywhere.
|
||||
const lead = node("lead", "head", true);
|
||||
const map = chain(node("ic", "lead"), lead, node("head", "ceo"));
|
||||
expect(resolveActingManager(lead, map).actingManagerId).toBe("head");
|
||||
});
|
||||
|
||||
it("keeps the recorded manager when everyone above is absent", () => {
|
||||
// Re-rooting the team to the top of the chart would be a bigger
|
||||
// distortion than showing the absent manager, whose absence is labelled
|
||||
// regardless.
|
||||
const ic = node("ic", "lead");
|
||||
const map = chain(ic, node("lead", "ceo", true), node("ceo", null, true));
|
||||
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "lead", coveredForId: null });
|
||||
});
|
||||
|
||||
it("returns nothing for somebody at the top", () => {
|
||||
const ceo = node("ceo", null);
|
||||
expect(resolveActingManager(ceo, chain(ceo))).toEqual({ actingManagerId: null, coveredForId: null });
|
||||
});
|
||||
|
||||
it("keeps the recorded manager when they are not in the visible set", () => {
|
||||
// A manager who had already left or has not started yet; the snapshot's
|
||||
// own re-rooting deals with that case.
|
||||
const ic = node("ic", "gone");
|
||||
expect(resolveActingManager(ic, chain(ic))).toEqual({ actingManagerId: "gone", coveredForId: null });
|
||||
});
|
||||
|
||||
it("terminates on a manager cycle instead of looping forever", () => {
|
||||
// Nothing in the schema forbids A -> B -> A.
|
||||
const ic = node("ic", "a");
|
||||
const map = chain(ic, node("a", "b", true), node("b", "a", true));
|
||||
expect(() => resolveActingManager(ic, map)).not.toThrow();
|
||||
expect(resolveActingManager(ic, map)).toEqual({ actingManagerId: "a", coveredForId: null });
|
||||
});
|
||||
|
||||
it("terminates when somebody is their own manager", () => {
|
||||
const ic = node("ic", "self");
|
||||
const map = chain(ic, node("self", "self", true));
|
||||
expect(resolveActingManager(ic, map).actingManagerId).toBe("self");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActingManagers", () => {
|
||||
it("moves a whole team up together", () => {
|
||||
const results = resolveActingManagers([
|
||||
node("ic1", "lead"),
|
||||
node("ic2", "lead"),
|
||||
node("lead", "head", true),
|
||||
node("head", null),
|
||||
]);
|
||||
expect(results.get("ic1")).toEqual({ actingManagerId: "head", coveredForId: "lead" });
|
||||
expect(results.get("ic2")).toEqual({ actingManagerId: "head", coveredForId: "lead" });
|
||||
// The absent lead stays under their own manager rather than vanishing.
|
||||
expect(results.get("lead")?.actingManagerId).toBe("head");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ function emp(id: string, overrides: Partial<EmployeeInput> = {}): EmployeeInput
|
||||
exit_date: null,
|
||||
karenz_start_date: null,
|
||||
karenz_return_date: null,
|
||||
absence_type: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user