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:
@@ -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<string>) {
|
||||
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" />
|
||||
)}
|
||||
<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">
|
||||
{e.first_name} {e.last_name}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">{e.job_title}</span>
|
||||
</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 && (
|
||||
<span className="shrink-0 text-xs text-ink-muted">
|
||||
{children.length} direkt · {totalReports} gesamt
|
||||
|
||||
@@ -39,7 +39,7 @@ const KIND_SHELL: Record<ChartNodeKind, string> = {
|
||||
// required, not just tidy, to keep that smooth at a few hundred nodes.
|
||||
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
|
||||
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<O
|
||||
{sublabel}
|
||||
</span>
|
||||
)}
|
||||
{totalReports !== undefined && totalReports > 0 && (
|
||||
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{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 ? (
|
||||
<span className="mt-0.5 truncate text-[10px] font-semibold text-warning-text">
|
||||
Abwesend{coveredBy ? ` · Vertretung: ${coveredBy}` : ""}
|
||||
</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>
|
||||
</>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user