@org-design-studio/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist-core/core.d.mts +642 -0
- package/dist-core/core.d.ts +642 -0
- package/dist-core/core.js +2323 -0
- package/dist-core/core.js.map +1 -0
- package/dist-core/core.mjs +2280 -0
- package/dist-core/core.mjs.map +1 -0
- package/docs/API.md +202 -0
- package/package.json +36 -0
|
@@ -0,0 +1,2280 @@
|
|
|
1
|
+
import { hierarchy, tree } from 'd3-hierarchy';
|
|
2
|
+
|
|
3
|
+
// src/lib/types.ts
|
|
4
|
+
function positionStatus(e) {
|
|
5
|
+
return e.position_status === "vacant" || e.position_status === "tbh" ? e.position_status : "filled";
|
|
6
|
+
}
|
|
7
|
+
var MONTH_NAMES = [
|
|
8
|
+
"January",
|
|
9
|
+
"February",
|
|
10
|
+
"March",
|
|
11
|
+
"April",
|
|
12
|
+
"May",
|
|
13
|
+
"June",
|
|
14
|
+
"July",
|
|
15
|
+
"August",
|
|
16
|
+
"September",
|
|
17
|
+
"October",
|
|
18
|
+
"November",
|
|
19
|
+
"December"
|
|
20
|
+
];
|
|
21
|
+
var DEFAULT_TITLE_NOISE_WORDS = [
|
|
22
|
+
"senior",
|
|
23
|
+
"sr",
|
|
24
|
+
"jr",
|
|
25
|
+
"junior",
|
|
26
|
+
"principal",
|
|
27
|
+
"i",
|
|
28
|
+
"ii",
|
|
29
|
+
"iii",
|
|
30
|
+
"iv",
|
|
31
|
+
"v",
|
|
32
|
+
"1",
|
|
33
|
+
"2",
|
|
34
|
+
"3"
|
|
35
|
+
];
|
|
36
|
+
var DEFAULT_ASSUMPTIONS = {
|
|
37
|
+
narrowSpanThreshold: 4,
|
|
38
|
+
wideSpanThreshold: 12,
|
|
39
|
+
targetSpansByLevel: [
|
|
40
|
+
{ level: 1, label: "Executive (L1\u2013L2)", min: 5, max: 8 },
|
|
41
|
+
{ level: 3, label: "Middle management (L3\u2013L4)", min: 6, max: 10 },
|
|
42
|
+
{ level: 5, label: "Frontline management (L5+)", min: 8, max: 15 }
|
|
43
|
+
],
|
|
44
|
+
targetMaxLayers: 7,
|
|
45
|
+
delayeringSavingPct: 50,
|
|
46
|
+
duplicateRoleSavingPct: 30,
|
|
47
|
+
spanOptPremiumPct: 20,
|
|
48
|
+
locationCostMultipliers: {
|
|
49
|
+
"New York": 1,
|
|
50
|
+
London: 0.95,
|
|
51
|
+
Singapore: 0.85,
|
|
52
|
+
Dublin: 0.8,
|
|
53
|
+
Warsaw: 0.45,
|
|
54
|
+
Bangalore: 0.35,
|
|
55
|
+
Manila: 0.3
|
|
56
|
+
},
|
|
57
|
+
currency: "USD",
|
|
58
|
+
currencySymbol: "$",
|
|
59
|
+
scoringWeights: {
|
|
60
|
+
financialImpact: 25,
|
|
61
|
+
spanImprovement: 15,
|
|
62
|
+
layerReduction: 15,
|
|
63
|
+
complexityReduction: 15,
|
|
64
|
+
changeRisk: 20,
|
|
65
|
+
implementationEase: 10
|
|
66
|
+
},
|
|
67
|
+
benchmarkLayersForSize: [
|
|
68
|
+
{ maxHeadcount: 100, layers: 4 },
|
|
69
|
+
{ maxHeadcount: 500, layers: 6 },
|
|
70
|
+
{ maxHeadcount: 2e3, layers: 7 },
|
|
71
|
+
{ maxHeadcount: 1e4, layers: 8 }
|
|
72
|
+
],
|
|
73
|
+
transitionAssumptions: {
|
|
74
|
+
severanceMonthsByLevel: [
|
|
75
|
+
{ maxLevel: 2, months: 12 },
|
|
76
|
+
{ maxLevel: 4, months: 9 },
|
|
77
|
+
{ maxLevel: 99, months: 6 }
|
|
78
|
+
],
|
|
79
|
+
backfillPct: 20,
|
|
80
|
+
oneTimeChangeCostPerMove: 2e3,
|
|
81
|
+
effectiveMonth: 4,
|
|
82
|
+
countrySeveranceMultipliers: {}
|
|
83
|
+
},
|
|
84
|
+
aiAugmentationByRoleFamily: {},
|
|
85
|
+
titleNoiseWords: [...DEFAULT_TITLE_NOISE_WORDS],
|
|
86
|
+
highCostThreshold: 4e5
|
|
87
|
+
};
|
|
88
|
+
var REQUIRED_FIELDS = [
|
|
89
|
+
"employee_id",
|
|
90
|
+
"employee_name",
|
|
91
|
+
"role_title",
|
|
92
|
+
"manager_id",
|
|
93
|
+
"department",
|
|
94
|
+
"function",
|
|
95
|
+
"business_unit",
|
|
96
|
+
"location",
|
|
97
|
+
"country",
|
|
98
|
+
"grade",
|
|
99
|
+
"level",
|
|
100
|
+
"employment_type",
|
|
101
|
+
"fte",
|
|
102
|
+
"salary",
|
|
103
|
+
"bonus",
|
|
104
|
+
"total_cost",
|
|
105
|
+
"status"
|
|
106
|
+
];
|
|
107
|
+
var OPTIONAL_FIELDS = [
|
|
108
|
+
"manager_name",
|
|
109
|
+
"position_status",
|
|
110
|
+
"tenure",
|
|
111
|
+
"gender",
|
|
112
|
+
"age_band",
|
|
113
|
+
"performance_rating",
|
|
114
|
+
"potential_rating",
|
|
115
|
+
"critical_role",
|
|
116
|
+
"role_family",
|
|
117
|
+
"role_type",
|
|
118
|
+
"cost_center",
|
|
119
|
+
"legal_entity",
|
|
120
|
+
"contract_type",
|
|
121
|
+
"remote_status",
|
|
122
|
+
"skills",
|
|
123
|
+
"succession_risk",
|
|
124
|
+
"attrition_risk",
|
|
125
|
+
"change_impact",
|
|
126
|
+
"comments"
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
// src/lib/hierarchy.ts
|
|
130
|
+
var ACTIVE = (e) => (e.status || "Active").toLowerCase() !== "inactive";
|
|
131
|
+
function buildOrgTree(employees) {
|
|
132
|
+
const active = employees.filter(ACTIVE);
|
|
133
|
+
const byId = /* @__PURE__ */ new Map();
|
|
134
|
+
for (const e of active) byId.set(e.employee_id, e);
|
|
135
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
136
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
137
|
+
const roots = [];
|
|
138
|
+
const orphans = [];
|
|
139
|
+
for (const e of active) {
|
|
140
|
+
nodes.set(e.employee_id, {
|
|
141
|
+
employee: e,
|
|
142
|
+
layer: 0,
|
|
143
|
+
directReports: [],
|
|
144
|
+
span: 0,
|
|
145
|
+
filledSpan: 0,
|
|
146
|
+
downstreamHeadcount: 0,
|
|
147
|
+
downstreamCost: 0,
|
|
148
|
+
isManager: false,
|
|
149
|
+
isManagerOfOne: false,
|
|
150
|
+
managesOnlyManagers: false,
|
|
151
|
+
managerChain: []
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
for (const e of active) {
|
|
155
|
+
const mid = e.manager_id;
|
|
156
|
+
if (!mid || mid === e.employee_id) {
|
|
157
|
+
roots.push(e.employee_id);
|
|
158
|
+
} else if (!byId.has(mid)) {
|
|
159
|
+
orphans.push(e.employee_id);
|
|
160
|
+
} else {
|
|
161
|
+
(childrenOf.get(mid) ?? childrenOf.set(mid, []).get(mid)).push(e.employee_id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const circular = /* @__PURE__ */ new Set();
|
|
165
|
+
const safe = new Set(roots);
|
|
166
|
+
for (const e of active) {
|
|
167
|
+
if (safe.has(e.employee_id) || circular.has(e.employee_id)) continue;
|
|
168
|
+
const path = [];
|
|
169
|
+
const onPath = /* @__PURE__ */ new Set();
|
|
170
|
+
let cur = e.employee_id;
|
|
171
|
+
while (cur) {
|
|
172
|
+
if (safe.has(cur)) break;
|
|
173
|
+
if (circular.has(cur)) {
|
|
174
|
+
path.forEach((p) => circular.add(p));
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (onPath.has(cur)) {
|
|
178
|
+
const loopStart = path.indexOf(cur);
|
|
179
|
+
path.slice(loopStart).forEach((p) => circular.add(p));
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
onPath.add(cur);
|
|
183
|
+
path.push(cur);
|
|
184
|
+
const mgr = byId.get(cur)?.manager_id ?? null;
|
|
185
|
+
if (!mgr || !byId.has(mgr) || mgr === cur) break;
|
|
186
|
+
cur = mgr;
|
|
187
|
+
}
|
|
188
|
+
if (!path.some((p) => circular.has(p))) path.forEach((p) => safe.add(p));
|
|
189
|
+
}
|
|
190
|
+
const startIds = [...roots, ...orphans];
|
|
191
|
+
let maxLayer = 0;
|
|
192
|
+
const queue = startIds.map((id) => ({
|
|
193
|
+
id,
|
|
194
|
+
layer: 1,
|
|
195
|
+
chain: []
|
|
196
|
+
}));
|
|
197
|
+
const visited = /* @__PURE__ */ new Set();
|
|
198
|
+
let head = 0;
|
|
199
|
+
while (head < queue.length) {
|
|
200
|
+
const { id, layer, chain } = queue[head++];
|
|
201
|
+
if (visited.has(id) || circular.has(id)) continue;
|
|
202
|
+
visited.add(id);
|
|
203
|
+
const node = nodes.get(id);
|
|
204
|
+
node.layer = layer;
|
|
205
|
+
node.managerChain = chain;
|
|
206
|
+
maxLayer = Math.max(maxLayer, layer);
|
|
207
|
+
const kids = (childrenOf.get(id) ?? []).filter((k) => !circular.has(k));
|
|
208
|
+
node.directReports = kids;
|
|
209
|
+
node.span = kids.length;
|
|
210
|
+
node.filledSpan = kids.filter((k) => positionStatus(byId.get(k)) === "filled").length;
|
|
211
|
+
node.isManager = kids.length > 0;
|
|
212
|
+
for (const kid of kids) queue.push({ id: kid, layer: layer + 1, chain: [...chain, id] });
|
|
213
|
+
}
|
|
214
|
+
const ordered = [...visited].sort((a, b) => nodes.get(b).layer - nodes.get(a).layer);
|
|
215
|
+
for (const id of ordered) {
|
|
216
|
+
const node = nodes.get(id);
|
|
217
|
+
let hc = 0;
|
|
218
|
+
let cost = 0;
|
|
219
|
+
for (const kid of node.directReports) {
|
|
220
|
+
const kn = nodes.get(kid);
|
|
221
|
+
hc += 1 + kn.downstreamHeadcount;
|
|
222
|
+
cost += (kn.employee.total_cost || 0) + kn.downstreamCost;
|
|
223
|
+
}
|
|
224
|
+
node.downstreamHeadcount = hc;
|
|
225
|
+
node.downstreamCost = cost;
|
|
226
|
+
node.isManagerOfOne = node.span === 1;
|
|
227
|
+
node.managesOnlyManagers = node.span > 0 && node.directReports.every((kid) => (nodes.get(kid)?.span ?? 0) > 0);
|
|
228
|
+
}
|
|
229
|
+
return { nodes, roots, maxLayer, orphans, circular: [...circular] };
|
|
230
|
+
}
|
|
231
|
+
function subtreeIds(tree2, id) {
|
|
232
|
+
const out = [];
|
|
233
|
+
const stack = [id];
|
|
234
|
+
while (stack.length > 0) {
|
|
235
|
+
const cur = stack.pop();
|
|
236
|
+
const node = tree2.nodes.get(cur);
|
|
237
|
+
if (!node) continue;
|
|
238
|
+
out.push(cur);
|
|
239
|
+
stack.push(...node.directReports);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
function deepestChains(tree2, topN = 5) {
|
|
244
|
+
const chains = [];
|
|
245
|
+
for (const [id, node] of tree2.nodes) {
|
|
246
|
+
if (node.directReports.length === 0 && node.layer > 0) {
|
|
247
|
+
chains.push({ ids: [...node.managerChain, id], depth: node.layer });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
chains.sort((a, b) => b.depth - a.depth);
|
|
251
|
+
return chains.slice(0, topN);
|
|
252
|
+
}
|
|
253
|
+
function wouldCreateCycle(tree2, employeeId, newManagerId) {
|
|
254
|
+
if (employeeId === newManagerId) return true;
|
|
255
|
+
return subtreeIds(tree2, employeeId).includes(newManagerId);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/lib/utils.ts
|
|
259
|
+
function fmtMoney(value, symbol = "$") {
|
|
260
|
+
const abs = Math.abs(value);
|
|
261
|
+
const sign = value < 0 ? "-" : "";
|
|
262
|
+
if (abs >= 1e9) return `${sign}${symbol}${(abs / 1e9).toFixed(2)}B`;
|
|
263
|
+
if (abs >= 1e6) return `${sign}${symbol}${(abs / 1e6).toFixed(1)}M`;
|
|
264
|
+
if (abs >= 1e4) return `${sign}${symbol}${Math.round(abs / 1e3)}K`;
|
|
265
|
+
if (abs >= 1e3) return `${sign}${symbol}${(abs / 1e3).toFixed(1)}K`;
|
|
266
|
+
return `${sign}${symbol}${Math.round(abs)}`;
|
|
267
|
+
}
|
|
268
|
+
function fmtPct(value, decimals = 0) {
|
|
269
|
+
return `${(value * 100).toFixed(decimals)}%`;
|
|
270
|
+
}
|
|
271
|
+
function median(values) {
|
|
272
|
+
if (values.length === 0) return 0;
|
|
273
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
274
|
+
const mid = Math.floor(sorted.length / 2);
|
|
275
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
276
|
+
}
|
|
277
|
+
function average(values) {
|
|
278
|
+
if (values.length === 0) return 0;
|
|
279
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
280
|
+
}
|
|
281
|
+
function sum(values) {
|
|
282
|
+
return values.reduce((a, b) => a + b, 0);
|
|
283
|
+
}
|
|
284
|
+
function groupBy(items, key) {
|
|
285
|
+
const out = {};
|
|
286
|
+
for (const item of items) {
|
|
287
|
+
const k = key(item) || "(blank)";
|
|
288
|
+
(out[k] ?? (out[k] = [])).push(item);
|
|
289
|
+
}
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
function sortedEntries(rec) {
|
|
293
|
+
return Object.entries(rec).sort((a, b) => b[1] - a[1]);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/lib/analytics.ts
|
|
297
|
+
var STRUCTURAL_WORD = /(region|zone|area|tier|band|grade|level|market)$/;
|
|
298
|
+
function normalizeTitle(title, noiseWords) {
|
|
299
|
+
const noise = new Set((noiseWords ?? DEFAULT_TITLE_NOISE_WORDS).map((w) => w.toLowerCase().trim()));
|
|
300
|
+
const base = title.toLowerCase().replace(/[^a-z0-9 ]/g, " ").replace(/\s+/g, " ").trim();
|
|
301
|
+
const kept = [];
|
|
302
|
+
for (const tok of base.split(" ")) {
|
|
303
|
+
if (!tok) continue;
|
|
304
|
+
const isNoise = noise.has(tok);
|
|
305
|
+
const prev = kept[kept.length - 1] ?? "";
|
|
306
|
+
if (isNoise && !STRUCTURAL_WORD.test(prev)) continue;
|
|
307
|
+
kept.push(tok);
|
|
308
|
+
}
|
|
309
|
+
return kept.join(" ").replace(/\s+/g, " ").trim();
|
|
310
|
+
}
|
|
311
|
+
function spanBandForLevel(level, assumptions) {
|
|
312
|
+
const bands = assumptions.targetSpansByLevel;
|
|
313
|
+
if (!bands || bands.length === 0) return void 0;
|
|
314
|
+
const sorted = [...bands].sort((a, b) => a.level - b.level);
|
|
315
|
+
let chosen = sorted[0];
|
|
316
|
+
for (const b of sorted) {
|
|
317
|
+
if (b.level <= level) chosen = b;
|
|
318
|
+
else break;
|
|
319
|
+
}
|
|
320
|
+
return chosen;
|
|
321
|
+
}
|
|
322
|
+
function classifySpan(span, level, assumptions) {
|
|
323
|
+
const band = level > 0 ? spanBandForLevel(level, assumptions) : void 0;
|
|
324
|
+
if (!band) {
|
|
325
|
+
return {
|
|
326
|
+
narrow: span < assumptions.narrowSpanThreshold,
|
|
327
|
+
wide: span > assumptions.wideSpanThreshold
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const narrow = span < band.min && span < assumptions.narrowSpanThreshold;
|
|
331
|
+
const wide = span > band.max;
|
|
332
|
+
return { narrow, wide };
|
|
333
|
+
}
|
|
334
|
+
var AI_HIGH_KEYWORDS = ["processing", "support", "coordinator", "analyst", "specialist"];
|
|
335
|
+
var AI_MEDIUM_KEYWORDS = ["engineer", "accountant", "recruiter", "marketing"];
|
|
336
|
+
var AI_LOW_KEYWORDS = ["counsel", "director", "officer", "manager", "executive"];
|
|
337
|
+
function classifyAiAugmentation(employee, assumptions) {
|
|
338
|
+
const fam = employee.role_family?.trim().toLowerCase();
|
|
339
|
+
if (fam && assumptions.aiAugmentationByRoleFamily[fam]) {
|
|
340
|
+
return assumptions.aiAugmentationByRoleFamily[fam];
|
|
341
|
+
}
|
|
342
|
+
if (employee.role_family && assumptions.aiAugmentationByRoleFamily[employee.role_family]) {
|
|
343
|
+
return assumptions.aiAugmentationByRoleFamily[employee.role_family];
|
|
344
|
+
}
|
|
345
|
+
const title = (employee.role_title || "").toLowerCase();
|
|
346
|
+
if (AI_LOW_KEYWORDS.some((k) => title.includes(k))) return "low";
|
|
347
|
+
if (AI_MEDIUM_KEYWORDS.some((k) => title.includes(k))) return "medium";
|
|
348
|
+
if (AI_HIGH_KEYWORDS.some((k) => title.includes(k))) return "high";
|
|
349
|
+
return "low";
|
|
350
|
+
}
|
|
351
|
+
function findDuplicateRoles(employees, assumptions) {
|
|
352
|
+
const seniors = employees.filter((e) => e.level >= 1 && e.level <= 4);
|
|
353
|
+
const unleveled = employees.filter((e) => !(e.level >= 1));
|
|
354
|
+
if (unleveled.length > 0) {
|
|
355
|
+
const costs = employees.map((e) => e.total_cost || 0).sort((a, b) => a - b);
|
|
356
|
+
const p75 = costs[Math.min(costs.length - 1, Math.floor(costs.length * 0.75))];
|
|
357
|
+
seniors.push(...unleveled.filter((e) => (e.total_cost || 0) >= p75));
|
|
358
|
+
}
|
|
359
|
+
const noiseWords = assumptions?.titleNoiseWords;
|
|
360
|
+
const byTitle = groupBy(seniors, (e) => normalizeTitle(e.role_title, noiseWords));
|
|
361
|
+
const groups = [];
|
|
362
|
+
for (const [normalizedTitle, emps] of Object.entries(byTitle)) {
|
|
363
|
+
if (!normalizedTitle || emps.length < 2) continue;
|
|
364
|
+
const units = [...new Set(emps.map((e) => e.business_unit))];
|
|
365
|
+
if (units.length < 2 && emps.length < 3) continue;
|
|
366
|
+
groups.push({
|
|
367
|
+
normalizedTitle,
|
|
368
|
+
employees: emps.map((e) => ({
|
|
369
|
+
id: e.employee_id,
|
|
370
|
+
name: e.employee_name,
|
|
371
|
+
title: e.role_title,
|
|
372
|
+
unit: e.business_unit,
|
|
373
|
+
cost: e.total_cost
|
|
374
|
+
})),
|
|
375
|
+
units,
|
|
376
|
+
totalCost: sum(emps.map((e) => e.total_cost)),
|
|
377
|
+
normalizedFrom: [...new Set(emps.map((e) => e.role_title))]
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return groups.sort((a, b) => b.totalCost - a.totalCost);
|
|
381
|
+
}
|
|
382
|
+
var BUCKET_DEFS = [
|
|
383
|
+
{ label: "1\u20132", min: 1, max: 2 },
|
|
384
|
+
{ label: "3\u20135", min: 3, max: 5 },
|
|
385
|
+
{ label: "6\u20138", min: 6, max: 8 },
|
|
386
|
+
{ label: "9\u201312", min: 9, max: 12 },
|
|
387
|
+
{ label: "13+", min: 13, max: Infinity }
|
|
388
|
+
];
|
|
389
|
+
function computeMetrics(employees, assumptions) {
|
|
390
|
+
const tree2 = buildOrgTree(employees);
|
|
391
|
+
return computeMetricsFromTree(employees, tree2, assumptions);
|
|
392
|
+
}
|
|
393
|
+
function computeMetricsFromTree(employees, tree2, assumptions) {
|
|
394
|
+
const active = employees.filter((e) => (e.status || "Active").toLowerCase() !== "inactive");
|
|
395
|
+
const nodes = [...tree2.nodes.values()].filter((n) => n.layer > 0);
|
|
396
|
+
const managers = nodes.filter((n) => n.isManager);
|
|
397
|
+
const ics = nodes.filter((n) => !n.isManager);
|
|
398
|
+
const headcount = nodes.length;
|
|
399
|
+
const totalFte = sum(nodes.map((n) => n.employee.fte || 1));
|
|
400
|
+
const totalCost = sum(nodes.map((n) => n.employee.total_cost || 0));
|
|
401
|
+
const managementCost = sum(managers.map((n) => n.employee.total_cost || 0));
|
|
402
|
+
const icCost = totalCost - managementCost;
|
|
403
|
+
const filledNodes = nodes.filter((n) => positionStatus(n.employee) === "filled");
|
|
404
|
+
const filledHeadcount = filledNodes.length;
|
|
405
|
+
const vacantSeats = nodes.filter((n) => positionStatus(n.employee) === "vacant").length;
|
|
406
|
+
const tbhSeats = nodes.filter((n) => positionStatus(n.employee) === "tbh").length;
|
|
407
|
+
const unfilledSeats = vacantSeats + tbhSeats;
|
|
408
|
+
const runRateCost = sum(filledNodes.map((n) => n.employee.total_cost || 0));
|
|
409
|
+
const unfilledSeatCost = totalCost - runRateCost;
|
|
410
|
+
const unfilledByFunction = {};
|
|
411
|
+
const unfilledByLayer = {};
|
|
412
|
+
for (const n of nodes) {
|
|
413
|
+
if (positionStatus(n.employee) === "filled") continue;
|
|
414
|
+
const f = n.employee.function || "(blank)";
|
|
415
|
+
unfilledByFunction[f] = (unfilledByFunction[f] || 0) + 1;
|
|
416
|
+
unfilledByLayer[n.layer] = (unfilledByLayer[n.layer] || 0) + 1;
|
|
417
|
+
}
|
|
418
|
+
const spans = managers.map((m) => m.span);
|
|
419
|
+
const filledSpans = managers.map((m) => m.filledSpan);
|
|
420
|
+
const narrow = managers.filter(
|
|
421
|
+
(m) => classifySpan(m.span, m.employee.level || 0, assumptions).narrow
|
|
422
|
+
);
|
|
423
|
+
const wide = managers.filter(
|
|
424
|
+
(m) => classifySpan(m.span, m.employee.level || 0, assumptions).wide
|
|
425
|
+
);
|
|
426
|
+
const spanBuckets = BUCKET_DEFS.map((b) => {
|
|
427
|
+
const inBucket = managers.filter((m) => m.span >= b.min && m.span <= b.max);
|
|
428
|
+
return { ...b, count: inBucket.length, managerIds: inBucket.map((m) => m.employee.employee_id) };
|
|
429
|
+
});
|
|
430
|
+
const layerStats = [];
|
|
431
|
+
for (let layer = 1; layer <= tree2.maxLayer; layer++) {
|
|
432
|
+
const atLayer = nodes.filter((n) => n.layer === layer);
|
|
433
|
+
if (atLayer.length === 0) continue;
|
|
434
|
+
const layerManagers = atLayer.filter((n) => n.isManager);
|
|
435
|
+
const layerSpans = layerManagers.map((m) => m.span);
|
|
436
|
+
layerStats.push({
|
|
437
|
+
layer,
|
|
438
|
+
headcount: atLayer.length,
|
|
439
|
+
managers: layerManagers.length,
|
|
440
|
+
ics: atLayer.length - layerManagers.length,
|
|
441
|
+
cost: sum(atLayer.map((n) => n.employee.total_cost || 0)),
|
|
442
|
+
managementCost: sum(layerManagers.map((n) => n.employee.total_cost || 0)),
|
|
443
|
+
avgSpan: average(layerSpans),
|
|
444
|
+
medianSpan: median(layerSpans),
|
|
445
|
+
unfilledSeats: atLayer.filter((n) => positionStatus(n.employee) !== "filled").length
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const dim = (key) => {
|
|
449
|
+
const out = {};
|
|
450
|
+
for (const n of nodes) {
|
|
451
|
+
const k = key(n.employee) || "(blank)";
|
|
452
|
+
out[k] = (out[k] || 0) + (n.employee.total_cost || 0);
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
};
|
|
456
|
+
const dimCount = (key) => {
|
|
457
|
+
const out = {};
|
|
458
|
+
for (const n of nodes) {
|
|
459
|
+
const k = key(n.employee) || "(blank)";
|
|
460
|
+
out[k] = (out[k] || 0) + 1;
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
};
|
|
464
|
+
const dimMaxLayer = (key) => {
|
|
465
|
+
const out = {};
|
|
466
|
+
const minL = {};
|
|
467
|
+
for (const n of nodes) {
|
|
468
|
+
const k = key(n.employee) || "(blank)";
|
|
469
|
+
out[k] = Math.max(out[k] || 0, n.layer);
|
|
470
|
+
minL[k] = Math.min(minL[k] ?? Infinity, n.layer);
|
|
471
|
+
}
|
|
472
|
+
for (const k of Object.keys(out)) out[k] = out[k] - (minL[k] === Infinity ? 1 : minL[k]) + 1;
|
|
473
|
+
return out;
|
|
474
|
+
};
|
|
475
|
+
const dimAvgSpan = (key) => {
|
|
476
|
+
const out = {};
|
|
477
|
+
for (const m of managers) {
|
|
478
|
+
const k = key(m.employee) || "(blank)";
|
|
479
|
+
(out[k] ?? (out[k] = [])).push(m.span);
|
|
480
|
+
}
|
|
481
|
+
return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, average(v)]));
|
|
482
|
+
};
|
|
483
|
+
const costByLayer = {};
|
|
484
|
+
const managementCostByLayer = {};
|
|
485
|
+
for (const ls of layerStats) {
|
|
486
|
+
costByLayer[ls.layer] = ls.cost;
|
|
487
|
+
managementCostByLayer[ls.layer] = ls.managementCost;
|
|
488
|
+
}
|
|
489
|
+
const layerTaxPerIC = ics.length ? managementCost / ics.length : 0;
|
|
490
|
+
const managerOfOneCount = managers.filter((m) => m.isManagerOfOne).length;
|
|
491
|
+
const managerOfManagersCount = managers.filter((m) => m.managesOnlyManagers).length;
|
|
492
|
+
const pureSupervisoryLayers = [];
|
|
493
|
+
for (let layer = 1; layer <= tree2.maxLayer; layer++) {
|
|
494
|
+
const atLayer = nodes.filter((n) => n.layer === layer);
|
|
495
|
+
if (atLayer.length === 0) continue;
|
|
496
|
+
if (atLayer.every((n) => n.isManager && n.managesOnlyManagers)) {
|
|
497
|
+
pureSupervisoryLayers.push(layer);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const aiBands = {
|
|
501
|
+
high: { headcount: 0, cost: 0 },
|
|
502
|
+
medium: { headcount: 0, cost: 0 },
|
|
503
|
+
low: { headcount: 0, cost: 0 }
|
|
504
|
+
};
|
|
505
|
+
for (const n of nodes) {
|
|
506
|
+
const band = classifyAiAugmentation(n.employee, assumptions);
|
|
507
|
+
aiBands[band].headcount += 1;
|
|
508
|
+
aiBands[band].cost += n.employee.total_cost || 0;
|
|
509
|
+
}
|
|
510
|
+
const aiExposure = ["high", "medium", "low"].map((band) => ({
|
|
511
|
+
band,
|
|
512
|
+
headcount: aiBands[band].headcount,
|
|
513
|
+
cost: aiBands[band].cost
|
|
514
|
+
}));
|
|
515
|
+
const duplicateRoles = findDuplicateRoles(active, assumptions);
|
|
516
|
+
const duplicateRoleCost = sum(
|
|
517
|
+
duplicateRoles.map((g) => g.totalCost - Math.max(...g.employees.map((e) => e.cost)))
|
|
518
|
+
);
|
|
519
|
+
const narrowSpanCost = sum(narrow.map((m) => m.employee.total_cost || 0));
|
|
520
|
+
const tinySpanMgrs = narrow.filter((m) => m.span <= 2);
|
|
521
|
+
const spanOptPremium = (assumptions.spanOptPremiumPct ?? 20) / 100;
|
|
522
|
+
const spanOptimizationOpportunity = sum(
|
|
523
|
+
tinySpanMgrs.map((m) => (m.employee.total_cost || 0) * spanOptPremium)
|
|
524
|
+
);
|
|
525
|
+
const delayerMgrs = narrow.filter((m) => m.span >= 3);
|
|
526
|
+
const delayerNarrowCost = sum(delayerMgrs.map((m) => m.employee.total_cost || 0));
|
|
527
|
+
const delayeringOpportunity = delayerNarrowCost * assumptions.delayeringSavingPct / 100;
|
|
528
|
+
const duplicationOpportunity = duplicateRoleCost * assumptions.duplicateRoleSavingPct / 100;
|
|
529
|
+
const orgHealth = computeOrgHealth(
|
|
530
|
+
{
|
|
531
|
+
maxLayers: tree2.maxLayer,
|
|
532
|
+
headcount,
|
|
533
|
+
narrowPct: managers.length ? narrow.length / managers.length : 0,
|
|
534
|
+
widePct: managers.length ? wide.length / managers.length : 0,
|
|
535
|
+
managerRatio: headcount ? managers.length / headcount : 0,
|
|
536
|
+
mgmtCostRatio: totalCost ? managementCost / totalCost : 0,
|
|
537
|
+
duplicateCost: duplicateRoleCost,
|
|
538
|
+
totalCost
|
|
539
|
+
},
|
|
540
|
+
assumptions
|
|
541
|
+
);
|
|
542
|
+
return {
|
|
543
|
+
headcount,
|
|
544
|
+
totalFte,
|
|
545
|
+
totalCost,
|
|
546
|
+
filledHeadcount,
|
|
547
|
+
vacantSeats,
|
|
548
|
+
tbhSeats,
|
|
549
|
+
unfilledSeats,
|
|
550
|
+
vacancyRate: headcount ? unfilledSeats / headcount : 0,
|
|
551
|
+
runRateCost,
|
|
552
|
+
unfilledSeatCost,
|
|
553
|
+
avgFilledSpan: average(filledSpans),
|
|
554
|
+
medianFilledSpan: median(filledSpans),
|
|
555
|
+
unfilledByFunction,
|
|
556
|
+
unfilledByLayer,
|
|
557
|
+
managerCount: managers.length,
|
|
558
|
+
icCount: ics.length,
|
|
559
|
+
managerRatio: headcount ? managers.length / headcount : 0,
|
|
560
|
+
managementCost,
|
|
561
|
+
icCost,
|
|
562
|
+
managementCostRatio: totalCost ? managementCost / totalCost : 0,
|
|
563
|
+
maxLayers: tree2.maxLayer,
|
|
564
|
+
avgSpan: average(spans),
|
|
565
|
+
medianSpan: median(spans),
|
|
566
|
+
narrowSpanManagers: narrow.map((m) => m.employee.employee_id),
|
|
567
|
+
wideSpanManagers: wide.map((m) => m.employee.employee_id),
|
|
568
|
+
narrowSpanPct: managers.length ? narrow.length / managers.length : 0,
|
|
569
|
+
wideSpanPct: managers.length ? wide.length / managers.length : 0,
|
|
570
|
+
narrowSpanCost,
|
|
571
|
+
spanBuckets,
|
|
572
|
+
layerStats,
|
|
573
|
+
costByFunction: dim((e) => e.function),
|
|
574
|
+
headcountByFunction: dimCount((e) => e.function),
|
|
575
|
+
layersByFunction: dimMaxLayer((e) => e.function),
|
|
576
|
+
avgSpanByFunction: dimAvgSpan((e) => e.function),
|
|
577
|
+
costByBU: dim((e) => e.business_unit),
|
|
578
|
+
headcountByBU: dimCount((e) => e.business_unit),
|
|
579
|
+
layersByBU: dimMaxLayer((e) => e.business_unit),
|
|
580
|
+
costByLocation: dim((e) => e.location),
|
|
581
|
+
headcountByLocation: dimCount((e) => e.location),
|
|
582
|
+
costByGrade: dim((e) => e.grade),
|
|
583
|
+
headcountByGrade: dimCount((e) => e.grade),
|
|
584
|
+
costByLayer,
|
|
585
|
+
managementCostByLayer,
|
|
586
|
+
layerTaxPerIC,
|
|
587
|
+
managerOfOneCount,
|
|
588
|
+
managerOfManagersCount,
|
|
589
|
+
pureSupervisoryLayers,
|
|
590
|
+
aiExposure,
|
|
591
|
+
duplicateRoles,
|
|
592
|
+
duplicateRoleCost,
|
|
593
|
+
deepestChains: deepestChains(tree2, 5),
|
|
594
|
+
delayeringOpportunity,
|
|
595
|
+
spanOptimizationOpportunity,
|
|
596
|
+
duplicationOpportunity,
|
|
597
|
+
totalOpportunity: delayeringOpportunity + duplicationOpportunity + spanOptimizationOpportunity,
|
|
598
|
+
orgHealth
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function computeOrgHealth(inp, assumptions) {
|
|
602
|
+
const bench = assumptions.benchmarkLayersForSize.find((b) => inp.headcount <= b.maxHeadcount)?.layers ?? 8;
|
|
603
|
+
const excessLayers = Math.max(0, inp.maxLayers - bench);
|
|
604
|
+
const layerScore = Math.max(0, 100 - excessLayers * 15);
|
|
605
|
+
const spanScore = Math.max(0, 100 - inp.narrowPct * 120 - inp.widePct * 80);
|
|
606
|
+
const mr = inp.managerRatio;
|
|
607
|
+
const ratioScore = mr <= 0.2 && mr >= 0.08 ? 100 : Math.max(0, 100 - Math.abs(mr - 0.15) * 400);
|
|
608
|
+
const mcScore = inp.mgmtCostRatio <= 0.3 ? 100 : Math.max(0, 100 - (inp.mgmtCostRatio - 0.3) * 300);
|
|
609
|
+
const dupShare = inp.totalCost ? inp.duplicateCost / inp.totalCost : 0;
|
|
610
|
+
const dupScore = Math.max(0, 100 - dupShare * 600);
|
|
611
|
+
const components = [
|
|
612
|
+
{
|
|
613
|
+
name: "Layer health",
|
|
614
|
+
score: Math.round(layerScore),
|
|
615
|
+
weight: 25,
|
|
616
|
+
explanation: `${inp.maxLayers} layers vs ~${bench} benchmark for ${inp.headcount} employees${excessLayers > 0 ? ` (+${excessLayers} excess)` : ""}.`
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
name: "Span health",
|
|
620
|
+
score: Math.round(spanScore),
|
|
621
|
+
weight: 25,
|
|
622
|
+
explanation: `${Math.round(inp.narrowPct * 100)}% of managers below / ${Math.round(inp.widePct * 100)}% above their level's target span band (flat thresholds ${assumptions.narrowSpanThreshold}/${assumptions.wideSpanThreshold} for unleveled roles).`
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
name: "Management ratio",
|
|
626
|
+
score: Math.round(ratioScore),
|
|
627
|
+
weight: 20,
|
|
628
|
+
explanation: `${Math.round(mr * 100)}% of employees are managers (healthy band \u2248 8\u201320%).`
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: "Management cost",
|
|
632
|
+
score: Math.round(mcScore),
|
|
633
|
+
weight: 15,
|
|
634
|
+
explanation: `Managers account for ${Math.round(inp.mgmtCostRatio * 100)}% of total cost (target \u2264 30%).`
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
name: "Role duplication",
|
|
638
|
+
score: Math.round(dupScore),
|
|
639
|
+
weight: 15,
|
|
640
|
+
explanation: `Potential duplicate senior roles represent ${(dupShare * 100).toFixed(1)}% of total cost.`
|
|
641
|
+
}
|
|
642
|
+
];
|
|
643
|
+
const totalWeight = sum(components.map((c) => c.weight));
|
|
644
|
+
const total = Math.round(sum(components.map((c) => c.score * c.weight / totalWeight)));
|
|
645
|
+
return { total, components };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/lib/validation.ts
|
|
649
|
+
var issueSeq = 0;
|
|
650
|
+
function issue(severity, category, message, employeeIds = []) {
|
|
651
|
+
issueSeq += 1;
|
|
652
|
+
return { id: `dq_${issueSeq}`, severity, category, message, employeeIds };
|
|
653
|
+
}
|
|
654
|
+
function validateEmployees(employees) {
|
|
655
|
+
const issues = [];
|
|
656
|
+
if (employees.length === 0) {
|
|
657
|
+
issues.push(issue("critical", "Empty dataset", "No employee rows were found."));
|
|
658
|
+
const counts = { critical: 1, warning: 0, info: 0 };
|
|
659
|
+
return { score: 0, band: "Poor", issues, counts };
|
|
660
|
+
}
|
|
661
|
+
const byId = groupBy(employees, (e) => e.employee_id);
|
|
662
|
+
const dups = Object.entries(byId).filter(([, v]) => v.length > 1);
|
|
663
|
+
if (dups.length > 0) {
|
|
664
|
+
issues.push(
|
|
665
|
+
issue(
|
|
666
|
+
"critical",
|
|
667
|
+
"Duplicate IDs",
|
|
668
|
+
`${dups.length} employee ID(s) appear more than once: ${dups.slice(0, 5).map(([k]) => k).join(", ")}${dups.length > 5 ? "\u2026" : ""}`,
|
|
669
|
+
dups.map(([k]) => k)
|
|
670
|
+
)
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const missingName = employees.filter(
|
|
674
|
+
(e) => positionStatus(e) === "filled" && !e.employee_name?.trim()
|
|
675
|
+
);
|
|
676
|
+
if (missingName.length > 0)
|
|
677
|
+
issues.push(issue("critical", "Missing names", `${missingName.length} employee(s) have no name.`, missingName.map((e) => e.employee_id)));
|
|
678
|
+
const unfilled = employees.filter((e) => positionStatus(e) !== "filled");
|
|
679
|
+
if (unfilled.length > 0)
|
|
680
|
+
issues.push(
|
|
681
|
+
issue(
|
|
682
|
+
"info",
|
|
683
|
+
"Unfilled positions",
|
|
684
|
+
`${unfilled.length} position(s) are vacant or to-be-hired \u2014 included in designed cost and structure, excluded from run-rate cost.`,
|
|
685
|
+
unfilled.map((e) => e.employee_id)
|
|
686
|
+
)
|
|
687
|
+
);
|
|
688
|
+
const missingTitle = employees.filter((e) => !e.role_title?.trim());
|
|
689
|
+
if (missingTitle.length > 0)
|
|
690
|
+
issues.push(issue("warning", "Missing role titles", `${missingTitle.length} employee(s) have no role title.`, missingTitle.map((e) => e.employee_id)));
|
|
691
|
+
const blankDept = employees.filter((e) => !e.department?.trim());
|
|
692
|
+
if (blankDept.length > 0)
|
|
693
|
+
issues.push(issue("warning", "Blank departments", `${blankDept.length} employee(s) have a blank department.`, blankDept.map((e) => e.employee_id)));
|
|
694
|
+
const blankFunc = employees.filter((e) => !e.function?.trim());
|
|
695
|
+
if (blankFunc.length > 0)
|
|
696
|
+
issues.push(issue("warning", "Blank functions", `${blankFunc.length} employee(s) have a blank function.`, blankFunc.map((e) => e.employee_id)));
|
|
697
|
+
const blankLoc = employees.filter((e) => !e.location?.trim());
|
|
698
|
+
if (blankLoc.length > 0)
|
|
699
|
+
issues.push(issue("info", "Blank locations", `${blankLoc.length} employee(s) have a blank location.`, blankLoc.map((e) => e.employee_id)));
|
|
700
|
+
const badCost = employees.filter(
|
|
701
|
+
(e) => e.total_cost == null || isNaN(e.total_cost) || e.total_cost < 0
|
|
702
|
+
);
|
|
703
|
+
if (badCost.length > 0)
|
|
704
|
+
issues.push(issue("critical", "Invalid costs", `${badCost.length} employee(s) have missing/negative/non-numeric total cost.`, badCost.map((e) => e.employee_id)));
|
|
705
|
+
const zeroCost = employees.filter((e) => e.total_cost === 0);
|
|
706
|
+
if (zeroCost.length > 0)
|
|
707
|
+
issues.push(issue("warning", "Zero cost", `${zeroCost.length} employee(s) have zero total cost \u2014 cost analytics will undercount.`, zeroCost.map((e) => e.employee_id)));
|
|
708
|
+
const costBelowSalary = employees.filter(
|
|
709
|
+
(e) => e.total_cost > 0 && e.salary > 0 && e.total_cost < e.salary
|
|
710
|
+
);
|
|
711
|
+
if (costBelowSalary.length > 0)
|
|
712
|
+
issues.push(issue("warning", "Cost below salary", `${costBelowSalary.length} employee(s) have total cost lower than base salary.`, costBelowSalary.map((e) => e.employee_id)));
|
|
713
|
+
const badFte = employees.filter((e) => e.fte != null && (isNaN(e.fte) || e.fte <= 0 || e.fte > 2));
|
|
714
|
+
if (badFte.length > 0)
|
|
715
|
+
issues.push(issue("warning", "Suspicious FTE", `${badFte.length} employee(s) have FTE outside (0, 2].`, badFte.map((e) => e.employee_id)));
|
|
716
|
+
const tree2 = buildOrgTree(employees);
|
|
717
|
+
if (tree2.orphans.length > 0)
|
|
718
|
+
issues.push(
|
|
719
|
+
issue(
|
|
720
|
+
"critical",
|
|
721
|
+
"Invalid manager IDs",
|
|
722
|
+
`${tree2.orphans.length} employee(s) reference a manager_id that does not exist in the data (treated as detached roots).`,
|
|
723
|
+
tree2.orphans
|
|
724
|
+
)
|
|
725
|
+
);
|
|
726
|
+
if (tree2.circular.length > 0)
|
|
727
|
+
issues.push(
|
|
728
|
+
issue(
|
|
729
|
+
"critical",
|
|
730
|
+
"Circular reporting lines",
|
|
731
|
+
`${tree2.circular.length} employee(s) sit in circular reporting chains and are excluded from hierarchy analytics.`,
|
|
732
|
+
tree2.circular
|
|
733
|
+
)
|
|
734
|
+
);
|
|
735
|
+
if (tree2.roots.length > 1)
|
|
736
|
+
issues.push(
|
|
737
|
+
issue(
|
|
738
|
+
"warning",
|
|
739
|
+
"Multiple root nodes",
|
|
740
|
+
`${tree2.roots.length} employees have no manager (multiple CEOs/roots). Layer analytics use each root independently.`,
|
|
741
|
+
tree2.roots
|
|
742
|
+
)
|
|
743
|
+
);
|
|
744
|
+
if (tree2.roots.length === 0)
|
|
745
|
+
issues.push(issue("critical", "No root node", "No employee without a manager was found \u2014 cannot identify the CEO/top of the organization."));
|
|
746
|
+
const inconsistent = [...tree2.nodes.values()].filter(
|
|
747
|
+
(n) => n.layer > 0 && n.employee.level > 0 && Math.abs(n.employee.level - n.layer) >= 3
|
|
748
|
+
);
|
|
749
|
+
if (inconsistent.length > 0)
|
|
750
|
+
issues.push(
|
|
751
|
+
issue(
|
|
752
|
+
"info",
|
|
753
|
+
"Level/layer mismatch",
|
|
754
|
+
`${inconsistent.length} employee(s) have a stated level differing from their computed layer by 3+, suggesting grade data drift.`,
|
|
755
|
+
inconsistent.map((n) => n.employee.employee_id)
|
|
756
|
+
)
|
|
757
|
+
);
|
|
758
|
+
const titleVariants = groupBy(
|
|
759
|
+
employees.filter((e) => e.role_title),
|
|
760
|
+
(e) => e.role_title.toLowerCase().replace(/[^a-z]/g, "")
|
|
761
|
+
);
|
|
762
|
+
const messyTitles = Object.values(titleVariants).filter(
|
|
763
|
+
(v) => new Set(v.map((e) => e.role_title)).size > 1
|
|
764
|
+
);
|
|
765
|
+
if (messyTitles.length > 0)
|
|
766
|
+
issues.push(
|
|
767
|
+
issue(
|
|
768
|
+
"info",
|
|
769
|
+
"Inconsistent role titles",
|
|
770
|
+
`${messyTitles.length} title(s) appear with multiple spellings/capitalizations (e.g. ${[...new Set(messyTitles[0].map((e) => e.role_title))].slice(0, 3).join(" / ")}).`,
|
|
771
|
+
[]
|
|
772
|
+
)
|
|
773
|
+
);
|
|
774
|
+
return finalize(issues, employees.length);
|
|
775
|
+
}
|
|
776
|
+
function finalize(issues, rowCount) {
|
|
777
|
+
const counts = {
|
|
778
|
+
critical: issues.filter((i) => i.severity === "critical").length,
|
|
779
|
+
warning: issues.filter((i) => i.severity === "warning").length,
|
|
780
|
+
info: issues.filter((i) => i.severity === "info").length
|
|
781
|
+
};
|
|
782
|
+
let score = 100;
|
|
783
|
+
for (const i of issues) {
|
|
784
|
+
const share = rowCount > 0 ? Math.min(1, (i.employeeIds.length || 1) / rowCount) : 1;
|
|
785
|
+
if (i.severity === "critical") score -= 15 + share * 25;
|
|
786
|
+
else if (i.severity === "warning") score -= 5 + share * 10;
|
|
787
|
+
else score -= 1 + share * 3;
|
|
788
|
+
}
|
|
789
|
+
score = Math.max(0, Math.round(score));
|
|
790
|
+
const band = score >= 90 ? "Strong" : score >= 75 ? "Usable" : score >= 50 ? "Needs cleanup" : "Poor";
|
|
791
|
+
return { score, band, issues, counts };
|
|
792
|
+
}
|
|
793
|
+
function num(v) {
|
|
794
|
+
if (v == null || v === "") return 0;
|
|
795
|
+
const n = typeof v === "number" ? v : parseFloat(String(v).replace(/[,$€£\s]/g, ""));
|
|
796
|
+
return isNaN(n) ? 0 : n;
|
|
797
|
+
}
|
|
798
|
+
function str(v) {
|
|
799
|
+
return v == null ? "" : String(v).trim();
|
|
800
|
+
}
|
|
801
|
+
function bool(v) {
|
|
802
|
+
const s = str(v).toLowerCase();
|
|
803
|
+
return s === "true" || s === "yes" || s === "y" || s === "1";
|
|
804
|
+
}
|
|
805
|
+
var VACANT_TOKENS = /* @__PURE__ */ new Set(["vacant", "open", "unfilled", "vacancy", "openposition", "openseat"]);
|
|
806
|
+
var TBH_TOKENS = /* @__PURE__ */ new Set(["tbh", "tobehired", "planned", "hiring"]);
|
|
807
|
+
var FILLED_TOKENS = /* @__PURE__ */ new Set(["filled", "active", "occupied"]);
|
|
808
|
+
function normalizePositionStatus(v) {
|
|
809
|
+
const s = str(v).toLowerCase().replace(/[^a-z]/g, "");
|
|
810
|
+
if (!s) return void 0;
|
|
811
|
+
if (VACANT_TOKENS.has(s)) return "vacant";
|
|
812
|
+
if (TBH_TOKENS.has(s)) return "tbh";
|
|
813
|
+
if (FILLED_TOKENS.has(s)) return "filled";
|
|
814
|
+
return void 0;
|
|
815
|
+
}
|
|
816
|
+
function rowsToEmployees(rows, mapping) {
|
|
817
|
+
const get = (row, field) => {
|
|
818
|
+
const col = mapping[field];
|
|
819
|
+
return col ? row[col] : void 0;
|
|
820
|
+
};
|
|
821
|
+
return rows.filter((row) => str(get(row, "employee_id"))).map((row) => {
|
|
822
|
+
const salary = num(get(row, "salary"));
|
|
823
|
+
const bonus = num(get(row, "bonus"));
|
|
824
|
+
const totalCostRaw = num(get(row, "total_cost"));
|
|
825
|
+
const name2 = str(get(row, "employee_name"));
|
|
826
|
+
const posStatus = normalizePositionStatus(get(row, "position_status")) ?? (name2 ? void 0 : "vacant");
|
|
827
|
+
return {
|
|
828
|
+
employee_id: str(get(row, "employee_id")),
|
|
829
|
+
employee_name: name2,
|
|
830
|
+
role_title: str(get(row, "role_title")),
|
|
831
|
+
manager_id: str(get(row, "manager_id")) || null,
|
|
832
|
+
manager_name: str(get(row, "manager_name")) || void 0,
|
|
833
|
+
department: str(get(row, "department")),
|
|
834
|
+
function: str(get(row, "function")),
|
|
835
|
+
business_unit: str(get(row, "business_unit")),
|
|
836
|
+
location: str(get(row, "location")),
|
|
837
|
+
country: str(get(row, "country")),
|
|
838
|
+
grade: str(get(row, "grade")),
|
|
839
|
+
level: num(get(row, "level")) || 0,
|
|
840
|
+
employment_type: str(get(row, "employment_type")) || "Full-time",
|
|
841
|
+
fte: num(get(row, "fte")) || 1,
|
|
842
|
+
salary,
|
|
843
|
+
bonus,
|
|
844
|
+
total_cost: totalCostRaw || salary + bonus,
|
|
845
|
+
// fall back to salary+bonus
|
|
846
|
+
status: str(get(row, "status")) || "Active",
|
|
847
|
+
position_status: posStatus === "filled" ? void 0 : posStatus,
|
|
848
|
+
tenure: get(row, "tenure") != null ? num(get(row, "tenure")) : void 0,
|
|
849
|
+
gender: str(get(row, "gender")) || void 0,
|
|
850
|
+
age_band: str(get(row, "age_band")) || void 0,
|
|
851
|
+
performance_rating: str(get(row, "performance_rating")) || void 0,
|
|
852
|
+
critical_role: get(row, "critical_role") != null ? bool(get(row, "critical_role")) : void 0,
|
|
853
|
+
role_family: str(get(row, "role_family")) || void 0,
|
|
854
|
+
cost_center: str(get(row, "cost_center")) || void 0,
|
|
855
|
+
contract_type: str(get(row, "contract_type")) || void 0,
|
|
856
|
+
remote_status: str(get(row, "remote_status")) || void 0,
|
|
857
|
+
skills: str(get(row, "skills")) || void 0,
|
|
858
|
+
attrition_risk: str(get(row, "attrition_risk")) || void 0,
|
|
859
|
+
comments: str(get(row, "comments")) || void 0
|
|
860
|
+
};
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
function autoMapColumns(columns, fields, extraAliases) {
|
|
864
|
+
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
865
|
+
const colNorm = columns.map((c) => ({ raw: c, norm: norm(c) }));
|
|
866
|
+
const ALIASES = {
|
|
867
|
+
employee_id: ["empid", "id", "workerid", "personid", "employeenumber"],
|
|
868
|
+
employee_name: ["name", "fullname", "employee", "workername"],
|
|
869
|
+
role_title: ["title", "jobtitle", "position", "role"],
|
|
870
|
+
manager_id: ["managerid", "supervisorid", "reportsto", "mgrid"],
|
|
871
|
+
manager_name: ["manager", "supervisor", "mgrname"],
|
|
872
|
+
position_status: ["positionstatus", "seatstatus", "vacancystatus", "vacancy"],
|
|
873
|
+
business_unit: ["bu", "division", "businessarea"],
|
|
874
|
+
total_cost: ["cost", "totalcomp", "totalcompensation", "fullycost", "loadedcost"],
|
|
875
|
+
employment_type: ["emptype", "workertype"],
|
|
876
|
+
fte: ["ftevalue", "workload"]
|
|
877
|
+
};
|
|
878
|
+
const mapping = {};
|
|
879
|
+
for (const field of fields) {
|
|
880
|
+
const fNorm = norm(field);
|
|
881
|
+
const base = [fNorm, ...(ALIASES[field] || []).map(norm)];
|
|
882
|
+
const extra = (extraAliases?.[field] ?? []).map(norm).filter(Boolean);
|
|
883
|
+
const exact = (aliases) => colNorm.find((c) => aliases.includes(c.norm));
|
|
884
|
+
const fuzzy = (aliases) => colNorm.find((c) => aliases.some((a) => c.norm.includes(a) || a.includes(c.norm)));
|
|
885
|
+
const hit = exact(extra) || exact(base) || fuzzy(extra) || fuzzy(base);
|
|
886
|
+
mapping[field] = hit?.raw ?? null;
|
|
887
|
+
}
|
|
888
|
+
return mapping;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/lib/scenario.ts
|
|
892
|
+
function applyChanges(baseline, changes) {
|
|
893
|
+
let employees = baseline.map((e) => ({ ...e }));
|
|
894
|
+
const warnings = [];
|
|
895
|
+
for (const change of changes) {
|
|
896
|
+
const byId = new Map(employees.map((e) => [e.employee_id, e]));
|
|
897
|
+
switch (change.type) {
|
|
898
|
+
case "move": {
|
|
899
|
+
const target = byId.get(change.employeeId);
|
|
900
|
+
const newMgr = byId.get(change.newManagerId);
|
|
901
|
+
if (!target || !newMgr) {
|
|
902
|
+
warnings.push(`Move skipped: employee not found (${change.employeeId}).`);
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
const tree2 = buildOrgTree(employees);
|
|
906
|
+
if (wouldCreateCycle(tree2, change.employeeId, change.newManagerId)) {
|
|
907
|
+
warnings.push(`Move skipped: moving ${target.employee_name} under ${newMgr.employee_name} would create a cycle.`);
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
target.manager_id = change.newManagerId;
|
|
911
|
+
target.manager_name = newMgr.employee_name;
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case "remove_role": {
|
|
915
|
+
const target = byId.get(change.employeeId);
|
|
916
|
+
if (!target) {
|
|
917
|
+
warnings.push(`Remove skipped: ${change.employeeId} not found.`);
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
const newMgrId = change.reportsTo === "specific" ? change.newManagerId ?? target.manager_id : change.reportsTo === "managers_manager" ? target.manager_id : null;
|
|
921
|
+
for (const e of employees) {
|
|
922
|
+
if (e.manager_id === change.employeeId) {
|
|
923
|
+
e.manager_id = newMgrId;
|
|
924
|
+
e.manager_name = newMgrId ? byId.get(newMgrId)?.employee_name : void 0;
|
|
925
|
+
if (!newMgrId) warnings.push(`${e.employee_name} is temporarily orphaned after removing ${target.employee_name}.`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
employees = employees.filter((e) => e.employee_id !== change.employeeId);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
case "add_role": {
|
|
932
|
+
if (byId.has(change.employee.employee_id)) {
|
|
933
|
+
warnings.push(`Add skipped: ID ${change.employee.employee_id} already exists.`);
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
employees.push({ ...change.employee });
|
|
937
|
+
break;
|
|
938
|
+
}
|
|
939
|
+
case "merge_teams": {
|
|
940
|
+
const source = byId.get(change.sourceManagerId);
|
|
941
|
+
const target = byId.get(change.targetManagerId);
|
|
942
|
+
if (!source || !target) {
|
|
943
|
+
warnings.push("Merge skipped: manager not found.");
|
|
944
|
+
break;
|
|
945
|
+
}
|
|
946
|
+
if (change.sourceManagerId === change.targetManagerId) {
|
|
947
|
+
warnings.push(`Merge skipped: source and target manager are the same (${source.employee_name}).`);
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
for (const e of employees) {
|
|
951
|
+
if (e.manager_id === change.sourceManagerId) {
|
|
952
|
+
e.manager_id = change.targetManagerId;
|
|
953
|
+
e.manager_name = target.employee_name;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
employees = employees.filter((e) => e.employee_id !== change.sourceManagerId);
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
case "delayer": {
|
|
960
|
+
const tree2 = buildOrgTree(employees);
|
|
961
|
+
const inScope = (e) => change.scope.field === "all" || change.scope.field === "function" && e.function === change.scope.value || change.scope.field === "business_unit" && e.business_unit === change.scope.value;
|
|
962
|
+
const toRemove = [...tree2.nodes.values()].filter(
|
|
963
|
+
(n) => n.layer === change.layer && n.isManager && inScope(n.employee)
|
|
964
|
+
);
|
|
965
|
+
if (toRemove.length === 0) {
|
|
966
|
+
warnings.push(`Delayer: no managers found at layer ${change.layer} in scope.`);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
const removeIds = new Set(toRemove.map((n) => n.employee.employee_id));
|
|
970
|
+
for (const e of employees) {
|
|
971
|
+
if (e.manager_id && removeIds.has(e.manager_id)) {
|
|
972
|
+
const removedMgr = byId.get(e.manager_id);
|
|
973
|
+
e.manager_id = removedMgr.manager_id;
|
|
974
|
+
e.manager_name = removedMgr.manager_id ? byId.get(removedMgr.manager_id)?.employee_name : void 0;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
employees = employees.filter((e) => !removeIds.has(e.employee_id));
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
case "location_shift": {
|
|
981
|
+
for (const id of change.employeeIds) {
|
|
982
|
+
const e = byId.get(id);
|
|
983
|
+
if (!e) continue;
|
|
984
|
+
e.location = change.targetLocation;
|
|
985
|
+
e.salary = Math.round(e.salary * change.costMultiplier);
|
|
986
|
+
e.bonus = Math.round(e.bonus * change.costMultiplier);
|
|
987
|
+
e.total_cost = Math.round(e.total_cost * change.costMultiplier);
|
|
988
|
+
}
|
|
989
|
+
break;
|
|
990
|
+
}
|
|
991
|
+
case "cost_change": {
|
|
992
|
+
const e = byId.get(change.employeeId);
|
|
993
|
+
if (!e) {
|
|
994
|
+
warnings.push(`Cost change skipped: ${change.employeeId} not found.`);
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
e.total_cost = change.newTotalCost;
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
case "fill_role": {
|
|
1001
|
+
const e = byId.get(change.employeeId);
|
|
1002
|
+
if (!e) {
|
|
1003
|
+
warnings.push(`Fill seat skipped: ${change.employeeId} not found.`);
|
|
1004
|
+
break;
|
|
1005
|
+
}
|
|
1006
|
+
if (positionStatus(e) === "filled") {
|
|
1007
|
+
warnings.push(`Fill seat skipped: ${e.role_title || e.employee_id} is already filled.`);
|
|
1008
|
+
break;
|
|
1009
|
+
}
|
|
1010
|
+
e.position_status = "filled";
|
|
1011
|
+
if (change.employeeName.trim()) e.employee_name = change.employeeName.trim();
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return { employees, warnings };
|
|
1017
|
+
}
|
|
1018
|
+
function diffEmployees(baseline, scenario) {
|
|
1019
|
+
const baseById = new Map(baseline.map((e) => [e.employee_id, e]));
|
|
1020
|
+
const scenById = new Map(scenario.map((e) => [e.employee_id, e]));
|
|
1021
|
+
const moved = [];
|
|
1022
|
+
const removed = [];
|
|
1023
|
+
const added = [];
|
|
1024
|
+
const costChanged = [];
|
|
1025
|
+
const locationChanged = [];
|
|
1026
|
+
const seatsFilled = [];
|
|
1027
|
+
for (const e of baseline) {
|
|
1028
|
+
const s = scenById.get(e.employee_id);
|
|
1029
|
+
if (!s) removed.push(e);
|
|
1030
|
+
else {
|
|
1031
|
+
if (s.manager_id !== e.manager_id) moved.push(s);
|
|
1032
|
+
if (s.total_cost !== e.total_cost) costChanged.push(s);
|
|
1033
|
+
if (s.location !== e.location) locationChanged.push(s);
|
|
1034
|
+
if (positionStatus(e) !== "filled" && positionStatus(s) === "filled") seatsFilled.push(s);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
for (const e of scenario) if (!baseById.has(e.employee_id)) added.push(e);
|
|
1038
|
+
return { moved, removed, added, costChanged, locationChanged, seatsFilled };
|
|
1039
|
+
}
|
|
1040
|
+
function computeChangeRisk(baseline, scenarioEmployees, baselineMetrics, scenarioMetrics) {
|
|
1041
|
+
const { moved, removed, added } = diffEmployees(baseline, scenarioEmployees);
|
|
1042
|
+
const headcount = Math.max(1, baseline.length);
|
|
1043
|
+
const seniorAffected = [...moved, ...removed].filter((e) => e.level <= 3).length;
|
|
1044
|
+
const criticalAffected = [...moved, ...removed].filter((e) => e.critical_role).length;
|
|
1045
|
+
const functionsImpacted = new Set([...moved, ...removed, ...added].map((e) => e.function)).size;
|
|
1046
|
+
const locationsImpacted = new Set([...moved, ...removed, ...added].map((e) => e.location)).size;
|
|
1047
|
+
const layerDelta = Math.abs(scenarioMetrics.maxLayers - baselineMetrics.maxLayers);
|
|
1048
|
+
const costDeltaPct = baselineMetrics.totalCost ? Math.abs(scenarioMetrics.totalCost - baselineMetrics.totalCost) / baselineMetrics.totalCost : 0;
|
|
1049
|
+
const factors = [
|
|
1050
|
+
{ name: "Employees moved", value: moved.length, contribution: Math.min(25, moved.length / headcount * 120) },
|
|
1051
|
+
{ name: "Roles eliminated", value: removed.length, contribution: Math.min(20, removed.length / headcount * 200) },
|
|
1052
|
+
{ name: "Senior leaders affected", value: seniorAffected, contribution: Math.min(15, seniorAffected * 3) },
|
|
1053
|
+
{ name: "Critical roles affected", value: criticalAffected, contribution: Math.min(15, criticalAffected * 5) },
|
|
1054
|
+
{ name: "Functions impacted", value: functionsImpacted, contribution: Math.min(10, functionsImpacted * 1.5) },
|
|
1055
|
+
{ name: "Locations impacted", value: locationsImpacted, contribution: Math.min(5, locationsImpacted * 1) },
|
|
1056
|
+
{ name: "Layer change", value: layerDelta, contribution: Math.min(5, layerDelta * 2.5) },
|
|
1057
|
+
{ name: "Cost change", value: `${(costDeltaPct * 100).toFixed(1)}%`, contribution: Math.min(5, costDeltaPct * 40) }
|
|
1058
|
+
];
|
|
1059
|
+
const score = Math.min(100, Math.round(factors.reduce((a, f) => a + f.contribution, 0)));
|
|
1060
|
+
const level = score < 20 ? "Low" : score < 45 ? "Medium" : score < 70 ? "High" : "Very High";
|
|
1061
|
+
const recommendation = {
|
|
1062
|
+
Low: "Suitable for quick implementation with standard communication.",
|
|
1063
|
+
Medium: "Requires stakeholder alignment and a clear communication plan.",
|
|
1064
|
+
High: "Requires a detailed change plan, consultation, and phased rollout.",
|
|
1065
|
+
"Very High": "Requires a phased transformation program with executive sponsorship and workforce consultation."
|
|
1066
|
+
}[level];
|
|
1067
|
+
return { level, score, factors, recommendation };
|
|
1068
|
+
}
|
|
1069
|
+
var DEFAULT_EFFECTIVE_MONTH = 4;
|
|
1070
|
+
function year1CapturePct(assumptions) {
|
|
1071
|
+
const raw = assumptions.transitionAssumptions?.effectiveMonth ?? DEFAULT_EFFECTIVE_MONTH;
|
|
1072
|
+
const month = Number.isFinite(raw) && raw >= 1 && raw <= 12 ? Math.round(raw) : DEFAULT_EFFECTIVE_MONTH;
|
|
1073
|
+
return (12 - month + 1) / 12;
|
|
1074
|
+
}
|
|
1075
|
+
function severanceCountryMultiplier(country, assumptions) {
|
|
1076
|
+
const map = assumptions.transitionAssumptions?.countrySeveranceMultipliers ?? {};
|
|
1077
|
+
const m = country ? map[country] : void 0;
|
|
1078
|
+
return typeof m === "number" && Number.isFinite(m) && m > 0 ? m : 1;
|
|
1079
|
+
}
|
|
1080
|
+
function severanceMonthsForLevel(level, assumptions) {
|
|
1081
|
+
const bands = assumptions.transitionAssumptions.severanceMonthsByLevel;
|
|
1082
|
+
const sorted = [...bands].sort((a, b) => a.maxLevel - b.maxLevel);
|
|
1083
|
+
const effectiveLevel = level > 0 ? level : 1;
|
|
1084
|
+
for (const b of sorted) {
|
|
1085
|
+
if (effectiveLevel <= b.maxLevel) return b.months;
|
|
1086
|
+
}
|
|
1087
|
+
return sorted.length ? sorted[sorted.length - 1].months : 0;
|
|
1088
|
+
}
|
|
1089
|
+
function computeTransitionCost(baseline, scenarioEmployees, assumptions) {
|
|
1090
|
+
const ta = assumptions.transitionAssumptions;
|
|
1091
|
+
const { moved, removed, costChanged, locationChanged, seatsFilled } = diffEmployees(
|
|
1092
|
+
baseline,
|
|
1093
|
+
scenarioEmployees
|
|
1094
|
+
);
|
|
1095
|
+
const baseById = new Map(baseline.map((e) => [e.employee_id, e]));
|
|
1096
|
+
const isFilled = (e) => !!e && positionStatus(e) === "filled";
|
|
1097
|
+
let severance = 0;
|
|
1098
|
+
let backfill = 0;
|
|
1099
|
+
for (const r of removed) {
|
|
1100
|
+
if (!isFilled(r)) continue;
|
|
1101
|
+
const months = severanceMonthsForLevel(r.level, assumptions) * severanceCountryMultiplier(r.country, assumptions);
|
|
1102
|
+
severance += (r.total_cost || 0) / 12 * months;
|
|
1103
|
+
backfill += (r.total_cost || 0) * ta.backfillPct / 100;
|
|
1104
|
+
}
|
|
1105
|
+
const filledLocationChanged = locationChanged.filter((s) => isFilled(baseById.get(s.employee_id)));
|
|
1106
|
+
const locationChangedIds = new Set(locationChanged.map((e) => e.employee_id));
|
|
1107
|
+
for (const s of filledLocationChanged) {
|
|
1108
|
+
const base = baseById.get(s.employee_id);
|
|
1109
|
+
const baseCost = base?.total_cost || 0;
|
|
1110
|
+
const months = severanceMonthsForLevel(base?.level ?? 0, assumptions) * severanceCountryMultiplier(base?.country, assumptions);
|
|
1111
|
+
severance += baseCost / 12 * months;
|
|
1112
|
+
backfill += baseCost * ta.backfillPct / 100;
|
|
1113
|
+
}
|
|
1114
|
+
let hiring = 0;
|
|
1115
|
+
for (const f of seatsFilled) hiring += (f.total_cost || 0) * ta.backfillPct / 100;
|
|
1116
|
+
const costOnlyChanged = costChanged.filter(
|
|
1117
|
+
(e) => !locationChangedIds.has(e.employee_id) && isFilled(e)
|
|
1118
|
+
);
|
|
1119
|
+
const filledMoved = moved.filter((e) => isFilled(e));
|
|
1120
|
+
const changeCost = ta.oneTimeChangeCostPerMove * (filledMoved.length + filledLocationChanged.length + costOnlyChanged.length);
|
|
1121
|
+
return Math.round(severance + backfill + hiring + changeCost);
|
|
1122
|
+
}
|
|
1123
|
+
function scoreScenario(baselineMetrics, scenarioMetrics, risk, assumptions, changeCount, netYear1Saving, affectedEmployees) {
|
|
1124
|
+
const w = assumptions.scoringWeights;
|
|
1125
|
+
const costDelta = baselineMetrics.totalCost - scenarioMetrics.totalCost;
|
|
1126
|
+
const netSaving = netYear1Saving ?? costDelta;
|
|
1127
|
+
const netPct = baselineMetrics.totalCost ? netSaving / baselineMetrics.totalCost : 0;
|
|
1128
|
+
const financial = clamp(50 + netPct * 500);
|
|
1129
|
+
const narrowDelta = baselineMetrics.narrowSpanPct - scenarioMetrics.narrowSpanPct;
|
|
1130
|
+
const wideDelta = baselineMetrics.wideSpanPct - scenarioMetrics.wideSpanPct;
|
|
1131
|
+
const spanImprovement = clamp(50 + narrowDelta * 250 + wideDelta * 150);
|
|
1132
|
+
const layerDelta = baselineMetrics.maxLayers - scenarioMetrics.maxLayers;
|
|
1133
|
+
const layerReduction = clamp(50 + layerDelta * 20);
|
|
1134
|
+
const mgrRatioDelta = baselineMetrics.managerRatio - scenarioMetrics.managerRatio;
|
|
1135
|
+
const dupDelta = baselineMetrics.duplicateRoleCost - scenarioMetrics.duplicateRoleCost;
|
|
1136
|
+
const complexity = clamp(
|
|
1137
|
+
50 + mgrRatioDelta * 400 + (baselineMetrics.totalCost ? dupDelta / baselineMetrics.totalCost * 800 : 0)
|
|
1138
|
+
);
|
|
1139
|
+
const changeRiskScore = clamp(100 - risk.score);
|
|
1140
|
+
const affected = affectedEmployees ?? 0;
|
|
1141
|
+
const implementationEase = clamp(100 - Math.min(40, affected * 0.8) - risk.score * 0.3);
|
|
1142
|
+
const categories = [
|
|
1143
|
+
{ name: "Financial impact", score: Math.round(financial), weight: w.financialImpact },
|
|
1144
|
+
{ name: "Span improvement", score: Math.round(spanImprovement), weight: w.spanImprovement },
|
|
1145
|
+
{ name: "Layer reduction", score: Math.round(layerReduction), weight: w.layerReduction },
|
|
1146
|
+
{ name: "Operational simplicity", score: Math.round(complexity), weight: w.complexityReduction },
|
|
1147
|
+
{ name: "People / change risk", score: Math.round(changeRiskScore), weight: w.changeRisk },
|
|
1148
|
+
{ name: "Speed to execute", score: Math.round(implementationEase), weight: w.implementationEase }
|
|
1149
|
+
];
|
|
1150
|
+
const totalWeight = categories.reduce((a, c) => a + c.weight, 0) || 1;
|
|
1151
|
+
const total = Math.round(categories.reduce((a, c) => a + c.score * c.weight / totalWeight, 0));
|
|
1152
|
+
return { total, categories };
|
|
1153
|
+
}
|
|
1154
|
+
function clamp(v) {
|
|
1155
|
+
return Math.max(0, Math.min(100, v));
|
|
1156
|
+
}
|
|
1157
|
+
function evaluateScenario(baseline, scenario, baselineMetrics, assumptions) {
|
|
1158
|
+
const { employees } = applyChanges(baseline, scenario.changes);
|
|
1159
|
+
const metrics = computeMetrics(employees, assumptions);
|
|
1160
|
+
const risk = computeChangeRisk(baseline, employees, baselineMetrics, metrics);
|
|
1161
|
+
const { moved, removed, added, seatsFilled } = diffEmployees(baseline, employees);
|
|
1162
|
+
const costDelta = metrics.totalCost - baselineMetrics.totalCost;
|
|
1163
|
+
const runRateSaving = -costDelta;
|
|
1164
|
+
const transitionCost = computeTransitionCost(baseline, employees, assumptions);
|
|
1165
|
+
const capturePct = year1CapturePct(assumptions);
|
|
1166
|
+
const netYear1Saving = runRateSaving * capturePct - transitionCost;
|
|
1167
|
+
const affectedEmployees = moved.length + removed.length + added.length + seatsFilled.length;
|
|
1168
|
+
const vacantSeatsRemoved = removed.filter((r) => positionStatus(r) !== "filled").length;
|
|
1169
|
+
const score = scoreScenario(
|
|
1170
|
+
baselineMetrics,
|
|
1171
|
+
metrics,
|
|
1172
|
+
risk,
|
|
1173
|
+
assumptions,
|
|
1174
|
+
scenario.changes.length,
|
|
1175
|
+
netYear1Saving,
|
|
1176
|
+
affectedEmployees
|
|
1177
|
+
);
|
|
1178
|
+
return {
|
|
1179
|
+
scenarioId: scenario.id,
|
|
1180
|
+
metrics,
|
|
1181
|
+
risk,
|
|
1182
|
+
score,
|
|
1183
|
+
costDelta,
|
|
1184
|
+
headcountDelta: metrics.headcount - baselineMetrics.headcount,
|
|
1185
|
+
layerDelta: metrics.maxLayers - baselineMetrics.maxLayers,
|
|
1186
|
+
employeesMoved: moved.length,
|
|
1187
|
+
rolesRemoved: removed.length,
|
|
1188
|
+
rolesAdded: added.length,
|
|
1189
|
+
transitionCost,
|
|
1190
|
+
netYear1Saving,
|
|
1191
|
+
runRateSaving,
|
|
1192
|
+
year1CapturePct: capturePct,
|
|
1193
|
+
vacantSeatsRemoved,
|
|
1194
|
+
seatsFilled: seatsFilled.length
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
function suggestConsolidations(employees, assumptions, scopeFunction) {
|
|
1198
|
+
const tree2 = buildOrgTree(employees);
|
|
1199
|
+
const out = [];
|
|
1200
|
+
const nodes = [...tree2.nodes.values()].filter((n) => n.layer > 1);
|
|
1201
|
+
const narrowMgrs = nodes.filter(
|
|
1202
|
+
(n) => n.isManager && n.span < assumptions.narrowSpanThreshold && n.span <= 2 && (!scopeFunction || n.employee.function === scopeFunction)
|
|
1203
|
+
);
|
|
1204
|
+
const isSensitive = (e) => !!e.critical_role || e.attrition_risk === "High";
|
|
1205
|
+
for (const mgr of narrowMgrs) {
|
|
1206
|
+
const parent = mgr.employee.manager_id ? tree2.nodes.get(mgr.employee.manager_id) : void 0;
|
|
1207
|
+
if (!parent) continue;
|
|
1208
|
+
const peers = parent.directReports.map((id) => tree2.nodes.get(id)).filter(
|
|
1209
|
+
(p) => p.employee.employee_id !== mgr.employee.employee_id && p.isManager && p.span + mgr.span <= assumptions.wideSpanThreshold && p.employee.function === mgr.employee.function
|
|
1210
|
+
).sort((a, b) => {
|
|
1211
|
+
const aRisk = a.employee.attrition_risk === "High" ? 1 : 0;
|
|
1212
|
+
const bRisk = b.employee.attrition_risk === "High" ? 1 : 0;
|
|
1213
|
+
if (aRisk !== bRisk) return aRisk - bRisk;
|
|
1214
|
+
return a.span - b.span;
|
|
1215
|
+
});
|
|
1216
|
+
const target = peers[0] ?? parent;
|
|
1217
|
+
let rationale = `${mgr.employee.employee_name} (${mgr.employee.role_title}) manages only ${mgr.span} ${mgr.span === 1 ? "person" : "people"} \u2014 below the ${assumptions.narrowSpanThreshold}-report threshold.`;
|
|
1218
|
+
const flags = [];
|
|
1219
|
+
if (mgr.employee.critical_role) flags.push("flagged as a critical role");
|
|
1220
|
+
if (mgr.employee.attrition_risk === "High") flags.push("at high attrition risk");
|
|
1221
|
+
if (flags.length) {
|
|
1222
|
+
rationale += ` Caution: this manager is ${flags.join(" and ")} \u2014 validate retention/continuity impact before acting (de-prioritized).`;
|
|
1223
|
+
}
|
|
1224
|
+
out.push({
|
|
1225
|
+
change: {
|
|
1226
|
+
type: "merge_teams",
|
|
1227
|
+
sourceManagerId: mgr.employee.employee_id,
|
|
1228
|
+
targetManagerId: target.employee.employee_id,
|
|
1229
|
+
description: `Merge ${mgr.employee.employee_name}'s team (${mgr.span} report${mgr.span === 1 ? "" : "s"}) into ${target.employee.employee_name}`
|
|
1230
|
+
},
|
|
1231
|
+
rationale,
|
|
1232
|
+
estimatedSaving: mgr.employee.total_cost
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
return out.sort((a, b) => {
|
|
1236
|
+
const aMgr = tree2.nodes.get(a.change.type === "merge_teams" ? a.change.sourceManagerId : "")?.employee;
|
|
1237
|
+
const bMgr = tree2.nodes.get(b.change.type === "merge_teams" ? b.change.sourceManagerId : "")?.employee;
|
|
1238
|
+
const aSens = aMgr && isSensitive(aMgr) ? 1 : 0;
|
|
1239
|
+
const bSens = bMgr && isSensitive(bMgr) ? 1 : 0;
|
|
1240
|
+
if (aSens !== bSens) return aSens - bSens;
|
|
1241
|
+
return b.estimatedSaving - a.estimatedSaving;
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// src/lib/insights.ts
|
|
1246
|
+
var seq = 0;
|
|
1247
|
+
function insight(partial) {
|
|
1248
|
+
seq += 1;
|
|
1249
|
+
return { id: `ins_${seq}`, ...partial };
|
|
1250
|
+
}
|
|
1251
|
+
function generateInsights(ctx) {
|
|
1252
|
+
const { metrics: m, assumptions: a, employees } = ctx;
|
|
1253
|
+
const sym = a.currencySymbol;
|
|
1254
|
+
const out = [];
|
|
1255
|
+
const tree2 = buildOrgTree(employees);
|
|
1256
|
+
if (m.headcount === 0) return [];
|
|
1257
|
+
const bench = a.benchmarkLayersForSize.find((b) => m.headcount <= b.maxHeadcount)?.layers ?? 8;
|
|
1258
|
+
if (m.maxLayers > bench) {
|
|
1259
|
+
out.push(
|
|
1260
|
+
insight({
|
|
1261
|
+
title: `Organization has ${m.maxLayers} layers \u2014 ${m.maxLayers - bench} above benchmark`,
|
|
1262
|
+
severity: m.maxLayers - bench >= 2 ? "high" : "medium",
|
|
1263
|
+
category: "layer",
|
|
1264
|
+
evidence: `The deepest reporting chain runs ${m.maxLayers} layers from CEO to frontline. For an organization of ${m.headcount} employees, a typical benchmark is ~${bench} layers.`,
|
|
1265
|
+
impact: "Excess layers slow decision-making, dilute accountability and add management cost.",
|
|
1266
|
+
recommendation: `Review the deepest chains (Diagnostics \u2192 deepest chains) and test a delayering scenario targeting layer ${Math.max(3, m.maxLayers - 2)}\u2013${m.maxLayers - 1} in the most layered functions.`,
|
|
1267
|
+
relatedEntities: m.deepestChains[0]?.ids ?? [],
|
|
1268
|
+
financialImpact: m.delayeringOpportunity,
|
|
1269
|
+
employeesAffected: m.deepestChains[0]?.ids.length ?? 0,
|
|
1270
|
+
confidence: "high",
|
|
1271
|
+
easeOfAction: "moderate",
|
|
1272
|
+
linkTo: "/diagnostics"
|
|
1273
|
+
})
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
for (const [func, layers] of sortedEntries(m.layersByFunction).slice(0, 2)) {
|
|
1277
|
+
if (layers > bench - 1 && func !== "(blank)") {
|
|
1278
|
+
out.push(
|
|
1279
|
+
insight({
|
|
1280
|
+
title: `${func} spans ${layers} internal layers \u2014 the most layered function`,
|
|
1281
|
+
severity: layers >= bench ? "high" : "medium",
|
|
1282
|
+
category: "layer",
|
|
1283
|
+
evidence: `${func} has ${layers} layers internally with ${m.headcountByFunction[func] ?? 0} employees and ${fmtMoney(m.costByFunction[func] ?? 0, sym)} total cost.`,
|
|
1284
|
+
impact: "Deep functional hierarchies create approval bottlenecks and fragmented spans.",
|
|
1285
|
+
recommendation: `Run a delayering scenario scoped to ${func} and compare before/after spans.`,
|
|
1286
|
+
relatedEntities: [func],
|
|
1287
|
+
financialImpact: 0,
|
|
1288
|
+
employeesAffected: m.headcountByFunction[func] ?? 0,
|
|
1289
|
+
confidence: "high",
|
|
1290
|
+
easeOfAction: "moderate",
|
|
1291
|
+
linkTo: "/diagnostics"
|
|
1292
|
+
})
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (m.deepestChains[0]) {
|
|
1297
|
+
const chain = m.deepestChains[0];
|
|
1298
|
+
const top = tree2.nodes.get(chain.ids[1] ?? chain.ids[0]);
|
|
1299
|
+
out.push(
|
|
1300
|
+
insight({
|
|
1301
|
+
title: `Deepest reporting chain has ${chain.depth} layers${top ? ` under ${top.employee.role_title}` : ""}`,
|
|
1302
|
+
severity: chain.depth > bench ? "medium" : "low",
|
|
1303
|
+
category: "structure",
|
|
1304
|
+
evidence: `The longest path from the CEO to the frontline passes through ${chain.depth - 1} managers: ${chain.ids.map((id) => tree2.nodes.get(id)?.employee.role_title ?? id).slice(0, 5).join(" \u2192 ")}${chain.depth > 5 ? " \u2192 \u2026" : ""}.`,
|
|
1305
|
+
impact: "Long chains mean frontline information passes through many filters before reaching leadership.",
|
|
1306
|
+
recommendation: "Validate whether each intermediate manager adds distinct decision rights.",
|
|
1307
|
+
relatedEntities: chain.ids,
|
|
1308
|
+
financialImpact: 0,
|
|
1309
|
+
employeesAffected: chain.ids.length,
|
|
1310
|
+
confidence: "high",
|
|
1311
|
+
easeOfAction: "moderate",
|
|
1312
|
+
linkTo: "/org"
|
|
1313
|
+
})
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
if (m.narrowSpanPct > 0.25) {
|
|
1317
|
+
const tiny = m.spanBuckets.find((b) => b.label === "1\u20132");
|
|
1318
|
+
out.push(
|
|
1319
|
+
insight({
|
|
1320
|
+
title: `${fmtPct(m.narrowSpanPct)} of managers fall below their level's target span band`,
|
|
1321
|
+
severity: m.narrowSpanPct > 0.4 ? "high" : "medium",
|
|
1322
|
+
category: "span",
|
|
1323
|
+
evidence: `${m.narrowSpanManagers.length} of ${m.managerCount} managers fall below the target span band for their level (bands are set per level in Settings; the flat ${a.narrowSpanThreshold}-report threshold applies as fallback for unleveled employees); ${tiny?.count ?? 0} manage only 1\u20132 people. Combined cost of narrow-span managers: ${fmtMoney(m.narrowSpanCost, sym)}.`,
|
|
1324
|
+
impact: "Fragmented spans suggest management capacity is underused and layers may be redundant.",
|
|
1325
|
+
recommendation: "Use Scenario Modelling \u2192 Apply Target Spans to generate consolidation options for review.",
|
|
1326
|
+
relatedEntities: m.narrowSpanManagers.slice(0, 10),
|
|
1327
|
+
financialImpact: m.delayeringOpportunity,
|
|
1328
|
+
employeesAffected: m.narrowSpanManagers.length,
|
|
1329
|
+
confidence: "high",
|
|
1330
|
+
easeOfAction: "moderate",
|
|
1331
|
+
linkTo: "/diagnostics"
|
|
1332
|
+
})
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
if (m.wideSpanManagers.length > 0) {
|
|
1336
|
+
const widest = m.wideSpanManagers.map((id) => tree2.nodes.get(id)).sort((x, y) => y.span - x.span)[0];
|
|
1337
|
+
out.push(
|
|
1338
|
+
insight({
|
|
1339
|
+
title: `${m.wideSpanManagers.length} manager(s) exceed their level's target span band (max ${a.wideSpanThreshold} for unleveled roles)`,
|
|
1340
|
+
severity: widest.span >= a.wideSpanThreshold + 4 ? "high" : "medium",
|
|
1341
|
+
category: "span",
|
|
1342
|
+
evidence: `The widest span is ${widest.span} reports (${widest.employee.employee_name}, ${widest.employee.role_title}, ${widest.employee.function}). Target span bands are set per level in Settings; the flat ${a.wideSpanThreshold}-report ceiling applies as fallback for unleveled employees.`,
|
|
1343
|
+
impact: "Overloaded managers risk supervision gaps, slower coaching and attrition in their teams.",
|
|
1344
|
+
recommendation: "Consider splitting overloaded teams or adding a team-lead level only where supervision genuinely requires it.",
|
|
1345
|
+
relatedEntities: m.wideSpanManagers,
|
|
1346
|
+
financialImpact: 0,
|
|
1347
|
+
employeesAffected: m.wideSpanManagers.length,
|
|
1348
|
+
confidence: "high",
|
|
1349
|
+
easeOfAction: "moderate",
|
|
1350
|
+
linkTo: "/diagnostics"
|
|
1351
|
+
})
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
if (m.managerOfOneCount >= 2) {
|
|
1355
|
+
const m1Nodes = [...tree2.nodes.values()].filter((n) => n.isManagerOfOne);
|
|
1356
|
+
const m1Cost = m1Nodes.reduce((s2, n) => s2 + (n.employee.total_cost || 0), 0);
|
|
1357
|
+
const share = m.managerCount ? m.managerOfOneCount / m.managerCount : 0;
|
|
1358
|
+
out.push(
|
|
1359
|
+
insight({
|
|
1360
|
+
title: `${m.managerOfOneCount} "manager of one" roles each oversee a single report`,
|
|
1361
|
+
severity: share > 0.15 ? "high" : "medium",
|
|
1362
|
+
category: "span",
|
|
1363
|
+
evidence: `${m.managerOfOneCount} of ${m.managerCount} managers (${fmtPct(share)}) have exactly one direct report, with combined cost ${fmtMoney(m1Cost, sym)}. Single-report management layers are a primary target in 2025\u201326 flattening playbooks.`,
|
|
1364
|
+
impact: "A manager with one report usually adds a hand-off and an approval step without widening coordination \u2014 a candidate to flatten by merging the role into player-coach or peer structures.",
|
|
1365
|
+
recommendation: "Review each manager-of-one: convert to an individual contributor / player-coach, or consolidate the pair under a peer manager. Validate against development and succession intent before acting.",
|
|
1366
|
+
relatedEntities: m1Nodes.map((n) => n.employee.employee_id).slice(0, 15),
|
|
1367
|
+
financialImpact: m.spanOptimizationOpportunity,
|
|
1368
|
+
employeesAffected: m.managerOfOneCount,
|
|
1369
|
+
confidence: "high",
|
|
1370
|
+
easeOfAction: "moderate",
|
|
1371
|
+
linkTo: "/diagnostics"
|
|
1372
|
+
})
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
if (m.pureSupervisoryLayers.length > 0) {
|
|
1376
|
+
const layersList = m.pureSupervisoryLayers.join(", ");
|
|
1377
|
+
const passThruNodes = [...tree2.nodes.values()].filter(
|
|
1378
|
+
(n) => m.pureSupervisoryLayers.includes(n.layer) && n.isManager
|
|
1379
|
+
);
|
|
1380
|
+
const passCost = passThruNodes.reduce((s2, n) => s2 + (n.employee.total_cost || 0), 0);
|
|
1381
|
+
out.push(
|
|
1382
|
+
insight({
|
|
1383
|
+
title: `Layer${m.pureSupervisoryLayers.length > 1 ? "s" : ""} ${layersList} ${m.pureSupervisoryLayers.length > 1 ? "are" : "is"} purely supervisory (managers managing managers)`,
|
|
1384
|
+
severity: "medium",
|
|
1385
|
+
category: "layer",
|
|
1386
|
+
evidence: `Every role at layer ${layersList} is a manager whose direct reports are all themselves managers \u2014 a management-only pass-through with no ICs (${passThruNodes.length} role(s), ${fmtMoney(passCost, sym)} cost).`,
|
|
1387
|
+
impact: "Pass-through layers add escalation distance and dilute accountability without owning delivery work.",
|
|
1388
|
+
recommendation: "Test a delayering scenario targeting these layers; confirm each retained layer holds distinct decision rights.",
|
|
1389
|
+
relatedEntities: passThruNodes.map((n) => n.employee.employee_id).slice(0, 15),
|
|
1390
|
+
financialImpact: 0,
|
|
1391
|
+
employeesAffected: passThruNodes.length,
|
|
1392
|
+
confidence: "high",
|
|
1393
|
+
easeOfAction: "moderate",
|
|
1394
|
+
linkTo: "/diagnostics"
|
|
1395
|
+
})
|
|
1396
|
+
);
|
|
1397
|
+
}
|
|
1398
|
+
const aiHigh = m.aiExposure.find((b) => b.band === "high");
|
|
1399
|
+
if (aiHigh && m.totalCost > 0 && aiHigh.cost / m.totalCost > 0.1) {
|
|
1400
|
+
const costShare = aiHigh.cost / m.totalCost;
|
|
1401
|
+
out.push(
|
|
1402
|
+
insight({
|
|
1403
|
+
title: `${fmtPct(costShare)} of workforce cost sits in roles with high AI-augmentation potential`,
|
|
1404
|
+
severity: costShare > 0.25 ? "high" : "medium",
|
|
1405
|
+
category: "cost",
|
|
1406
|
+
evidence: `${aiHigh.headcount} role(s) (${fmtMoney(aiHigh.cost, sym)}) fall in the high AI-augmentation band \u2014 predominantly processing, support, coordination and analytical work. Banding is heuristic and indicative.`,
|
|
1407
|
+
impact: "High-augmentation work is where AI-enabled productivity and role redesign can compound \u2014 but augmentation is not elimination; the lens is about reshaping work, not headcount cuts.",
|
|
1408
|
+
recommendation: "Run a role-level assessment for the high-band population: identify tasks suited to augmentation, redesign workflows, and reinvest freed capacity. Treat as a productivity/redesign opportunity requiring validation, not an automatic reduction.",
|
|
1409
|
+
relatedEntities: [],
|
|
1410
|
+
financialImpact: 0,
|
|
1411
|
+
employeesAffected: aiHigh.headcount,
|
|
1412
|
+
confidence: "low",
|
|
1413
|
+
easeOfAction: "hard",
|
|
1414
|
+
linkTo: "/cost"
|
|
1415
|
+
})
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
if (m.totalCost > 0 && m.unfilledSeatCost / m.totalCost >= 0.03) {
|
|
1419
|
+
const gapShare = m.unfilledSeatCost / m.totalCost;
|
|
1420
|
+
out.push(
|
|
1421
|
+
insight({
|
|
1422
|
+
title: `Designed cost runs ${fmtMoney(m.unfilledSeatCost, sym)} above current run-rate \u2014 ${m.unfilledSeats} unfilled seat${m.unfilledSeats === 1 ? "" : "s"}`,
|
|
1423
|
+
severity: gapShare >= 0.08 ? "high" : "medium",
|
|
1424
|
+
category: "cost",
|
|
1425
|
+
evidence: `${m.unfilledSeats} budgeted seat${m.unfilledSeats === 1 ? " is" : "s are"} vacant or to-be-hired (${m.vacantSeats} vacant, ${m.tbhSeats} TBH), carrying ${fmtMoney(m.unfilledSeatCost, sym)} of budgeted cost \u2014 ${fmtPct(gapShare)} of the ${fmtMoney(m.totalCost, sym)} designed cost base. Run-rate today is ${fmtMoney(m.runRateCost, sym)}.`,
|
|
1426
|
+
impact: "Every unfilled seat is a pending cost commitment. Closing vacancies instead of filling them avoids future cost with zero severance \u2014 the cheapest restructuring lever available.",
|
|
1427
|
+
recommendation: "Review each open seat against the target structure before recruiting: model a scenario eliminating the vacancies that the future design doesn't need.",
|
|
1428
|
+
relatedEntities: employees.filter((e) => e.position_status === "vacant" || e.position_status === "tbh").map((e) => e.employee_id).slice(0, 15),
|
|
1429
|
+
financialImpact: m.unfilledSeatCost,
|
|
1430
|
+
employeesAffected: m.unfilledSeats,
|
|
1431
|
+
confidence: "high",
|
|
1432
|
+
easeOfAction: "easy",
|
|
1433
|
+
linkTo: "/scenarios"
|
|
1434
|
+
})
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
if (m.vacancyRate > 0) {
|
|
1438
|
+
const concentrated = Object.entries(m.unfilledByFunction).map(([func, count]) => ({
|
|
1439
|
+
func,
|
|
1440
|
+
count,
|
|
1441
|
+
rate: count / Math.max(1, m.headcountByFunction[func] ?? 0)
|
|
1442
|
+
})).filter((f) => f.count >= 3 && f.rate >= m.vacancyRate * 2).sort((x, y) => y.count - x.count)[0];
|
|
1443
|
+
if (concentrated) {
|
|
1444
|
+
out.push(
|
|
1445
|
+
insight({
|
|
1446
|
+
title: `${concentrated.func} concentrates ${concentrated.count} of the organization's ${m.unfilledSeats} unfilled seats`,
|
|
1447
|
+
severity: concentrated.rate >= 0.25 ? "high" : "medium",
|
|
1448
|
+
category: "structure",
|
|
1449
|
+
evidence: `${fmtPct(concentrated.rate)} of ${concentrated.func}'s ${m.headcountByFunction[concentrated.func] ?? 0} seats are vacant or to-be-hired, versus ${fmtPct(m.vacancyRate)} organization-wide.`,
|
|
1450
|
+
impact: "Concentrated vacancy signals either acute hiring exposure (delivery risk in that function) or phantom structure \u2014 seats budgeted for an organization that no longer matches the plan.",
|
|
1451
|
+
recommendation: `Validate ${concentrated.func}'s open seats with its leadership: confirm which are genuinely needed in the target design, then either prioritize the hires or model eliminating the remainder.`,
|
|
1452
|
+
relatedEntities: [concentrated.func],
|
|
1453
|
+
financialImpact: 0,
|
|
1454
|
+
employeesAffected: concentrated.count,
|
|
1455
|
+
confidence: "high",
|
|
1456
|
+
easeOfAction: "moderate",
|
|
1457
|
+
linkTo: "/org"
|
|
1458
|
+
})
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
const avgMgmtRatio = m.managementCostRatio;
|
|
1463
|
+
const funcMgmtRatios = [];
|
|
1464
|
+
for (const func of Object.keys(m.costByFunction)) {
|
|
1465
|
+
const funcNodes = [...tree2.nodes.values()].filter((n) => n.layer > 0 && n.employee.function === func);
|
|
1466
|
+
const funcCost = funcNodes.reduce((s2, n) => s2 + (n.employee.total_cost || 0), 0);
|
|
1467
|
+
const funcMgmt = funcNodes.filter((n) => n.isManager).reduce((s2, n) => s2 + (n.employee.total_cost || 0), 0);
|
|
1468
|
+
if (funcCost > 0 && funcNodes.length >= 5) funcMgmtRatios.push([func, funcMgmt / funcCost]);
|
|
1469
|
+
}
|
|
1470
|
+
funcMgmtRatios.sort((x, y) => y[1] - x[1]);
|
|
1471
|
+
if (funcMgmtRatios[0] && funcMgmtRatios[0][1] > avgMgmtRatio + 0.08) {
|
|
1472
|
+
const [func, ratio] = funcMgmtRatios[0];
|
|
1473
|
+
out.push(
|
|
1474
|
+
insight({
|
|
1475
|
+
title: `${func} has the highest management cost ratio at ${fmtPct(ratio)}`,
|
|
1476
|
+
severity: ratio > 0.45 ? "high" : "medium",
|
|
1477
|
+
category: "cost",
|
|
1478
|
+
evidence: `${fmtPct(ratio)} of ${func}'s cost sits with people managers, versus a company average of ${fmtPct(avgMgmtRatio)}.`,
|
|
1479
|
+
impact: "A high management cost share can indicate over-layering or narrow spans within the function.",
|
|
1480
|
+
recommendation: `Review ${func}'s structure in the Org Explorer and test span/layer scenarios.`,
|
|
1481
|
+
relatedEntities: [func],
|
|
1482
|
+
financialImpact: (ratio - avgMgmtRatio) * (m.costByFunction[func] ?? 0),
|
|
1483
|
+
employeesAffected: m.headcountByFunction[func] ?? 0,
|
|
1484
|
+
confidence: "medium",
|
|
1485
|
+
easeOfAction: "moderate",
|
|
1486
|
+
linkTo: "/cost"
|
|
1487
|
+
})
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
const topFunc = sortedEntries(m.costByFunction)[0];
|
|
1491
|
+
if (topFunc) {
|
|
1492
|
+
out.push(
|
|
1493
|
+
insight({
|
|
1494
|
+
title: `${topFunc[0]} is the highest-cost function at ${fmtMoney(topFunc[1], sym)}`,
|
|
1495
|
+
severity: "low",
|
|
1496
|
+
category: "cost",
|
|
1497
|
+
evidence: `${topFunc[0]} accounts for ${fmtPct(topFunc[1] / m.totalCost)} of total workforce cost with ${m.headcountByFunction[topFunc[0]] ?? 0} employees.`,
|
|
1498
|
+
impact: "Cost concentration identifies where structural decisions have the largest financial leverage.",
|
|
1499
|
+
recommendation: "Prioritize this function when evaluating delayering and span scenarios.",
|
|
1500
|
+
relatedEntities: [topFunc[0]],
|
|
1501
|
+
financialImpact: 0,
|
|
1502
|
+
employeesAffected: m.headcountByFunction[topFunc[0]] ?? 0,
|
|
1503
|
+
confidence: "high",
|
|
1504
|
+
easeOfAction: "easy",
|
|
1505
|
+
linkTo: "/cost"
|
|
1506
|
+
})
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
if (m.duplicateRoles.length > 0) {
|
|
1510
|
+
const top = m.duplicateRoles[0];
|
|
1511
|
+
out.push(
|
|
1512
|
+
insight({
|
|
1513
|
+
title: `${m.duplicateRoles.length} potential duplicate senior role group(s) across business units`,
|
|
1514
|
+
severity: m.duplicateRoleCost > m.totalCost * 0.02 ? "high" : "medium",
|
|
1515
|
+
category: "duplication",
|
|
1516
|
+
evidence: `Largest group: "${top.employees[0].title}" held by ${top.employees.length} people across ${top.units.join(", ")} (combined cost ${fmtMoney(top.totalCost, sym)}). Total cost of potential duplicates beyond one holder per group: ${fmtMoney(m.duplicateRoleCost, sym)}.`,
|
|
1517
|
+
impact: "Duplicated leadership roles across units may indicate decentralization without deliberate design.",
|
|
1518
|
+
recommendation: "Mark as potential duplication requiring review \u2014 consider a shared-services scenario to test consolidation. Duplication is not automatically bad; validate with business owners.",
|
|
1519
|
+
relatedEntities: m.duplicateRoles.flatMap((g) => g.employees.map((e) => e.id)).slice(0, 15),
|
|
1520
|
+
financialImpact: m.duplicationOpportunity,
|
|
1521
|
+
employeesAffected: m.duplicateRoles.reduce((s2, g) => s2 + g.employees.length, 0),
|
|
1522
|
+
confidence: "medium",
|
|
1523
|
+
easeOfAction: "hard",
|
|
1524
|
+
linkTo: "/insights"
|
|
1525
|
+
})
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
const execNodes = [...tree2.nodes.values()].filter((n) => n.layer <= 2 && n.isManager);
|
|
1529
|
+
const seniorIcs = execNodes.flatMap(
|
|
1530
|
+
(ex) => ex.directReports.map((id) => tree2.nodes.get(id)).filter((n) => !n.isManager && (n.employee.total_cost || 0) > 35e4)
|
|
1531
|
+
);
|
|
1532
|
+
if (seniorIcs.length >= 2) {
|
|
1533
|
+
const totalIcCost = seniorIcs.reduce((s2, n) => s2 + n.employee.total_cost, 0);
|
|
1534
|
+
out.push(
|
|
1535
|
+
insight({
|
|
1536
|
+
title: `${seniorIcs.length} expensive individual contributors report directly to executives`,
|
|
1537
|
+
severity: "medium",
|
|
1538
|
+
category: "structure",
|
|
1539
|
+
evidence: `${seniorIcs.length} ICs costing over ${fmtMoney(35e4, sym)} each (combined ${fmtMoney(totalIcCost, sym)}) sit at executive level: ${seniorIcs.slice(0, 3).map((n) => n.employee.role_title).join("; ")}.`,
|
|
1540
|
+
impact: "Senior IC roles at the top consume executive attention and can blur accountability for delivery.",
|
|
1541
|
+
recommendation: "Validate the mandate of each senior advisory role; consider anchoring them within functions.",
|
|
1542
|
+
relatedEntities: seniorIcs.map((n) => n.employee.employee_id),
|
|
1543
|
+
financialImpact: 0,
|
|
1544
|
+
employeesAffected: seniorIcs.length,
|
|
1545
|
+
confidence: "medium",
|
|
1546
|
+
easeOfAction: "easy",
|
|
1547
|
+
linkTo: "/org"
|
|
1548
|
+
})
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
const locEntries = sortedEntries(m.costByLocation);
|
|
1552
|
+
if (locEntries.length >= 2) {
|
|
1553
|
+
const hqLoc = locEntries[0];
|
|
1554
|
+
const avgCostByLoc = Object.entries(m.costByLocation).map(([loc, cost]) => ({
|
|
1555
|
+
loc,
|
|
1556
|
+
avg: cost / Math.max(1, m.headcountByLocation[loc] ?? 1),
|
|
1557
|
+
hc: m.headcountByLocation[loc] ?? 0
|
|
1558
|
+
}));
|
|
1559
|
+
avgCostByLoc.sort((x, y) => y.avg - x.avg);
|
|
1560
|
+
const high = avgCostByLoc[0];
|
|
1561
|
+
const low = avgCostByLoc[avgCostByLoc.length - 1];
|
|
1562
|
+
if (high && low && high.avg > low.avg * 2 && low.hc >= 5) {
|
|
1563
|
+
out.push(
|
|
1564
|
+
insight({
|
|
1565
|
+
title: `Average cost per head in ${high.loc} is ${(high.avg / low.avg).toFixed(1)}\xD7 ${low.loc}`,
|
|
1566
|
+
severity: "low",
|
|
1567
|
+
category: "location",
|
|
1568
|
+
evidence: `${high.loc}: ${fmtMoney(high.avg, sym)}/head across ${high.hc} employees vs ${low.loc}: ${fmtMoney(low.avg, sym)}/head across ${low.hc}. ${hqLoc[0]} carries the largest absolute cost (${fmtMoney(hqLoc[1], sym)}).`,
|
|
1569
|
+
impact: "Location mix is a structural cost lever, particularly for transactional and delivery roles.",
|
|
1570
|
+
recommendation: "Test a location-shift scenario for portable role families. Requires validation against service, regulatory and talent constraints.",
|
|
1571
|
+
relatedEntities: [high.loc, low.loc],
|
|
1572
|
+
financialImpact: 0,
|
|
1573
|
+
employeesAffected: high.hc,
|
|
1574
|
+
confidence: "medium",
|
|
1575
|
+
easeOfAction: "hard",
|
|
1576
|
+
linkTo: "/scenarios"
|
|
1577
|
+
})
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
const gradeCounts = sortedEntries(m.headcountByGrade);
|
|
1582
|
+
const seniorShare = (m.headcountByGrade["E1"] ?? 0) + (m.headcountByGrade["E2"] ?? 0) + (m.headcountByGrade["M1"] ?? 0);
|
|
1583
|
+
if (m.headcount > 0 && seniorShare / m.headcount > 0.12 && gradeCounts.length >= 4) {
|
|
1584
|
+
out.push(
|
|
1585
|
+
insight({
|
|
1586
|
+
title: `Senior grades represent ${fmtPct(seniorShare / m.headcount)} of headcount \u2014 top-heavy pyramid`,
|
|
1587
|
+
severity: "medium",
|
|
1588
|
+
category: "grade",
|
|
1589
|
+
evidence: `${seniorShare} employees sit in the top three grades out of ${m.headcount} total.`,
|
|
1590
|
+
impact: "A top-heavy grade mix inflates cost and can compress development paths for mid-level talent.",
|
|
1591
|
+
recommendation: "Review the grade pyramid in Cost Analytics and validate spans at senior grades.",
|
|
1592
|
+
relatedEntities: ["E1", "E2", "M1"],
|
|
1593
|
+
financialImpact: 0,
|
|
1594
|
+
employeesAffected: seniorShare,
|
|
1595
|
+
confidence: "medium",
|
|
1596
|
+
easeOfAction: "hard",
|
|
1597
|
+
linkTo: "/cost"
|
|
1598
|
+
})
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
const overloadedHighRisk = m.wideSpanManagers.map((id) => tree2.nodes.get(id)).filter((n) => n.directReports.some((rid) => tree2.nodes.get(rid)?.employee.attrition_risk === "High"));
|
|
1602
|
+
if (overloadedHighRisk.length > 0) {
|
|
1603
|
+
out.push(
|
|
1604
|
+
insight({
|
|
1605
|
+
title: `Overloaded teams contain high attrition-risk employees`,
|
|
1606
|
+
severity: "high",
|
|
1607
|
+
category: "risk",
|
|
1608
|
+
evidence: `${overloadedHighRisk.length} overloaded manager(s) have direct reports flagged high attrition risk (e.g. ${overloadedHighRisk[0].employee.employee_name}'s team of ${overloadedHighRisk[0].span}).`,
|
|
1609
|
+
impact: "Wide spans plus attrition risk compound: less manager attention precisely where retention is fragile.",
|
|
1610
|
+
recommendation: "Prioritize span relief for these teams before broader restructuring.",
|
|
1611
|
+
relatedEntities: overloadedHighRisk.map((n) => n.employee.employee_id),
|
|
1612
|
+
financialImpact: 0,
|
|
1613
|
+
employeesAffected: overloadedHighRisk.reduce((s2, n) => s2 + n.span, 0),
|
|
1614
|
+
confidence: "medium",
|
|
1615
|
+
easeOfAction: "moderate",
|
|
1616
|
+
linkTo: "/diagnostics"
|
|
1617
|
+
})
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
if (m.totalOpportunity > 0) {
|
|
1621
|
+
out.push(
|
|
1622
|
+
insight({
|
|
1623
|
+
title: `Indicative simplification opportunity of ${fmtMoney(m.totalOpportunity, sym)}`,
|
|
1624
|
+
severity: m.totalOpportunity > m.totalCost * 0.03 ? "high" : "medium",
|
|
1625
|
+
category: "executive",
|
|
1626
|
+
evidence: `Composed of three levers: delayering of narrow-span managers with 3+ reports (${fmtMoney(m.delayeringOpportunity, sym)}), span optimization of managers with only 1\u20132 reports who could be ICs (${fmtMoney(m.spanOptimizationOpportunity, sym)}) and duplicate-role consolidation (${fmtMoney(m.duplicationOpportunity, sym)}). The two span levers are disjoint (no manager counted in both); duplication is a separate lens that may overlap with delayering. Based on configurable assumptions (${a.delayeringSavingPct}% / ${a.duplicateRoleSavingPct}% capture rates, ${a.spanOptPremiumPct ?? 20}% management premium).`,
|
|
1627
|
+
impact: `Equivalent to ${fmtPct(m.totalOpportunity / m.totalCost)} of total workforce cost. Indicative only \u2014 requires validation through detailed design.`,
|
|
1628
|
+
recommendation: "Build 2\u20133 scenarios capturing different portions of this opportunity and compare them in From-To Comparison.",
|
|
1629
|
+
relatedEntities: [],
|
|
1630
|
+
financialImpact: m.totalOpportunity,
|
|
1631
|
+
employeesAffected: m.narrowSpanManagers.length,
|
|
1632
|
+
confidence: "medium",
|
|
1633
|
+
easeOfAction: "moderate",
|
|
1634
|
+
linkTo: "/scenarios"
|
|
1635
|
+
})
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
for (const { name: name2, evaluation: ev } of ctx.scenarioEvaluations ?? []) {
|
|
1639
|
+
const saving = -ev.costDelta;
|
|
1640
|
+
out.push(
|
|
1641
|
+
insight({
|
|
1642
|
+
title: `Scenario "${name2}": ${saving >= 0 ? `${fmtMoney(saving, sym)} indicative saving` : `${fmtMoney(-saving, sym)} cost increase`}, ${ev.layerDelta === 0 ? "no layer change" : `${Math.abs(ev.layerDelta)} layer${Math.abs(ev.layerDelta) > 1 ? "s" : ""} ${ev.layerDelta < 0 ? "removed" : "added"}`}`,
|
|
1643
|
+
severity: ev.risk.level === "High" || ev.risk.level === "Very High" ? "medium" : "low",
|
|
1644
|
+
category: "scenario",
|
|
1645
|
+
evidence: `Headcount ${ev.headcountDelta >= 0 ? "+" : ""}${ev.headcountDelta}; ${ev.employeesMoved} moved, ${ev.rolesRemoved} roles removed, ${ev.rolesAdded} added. Change risk: ${ev.risk.level} (${ev.risk.score}/100). Scenario score: ${ev.score.total}/100.`,
|
|
1646
|
+
impact: ev.risk.recommendation,
|
|
1647
|
+
recommendation: `Compare against other options in From-To Comparison before committing.`,
|
|
1648
|
+
relatedEntities: [],
|
|
1649
|
+
financialImpact: Math.max(0, saving),
|
|
1650
|
+
employeesAffected: ev.employeesMoved + ev.rolesRemoved,
|
|
1651
|
+
confidence: "high",
|
|
1652
|
+
easeOfAction: ev.risk.level === "Low" ? "easy" : ev.risk.level === "Medium" ? "moderate" : "hard",
|
|
1653
|
+
linkTo: "/compare"
|
|
1654
|
+
})
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
return rankInsights(out);
|
|
1658
|
+
}
|
|
1659
|
+
function rankInsights(insights) {
|
|
1660
|
+
const sevW = { high: 3, medium: 2, low: 1 };
|
|
1661
|
+
const confW = { high: 1.2, medium: 1, low: 0.8 };
|
|
1662
|
+
const easeW = { easy: 1.2, moderate: 1, hard: 0.85 };
|
|
1663
|
+
const maxFin = Math.max(1, ...insights.map((i) => i.financialImpact));
|
|
1664
|
+
const maxEmp = Math.max(1, ...insights.map((i) => i.employeesAffected));
|
|
1665
|
+
const scored = insights.map((i) => ({
|
|
1666
|
+
i,
|
|
1667
|
+
s: sevW[i.severity] * 40 + i.financialImpact / maxFin * 30 + i.employeesAffected / maxEmp * 15 + confW[i.confidence] * 10 + easeW[i.easeOfAction] * 5
|
|
1668
|
+
}));
|
|
1669
|
+
scored.sort((a, b) => b.s - a.s);
|
|
1670
|
+
return scored.map(({ i }, idx) => ({ ...i, rank: idx + 1 }));
|
|
1671
|
+
}
|
|
1672
|
+
function generateInsightsFromEmployees(employees, assumptions, scenarioEvaluations) {
|
|
1673
|
+
const metrics = computeMetrics(employees, assumptions);
|
|
1674
|
+
return generateInsights({ employees, metrics, assumptions, scenarioEvaluations });
|
|
1675
|
+
}
|
|
1676
|
+
var FULL_NODE = { nodeWidth: 224, nodeHeight: 104, gapX: 24, gapY: 56 };
|
|
1677
|
+
var COMPACT_NODE = { nodeWidth: 128, nodeHeight: 40, gapX: 14, gapY: 36 };
|
|
1678
|
+
function buildLayoutTree(orgTree, rootId, isExpanded) {
|
|
1679
|
+
const rootNode = orgTree.nodes.get(rootId);
|
|
1680
|
+
if (!rootNode) return null;
|
|
1681
|
+
const root = { id: rootId, node: rootNode, collapsed: false };
|
|
1682
|
+
const stack = [root];
|
|
1683
|
+
while (stack.length > 0) {
|
|
1684
|
+
const datum = stack.pop();
|
|
1685
|
+
const open = isExpanded(datum.id);
|
|
1686
|
+
datum.collapsed = datum.node.directReports.length > 0 && !open;
|
|
1687
|
+
if (!open) continue;
|
|
1688
|
+
const kids = [];
|
|
1689
|
+
for (const id of datum.node.directReports) {
|
|
1690
|
+
const node = orgTree.nodes.get(id);
|
|
1691
|
+
if (!node) continue;
|
|
1692
|
+
const kid = { id, node, collapsed: false };
|
|
1693
|
+
kids.push(kid);
|
|
1694
|
+
stack.push(kid);
|
|
1695
|
+
}
|
|
1696
|
+
if (kids.length > 0) datum.children = kids;
|
|
1697
|
+
}
|
|
1698
|
+
return root;
|
|
1699
|
+
}
|
|
1700
|
+
function layoutOrg(orgTree, rootIds, isExpanded, opts) {
|
|
1701
|
+
const nodes = [];
|
|
1702
|
+
const edges = [];
|
|
1703
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1704
|
+
let offsetX = 0;
|
|
1705
|
+
for (const rootId of rootIds) {
|
|
1706
|
+
const datum = buildLayoutTree(orgTree, rootId, isExpanded);
|
|
1707
|
+
if (!datum) continue;
|
|
1708
|
+
const root = hierarchy(datum, (d) => d.children);
|
|
1709
|
+
const layout = tree().nodeSize([
|
|
1710
|
+
opts.nodeWidth + opts.gapX,
|
|
1711
|
+
opts.nodeHeight + opts.gapY
|
|
1712
|
+
]);
|
|
1713
|
+
const positioned = layout(root);
|
|
1714
|
+
let treeMinX = Infinity, treeMaxX = -Infinity;
|
|
1715
|
+
positioned.each((n) => {
|
|
1716
|
+
treeMinX = Math.min(treeMinX, n.x);
|
|
1717
|
+
treeMaxX = Math.max(treeMaxX, n.x);
|
|
1718
|
+
});
|
|
1719
|
+
const shift = offsetX - treeMinX + opts.nodeWidth / 2;
|
|
1720
|
+
positioned.each((n) => {
|
|
1721
|
+
const x = n.x + shift;
|
|
1722
|
+
const y = n.depth * (opts.nodeHeight + opts.gapY);
|
|
1723
|
+
nodes.push({ id: n.data.id, x, y, node: n.data.node, collapsed: n.data.collapsed });
|
|
1724
|
+
minX = Math.min(minX, x - opts.nodeWidth / 2);
|
|
1725
|
+
maxX = Math.max(maxX, x + opts.nodeWidth / 2);
|
|
1726
|
+
minY = Math.min(minY, y);
|
|
1727
|
+
maxY = Math.max(maxY, y + opts.nodeHeight);
|
|
1728
|
+
if (n.parent) {
|
|
1729
|
+
const px = n.parent.x + shift;
|
|
1730
|
+
const py = n.parent.depth * (opts.nodeHeight + opts.gapY);
|
|
1731
|
+
edges.push({
|
|
1732
|
+
id: `${n.parent.data.id}->${n.data.id}`,
|
|
1733
|
+
sourceId: n.parent.data.id,
|
|
1734
|
+
targetId: n.data.id,
|
|
1735
|
+
path: elbowPath(px, py + opts.nodeHeight, x, y)
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
offsetX += treeMaxX - treeMinX + opts.nodeWidth + opts.gapX * 4;
|
|
1740
|
+
}
|
|
1741
|
+
if (nodes.length === 0) return { nodes, edges, bounds: { minX: 0, minY: 0, maxX: 0, maxY: 0 }, visibleCount: 0 };
|
|
1742
|
+
return { nodes, edges, bounds: { minX, minY, maxX, maxY }, visibleCount: nodes.length };
|
|
1743
|
+
}
|
|
1744
|
+
function elbowPath(x1, y1, x2, y2) {
|
|
1745
|
+
const midY = y1 + (y2 - y1) / 2;
|
|
1746
|
+
if (Math.abs(x1 - x2) < 1) return `M${x1},${y1} L${x2},${y2}`;
|
|
1747
|
+
const r = Math.min(8, Math.abs(x2 - x1) / 2, Math.abs(y2 - midY));
|
|
1748
|
+
const dir = x2 > x1 ? 1 : -1;
|
|
1749
|
+
return [
|
|
1750
|
+
`M${x1},${y1}`,
|
|
1751
|
+
`L${x1},${midY - r}`,
|
|
1752
|
+
`Q${x1},${midY} ${x1 + r * dir},${midY}`,
|
|
1753
|
+
`L${x2 - r * dir},${midY}`,
|
|
1754
|
+
`Q${x2},${midY} ${x2},${midY + r}`,
|
|
1755
|
+
`L${x2},${y2}`
|
|
1756
|
+
].join(" ");
|
|
1757
|
+
}
|
|
1758
|
+
function fitToBounds(bounds, containerW, containerH, padding = 48, maxK = 1, minK = 0.05) {
|
|
1759
|
+
const bw = Math.max(1, bounds.maxX - bounds.minX);
|
|
1760
|
+
const bh = Math.max(1, bounds.maxY - bounds.minY);
|
|
1761
|
+
const k = Math.max(
|
|
1762
|
+
minK,
|
|
1763
|
+
Math.min(maxK, (containerW - padding * 2) / bw, (containerH - padding * 2) / bh)
|
|
1764
|
+
);
|
|
1765
|
+
return {
|
|
1766
|
+
k,
|
|
1767
|
+
x: (containerW - bw * k) / 2 - bounds.minX * k,
|
|
1768
|
+
y: (containerH - bh * k) / 2 - bounds.minY * k
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
function zoomAround(vp, screenX, screenY, factor, minK = 0.08, maxK = 2) {
|
|
1772
|
+
const k = Math.min(maxK, Math.max(minK, vp.k * factor));
|
|
1773
|
+
const ratio = k / vp.k;
|
|
1774
|
+
return { k, x: screenX - (screenX - vp.x) * ratio, y: screenY - (screenY - vp.y) * ratio };
|
|
1775
|
+
}
|
|
1776
|
+
function visibleNodes(nodes, vp, containerW, containerH, opts, overscan = 400) {
|
|
1777
|
+
const left = (-vp.x - overscan) / vp.k;
|
|
1778
|
+
const right = (containerW - vp.x + overscan) / vp.k;
|
|
1779
|
+
const top = (-vp.y - overscan) / vp.k;
|
|
1780
|
+
const bottom = (containerH - vp.y + overscan) / vp.k;
|
|
1781
|
+
return nodes.filter(
|
|
1782
|
+
(n) => n.x + opts.nodeWidth / 2 >= left && n.x - opts.nodeWidth / 2 <= right && n.y + opts.nodeHeight >= top && n.y <= bottom
|
|
1783
|
+
);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// src/lib/demo-data.ts
|
|
1787
|
+
function mulberry32(seed) {
|
|
1788
|
+
let a = seed;
|
|
1789
|
+
return () => {
|
|
1790
|
+
a |= 0;
|
|
1791
|
+
a = a + 1831565813 | 0;
|
|
1792
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
1793
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
1794
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
var rand = mulberry32(420042);
|
|
1798
|
+
var pick = (arr) => arr[Math.floor(rand() * arr.length)];
|
|
1799
|
+
var randint = (min, max) => min + Math.floor(rand() * (max - min + 1));
|
|
1800
|
+
var FIRST = ["James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", "David", "Elena", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica", "Thomas", "Sarah", "Carlos", "Karen", "Daniel", "Nancy", "Matthew", "Lisa", "Anthony", "Betty", "Mark", "Maya", "Donald", "Sandra", "Steven", "Ashley", "Paul", "Kim", "Andrew", "Donna", "Joshua", "Emily", "Kenneth", "Priya", "Kevin", "Carol", "Brian", "Amanda", "George", "Melissa", "Timothy", "Deborah", "Ronald", "Stephanie", "Wei", "Rebecca", "Jason", "Sharon", "Edward", "Laura", "Jeffrey", "Cynthia", "Ryan", "Aisha", "Jacob", "Amy", "Gary", "Anna", "Nicholas", "Ingrid", "Eric", "Ruth", "Jonathan", "Fatima", "Stephen", "Olivia", "Larry", "Noor", "Justin", "Diane", "Scott", "Alice", "Brandon", "Julie", "Raj", "Heather", "Samuel", "Teresa", "Benjamin", "Gloria", "Hiroshi", "Sofia", "Frank", "Evelyn", "Gregory", "Joan", "Tomasz", "Lucia", "Alexander", "Judith", "Patrick", "Mei", "Jack", "Andrea"];
|
|
1801
|
+
var LAST = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", "Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", "Green", "Adams", "Nelson", "Baker", "Hall", "Rivera", "Campbell", "Mitchell", "Carter", "Roberts", "Sharma", "Patel", "Kowalski", "Tan", "Lim", "Murphy", "O'Brien", "Kelly", "Novak", "Kumar", "Singh", "Reyes", "Santos", "Cruz", "Mendoza", "Fischer", "Weber", "Meyer", "Wagner", "Becker"];
|
|
1802
|
+
var usedNames = /* @__PURE__ */ new Set();
|
|
1803
|
+
function name() {
|
|
1804
|
+
for (let i = 0; i < 50; i++) {
|
|
1805
|
+
const n = `${pick(FIRST)} ${pick(LAST)}`;
|
|
1806
|
+
if (!usedNames.has(n)) {
|
|
1807
|
+
usedNames.add(n);
|
|
1808
|
+
return n;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return `${pick(FIRST)} ${pick(LAST)} ${randint(1, 99)}`;
|
|
1812
|
+
}
|
|
1813
|
+
var HQ = { city: "New York", country: "United States", mult: 1 };
|
|
1814
|
+
var LOCS = [
|
|
1815
|
+
HQ,
|
|
1816
|
+
{ city: "London", country: "United Kingdom", mult: 0.95 },
|
|
1817
|
+
{ city: "Singapore", country: "Singapore", mult: 0.85 },
|
|
1818
|
+
{ city: "Dublin", country: "Ireland", mult: 0.8 },
|
|
1819
|
+
{ city: "Warsaw", country: "Poland", mult: 0.45 },
|
|
1820
|
+
{ city: "Bangalore", country: "India", mult: 0.35 },
|
|
1821
|
+
{ city: "Manila", country: "Philippines", mult: 0.3 }
|
|
1822
|
+
];
|
|
1823
|
+
var GRADES = ["E1", "E2", "M1", "M2", "M3", "P3", "P2", "P1"];
|
|
1824
|
+
var BASE_SALARY = [95e4, 52e4, 32e4, 23e4, 165e3, 125e3, 92e3, 62e3];
|
|
1825
|
+
var seq2 = 0;
|
|
1826
|
+
function emp(opts) {
|
|
1827
|
+
seq2 += 1;
|
|
1828
|
+
const id = `E${String(seq2).padStart(4, "0")}`;
|
|
1829
|
+
const loc = opts.loc ?? (rand() < 0.55 ? HQ : pick(LOCS));
|
|
1830
|
+
const lvlIdx = Math.min(opts.level, 8) - 1;
|
|
1831
|
+
const base = BASE_SALARY[lvlIdx] * loc.mult * (0.85 + rand() * 0.3);
|
|
1832
|
+
const salary = Math.round(base / 1e3) * 1e3;
|
|
1833
|
+
const bonusPct = opts.level <= 2 ? 0.5 : opts.level <= 4 ? 0.25 : 0.1;
|
|
1834
|
+
const bonus = Math.round(salary * bonusPct * (0.6 + rand() * 0.8) / 1e3) * 1e3;
|
|
1835
|
+
const total = opts.costOverride ?? Math.round((salary + bonus) * 1.18);
|
|
1836
|
+
return {
|
|
1837
|
+
employee_id: id,
|
|
1838
|
+
employee_name: opts.nameOverride ?? name(),
|
|
1839
|
+
role_title: opts.title,
|
|
1840
|
+
manager_id: opts.managerId,
|
|
1841
|
+
manager_name: opts.managerName,
|
|
1842
|
+
department: opts.dept,
|
|
1843
|
+
function: opts.func,
|
|
1844
|
+
business_unit: opts.bu,
|
|
1845
|
+
location: loc.city,
|
|
1846
|
+
country: loc.country,
|
|
1847
|
+
grade: GRADES[lvlIdx],
|
|
1848
|
+
level: opts.level,
|
|
1849
|
+
employment_type: rand() < 0.93 ? "Full-time" : "Contractor",
|
|
1850
|
+
fte: rand() < 0.95 ? 1 : 0.5,
|
|
1851
|
+
salary,
|
|
1852
|
+
bonus,
|
|
1853
|
+
total_cost: total,
|
|
1854
|
+
status: "Active",
|
|
1855
|
+
tenure: randint(0, 18),
|
|
1856
|
+
gender: rand() < 0.48 ? "Female" : rand() < 0.96 ? "Male" : "Non-binary",
|
|
1857
|
+
performance_rating: pick(["Exceeds", "Meets", "Meets", "Meets", "Below"]),
|
|
1858
|
+
critical_role: opts.critical ?? rand() < 0.06,
|
|
1859
|
+
role_family: opts.roleFamily,
|
|
1860
|
+
attrition_risk: pick(["Low", "Low", "Low", "Medium", "Medium", "High"])
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
function team(opts) {
|
|
1864
|
+
const out = [];
|
|
1865
|
+
const mgr = emp({
|
|
1866
|
+
title: opts.mgrTitle,
|
|
1867
|
+
managerId: opts.managerId,
|
|
1868
|
+
dept: opts.dept,
|
|
1869
|
+
func: opts.func,
|
|
1870
|
+
bu: opts.bu,
|
|
1871
|
+
loc: opts.loc,
|
|
1872
|
+
level: opts.mgrLevel
|
|
1873
|
+
});
|
|
1874
|
+
out.push(mgr);
|
|
1875
|
+
for (let i = 0; i < opts.size; i++) {
|
|
1876
|
+
out.push(
|
|
1877
|
+
emp({
|
|
1878
|
+
title: opts.reportTitle,
|
|
1879
|
+
managerId: mgr.employee_id,
|
|
1880
|
+
managerName: mgr.employee_name,
|
|
1881
|
+
dept: opts.dept,
|
|
1882
|
+
func: opts.func,
|
|
1883
|
+
bu: opts.bu,
|
|
1884
|
+
loc: opts.loc,
|
|
1885
|
+
level: Math.min(8, opts.mgrLevel + randint(1, 2))
|
|
1886
|
+
})
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
return out;
|
|
1890
|
+
}
|
|
1891
|
+
function generateDemoData() {
|
|
1892
|
+
seq2 = 0;
|
|
1893
|
+
usedNames.clear();
|
|
1894
|
+
rand = mulberry32(420042);
|
|
1895
|
+
const all = [];
|
|
1896
|
+
const add = (...es) => {
|
|
1897
|
+
all.push(...es);
|
|
1898
|
+
return es[0];
|
|
1899
|
+
};
|
|
1900
|
+
const ceo = add(emp({ nameOverride: "Margaret Chen", title: "Chief Executive Officer", managerId: null, dept: "Executive", func: "Executive", bu: "Corporate", loc: HQ, level: 1, critical: true }));
|
|
1901
|
+
add(emp({ title: "Chief of Staff", managerId: ceo.employee_id, dept: "Executive", func: "Executive", bu: "Corporate", loc: HQ, level: 3, critical: true }));
|
|
1902
|
+
add(emp({ title: "Executive Assistant", managerId: ceo.employee_id, dept: "Executive", func: "Executive", bu: "Corporate", loc: HQ, level: 7 }));
|
|
1903
|
+
const exec = (title, func, bu = "Corporate") => add(emp({ title, managerId: ceo.employee_id, dept: func, func, bu, loc: HQ, level: 2, critical: true }));
|
|
1904
|
+
const cfo = exec("Chief Financial Officer", "Finance");
|
|
1905
|
+
const chro = exec("Chief Human Resources Officer", "HR");
|
|
1906
|
+
const cto = exec("Chief Technology Officer", "Technology");
|
|
1907
|
+
const coo = exec("Chief Operating Officer", "Operations");
|
|
1908
|
+
const cro = exec("Chief Revenue Officer", "Sales");
|
|
1909
|
+
const cmo = exec("Chief Marketing Officer", "Marketing");
|
|
1910
|
+
const gc = exec("General Counsel", "Legal");
|
|
1911
|
+
const cpo = exec("Chief Product Officer", "Product");
|
|
1912
|
+
const cco = exec("Chief Customer Officer", "Customer Service");
|
|
1913
|
+
const finVp = (t) => add(emp({ title: t, managerId: cfo.employee_id, dept: "Finance", func: "Finance", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1914
|
+
const vpFpa = finVp("VP Financial Planning & Analysis");
|
|
1915
|
+
const vpCtrl = finVp("VP Controller");
|
|
1916
|
+
const vpTreas = finVp("VP Treasury");
|
|
1917
|
+
const vpTax = finVp("VP Tax");
|
|
1918
|
+
add(emp({ title: "Senior Finance Advisor", managerId: cfo.employee_id, dept: "Finance", func: "Finance", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1919
|
+
add(...team({ mgrTitle: "Director, FP&A", reportTitle: "FP&A Analyst", managerId: vpFpa.employee_id, dept: "FP&A", func: "Finance", bu: "Corporate", mgrLevel: 4, size: 5 }));
|
|
1920
|
+
add(...team({ mgrTitle: "Director, Business Finance", reportTitle: "Finance Business Partner", managerId: vpFpa.employee_id, dept: "FP&A", func: "Finance", bu: "Corporate", mgrLevel: 4, size: 4 }));
|
|
1921
|
+
add(...team({ mgrTitle: "Director, Accounting", reportTitle: "Senior Accountant", managerId: vpCtrl.employee_id, dept: "Accounting", func: "Finance", bu: "Corporate", loc: LOCS[4], mgrLevel: 4, size: 6 }));
|
|
1922
|
+
add(...team({ mgrTitle: "Director, Accounts Payable", reportTitle: "AP Specialist", managerId: vpCtrl.employee_id, dept: "Accounting", func: "Finance", bu: "Corporate", loc: LOCS[5], mgrLevel: 4, size: 5 }));
|
|
1923
|
+
add(...team({ mgrTitle: "Treasury Manager", reportTitle: "Treasury Analyst", managerId: vpTreas.employee_id, dept: "Treasury", func: "Finance", bu: "Corporate", mgrLevel: 4, size: 1 }));
|
|
1924
|
+
add(emp({ title: "Cash Management Analyst", managerId: vpTreas.employee_id, dept: "Treasury", func: "Finance", bu: "Corporate", level: 6 }));
|
|
1925
|
+
add(emp({ title: "Tax Manager", managerId: vpTax.employee_id, dept: "Tax", func: "Finance", bu: "Corporate", level: 4 }));
|
|
1926
|
+
const vpPeopleOps = add(emp({ title: "VP People Operations", managerId: chro.employee_id, dept: "HR", func: "HR", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1927
|
+
const vpTalent = add(emp({ title: "VP Talent Acquisition", managerId: chro.employee_id, dept: "HR", func: "HR", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1928
|
+
add(emp({ title: "Head of Total Rewards", managerId: chro.employee_id, dept: "HR", func: "HR", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1929
|
+
add(...team({ mgrTitle: "Manager, HR Business Partners", reportTitle: "HR Business Partner", managerId: vpPeopleOps.employee_id, dept: "HR", func: "HR", bu: "Corporate", mgrLevel: 4, size: 6 }));
|
|
1930
|
+
add(...team({ mgrTitle: "Manager, HR Shared Services", reportTitle: "HR Operations Specialist", managerId: vpPeopleOps.employee_id, dept: "HR", func: "HR", bu: "Corporate", loc: LOCS[5], mgrLevel: 4, size: 7 }));
|
|
1931
|
+
add(...team({ mgrTitle: "Manager, Recruiting", reportTitle: "Recruiter", managerId: vpTalent.employee_id, dept: "HR", func: "HR", bu: "Corporate", mgrLevel: 4, size: 5 }));
|
|
1932
|
+
const svpEng = add(emp({ title: "SVP Engineering", managerId: cto.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", loc: HQ, level: 3, critical: true }));
|
|
1933
|
+
const vpInfra = add(emp({ title: "VP Infrastructure", managerId: svpEng.employee_id, dept: "Infrastructure", func: "Technology", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1934
|
+
const vpApps = add(emp({ title: "VP Application Development", managerId: svpEng.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1935
|
+
add(emp({ title: "Head of PMO", managerId: cto.employee_id, dept: "Technology PMO", func: "Technology", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1936
|
+
add(emp({ title: "Chief Architect", managerId: cto.employee_id, dept: "Architecture", func: "Technology", bu: "Corporate", loc: HQ, level: 3, costOverride: 72e4, critical: true }));
|
|
1937
|
+
const vpSec = add(emp({ title: "VP Information Security", managerId: cto.employee_id, dept: "Security", func: "Technology", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1938
|
+
const srDirInfra = add(emp({ title: "Senior Director, Cloud Platform", managerId: vpInfra.employee_id, dept: "Infrastructure", func: "Technology", bu: "Corporate", level: 4 }));
|
|
1939
|
+
const dirCloud = add(emp({ title: "Director, Cloud Operations", managerId: srDirInfra.employee_id, dept: "Infrastructure", func: "Technology", bu: "Corporate", level: 5 }));
|
|
1940
|
+
add(...team({ mgrTitle: "Manager, Cloud Operations", reportTitle: "Cloud Engineer", managerId: dirCloud.employee_id, dept: "Infrastructure", func: "Technology", bu: "Corporate", loc: LOCS[5], mgrLevel: 6, size: 6 }));
|
|
1941
|
+
add(...team({ mgrTitle: "Manager, Site Reliability", reportTitle: "SRE Engineer", managerId: dirCloud.employee_id, dept: "Infrastructure", func: "Technology", bu: "Corporate", loc: LOCS[4], mgrLevel: 6, size: 5 }));
|
|
1942
|
+
add(...team({ mgrTitle: "Director, Network Engineering", reportTitle: "Network Engineer", managerId: srDirInfra.employee_id, dept: "Infrastructure", func: "Technology", bu: "Corporate", mgrLevel: 5, size: 2 }));
|
|
1943
|
+
add(...team({ mgrTitle: "Director, Platform Engineering", reportTitle: "Software Engineer", managerId: vpApps.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", loc: LOCS[5], mgrLevel: 4, size: 8 }));
|
|
1944
|
+
add(...team({ mgrTitle: "Director, Data Engineering", reportTitle: "Data Engineer", managerId: vpApps.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", mgrLevel: 4, size: 6 }));
|
|
1945
|
+
const dirQa = add(emp({ title: "Director, Quality Engineering", managerId: vpApps.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", level: 4 }));
|
|
1946
|
+
add(emp({ title: "Sr. Software Engineer", managerId: dirQa.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", level: 6 }));
|
|
1947
|
+
add(emp({ title: "Senior Software Engineer", managerId: dirQa.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", level: 6 }));
|
|
1948
|
+
add(emp({ title: "QA Engineer", managerId: dirQa.employee_id, dept: "Engineering", func: "Technology", bu: "Corporate", level: 7 }));
|
|
1949
|
+
add(...team({ mgrTitle: "Manager, Security Operations", reportTitle: "Security Analyst", managerId: vpSec.employee_id, dept: "Security", func: "Technology", bu: "Corporate", mgrLevel: 5, size: 4 }));
|
|
1950
|
+
const svpOps = add(emp({ title: "SVP Global Operations", managerId: coo.employee_id, dept: "Operations", func: "Operations", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1951
|
+
add(emp({ title: "Head of Operational Excellence", managerId: coo.employee_id, dept: "Operations", func: "Operations", bu: "Corporate", level: 3 }));
|
|
1952
|
+
add(emp({ title: "Head of PMO", managerId: coo.employee_id, dept: "Operations PMO", func: "Operations", bu: "Corporate", level: 3 }));
|
|
1953
|
+
const regions = [["Americas", HQ], ["EMEA", LOCS[1]], ["APAC", LOCS[2]]];
|
|
1954
|
+
for (const [region, loc] of regions) {
|
|
1955
|
+
const rh = add(emp({ title: `VP Operations, ${region}`, managerId: svpOps.employee_id, dept: "Operations", func: "Operations", bu: region, loc, level: 3 }));
|
|
1956
|
+
add(emp({ title: "Head of Operational Excellence", managerId: rh.employee_id, dept: "Operations", func: "Operations", bu: region, loc, level: 4 }));
|
|
1957
|
+
add(...team({ mgrTitle: `Director, Service Delivery ${region}`, reportTitle: "Operations Analyst", managerId: rh.employee_id, dept: "Service Delivery", func: "Operations", bu: region, loc, mgrLevel: 4, size: region === "Americas" ? 7 : 5 }));
|
|
1958
|
+
if (region !== "APAC") {
|
|
1959
|
+
add(...team({ mgrTitle: `Manager, Logistics ${region}`, reportTitle: "Logistics Coordinator", managerId: rh.employee_id, dept: "Logistics", func: "Operations", bu: region, loc, mgrLevel: 5, size: 4 }));
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
const bigOpsMgr = add(emp({ title: "Manager, Processing Center", managerId: svpOps.employee_id, dept: "Processing", func: "Operations", bu: "Americas", loc: LOCS[6], level: 5 }));
|
|
1963
|
+
for (let i = 0; i < 15; i++) add(emp({ title: "Processing Associate", managerId: bigOpsMgr.employee_id, managerName: bigOpsMgr.employee_name, dept: "Processing", func: "Operations", bu: "Americas", loc: LOCS[6], level: 8 }));
|
|
1964
|
+
const salesRegions = [["Americas", HQ], ["EMEA", LOCS[1]], ["APAC", LOCS[2]]];
|
|
1965
|
+
for (const [region, loc] of salesRegions) {
|
|
1966
|
+
const rvp = add(emp({ title: `RVP Sales, ${region}`, managerId: cro.employee_id, dept: "Sales", func: "Sales", bu: region, loc, level: 3 }));
|
|
1967
|
+
add(...team({ mgrTitle: `Director, Enterprise Sales ${region}`, reportTitle: "Account Executive", managerId: rvp.employee_id, dept: "Sales", func: "Sales", bu: region, loc, mgrLevel: 4, size: region === "Americas" ? 8 : 6 }));
|
|
1968
|
+
add(...team({ mgrTitle: `Manager, Sales Development ${region}`, reportTitle: "Sales Development Rep", managerId: rvp.employee_id, dept: "Sales", func: "Sales", bu: region, loc, mgrLevel: 5, size: 6 }));
|
|
1969
|
+
add(emp({ title: "Head of Sales Operations", managerId: rvp.employee_id, dept: "Sales Ops", func: "Sales", bu: region, loc, level: 4 }));
|
|
1970
|
+
}
|
|
1971
|
+
add(emp({ title: "VP Strategic Accounts", managerId: cro.employee_id, dept: "Sales", func: "Sales", bu: "Corporate", loc: HQ, level: 3, costOverride: 68e4 }));
|
|
1972
|
+
add(emp({ title: "Strategic Account Director", managerId: cro.employee_id, dept: "Sales", func: "Sales", bu: "Corporate", loc: HQ, level: 4 }));
|
|
1973
|
+
const vpBrand = add(emp({ title: "VP Brand & Communications", managerId: cmo.employee_id, dept: "Marketing", func: "Marketing", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1974
|
+
const vpGrowth = add(emp({ title: "VP Growth Marketing", managerId: cmo.employee_id, dept: "Marketing", func: "Marketing", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1975
|
+
add(...team({ mgrTitle: "Director, Content & Creative", reportTitle: "Content Specialist", managerId: vpBrand.employee_id, dept: "Marketing", func: "Marketing", bu: "Corporate", mgrLevel: 4, size: 4 }));
|
|
1976
|
+
add(...team({ mgrTitle: "Director, Digital Marketing", reportTitle: "Digital Marketing Manager", managerId: vpGrowth.employee_id, dept: "Marketing", func: "Marketing", bu: "Corporate", loc: LOCS[3], mgrLevel: 4, size: 5 }));
|
|
1977
|
+
add(emp({ title: "Events Manager", managerId: vpBrand.employee_id, dept: "Marketing", func: "Marketing", bu: "Corporate", level: 5 }));
|
|
1978
|
+
add(...team({ mgrTitle: "Deputy General Counsel", reportTitle: "Corporate Counsel", managerId: gc.employee_id, dept: "Legal", func: "Legal", bu: "Corporate", mgrLevel: 3, size: 3 }));
|
|
1979
|
+
const vpRisk = add(emp({ title: "VP Risk & Compliance", managerId: gc.employee_id, dept: "Risk & Compliance", func: "Risk", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1980
|
+
add(...team({ mgrTitle: "Director, Compliance", reportTitle: "Compliance Officer", managerId: vpRisk.employee_id, dept: "Risk & Compliance", func: "Risk", bu: "Corporate", loc: LOCS[3], mgrLevel: 4, size: 4 }));
|
|
1981
|
+
add(emp({ title: "Risk Analyst", managerId: vpRisk.employee_id, dept: "Risk & Compliance", func: "Risk", bu: "Corporate", level: 6 }));
|
|
1982
|
+
const vpProd = add(emp({ title: "VP Product Management", managerId: cpo.employee_id, dept: "Product", func: "Product", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1983
|
+
const vpDesign = add(emp({ title: "VP Design", managerId: cpo.employee_id, dept: "Design", func: "Product", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1984
|
+
add(...team({ mgrTitle: "Director, Product \u2014 Core Platform", reportTitle: "Product Manager", managerId: vpProd.employee_id, dept: "Product", func: "Product", bu: "Corporate", mgrLevel: 4, size: 5 }));
|
|
1985
|
+
add(...team({ mgrTitle: "Director, Product \u2014 New Ventures", reportTitle: "Product Manager", managerId: vpProd.employee_id, dept: "Product", func: "Product", bu: "Corporate", mgrLevel: 4, size: 3 }));
|
|
1986
|
+
add(...team({ mgrTitle: "Manager, UX Design", reportTitle: "Product Designer", managerId: vpDesign.employee_id, dept: "Design", func: "Product", bu: "Corporate", mgrLevel: 5, size: 4 }));
|
|
1987
|
+
add(emp({ title: "Principal Product Strategist", managerId: cpo.employee_id, dept: "Product", func: "Product", bu: "Corporate", level: 4, costOverride: 54e4 }));
|
|
1988
|
+
const vpCs = add(emp({ title: "VP Customer Support", managerId: cco.employee_id, dept: "Customer Service", func: "Customer Service", bu: "Corporate", loc: LOCS[6], level: 3 }));
|
|
1989
|
+
const vpCsm = add(emp({ title: "VP Customer Success", managerId: cco.employee_id, dept: "Customer Success", func: "Customer Service", bu: "Corporate", loc: HQ, level: 3 }));
|
|
1990
|
+
const bigCsMgr = add(emp({ title: "Manager, Support Tier 1", managerId: vpCs.employee_id, dept: "Customer Service", func: "Customer Service", bu: "Corporate", loc: LOCS[6], level: 5 }));
|
|
1991
|
+
for (let i = 0; i < 16; i++) add(emp({ title: "Support Agent", managerId: bigCsMgr.employee_id, managerName: bigCsMgr.employee_name, dept: "Customer Service", func: "Customer Service", bu: "Corporate", loc: LOCS[6], level: 8 }));
|
|
1992
|
+
add(...team({ mgrTitle: "Manager, Support Tier 2", reportTitle: "Senior Support Specialist", managerId: vpCs.employee_id, dept: "Customer Service", func: "Customer Service", bu: "Corporate", loc: LOCS[6], mgrLevel: 5, size: 9 }));
|
|
1993
|
+
add(...team({ mgrTitle: "Manager, Customer Success", reportTitle: "Customer Success Manager", managerId: vpCsm.employee_id, dept: "Customer Success", func: "Customer Service", bu: "Corporate", mgrLevel: 5, size: 7 }));
|
|
1994
|
+
const ghost = emp({ title: "Business Analyst", managerId: "E9999", dept: "Operations", func: "Operations", bu: "EMEA", loc: LOCS[1], level: 6 });
|
|
1995
|
+
all.push(ghost);
|
|
1996
|
+
const zc = emp({ title: "Operations Coordinator", managerId: svpOps.employee_id, dept: "Operations", func: "Operations", bu: "Corporate", level: 7, costOverride: 0 });
|
|
1997
|
+
zc.salary = 0;
|
|
1998
|
+
zc.bonus = 0;
|
|
1999
|
+
all.push(zc);
|
|
2000
|
+
const byId = new Map(all.map((e) => [e.employee_id, e]));
|
|
2001
|
+
for (const e of all) {
|
|
2002
|
+
if (e.manager_id && !e.manager_name) e.manager_name = byId.get(e.manager_id)?.employee_name;
|
|
2003
|
+
}
|
|
2004
|
+
return all;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
// src/lib/hris-templates.ts
|
|
2008
|
+
var HRIS_TEMPLATES = [
|
|
2009
|
+
{
|
|
2010
|
+
id: "workday",
|
|
2011
|
+
name: "Workday",
|
|
2012
|
+
aliases: {
|
|
2013
|
+
employee_id: ["Employee ID", "Worker ID", "WID"],
|
|
2014
|
+
employee_name: ["Worker", "Legal Name", "Preferred Name"],
|
|
2015
|
+
role_title: ["Business Title", "Job Profile", "Job Title"],
|
|
2016
|
+
manager_id: ["Manager Employee ID", "Worker's Manager ID", "Manager ID"],
|
|
2017
|
+
business_unit: ["Supervisory Organization", "Cost Center Hierarchy"],
|
|
2018
|
+
location: ["Location", "Work Location"],
|
|
2019
|
+
country: ["Location Country", "Country"],
|
|
2020
|
+
grade: ["Compensation Grade", "Pay Grade"],
|
|
2021
|
+
level: ["Job Level", "Management Level"],
|
|
2022
|
+
employment_type: ["Worker Type", "Employee Type", "Time Type"],
|
|
2023
|
+
fte: ["FTE", "FTE %", "Scheduled Weekly Hours FTE"],
|
|
2024
|
+
salary: ["Annual Base Pay", "Base Pay Annualized", "Primary Compensation"],
|
|
2025
|
+
total_cost: ["Total Base Pay Annualized", "Total Compensation"],
|
|
2026
|
+
status: ["Active Status", "Worker Status"]
|
|
2027
|
+
},
|
|
2028
|
+
exportInstructions: [
|
|
2029
|
+
"Create or run a custom report on the Worker business object (typically under Reports/Analytics).",
|
|
2030
|
+
"Include the fields: Employee ID, Worker, Business Title, Worker's Manager ID, Supervisory Organization, Location, Compensation Grade, Worker Type, FTE, Annual Base Pay and Total Base Pay Annualized.",
|
|
2031
|
+
"Filter to active workers (or include terminations if you want leavers in the analysis).",
|
|
2032
|
+
"Export the report output to XLSX or CSV and upload the file here."
|
|
2033
|
+
],
|
|
2034
|
+
notes: "Workday reports are highly configurable \u2014 column labels follow your tenant's report definition, so review the mapping step carefully."
|
|
2035
|
+
},
|
|
2036
|
+
{
|
|
2037
|
+
id: "successfactors",
|
|
2038
|
+
name: "SAP SuccessFactors",
|
|
2039
|
+
aliases: {
|
|
2040
|
+
employee_id: ["userId", "Person ID External", "User/Employee ID"],
|
|
2041
|
+
employee_name: ["Full Name", "Display Name", "defaultFullName"],
|
|
2042
|
+
role_title: ["Job Title", "jobTitle"],
|
|
2043
|
+
manager_id: ["Manager User Sys ID", "managerId", "Manager ID"],
|
|
2044
|
+
function: ["Job Function"],
|
|
2045
|
+
business_unit: ["Business Unit", "Division"],
|
|
2046
|
+
location: ["Location", "Location Code"],
|
|
2047
|
+
grade: ["Pay Grade", "payGrade"],
|
|
2048
|
+
level: ["Job Level", "jobLevel"],
|
|
2049
|
+
employment_type: ["Employee Class", "employeeClass", "Employment Type"],
|
|
2050
|
+
fte: ["FTE", "Standard Hours FTE"],
|
|
2051
|
+
salary: ["Annual Salary", "annualSalary", "Base Salary"],
|
|
2052
|
+
total_cost: ["Total Target Cash", "Total Compensation"],
|
|
2053
|
+
status: ["Employee Status", "emplStatus"]
|
|
2054
|
+
},
|
|
2055
|
+
exportInstructions: [
|
|
2056
|
+
"Open the reporting area (typically Report Center / Report Story under Analytics).",
|
|
2057
|
+
"Build a live-data report on Employee Profile / Job Information including userId, Manager User Sys ID, Department, Division, Location, Pay Grade and Annual Salary.",
|
|
2058
|
+
"Alternatively, use an ad-hoc report or the Employee Export if your role allows it.",
|
|
2059
|
+
"Run the report and download it as CSV or XLSX, then upload the file here."
|
|
2060
|
+
]
|
|
2061
|
+
},
|
|
2062
|
+
{
|
|
2063
|
+
id: "bamboohr",
|
|
2064
|
+
name: "BambooHR",
|
|
2065
|
+
aliases: {
|
|
2066
|
+
employee_id: ["Employee #", "employeeNumber", "EEID"],
|
|
2067
|
+
employee_name: ["Full Name", "Display Name"],
|
|
2068
|
+
role_title: ["Job Title", "jobTitle"],
|
|
2069
|
+
manager_id: ["ReportsTo", "Reports To", "Manager ID"],
|
|
2070
|
+
manager_name: ["Manager Name", "Reporting To"],
|
|
2071
|
+
business_unit: ["Division"],
|
|
2072
|
+
location: ["Location", "Work Location"],
|
|
2073
|
+
country: ["Country"],
|
|
2074
|
+
employment_type: ["Employment Status", "FLSA Code"],
|
|
2075
|
+
fte: ["FTE"],
|
|
2076
|
+
salary: ["Pay Rate", "Annual Salary", "payRate"],
|
|
2077
|
+
total_cost: ["Total Annual Compensation"],
|
|
2078
|
+
status: ["Status"],
|
|
2079
|
+
tenure: ["Years of Service"]
|
|
2080
|
+
},
|
|
2081
|
+
exportInstructions: [
|
|
2082
|
+
"Open the reports area (typically Reports in the main navigation).",
|
|
2083
|
+
"Create a custom report (or copy a standard roster report) and add: Employee #, Full Name, Job Title, ReportsTo, Department, Division, Location, Employment Status and Pay Rate.",
|
|
2084
|
+
"Filter to active employees if you only want the current organization.",
|
|
2085
|
+
"Download the report as CSV or Excel and upload the file here."
|
|
2086
|
+
],
|
|
2087
|
+
notes: "BambooHR's ReportsTo column often contains the manager's name rather than their ID \u2014 map it to manager_id only if your report exports IDs."
|
|
2088
|
+
},
|
|
2089
|
+
{
|
|
2090
|
+
id: "adp",
|
|
2091
|
+
name: "ADP Workforce Now",
|
|
2092
|
+
aliases: {
|
|
2093
|
+
employee_id: ["Associate ID", "Position ID", "File Number"],
|
|
2094
|
+
employee_name: ["Payroll Name", "Legal Name", "Name"],
|
|
2095
|
+
role_title: ["Job Title Description", "Position Description"],
|
|
2096
|
+
manager_id: ["Reports To Associate ID", "Reports To Position ID"],
|
|
2097
|
+
manager_name: ["Reports To Name"],
|
|
2098
|
+
department: ["Home Department Description", "Department Description"],
|
|
2099
|
+
business_unit: ["Business Unit Description", "Business Unit Code"],
|
|
2100
|
+
location: ["Location Description", "Home Work Location"],
|
|
2101
|
+
country: ["Work Country"],
|
|
2102
|
+
grade: ["Pay Grade Code"],
|
|
2103
|
+
employment_type: ["Worker Category Description", "Full Time/Part Time Code"],
|
|
2104
|
+
salary: ["Annual Salary", "Regular Pay Amount"],
|
|
2105
|
+
total_cost: ["Total Annual Compensation", "Annual Benefit Base Rate"],
|
|
2106
|
+
status: ["Position Status Code", "Status Type"]
|
|
2107
|
+
},
|
|
2108
|
+
exportInstructions: [
|
|
2109
|
+
"Open the reporting area (typically Reports & Analytics).",
|
|
2110
|
+
"Run or build a worker-level report including: Associate ID, Payroll Name, Job Title Description, Reports To Associate ID, Home Department Description, Business Unit Description, Location Description and Annual Salary.",
|
|
2111
|
+
"Filter to active associates unless you want terminated workers included.",
|
|
2112
|
+
"Export the output to CSV or Excel and upload the file here."
|
|
2113
|
+
]
|
|
2114
|
+
},
|
|
2115
|
+
{
|
|
2116
|
+
id: "dayforce",
|
|
2117
|
+
name: "Dayforce",
|
|
2118
|
+
aliases: {
|
|
2119
|
+
employee_id: ["Employee Number", "XRefCode", "Employee XRefCode"],
|
|
2120
|
+
employee_name: ["Display Name", "Employee Name"],
|
|
2121
|
+
role_title: ["Job Assignment", "Job Name", "Position"],
|
|
2122
|
+
manager_id: ["Manager XRefCode", "Manager Employee Number"],
|
|
2123
|
+
department: ["Department", "Org Unit"],
|
|
2124
|
+
business_unit: ["Business Unit"],
|
|
2125
|
+
location: ["Location", "Site"],
|
|
2126
|
+
country: ["Country Code"],
|
|
2127
|
+
grade: ["Pay Grade"],
|
|
2128
|
+
employment_type: ["Pay Class", "Employment Status Type"],
|
|
2129
|
+
fte: ["FTE", "Normal Weekly Hours FTE"],
|
|
2130
|
+
salary: ["Annual Salary", "Base Salary"],
|
|
2131
|
+
total_cost: ["Total Remuneration", "Total Annual Compensation"],
|
|
2132
|
+
status: ["Employment Status", "Status"]
|
|
2133
|
+
},
|
|
2134
|
+
exportInstructions: [
|
|
2135
|
+
"Open the reporting area (typically Reporting / Reports and Analytics).",
|
|
2136
|
+
"Run an employee or position report including: Employee Number (or XRefCode), Display Name, Job Assignment, Manager XRefCode, Department, Location, Pay Class and Annual Salary.",
|
|
2137
|
+
"Filter to active employees as needed.",
|
|
2138
|
+
"Export the report to CSV or Excel and upload the file here."
|
|
2139
|
+
]
|
|
2140
|
+
},
|
|
2141
|
+
{
|
|
2142
|
+
id: "ukg",
|
|
2143
|
+
name: "UKG Pro",
|
|
2144
|
+
aliases: {
|
|
2145
|
+
employee_id: ["Employee Number", "EmpNo", "Employee ID"],
|
|
2146
|
+
employee_name: ["Employee Name", "Full Name"],
|
|
2147
|
+
role_title: ["Job Description", "Job Title"],
|
|
2148
|
+
manager_id: ["Supervisor Employee Number", "Supervisor ID"],
|
|
2149
|
+
manager_name: ["Supervisor Name", "Supervisor"],
|
|
2150
|
+
department: ["Department", "Org Level 2"],
|
|
2151
|
+
business_unit: ["Org Level 1", "Division"],
|
|
2152
|
+
location: ["Location", "Work Location Description"],
|
|
2153
|
+
grade: ["Salary Grade", "Pay Grade"],
|
|
2154
|
+
employment_type: ["Employee Type", "Full/Part Time"],
|
|
2155
|
+
fte: ["Scheduled FTE", "FTE"],
|
|
2156
|
+
salary: ["Annual Pay", "Annual Salary"],
|
|
2157
|
+
total_cost: ["Total Annual Compensation"],
|
|
2158
|
+
status: ["Employment Status", "Status Code"]
|
|
2159
|
+
},
|
|
2160
|
+
exportInstructions: [
|
|
2161
|
+
"Open the reporting/BI area (typically under Administration > Reporting or People Analytics).",
|
|
2162
|
+
"Run an employee roster report including: Employee Number, Employee Name, Job Description, Supervisor Employee Number, Org Levels, Location, Employee Type and Annual Pay.",
|
|
2163
|
+
"Filter to active employment status if you only want the current organization.",
|
|
2164
|
+
"Export the result to CSV or Excel and upload the file here."
|
|
2165
|
+
],
|
|
2166
|
+
notes: "UKG Pro org levels are tenant-specific \u2014 confirm which Org Level corresponds to your business units before mapping."
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
id: "rippling",
|
|
2170
|
+
name: "Rippling",
|
|
2171
|
+
aliases: {
|
|
2172
|
+
employee_id: ["Employee Id", "Work Email", "Employee Number"],
|
|
2173
|
+
employee_name: ["Full Name", "Preferred Full Name", "Name"],
|
|
2174
|
+
role_title: ["Job Title", "Title"],
|
|
2175
|
+
manager_id: ["Manager Id", "Manager Email", "Manager's Work Email"],
|
|
2176
|
+
manager_name: ["Manager", "Manager Name"],
|
|
2177
|
+
department: ["Department"],
|
|
2178
|
+
business_unit: ["Team", "Division"],
|
|
2179
|
+
location: ["Work Location", "Office"],
|
|
2180
|
+
country: ["Work Country", "Country"],
|
|
2181
|
+
level: ["Level", "Job Level"],
|
|
2182
|
+
employment_type: ["Employment Type", "Worker Type"],
|
|
2183
|
+
salary: ["Annual Salary", "Base Salary", "Compensation Annual Salary"],
|
|
2184
|
+
total_cost: ["Total Compensation"],
|
|
2185
|
+
status: ["Employment Status", "Roster Status"]
|
|
2186
|
+
},
|
|
2187
|
+
exportInstructions: [
|
|
2188
|
+
"Open the reports area (typically Reports in the left navigation).",
|
|
2189
|
+
"Create a report on employee data including: Employee Id, Full Name, Job Title, Manager, Department, Work Location, Employment Type and Annual Salary.",
|
|
2190
|
+
"Filter to active employees as needed.",
|
|
2191
|
+
"Download the report as CSV and upload the file here."
|
|
2192
|
+
],
|
|
2193
|
+
notes: "If your export identifies people by work email, map both employee_id and manager_id to the email columns \u2014 IDs just need to be consistent."
|
|
2194
|
+
},
|
|
2195
|
+
{
|
|
2196
|
+
id: "hibob",
|
|
2197
|
+
name: "HiBob",
|
|
2198
|
+
aliases: {
|
|
2199
|
+
employee_id: ["Employee ID", "Emp ID"],
|
|
2200
|
+
employee_name: ["Display name", "Full name"],
|
|
2201
|
+
role_title: ["Job title", "Title"],
|
|
2202
|
+
manager_id: ["Manager ID", "Reports to ID"],
|
|
2203
|
+
manager_name: ["Reports to", "Manager"],
|
|
2204
|
+
business_unit: ["Division"],
|
|
2205
|
+
location: ["Site", "Work site"],
|
|
2206
|
+
country: ["Site country", "Country"],
|
|
2207
|
+
level: ["Level", "Job level"],
|
|
2208
|
+
employment_type: ["Employment type", "Employee type"],
|
|
2209
|
+
fte: ["FTE", "Working pattern FTE"],
|
|
2210
|
+
salary: ["Base salary", "Annual salary"],
|
|
2211
|
+
total_cost: ["Total compensation"],
|
|
2212
|
+
status: ["Lifecycle status", "Employment status"]
|
|
2213
|
+
},
|
|
2214
|
+
exportInstructions: [
|
|
2215
|
+
"Open the analytics/reports area (typically Analytics > Reports).",
|
|
2216
|
+
"Create a people report including: Employee ID, Display name, Job title, Reports to ID, Department, Site, Employment type and Base salary.",
|
|
2217
|
+
"Filter the lifecycle status to Employed if you only want current staff.",
|
|
2218
|
+
"Export the report to CSV or Excel and upload the file here."
|
|
2219
|
+
]
|
|
2220
|
+
},
|
|
2221
|
+
{
|
|
2222
|
+
id: "personio",
|
|
2223
|
+
name: "Personio",
|
|
2224
|
+
aliases: {
|
|
2225
|
+
employee_id: ["Employee ID", "ID"],
|
|
2226
|
+
employee_name: ["Full name", "Name"],
|
|
2227
|
+
role_title: ["Position", "Job title"],
|
|
2228
|
+
manager_id: ["Supervisor ID", "Manager ID"],
|
|
2229
|
+
manager_name: ["Supervisor", "Manager"],
|
|
2230
|
+
business_unit: ["Subcompany", "Division"],
|
|
2231
|
+
location: ["Office", "Location"],
|
|
2232
|
+
country: ["Office country", "Country"],
|
|
2233
|
+
employment_type: ["Employment type"],
|
|
2234
|
+
contract_type: ["Contract type"],
|
|
2235
|
+
fte: ["FTE", "Weekly hours FTE"],
|
|
2236
|
+
salary: ["Fix salary", "Annual fixed salary"],
|
|
2237
|
+
bonus: ["Variable salary", "Bonus"],
|
|
2238
|
+
total_cost: ["Total compensation", "Total salary"]
|
|
2239
|
+
},
|
|
2240
|
+
exportInstructions: [
|
|
2241
|
+
"Open the employee list (typically under People) or the reports area.",
|
|
2242
|
+
"Choose the columns to include: Employee ID, Full name, Position, Supervisor ID, Department, Subcompany, Office, Employment type and Fix salary.",
|
|
2243
|
+
"Filter to active employees as needed.",
|
|
2244
|
+
"Use the export action to download CSV or Excel, then upload the file here."
|
|
2245
|
+
],
|
|
2246
|
+
notes: "Personio often exports first and last name as separate columns \u2014 add a combined full-name column in the export or in your spreadsheet before uploading."
|
|
2247
|
+
},
|
|
2248
|
+
{
|
|
2249
|
+
id: "gusto",
|
|
2250
|
+
name: "Gusto",
|
|
2251
|
+
aliases: {
|
|
2252
|
+
employee_id: ["Employee ID", "Employee UUID"],
|
|
2253
|
+
employee_name: ["Employee name", "Full name"],
|
|
2254
|
+
role_title: ["Job title", "Title"],
|
|
2255
|
+
manager_id: ["Manager", "Manager ID"],
|
|
2256
|
+
manager_name: ["Manager name"],
|
|
2257
|
+
department: ["Department"],
|
|
2258
|
+
location: ["Work address", "Work location"],
|
|
2259
|
+
country: ["Country"],
|
|
2260
|
+
employment_type: ["Employment type", "Employee type"],
|
|
2261
|
+
salary: ["Compensation rate", "Annual salary"],
|
|
2262
|
+
total_cost: ["Total compensation"],
|
|
2263
|
+
status: ["Employment status", "Status"]
|
|
2264
|
+
},
|
|
2265
|
+
exportInstructions: [
|
|
2266
|
+
"Open the reports area (typically Reports in the main navigation).",
|
|
2267
|
+
"Create a custom employee report including: Employee ID, Employee name, Job title, Manager, Department, Work address, Employment type and Compensation rate.",
|
|
2268
|
+
"Filter to active employees if you only want the current organization.",
|
|
2269
|
+
"Download the report as CSV and upload the file here."
|
|
2270
|
+
],
|
|
2271
|
+
notes: "Gusto's Manager column usually contains a name, not an ID \u2014 if so, ensure manager names exactly match employee names, or add a manager-ID column before uploading."
|
|
2272
|
+
}
|
|
2273
|
+
];
|
|
2274
|
+
function getHrisTemplate(id) {
|
|
2275
|
+
return HRIS_TEMPLATES.find((t) => t.id === id);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
export { COMPACT_NODE, DEFAULT_ASSUMPTIONS, DEFAULT_TITLE_NOISE_WORDS, FULL_NODE, HRIS_TEMPLATES, MONTH_NAMES, OPTIONAL_FIELDS, REQUIRED_FIELDS, applyChanges, autoMapColumns, buildOrgTree, classifyAiAugmentation, classifySpan, computeChangeRisk, computeMetrics, computeOrgHealth, computeTransitionCost, deepestChains, diffEmployees, elbowPath, evaluateScenario, findDuplicateRoles, fitToBounds, generateDemoData, generateInsights, generateInsightsFromEmployees, getHrisTemplate, layoutOrg, normalizePositionStatus, normalizeTitle, positionStatus, rankInsights, rowsToEmployees, scoreScenario, spanBandForLevel, subtreeIds, suggestConsolidations, validateEmployees, visibleNodes, wouldCreateCycle, year1CapturePct, zoomAround };
|
|
2279
|
+
//# sourceMappingURL=core.mjs.map
|
|
2280
|
+
//# sourceMappingURL=core.mjs.map
|