From 2776c33d0885682473e63d7ed0477f4492f8e508 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Mon, 27 Jul 2026 12:03:34 +0200 Subject: [PATCH] Roll reporting up past an absent manager, and say so on both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/orgchart/EmployeeTree.tsx | 28 ++++++++- components/orgchart/OrgChartNode.tsx | 20 ++++-- components/orgchart/types.ts | 16 +++++ lib/acting-manager.ts | 62 +++++++++++++++++++ lib/orgchart-data.ts | 45 ++++++++++---- tests/unit/acting-manager.test.ts | 93 ++++++++++++++++++++++++++++ tests/unit/orgchart-data.test.ts | 1 + 7 files changed, 246 insertions(+), 19 deletions(-) create mode 100644 lib/acting-manager.ts create mode 100644 tests/unit/acting-manager.test.ts diff --git a/components/orgchart/EmployeeTree.tsx b/components/orgchart/EmployeeTree.tsx index 4c84921..1ffcacb 100644 --- a/components/orgchart/EmployeeTree.tsx +++ b/components/orgchart/EmployeeTree.tsx @@ -51,6 +51,11 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] }; }, [employees]); + // Both badges name a person by id, so the lookup is shared rather than + // rebuilt per node. + const nameById = useMemo(() => new Map(employees.map((e) => [e.id, `${e.first_name} ${e.last_name}`])), [employees]); + const nameOf = useCallback((id: string | null) => (id ? nameById.get(id) : undefined), [nameById]); + const matchIds = useMemo(() => { // A focus target behaves exactly like a search hit — same ancestor // auto-expand, same ring, same fitView in graph mode — until the user @@ -119,11 +124,16 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl avatar: { firstName: e.first_name, lastName: e.last_name }, totalReports: total, matched: matchIds?.has(e.id) ?? false, + absent: e.absent, + // Who is standing in for this absent person: their own acting + // manager, which is the same one their reports moved to. + coveredBy: e.absent ? nameOf(e.manager_id) : undefined, + coveringFor: nameOf(e.formal_manager_id), children: children.map((c) => toChartNode(c, nextAncestors)), }; } return root.map((r) => toChartNode(r, new Set())); - }, [root, childrenByManager, totalReportsById, matchIds]); + }, [root, childrenByManager, totalReportsById, matchIds, nameOf]); function renderNode(e: OrgEmployee, depth: number, ancestors: Set) { const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []); @@ -147,12 +157,26 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl )} - + {e.first_name} {e.last_name} {e.job_title} + {/* The stand-in arrangement is stated on both sides: on the absent + person (whose team has moved away) and on anyone now reporting + elsewhere — otherwise the chart would silently show a reporting + line that is not the recorded one. */} + {e.absent && ( + + Abwesend{nameOf(e.manager_id) ? ` · Vertretung: ${nameOf(e.manager_id)}` : ""} + + )} + {e.formal_manager_id && ( + + Vertretung für {nameOf(e.formal_manager_id)} + + )} {hasChildren && ( {children.length} direkt · {totalReports} gesamt diff --git a/components/orgchart/OrgChartNode.tsx b/components/orgchart/OrgChartNode.tsx index 5e0fe17..065057b 100644 --- a/components/orgchart/OrgChartNode.tsx +++ b/components/orgchart/OrgChartNode.tsx @@ -39,7 +39,7 @@ const KIND_SHELL: Record = { // required, not just tidy, to keep that smooth at a few hundred nodes. export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps) { const { chartNode, expanded, hasChildren, childCount, onToggle } = data; - const { kind, label, sublabel, avatar, href, vacant, totalReports } = chartNode; + const { kind, label, sublabel, avatar, href, vacant, totalReports, absent, coveredBy, coveringFor } = chartNode; const isMatch = chartNode.matched ?? false; const { width, height } = NODE_DIMENSIONS[kind]; @@ -73,10 +73,22 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps )} - {totalReports !== undefined && totalReports > 0 && ( - - {childCount} direkt · {totalReports} gesamt + {/* The stand-in replaces the report count rather than crowding in + next to it: on a card this size the arrangement is the more + important of the two, and it only appears while one is in force. */} + {absent ? ( + + Abwesend{coveredBy ? ` · Vertretung: ${coveredBy}` : ""} + ) : coveringFor ? ( + Vertretung für {coveringFor} + ) : ( + totalReports !== undefined && + totalReports > 0 && ( + + {childCount} direkt · {totalReports} gesamt + + ) )} diff --git a/components/orgchart/types.ts b/components/orgchart/types.ts index dd40239..11de058 100644 --- a/components/orgchart/types.ts +++ b/components/orgchart/types.ts @@ -4,7 +4,17 @@ export type OrgEmployee = { first_name: string; last_name: string; job_title: string; + /** + * The *acting* manager: while somebody is on a long-term absence their + * reports roll up to the next present level, so this is what the chart is + * built from. The recorded manager is kept in `formal_manager_id`. + */ manager_id: string | null; + /** Set only when it differs from `manager_id`, i.e. somebody is standing in. */ + formal_manager_id: string | null; + /** This person is on a long-term absence as of the chart's date. */ + absent: boolean; + absence_type: string | null; team_id: string | null; division_id: string; is_lead: boolean; @@ -34,6 +44,12 @@ export type ChartNode = { matched?: boolean; /** A structural role with nobody in it — drawn as an open slot. */ vacant?: boolean; + /** On a long-term absence: their reports are shown under the stand-in. */ + absent?: boolean; + /** Name of the person actually leading while `absent`, for the node label. */ + coveredBy?: string; + /** Name of the absent manager this person currently reports away from. */ + coveringFor?: string; /** Reports below this node in total, not just direct ones. */ totalReports?: number; children: ChartNode[]; diff --git a/lib/acting-manager.ts b/lib/acting-manager.ts new file mode 100644 index 0000000..3e60c01 --- /dev/null +++ b/lib/acting-manager.ts @@ -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): 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([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 { + const byId = new Map(nodes.map((n) => [n.id, n])); + return new Map(nodes.map((n) => [n.id, resolveActingManager(n, byId)])); +} diff --git a/lib/orgchart-data.ts b/lib/orgchart-data.ts index b10da7c..3436f2c 100644 --- a/lib/orgchart-data.ts +++ b/lib/orgchart-data.ts @@ -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( (min, a) => (min === null || a.valid_from < min ? a.valid_from : min), diff --git a/tests/unit/acting-manager.test.ts b/tests/unit/acting-manager.test.ts new file mode 100644 index 0000000..a0c6ea9 --- /dev/null +++ b/tests/unit/acting-manager.test.ts @@ -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"); + }); +}); diff --git a/tests/unit/orgchart-data.test.ts b/tests/unit/orgchart-data.test.ts index 540dff5..d019b3e 100644 --- a/tests/unit/orgchart-data.test.ts +++ b/tests/unit/orgchart-data.test.ts @@ -24,6 +24,7 @@ function emp(id: string, overrides: Partial = {}): EmployeeInput exit_date: null, karenz_start_date: null, karenz_return_date: null, + absence_type: null, ...overrides, }; }