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

View File

@@ -51,6 +51,11 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] }; return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
}, [employees]); }, [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(() => { const matchIds = useMemo(() => {
// A focus target behaves exactly like a search hit — same ancestor // A focus target behaves exactly like a search hit — same ancestor
// auto-expand, same ring, same fitView in graph mode — until the user // 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 }, avatar: { firstName: e.first_name, lastName: e.last_name },
totalReports: total, totalReports: total,
matched: matchIds?.has(e.id) ?? false, 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)), children: children.map((c) => toChartNode(c, nextAncestors)),
}; };
} }
return root.map((r) => toChartNode(r, new Set())); 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<string>) { function renderNode(e: OrgEmployee, depth: number, ancestors: Set<string>) {
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []); const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
@@ -147,12 +157,26 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
<span className="w-4 shrink-0" /> <span className="w-4 shrink-0" />
)} )}
<Avatar firstName={e.first_name} lastName={e.last_name} size="sm" /> <Avatar firstName={e.first_name} lastName={e.last_name} size="sm" />
<Link href={`/employees/${e.id}`} className="flex-1 hover:underline"> <Link href={`/employees/${e.id}`} className="min-w-0 flex-1 hover:underline">
<span className="text-sm font-semibold text-ink"> <span className="text-sm font-semibold text-ink">
{e.first_name} {e.last_name} {e.first_name} {e.last_name}
</span> </span>
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span> <span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
</Link> </Link>
{/* 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 && (
<span className="shrink-0 rounded-full bg-warning-bg px-2 py-0.5 text-[11px] font-semibold text-warning-text">
Abwesend{nameOf(e.manager_id) ? ` · Vertretung: ${nameOf(e.manager_id)}` : ""}
</span>
)}
{e.formal_manager_id && (
<span className="shrink-0 rounded-full bg-info-bg px-2 py-0.5 text-[11px] font-semibold text-info-text">
Vertretung für {nameOf(e.formal_manager_id)}
</span>
)}
{hasChildren && ( {hasChildren && (
<span className="shrink-0 text-xs text-ink-muted"> <span className="shrink-0 text-xs text-ink-muted">
{children.length} direkt · {totalReports} gesamt {children.length} direkt · {totalReports} gesamt

View File

@@ -39,7 +39,7 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
// required, not just tidy, to keep that smooth at a few hundred nodes. // required, not just tidy, to keep that smooth at a few hundred nodes.
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) { export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
const { chartNode, expanded, hasChildren, childCount, onToggle } = data; 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 isMatch = chartNode.matched ?? false;
const { width, height } = NODE_DIMENSIONS[kind]; const { width, height } = NODE_DIMENSIONS[kind];
@@ -73,10 +73,22 @@ export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<O
{sublabel} {sublabel}
</span> </span>
)} )}
{totalReports !== undefined && totalReports > 0 && ( {/* The stand-in replaces the report count rather than crowding in
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted"> next to it: on a card this size the arrangement is the more
{childCount} direkt · {totalReports} gesamt important of the two, and it only appears while one is in force. */}
{absent ? (
<span className="mt-0.5 truncate text-[10px] font-semibold text-warning-text">
Abwesend{coveredBy ? ` · Vertretung: ${coveredBy}` : ""}
</span> </span>
) : coveringFor ? (
<span className="mt-0.5 truncate text-[10px] font-semibold text-info-text">Vertretung für {coveringFor}</span>
) : (
totalReports !== undefined &&
totalReports > 0 && (
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
{childCount} direkt · {totalReports} gesamt
</span>
)
)} )}
</span> </span>
</> </>

View File

@@ -4,7 +4,17 @@ export type OrgEmployee = {
first_name: string; first_name: string;
last_name: string; last_name: string;
job_title: 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; 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; team_id: string | null;
division_id: string; division_id: string;
is_lead: boolean; is_lead: boolean;
@@ -34,6 +44,12 @@ export type ChartNode = {
matched?: boolean; matched?: boolean;
/** A structural role with nobody in it — drawn as an open slot. */ /** A structural role with nobody in it — drawn as an open slot. */
vacant?: boolean; 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. */ /** Reports below this node in total, not just direct ones. */
totalReports?: number; totalReports?: number;
children: ChartNode[]; children: ChartNode[];

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 { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee } from "@/components/orgchart/types"; import type { OrgEmployee } from "@/components/orgchart/types";
import { resolveActingManagers } from "./acting-manager";
import { todayIso } from "./format"; import { todayIso } from "./format";
import { deriveStatusAsOf } from "./reports"; import { deriveStatusAsOf } from "./reports";
import { fetchAllRows } from "./supabase/query"; 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. // placement timeline is captured by a trigger rather than per-RPC.
const ORG_COLUMNS = 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. */ /** Change types that move someone in the org; the rest only affect status or contract. */
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const; const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
@@ -44,6 +45,7 @@ type EmployeeRow = {
exit_date: string | null; exit_date: string | null;
karenz_start_date: string | null; karenz_start_date: string | null;
karenz_return_date: string | null; karenz_return_date: string | null;
absence_type: string | null;
}; };
type AssignmentRow = { type AssignmentRow = {
@@ -189,19 +191,36 @@ export function resolveOrgSnapshot({
// set — without re-rooting, their whole reporting line would silently // set — without re-rooting, their whole reporting line would silently
// vanish from the chart rather than showing up one level higher. // vanish from the chart rather than showing up one level higher.
const presentIds = new Set(resolved.map((r) => r.employee.id)); 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) => ({ // While somebody is on a long-term absence their reports roll up to the
id: r.employee.id, // next present level. Derived here rather than written to the database:
personnel_number: r.employee.personnel_number, // the absent person stays formally in charge, and the stand-in is only a
first_name: r.employee.first_name, // stand-in — which is why both ids travel to the UI.
last_name: r.employee.last_name, const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id));
job_title: r.placement.job_title, const acting = resolveActingManagers(
manager_id: r.managerId && presentIds.has(r.managerId) ? r.managerId : null, resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) }))
team_id: r.placement.team_id, );
division_id: r.placement.division_id,
is_lead: r.placement.is_lead, const employees: OrgEmployee[] = resolved.map((r) => {
org_level: r.placement.org_level, 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>( const historyStartsAt = assignments.reduce<string | null>(
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min), (min, a) => (min === null || a.valid_from < min ? a.valid_from : min),

View 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");
});
});

View File

@@ -24,6 +24,7 @@ function emp(id: string, overrides: Partial<EmployeeInput> = {}): EmployeeInput
exit_date: null, exit_date: null,
karenz_start_date: null, karenz_start_date: null,
karenz_return_date: null, karenz_return_date: null,
absence_type: null,
...overrides, ...overrides,
}; };
} }