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__") ?? [] };
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
62
lib/acting-manager.ts
Normal 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)]));
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
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,
|
exit_date: null,
|
||||||
karenz_start_date: null,
|
karenz_start_date: null,
|
||||||
karenz_return_date: null,
|
karenz_return_date: null,
|
||||||
|
absence_type: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user