@burdenoff/microfe-crewfoundry 2026.1030.3 → 2026.1102.1

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.
Files changed (55) hide show
  1. package/dist/crewfoundry/CrewFoundryRoutes.d.ts.map +1 -1
  2. package/dist/crewfoundry/CrewFoundryRoutes.js +18 -14
  3. package/dist/crewfoundry/CrewFoundryRoutes.js.map +1 -1
  4. package/dist/crewfoundry/components/EmployeeSkillsPanel.d.ts +9 -0
  5. package/dist/crewfoundry/components/EmployeeSkillsPanel.d.ts.map +1 -1
  6. package/dist/crewfoundry/components/EmployeeSkillsPanel.js +111 -77
  7. package/dist/crewfoundry/components/EmployeeSkillsPanel.js.map +1 -1
  8. package/dist/crewfoundry/components/skills/FindPeopleBySkillCard.d.ts +13 -0
  9. package/dist/crewfoundry/components/skills/FindPeopleBySkillCard.d.ts.map +1 -0
  10. package/dist/crewfoundry/components/skills/FindPeopleBySkillCard.js +128 -0
  11. package/dist/crewfoundry/components/skills/FindPeopleBySkillCard.js.map +1 -0
  12. package/dist/crewfoundry/components/skills/MergeSkillDialog.d.ts +15 -0
  13. package/dist/crewfoundry/components/skills/MergeSkillDialog.d.ts.map +1 -0
  14. package/dist/crewfoundry/components/skills/MergeSkillDialog.js +58 -0
  15. package/dist/crewfoundry/components/skills/MergeSkillDialog.js.map +1 -0
  16. package/dist/crewfoundry/components/skills/RoleSkillMatchPanel.d.ts +15 -0
  17. package/dist/crewfoundry/components/skills/RoleSkillMatchPanel.d.ts.map +1 -0
  18. package/dist/crewfoundry/components/skills/RoleSkillMatchPanel.js +163 -0
  19. package/dist/crewfoundry/components/skills/RoleSkillMatchPanel.js.map +1 -0
  20. package/dist/crewfoundry/components/skills/SkillPicker.d.ts +20 -0
  21. package/dist/crewfoundry/components/skills/SkillPicker.d.ts.map +1 -0
  22. package/dist/crewfoundry/components/skills/SkillPicker.js +73 -0
  23. package/dist/crewfoundry/components/skills/SkillPicker.js.map +1 -0
  24. package/dist/crewfoundry/components/skills/SkillRelationsDialog.d.ts +13 -0
  25. package/dist/crewfoundry/components/skills/SkillRelationsDialog.d.ts.map +1 -0
  26. package/dist/crewfoundry/components/skills/SkillRelationsDialog.js +156 -0
  27. package/dist/crewfoundry/components/skills/SkillRelationsDialog.js.map +1 -0
  28. package/dist/crewfoundry/lib/analytics.d.ts +1 -29
  29. package/dist/crewfoundry/lib/analytics.d.ts.map +1 -1
  30. package/dist/crewfoundry/lib/analytics.js +3 -48
  31. package/dist/crewfoundry/lib/analytics.js.map +1 -1
  32. package/dist/crewfoundry/lib/skillMatching.d.ts +70 -0
  33. package/dist/crewfoundry/lib/skillMatching.d.ts.map +1 -0
  34. package/dist/crewfoundry/lib/skillMatching.js +73 -0
  35. package/dist/crewfoundry/lib/skillMatching.js.map +1 -0
  36. package/dist/crewfoundry/pages/DashboardExecutiveDashboardPage.d.ts +8 -0
  37. package/dist/crewfoundry/pages/DashboardExecutiveDashboardPage.d.ts.map +1 -1
  38. package/dist/crewfoundry/pages/DashboardExecutiveDashboardPage.js +468 -212
  39. package/dist/crewfoundry/pages/DashboardExecutiveDashboardPage.js.map +1 -1
  40. package/dist/crewfoundry/pages/HiringCandidatesPagePage.d.ts.map +1 -1
  41. package/dist/crewfoundry/pages/HiringCandidatesPagePage.js +210 -177
  42. package/dist/crewfoundry/pages/HiringCandidatesPagePage.js.map +1 -1
  43. package/dist/crewfoundry/pages/HrEmployeeProfilePagePage.js +6 -6
  44. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.d.ts +9 -0
  45. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.d.ts.map +1 -0
  46. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.js +456 -0
  47. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.js.map +1 -0
  48. package/dist/crewfoundry/pages/ProjectsResourcingPagePage.d.ts.map +1 -1
  49. package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js +50 -102
  50. package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js.map +1 -1
  51. package/dist/crewfoundry/store/crewfoundryStore.d.ts +145 -0
  52. package/dist/crewfoundry/store/crewfoundryStore.d.ts.map +1 -1
  53. package/dist/crewfoundry/store/crewfoundryStore.js +188 -57
  54. package/dist/crewfoundry/store/crewfoundryStore.js.map +1 -1
  55. package/package.json +1 -1
@@ -0,0 +1,73 @@
1
+ import { formatEnumLabel as e } from "../../utils/formatEnumLabel.js";
2
+ import { searchSkills as t } from "../../lib/skillMatching.js";
3
+ import { useId as n, useMemo as r, useState as i } from "react";
4
+ import { Search as a } from "lucide-react";
5
+ import { jsx as o, jsxs as s } from "react/jsx-runtime";
6
+ import { Badge as c, Input as l, Label as u } from "@burdenoff/fe-libs/ui";
7
+ //#region src/crewfoundry/components/skills/SkillPicker.tsx
8
+ function d({ skills: d, label: f, placeholder: p = "Search skills, e.g. Kubernetes or K8s", excludeIds: m, selectedId: h, onSelect: g, disabled: _ }) {
9
+ let v = n(), [y, b] = i(""), x = r(() => new Set(m ?? []), [m]), S = r(() => t(d, y, 16).filter((e) => !x.has(e.id)).slice(0, 8), [
10
+ d,
11
+ y,
12
+ x
13
+ ]), C = h ? d.find((e) => e.id === h) : void 0;
14
+ return /* @__PURE__ */ s("div", {
15
+ className: "space-y-2",
16
+ children: [
17
+ /* @__PURE__ */ o(u, {
18
+ htmlFor: v,
19
+ children: f
20
+ }),
21
+ /* @__PURE__ */ s("div", {
22
+ className: "relative",
23
+ children: [/* @__PURE__ */ o(a, { className: "absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" }), /* @__PURE__ */ o(l, {
24
+ id: v,
25
+ className: "pl-9",
26
+ value: y,
27
+ disabled: _,
28
+ placeholder: p,
29
+ autoComplete: "off",
30
+ onChange: (e) => b(e.target.value)
31
+ })]
32
+ }),
33
+ C ? /* @__PURE__ */ s("p", {
34
+ className: "text-xs text-text-secondary",
35
+ children: ["Selected: ", /* @__PURE__ */ o("span", {
36
+ className: "font-medium text-text-primary",
37
+ children: C.name
38
+ })]
39
+ }) : null,
40
+ y.trim() === "" ? null : S.length > 0 ? /* @__PURE__ */ o("ul", {
41
+ className: "space-y-1",
42
+ "aria-label": `${f} suggestions`,
43
+ children: S.map((t) => /* @__PURE__ */ o("li", { children: /* @__PURE__ */ s("button", {
44
+ type: "button",
45
+ disabled: _,
46
+ onClick: () => {
47
+ g(t), b("");
48
+ },
49
+ className: `flex w-full items-center justify-between gap-2 rounded-[var(--radius-card)] border px-3 py-2 text-left text-sm transition-colors hover:bg-bg-sunken disabled:opacity-50 ${t.id === h ? "border-border-focus bg-bg-sunken" : "border-border-subtle bg-bg-surface"}`,
50
+ children: [/* @__PURE__ */ o("span", {
51
+ className: "min-w-0 truncate text-text-primary",
52
+ children: t.name
53
+ }), /* @__PURE__ */ o(c, {
54
+ variant: "outline",
55
+ className: "shrink-0 text-[10px]",
56
+ children: e(t.category)
57
+ })]
58
+ }) }, t.id))
59
+ }) : /* @__PURE__ */ s("p", {
60
+ className: "text-xs text-text-muted",
61
+ children: [
62
+ "No skill in the taxonomy matches “",
63
+ y,
64
+ "”."
65
+ ]
66
+ })
67
+ ]
68
+ });
69
+ }
70
+ //#endregion
71
+ export { d as SkillPicker };
72
+
73
+ //# sourceMappingURL=SkillPicker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SkillPicker.js","names":[],"sources":["../../../../src/crewfoundry/components/skills/SkillPicker.tsx"],"sourcesContent":["import { useId, useMemo, useState } from 'react';\nimport { Badge, Input, Label } from '@burdenoff/fe-libs/ui';\nimport { Search } from 'lucide-react';\nimport type { CrewTaxonomySkill } from '../../store/crewfoundryStore';\nimport { searchSkills } from '../../lib/skillMatching';\nimport { formatEnumLabel } from '../../utils/formatEnumLabel';\n\nexport interface SkillPickerProps {\n skills: readonly CrewTaxonomySkill[];\n label: string;\n placeholder?: string;\n /** Skills that cannot be picked (e.g. the skill being merged or related). */\n excludeIds?: readonly string[];\n selectedId?: string | null;\n onSelect: (skill: CrewTaxonomySkill) => void;\n disabled?: boolean;\n}\n\n/**\n * Type-ahead over the workspace skills taxonomy (BOFF-10361). Matches names\n * and aliases, so typing \"K8s\" offers Kubernetes. Shows at most eight\n * suggestions as a list of buttons, which works the same with a mouse, a\n * keyboard and a 390px touch screen.\n */\nexport function SkillPicker({\n skills,\n label,\n placeholder = 'Search skills, e.g. Kubernetes or K8s',\n excludeIds,\n selectedId,\n onSelect,\n disabled,\n}: SkillPickerProps) {\n const inputId = useId();\n const [query, setQuery] = useState('');\n const excluded = useMemo(() => new Set(excludeIds ?? []), [excludeIds]);\n const suggestions = useMemo(\n () =>\n searchSkills(skills, query, 16)\n .filter((skill) => !excluded.has(skill.id))\n .slice(0, 8),\n [skills, query, excluded]\n );\n const selected = selectedId ? skills.find((skill) => skill.id === selectedId) : undefined;\n\n return (\n <div className=\"space-y-2\">\n <Label htmlFor={inputId}>{label}</Label>\n <div className=\"relative\">\n <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted\" />\n <Input\n id={inputId}\n className=\"pl-9\"\n value={query}\n disabled={disabled}\n placeholder={placeholder}\n autoComplete=\"off\"\n onChange={(event) => setQuery(event.target.value)}\n />\n </div>\n {selected ? (\n <p className=\"text-xs text-text-secondary\">\n Selected: <span className=\"font-medium text-text-primary\">{selected.name}</span>\n </p>\n ) : null}\n {query.trim() !== '' ? (\n suggestions.length > 0 ? (\n <ul className=\"space-y-1\" aria-label={`${label} suggestions`}>\n {suggestions.map((skill) => (\n <li key={skill.id}>\n <button\n type=\"button\"\n disabled={disabled}\n onClick={() => {\n onSelect(skill);\n setQuery('');\n }}\n className={`flex w-full items-center justify-between gap-2 rounded-[var(--radius-card)] border px-3 py-2 text-left text-sm transition-colors hover:bg-bg-sunken disabled:opacity-50 ${\n skill.id === selectedId\n ? 'border-border-focus bg-bg-sunken'\n : 'border-border-subtle bg-bg-surface'\n }`}\n >\n <span className=\"min-w-0 truncate text-text-primary\">{skill.name}</span>\n <Badge variant=\"outline\" className=\"shrink-0 text-[10px]\">\n {formatEnumLabel(skill.category)}\n </Badge>\n </button>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"text-xs text-text-muted\">No skill in the taxonomy matches “{query}”.</p>\n )\n ) : null}\n </div>\n );\n}\n"],"mappings":";;;;;;;AAwBA,SAAgB,EAAY,EAC1B,WACA,UACA,iBAAc,yCACd,eACA,eACA,aACA,eACmB;CACnB,IAAM,IAAU,GAAO,EACjB,CAAC,GAAO,KAAY,EAAS,GAAG,EAChC,IAAW,QAAc,IAAI,IAAI,KAAc,EAAE,CAAC,EAAE,CAAC,EAAW,CAAC,EACjE,IAAc,QAEhB,EAAa,GAAQ,GAAO,GAAG,CAC5B,QAAQ,MAAU,CAAC,EAAS,IAAI,EAAM,GAAG,CAAC,CAC1C,MAAM,GAAG,EAAE,EAChB;EAAC;EAAQ;EAAO;EAAS,CAC1B,EACK,IAAW,IAAa,EAAO,MAAM,MAAU,EAAM,OAAO,EAAW,GAAG,KAAA;AAEhF,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf;GACE,kBAAC,GAAD;IAAO,SAAS;cAAU;IAAc,CAAA;GACxC,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,GAAD,EAAQ,WAAU,oEAAqE,CAAA,EACvF,kBAAC,GAAD;KACE,IAAI;KACJ,WAAU;KACV,OAAO;KACG;KACG;KACb,cAAa;KACb,WAAW,MAAU,EAAS,EAAM,OAAO,MAAM;KACjD,CAAA,CACE;;GACL,IACC,kBAAC,KAAD;IAAG,WAAU;cAAb,CAA2C,cAC/B,kBAAC,QAAD;KAAM,WAAU;eAAiC,EAAS;KAAY,CAAA,CAC9E;QACF;GACH,EAAM,MAAM,KAAK,KA6Bd,OA5BF,EAAY,SAAS,IACnB,kBAAC,MAAD;IAAI,WAAU;IAAY,cAAY,GAAG,EAAM;cAC5C,EAAY,KAAK,MAChB,kBAAC,MAAD,EAAA,UACE,kBAAC,UAAD;KACE,MAAK;KACK;KACV,eAAe;AAEb,MADA,EAAS,EAAM,EACf,EAAS,GAAG;;KAEd,WAAW,2KACT,EAAM,OAAO,IACT,qCACA;eAVR,CAaE,kBAAC,QAAD;MAAM,WAAU;gBAAsC,EAAM;MAAY,CAAA,EACxE,kBAAC,GAAD;MAAO,SAAQ;MAAU,WAAU;gBAChC,EAAgB,EAAM,SAAS;MAC1B,CAAA,CACD;QACN,EAnBI,EAAM,GAmBV,CACL;IACC,CAAA,GAEL,kBAAC,KAAD;IAAG,WAAU;cAAb;KAAuC;KAAmC;KAAM;KAAM;;GAGtF"}
@@ -0,0 +1,13 @@
1
+ import { CrewTaxonomySkill } from '../../store/crewfoundryStore';
2
+
3
+ export interface SkillRelationsDialogProps {
4
+ skill: CrewTaxonomySkill | null;
5
+ skills: readonly CrewTaxonomySkill[];
6
+ onOpenChange: (open: boolean) => void;
7
+ }
8
+ /**
9
+ * View, add and remove the typed edges around one skill (BOFF-10361). These
10
+ * edges are what "find people by skill" and role matching traverse.
11
+ */
12
+ export declare function SkillRelationsDialog({ skill, skills, onOpenChange }: SkillRelationsDialogProps): import("react").JSX.Element;
13
+ //# sourceMappingURL=SkillRelationsDialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SkillRelationsDialog.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/skills/SkillRelationsDialog.tsx"],"names":[],"mappings":"AAkBA,OAAO,EAAuB,KAAK,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AA4B3F,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,iBAAiB,GAAG,IAAI,CAAC;IAChC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACrC,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE,yBAAyB,+BAwI9F"}
@@ -0,0 +1,156 @@
1
+ import { useCrewFoundryStore as e } from "../../store/crewfoundryStore.js";
2
+ import { SkillPicker as t } from "./SkillPicker.js";
3
+ import { useMemo as n, useState as r } from "react";
4
+ import { ArrowRight as i, Plus as a, X as o } from "lucide-react";
5
+ import { jsx as s, jsxs as c } from "react/jsx-runtime";
6
+ import { Badge as l, Button as u, Label as d, ResponsiveDialog as f, ResponsiveDialogContent as p, ResponsiveDialogDescription as m, ResponsiveDialogHeader as h, ResponsiveDialogTitle as g, Select as _, SelectContent as v, SelectItem as y, SelectTrigger as b, SelectValue as x } from "@burdenoff/fe-libs/ui";
7
+ import { useGlobalUi as S } from "@burdenoff/fe-libs/shared/providers/shell";
8
+ //#region src/crewfoundry/components/skills/SkillRelationsDialog.tsx
9
+ var C = [
10
+ {
11
+ value: "IMPLIES",
12
+ label: "Implies",
13
+ help: "Anyone who holds this skill can also do the other one."
14
+ },
15
+ {
16
+ value: "SUPERSEDES",
17
+ label: "Supersedes",
18
+ help: "This skill replaces the other one (a newer or higher credential) and satisfies it."
19
+ },
20
+ {
21
+ value: "RELATED_TO",
22
+ label: "Related to",
23
+ help: "Adjacent skills. Informative only; never counts as a match."
24
+ }
25
+ ], w = {
26
+ IMPLIES: "implies",
27
+ SUPERSEDES: "supersedes",
28
+ RELATED_TO: "is related to"
29
+ };
30
+ function T({ skill: T, skills: E, onOpenChange: D }) {
31
+ let { showToast: O } = S(), k = e((e) => e.createSkillRelation), A = e((e) => e.removeSkillRelation), [j, M] = r("IMPLIES"), [N, P] = r(null), [F, I] = r(!1), L = n(() => new Map(E.map((e) => [e.id, e.name])), [E]), R = T ? E.find((e) => e.id === T.id) ?? T : null, z = R?.relations ?? [], B = C.find((e) => e.value === j) ?? C[0], V = (e) => {
32
+ e || (P(null), M("IMPLIES")), D(e);
33
+ }, H = async () => {
34
+ if (!R || !N) {
35
+ O("Pick the other skill first", "error");
36
+ return;
37
+ }
38
+ I(!0);
39
+ try {
40
+ await k({
41
+ fromSkillId: R.id,
42
+ toSkillId: N,
43
+ type: j
44
+ }), P(null), O("Relation added", "success");
45
+ } catch (e) {
46
+ O(e instanceof Error ? e.message : "Unable to add this relation", "error");
47
+ } finally {
48
+ I(!1);
49
+ }
50
+ }, U = async (e) => {
51
+ I(!0);
52
+ try {
53
+ await A(e), O("Relation removed", "success");
54
+ } catch (e) {
55
+ O(e instanceof Error ? e.message : "Unable to remove this relation", "error");
56
+ } finally {
57
+ I(!1);
58
+ }
59
+ };
60
+ return /* @__PURE__ */ s(f, {
61
+ open: R !== null,
62
+ onOpenChange: V,
63
+ children: /* @__PURE__ */ c(p, {
64
+ className: "sm:max-w-[560px]",
65
+ children: [/* @__PURE__ */ c(h, { children: [/* @__PURE__ */ s(g, { children: R ? `${R.name} · Relations` : "Relations" }), /* @__PURE__ */ s(m, { children: "Matching follows Implies and Supersedes edges, so people are found through skills that cover the one you need." })] }), /* @__PURE__ */ c("div", {
66
+ className: "space-y-5",
67
+ children: [z.length > 0 ? /* @__PURE__ */ s("ul", {
68
+ className: "space-y-2",
69
+ "aria-label": "Relations",
70
+ children: z.map((e) => /* @__PURE__ */ c("li", {
71
+ className: "flex items-center justify-between gap-2 rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface px-3 py-2 text-sm",
72
+ children: [/* @__PURE__ */ c("span", {
73
+ className: "flex min-w-0 flex-wrap items-center gap-1.5 text-text-primary",
74
+ children: [
75
+ /* @__PURE__ */ s("span", {
76
+ className: "font-medium",
77
+ children: L.get(e.fromSkillId) ?? "Unknown skill"
78
+ }),
79
+ /* @__PURE__ */ s(l, {
80
+ variant: "outline",
81
+ className: "text-[10px]",
82
+ children: w[e.type] ?? e.type
83
+ }),
84
+ /* @__PURE__ */ s(i, {
85
+ className: "h-3 w-3 text-text-muted",
86
+ "aria-hidden": "true"
87
+ }),
88
+ /* @__PURE__ */ s("span", {
89
+ className: "font-medium",
90
+ children: L.get(e.toSkillId) ?? "Unknown skill"
91
+ })
92
+ ]
93
+ }), /* @__PURE__ */ s(u, {
94
+ size: "sm",
95
+ variant: "ghost",
96
+ disabled: F,
97
+ "aria-label": `Remove relation ${L.get(e.fromSkillId) ?? ""} ${w[e.type] ?? e.type} ${L.get(e.toSkillId) ?? ""}`,
98
+ onClick: () => void U(e.id),
99
+ children: /* @__PURE__ */ s(o, { className: "h-3.5 w-3.5" })
100
+ })]
101
+ }, e.id))
102
+ }) : /* @__PURE__ */ s("p", {
103
+ className: "text-sm text-text-muted",
104
+ children: "No relations yet."
105
+ }), /* @__PURE__ */ c("div", {
106
+ className: "space-y-3 border-t border-border-subtle pt-4",
107
+ children: [
108
+ /* @__PURE__ */ c("div", {
109
+ className: "space-y-1.5",
110
+ children: [
111
+ /* @__PURE__ */ s(d, {
112
+ htmlFor: "skill-relation-type",
113
+ children: R ? `${R.name}…` : "This skill…"
114
+ }),
115
+ /* @__PURE__ */ c(_, {
116
+ value: j,
117
+ onValueChange: M,
118
+ children: [/* @__PURE__ */ s(b, {
119
+ id: "skill-relation-type",
120
+ className: "w-full",
121
+ children: /* @__PURE__ */ s(x, {})
122
+ }), /* @__PURE__ */ s(v, { children: C.map((e) => /* @__PURE__ */ s(y, {
123
+ value: e.value,
124
+ children: e.label
125
+ }, e.value)) })]
126
+ }),
127
+ /* @__PURE__ */ s("p", {
128
+ className: "text-xs text-text-muted",
129
+ children: B.help
130
+ })
131
+ ]
132
+ }),
133
+ /* @__PURE__ */ s(t, {
134
+ skills: E,
135
+ label: "Other skill",
136
+ excludeIds: R ? [R.id] : [],
137
+ selectedId: N,
138
+ onSelect: (e) => P(e.id),
139
+ disabled: F
140
+ }),
141
+ /* @__PURE__ */ c(u, {
142
+ type: "button",
143
+ disabled: F || !N,
144
+ onClick: () => void H(),
145
+ children: [/* @__PURE__ */ s(a, { className: "mr-1.5 h-3.5 w-3.5" }), "Add relation"]
146
+ })
147
+ ]
148
+ })]
149
+ })]
150
+ })
151
+ });
152
+ }
153
+ //#endregion
154
+ export { T as SkillRelationsDialog };
155
+
156
+ //# sourceMappingURL=SkillRelationsDialog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SkillRelationsDialog.js","names":[],"sources":["../../../../src/crewfoundry/components/skills/SkillRelationsDialog.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport {\n Badge,\n Button,\n Label,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogDescription,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { ArrowRight, Plus, X } from 'lucide-react';\nimport { useCrewFoundryStore, type CrewTaxonomySkill } from '../../store/crewfoundryStore';\nimport { SkillPicker } from './SkillPicker';\n\n/** Relation types, phrased from the selected skill's point of view. */\nconst RELATION_TYPES: { value: string; label: string; help: string }[] = [\n {\n value: 'IMPLIES',\n label: 'Implies',\n help: 'Anyone who holds this skill can also do the other one.',\n },\n {\n value: 'SUPERSEDES',\n label: 'Supersedes',\n help: 'This skill replaces the other one (a newer or higher credential) and satisfies it.',\n },\n {\n value: 'RELATED_TO',\n label: 'Related to',\n help: 'Adjacent skills. Informative only; never counts as a match.',\n },\n];\n\nconst RELATION_VERB: Record<string, string> = {\n IMPLIES: 'implies',\n SUPERSEDES: 'supersedes',\n RELATED_TO: 'is related to',\n};\n\nexport interface SkillRelationsDialogProps {\n skill: CrewTaxonomySkill | null;\n skills: readonly CrewTaxonomySkill[];\n onOpenChange: (open: boolean) => void;\n}\n\n/**\n * View, add and remove the typed edges around one skill (BOFF-10361). These\n * edges are what \"find people by skill\" and role matching traverse.\n */\nexport function SkillRelationsDialog({ skill, skills, onOpenChange }: SkillRelationsDialogProps) {\n const { showToast } = useGlobalUi();\n const createSkillRelation = useCrewFoundryStore((state) => state.createSkillRelation);\n const removeSkillRelation = useCrewFoundryStore((state) => state.removeSkillRelation);\n const [type, setType] = useState('IMPLIES');\n const [targetId, setTargetId] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n\n const nameById = useMemo(() => new Map(skills.map((item) => [item.id, item.name])), [skills]);\n // Read the live row so the list refreshes after each add/remove.\n const current = skill ? (skills.find((item) => item.id === skill.id) ?? skill) : null;\n const relations = current?.relations ?? [];\n const selectedType = RELATION_TYPES.find((item) => item.value === type) ?? RELATION_TYPES[0];\n\n const close = (open: boolean) => {\n if (!open) {\n setTargetId(null);\n setType('IMPLIES');\n }\n onOpenChange(open);\n };\n\n const handleAdd = async () => {\n if (!current || !targetId) {\n showToast('Pick the other skill first', 'error');\n return;\n }\n setBusy(true);\n try {\n await createSkillRelation({ fromSkillId: current.id, toSkillId: targetId, type });\n setTargetId(null);\n showToast('Relation added', 'success');\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to add this relation', 'error');\n } finally {\n setBusy(false);\n }\n };\n\n const handleRemove = async (relationId: string) => {\n setBusy(true);\n try {\n await removeSkillRelation(relationId);\n showToast('Relation removed', 'success');\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to remove this relation', 'error');\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <ResponsiveDialog open={current !== null} onOpenChange={close}>\n <ResponsiveDialogContent className=\"sm:max-w-[560px]\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>\n {current ? `${current.name} · Relations` : 'Relations'}\n </ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n Matching follows Implies and Supersedes edges, so people are found through skills that\n cover the one you need.\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n\n <div className=\"space-y-5\">\n {relations.length > 0 ? (\n <ul className=\"space-y-2\" aria-label=\"Relations\">\n {relations.map((relation) => (\n <li\n key={relation.id}\n className=\"flex items-center justify-between gap-2 rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface px-3 py-2 text-sm\"\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1.5 text-text-primary\">\n <span className=\"font-medium\">\n {nameById.get(relation.fromSkillId) ?? 'Unknown skill'}\n </span>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {RELATION_VERB[relation.type] ?? relation.type}\n </Badge>\n <ArrowRight className=\"h-3 w-3 text-text-muted\" aria-hidden=\"true\" />\n <span className=\"font-medium\">\n {nameById.get(relation.toSkillId) ?? 'Unknown skill'}\n </span>\n </span>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n disabled={busy}\n aria-label={`Remove relation ${nameById.get(relation.fromSkillId) ?? ''} ${RELATION_VERB[relation.type] ?? relation.type} ${nameById.get(relation.toSkillId) ?? ''}`}\n onClick={() => void handleRemove(relation.id)}\n >\n <X className=\"h-3.5 w-3.5\" />\n </Button>\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"text-sm text-text-muted\">No relations yet.</p>\n )}\n\n <div className=\"space-y-3 border-t border-border-subtle pt-4\">\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"skill-relation-type\">\n {current ? `${current.name}…` : 'This skill…'}\n </Label>\n <Select value={type} onValueChange={setType}>\n <SelectTrigger id=\"skill-relation-type\" className=\"w-full\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {RELATION_TYPES.map((item) => (\n <SelectItem key={item.value} value={item.value}>\n {item.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"text-xs text-text-muted\">{selectedType.help}</p>\n </div>\n <SkillPicker\n skills={skills}\n label=\"Other skill\"\n excludeIds={current ? [current.id] : []}\n selectedId={targetId}\n onSelect={(picked) => setTargetId(picked.id)}\n disabled={busy}\n />\n <Button type=\"button\" disabled={busy || !targetId} onClick={() => void handleAdd()}>\n <Plus className=\"mr-1.5 h-3.5 w-3.5\" />\n Add relation\n </Button>\n </div>\n </div>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { Candidate, Department, Employee, JobPosting, PayrollRecord, TimeEntry } from '../store/crewfoundryStore';
1
+ import { Candidate, Department, Employee, PayrollRecord, TimeEntry } from '../store/crewfoundryStore';
2
2
  import { HeatmapRow, LabeledValue, Segment } from '../components/viz';
3
3
 
4
4
  /**
@@ -107,34 +107,6 @@ export interface AllocationRow {
107
107
  * no time entries exist. Sorted by utilization (busiest first).
108
108
  */
109
109
  export declare function allocationByPerson(timeEntries: TimeEntry[], employees: Employee[]): AllocationRow[];
110
- /** Which real pool the skill matches were drawn from. */
111
- export type SkillMatchSource = 'employee' | 'candidate';
112
- export interface SkillMatchResult {
113
- id: string;
114
- name: string;
115
- matchPercent: number;
116
- matchedSkills: string[];
117
- missingSkills: string[];
118
- /** Short descriptor: role · department (employees) or experience · status (candidates). */
119
- detail: string;
120
- }
121
- export interface SkillMatchPanel {
122
- roleTitle: string;
123
- /** Real skill tags the role calls for, derived from the matched pool's skill universe. */
124
- requiredSkills: string[];
125
- matches: SkillMatchResult[];
126
- /** Whether matches came from internal employees or the candidate pool. */
127
- source: SkillMatchSource;
128
- }
129
- /**
130
- * Rank people by how well their real skill tags overlap a role's required
131
- * skills. Prefers internal employees (`Employee.skills`); falls back to the
132
- * candidate pool when no employee has skills recorded. The role's required
133
- * skills are themselves derived from real data — the matched pool's skill
134
- * universe intersected with the job's title + requirement text — so nothing is
135
- * invented, and the panel is empty when the data is absent.
136
- */
137
- export declare function skillMatch(job: JobPosting, employees: Employee[], candidates: Candidate[], limit?: number): SkillMatchPanel;
138
110
  export interface PayrollPeriodTotal {
139
111
  period: string;
140
112
  gross: number;
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../../../src/crewfoundry/lib/analytics.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../../../src/crewfoundry/lib/analytics.ts"],"names":[],"mappings":"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"}
@@ -231,52 +231,7 @@ function b(e, t) {
231
231
  }
232
232
  return m.sort((e, t) => t.utilization - e.utilization), m;
233
233
  }
234
- function x(e) {
235
- let t = e.experience;
236
- return `${`${t} yr${t === 1 ? "" : "s"} experience`} · ${e.status.replace(/_/g, " ")}`;
237
- }
238
- function S(e, t, n, r = 6) {
239
- let i = t.filter((e) => e.skills.length > 0).map((e) => ({
240
- id: e.id,
241
- name: `${e.firstName} ${e.lastName}`.trim(),
242
- skills: e.skills,
243
- detail: [e.position, e.department].filter(Boolean).join(" · ")
244
- })), a = i.length > 0 ? "employee" : "candidate", s = a === "employee" ? i : n.map((e) => ({
245
- id: e.id,
246
- name: `${e.firstName} ${e.lastName}`.trim(),
247
- skills: e.skills,
248
- detail: x(e)
249
- })), c = /* @__PURE__ */ new Map();
250
- for (let e of s) for (let t of e.skills) {
251
- let e = t.trim();
252
- e.length >= 2 && !c.has(e.toLowerCase()) && c.set(e.toLowerCase(), e);
253
- }
254
- let l = `${e.title} ${e.requirements.join(" ")}`.toLowerCase(), u = [...c.entries()].filter(([e]) => l.includes(e)).map(([, e]) => e);
255
- if (u.length === 0) return {
256
- roleTitle: e.title,
257
- requiredSkills: [],
258
- matches: [],
259
- source: a
260
- };
261
- let d = u.map((e) => e.toLowerCase()), f = s.map((e) => {
262
- let t = new Set(e.skills.map((e) => e.toLowerCase())), n = u.filter((e, n) => t.has(d[n])), r = u.filter((e, n) => !t.has(d[n]));
263
- return {
264
- id: e.id,
265
- name: e.name,
266
- matchPercent: o(n.length / u.length * 100),
267
- matchedSkills: n,
268
- missingSkills: r,
269
- detail: e.detail
270
- };
271
- }).filter((e) => e.matchedSkills.length > 0).sort((e, t) => t.matchPercent - e.matchPercent || t.matchedSkills.length - e.matchedSkills.length).slice(0, r);
272
- return {
273
- roleTitle: e.title,
274
- requiredSkills: u,
275
- matches: f,
276
- source: a
277
- };
278
- }
279
- function C(e, t = 12) {
234
+ function x(e, t = 12) {
280
235
  let n = e.map((e) => r(e.terminationDate)).filter((e) => e !== null).map(i);
281
236
  if (n.length === 0) return [];
282
237
  let o = Math.max(...n), s = Math.max(Math.min(...n), o - (t - 1)), c = Math.floor(s / 12) !== Math.floor(o / 12), l = [];
@@ -289,7 +244,7 @@ function C(e, t = 12) {
289
244
  }
290
245
  return l;
291
246
  }
292
- function w(e) {
247
+ function S(e) {
293
248
  let t = 0, n = 0;
294
249
  for (let i of e) {
295
250
  let e = r(i.hiredDate), a = r(i.appliedDate);
@@ -303,6 +258,6 @@ function w(e) {
303
258
  };
304
259
  }
305
260
  //#endregion
306
- export { b as allocationByPerson, C as attritionTrend, v as benchAndOverallocated, y as billableSplit, g as deptHeadcount, p as headcountByMonth, m as momDelta, h as performanceDistribution, S as skillMatch, w as timeToHire, _ as utilizationByPersonWeek };
261
+ export { b as allocationByPerson, x as attritionTrend, v as benchAndOverallocated, y as billableSplit, g as deptHeadcount, p as headcountByMonth, m as momDelta, h as performanceDistribution, S as timeToHire, _ as utilizationByPersonWeek };
307
262
 
308
263
  //# sourceMappingURL=analytics.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"analytics.js","names":[],"sources":["../../../src/crewfoundry/lib/analytics.ts"],"sourcesContent":["/**\n * CrewFoundry real-data analytics helpers.\n *\n * Pure, dependency-free (no React, no store access) transforms that turn REAL\n * store entities into viz-ready data structures for the visualization kit.\n * Every function takes its data as arguments and returns plain data — nothing\n * here reaches for the network, the DOM, or global state, so the results are\n * deterministic and unit-testable.\n *\n * Guiding rule: NEVER fabricate. When a needed field is absent from the backend\n * schema, the corresponding helper returns an empty/blank result (and carries a\n * `// needs backend field: …` note) rather than inventing values.\n */\n\nimport type {\n Candidate,\n Department,\n Employee,\n JobPosting,\n PayrollRecord,\n TimeEntry,\n} from '../store/crewfoundryStore';\nimport type { HeatmapRow, LabeledValue, Segment } from '../components/viz';\n// Tokenized colors from the (pure, React-free) palette module — importing the\n// viz index here would pull React components into this dependency-free module.\nimport { CHART_GRID, STATUS } from '../components/viz/lib/palette';\n\n// ── Small pure date helpers ──────────────────────────────────────────────────\n\nconst MONTHS_SHORT = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n] as const;\n\n/** Parse a date-only or ISO string; returns null for empty / \"TBD\" / invalid. */\nfunction parseDate(value?: string | null): Date | null {\n if (!value) {\n return null;\n }\n const date = new Date(value);\n return Number.isNaN(date.getTime()) ? null : date;\n}\n\n/** Absolute month index (year * 12 + month) for iterating month ranges. */\nfunction monthIndex(date: Date): number {\n return date.getUTCFullYear() * 12 + date.getUTCMonth();\n}\n\nfunction monthLabelFromIndex(index: number, includeYear: boolean): string {\n const year = Math.floor(index / 12);\n const month = index % 12;\n const short = MONTHS_SHORT[month];\n return includeYear ? `${short} ${String(year).slice(2)}` : short;\n}\n\nfunction round(value: number): number {\n return Math.round(value);\n}\n\n// ── ISO week helpers (used by utilization + allocation) ──────────────────────\n\ninterface IsoWeek {\n year: number;\n week: number;\n}\n\n/** ISO-8601 week number for a date (weeks start Monday; week 1 owns Jan 4th). */\nfunction isoWeek(date: Date): IsoWeek {\n const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));\n const dayNum = d.getUTCDay() || 7; // Sunday → 7\n d.setUTCDate(d.getUTCDate() + 4 - dayNum); // shift to the week's Thursday\n const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));\n const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);\n return { year: d.getUTCFullYear(), week };\n}\n\nfunction isoWeekKey(date: Date): string {\n const { year, week } = isoWeek(date);\n return `${year}-W${String(week).padStart(2, '0')}`;\n}\n\n/** Monday (UTC) that opens the given ISO week key. */\nfunction isoWeekMonday(key: string): Date | null {\n const match = /^(\\d{4})-W(\\d{2})$/.exec(key);\n if (!match) {\n return null;\n }\n const year = Number(match[1]);\n const week = Number(match[2]);\n const jan4 = new Date(Date.UTC(year, 0, 4));\n const jan4Day = jan4.getUTCDay() || 7;\n const week1Monday = new Date(jan4);\n week1Monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1));\n const monday = new Date(week1Monday);\n monday.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7);\n return monday;\n}\n\n/** Compact \"Jul 6–12\" / \"Jun 29 – Jul 5\" range label for an ISO week key. */\nfunction isoWeekRangeLabel(key: string): string {\n const monday = isoWeekMonday(key);\n if (!monday) {\n return key;\n }\n const sunday = new Date(monday);\n sunday.setUTCDate(monday.getUTCDate() + 6);\n const startMonth = MONTHS_SHORT[monday.getUTCMonth()];\n const endMonth = MONTHS_SHORT[sunday.getUTCMonth()];\n if (monday.getUTCMonth() === sunday.getUTCMonth()) {\n return `${startMonth} ${monday.getUTCDate()}–${sunday.getUTCDate()}`;\n }\n return `${startMonth} ${monday.getUTCDate()} – ${endMonth} ${sunday.getUTCDate()}`;\n}\n\nconst WEEKLY_CAPACITY_HOURS = 40;\n\nfunction employeeDisplayName(employee: Employee): string {\n return `${employee.firstName} ${employee.lastName}`.trim();\n}\n\n// ── Headcount ────────────────────────────────────────────────────────────────\n\n/**\n * Cumulative headcount series by month, derived from each employee's `startDate`.\n * Spans the first hire month through the last hire month (inclusive); months\n * with no hires keep the running total flat. Empty when no employee has a\n * parseable start date.\n */\nexport function headcountByMonth(employees: Employee[]): LabeledValue[] {\n const startIndices = employees\n .map((employee) => parseDate(employee.startDate))\n .filter((date): date is Date => date !== null)\n .map(monthIndex);\n\n if (startIndices.length === 0) {\n return [];\n }\n\n const minIndex = Math.min(...startIndices);\n const maxIndex = Math.max(...startIndices);\n const includeYear = Math.floor(minIndex / 12) !== Math.floor(maxIndex / 12);\n\n const series: LabeledValue[] = [];\n for (let index = minIndex; index <= maxIndex; index += 1) {\n const cumulative = startIndices.filter((start) => start <= index).length;\n series.push({ label: monthLabelFromIndex(index, includeYear), value: cumulative });\n }\n return series;\n}\n\nexport interface MomDelta {\n current: number;\n previous: number;\n delta: number;\n deltaPercent: number;\n}\n\n/** Last-vs-prior comparison of a series, for KPI trend indicators. */\nexport function momDelta(series: LabeledValue[]): MomDelta {\n if (series.length === 0) {\n return { current: 0, previous: 0, delta: 0, deltaPercent: 0 };\n }\n const current = series[series.length - 1].value;\n const previous = series.length > 1 ? series[series.length - 2].value : current;\n const delta = current - previous;\n const deltaPercent = previous > 0 ? round((delta / previous) * 100) : 0;\n return { current, previous, delta, deltaPercent };\n}\n\n// ── Hiring funnel ────────────────────────────────────────────────────────────\n\nconst FUNNEL_STAGES: { key: Candidate['status']; label: string }[] = [\n { key: 'new', label: 'New' },\n { key: 'screening', label: 'Screening' },\n { key: 'interview', label: 'Interview' },\n { key: 'assessment', label: 'Assessment' },\n { key: 'offer', label: 'Offer' },\n { key: 'hired', label: 'Hired' },\n];\n\nexport interface FunnelConversion {\n from: string;\n to: string;\n percent: number;\n}\n\nexport interface HiringFunnel {\n stages: LabeledValue[];\n conversions: FunnelConversion[];\n}\n\n/**\n * Ordered hiring pipeline stages (new → hired) with the count of candidates\n * currently at each status, plus the stage-to-stage conversion ratio. Counts\n * reflect current status (the backend stores no per-candidate stage history).\n */\nexport function hiringFunnel(candidates: Candidate[]): HiringFunnel {\n const stages: LabeledValue[] = FUNNEL_STAGES.map((stage) => ({\n label: stage.label,\n value: candidates.filter((candidate) => candidate.status === stage.key).length,\n }));\n\n const conversions: FunnelConversion[] = [];\n for (let i = 0; i < stages.length - 1; i += 1) {\n const from = stages[i];\n const to = stages[i + 1];\n conversions.push({\n from: from.label,\n to: to.label,\n percent: from.value > 0 ? round((to.value / from.value) * 100) : 0,\n });\n }\n\n return { stages, conversions };\n}\n\n// ── Performance distribution ─────────────────────────────────────────────────\n\n/**\n * Employee performance-score histogram in four bands derived from the real\n * `performanceScore` (0–5). Employees without a numeric score are excluded.\n */\nexport function performanceDistribution(employees: Employee[]): LabeledValue[] {\n const scores = employees\n .map((employee) => employee.performanceScore)\n .filter((score): score is number => typeof score === 'number' && !Number.isNaN(score));\n\n const buckets: LabeledValue[] = [\n { label: '< 2.5', value: scores.filter((s) => s < 2.5).length },\n { label: '2.5–3.5', value: scores.filter((s) => s >= 2.5 && s < 3.5).length },\n { label: '3.5–4.5', value: scores.filter((s) => s >= 3.5 && s < 4.5).length },\n { label: '4.5+', value: scores.filter((s) => s >= 4.5).length },\n ];\n return buckets;\n}\n\n// ── Department headcount ─────────────────────────────────────────────────────\n\n/** Headcount per department (sorted high → low) from the real `employeeCount`. */\nexport function deptHeadcount(departments: Department[]): LabeledValue[] {\n return departments\n .map((department) => ({ label: department.name, value: department.employeeCount }))\n .sort((a, b) => b.value - a.value);\n}\n\n// ── Utilization heatmap ──────────────────────────────────────────────────────\n\nexport interface PersonUtil {\n employeeId: string;\n name: string;\n /** Utilization % per shown week; null where the person logged no time. */\n weekly: (number | null)[];\n /** Mean utilization across the weeks the person actually logged time. */\n avg: number;\n /** Utilization % of the most recent week the person logged time. */\n latest: number;\n}\n\nexport interface UtilizationMatrix {\n /** Heatmap rows (people × weeks) ready for the `Heatmap` component. */\n rows: HeatmapRow[];\n /** ISO week keys for the shown columns (ascending). */\n weekKeys: string[];\n /** Short column labels, e.g. \"W1\" … \"W8\". */\n weekLabels: string[];\n /** Human date-range labels aligned to `weekKeys`, e.g. \"Jul 6–12\". */\n weekRanges: string[];\n /** Per-person utilization, aligned to `rows`. */\n personUtil: PersonUtil[];\n hasData: boolean;\n}\n\n/**\n * Per-person, per-ISO-week utilization (Σ totalHours ÷ 40h capacity) built from\n * real time entries. Columns are only the weeks that actually contain entries\n * (optionally capped to the most recent `weeks`); a person's cell is left blank\n * for any week they logged no time — never fabricated. Rows are sorted by\n * average utilization (busiest first).\n */\nexport function utilizationByPersonWeek(\n timeEntries: TimeEntry[],\n employees: Employee[],\n weeks?: number\n): UtilizationMatrix {\n const employeeById = new Map(employees.map((employee) => [employee.id, employee]));\n\n // employeeId -> weekKey -> summed hours\n const hoursByPersonWeek = new Map<string, Map<string, number>>();\n const weekKeySet = new Set<string>();\n\n for (const entry of timeEntries) {\n const date = parseDate(entry.date);\n if (!date || typeof entry.totalHours !== 'number' || Number.isNaN(entry.totalHours)) {\n continue;\n }\n const key = isoWeekKey(date);\n weekKeySet.add(key);\n let weekMap = hoursByPersonWeek.get(entry.employeeId);\n if (!weekMap) {\n weekMap = new Map<string, number>();\n hoursByPersonWeek.set(entry.employeeId, weekMap);\n }\n weekMap.set(key, (weekMap.get(key) ?? 0) + entry.totalHours);\n }\n\n const allWeekKeys = [...weekKeySet].sort();\n const shownKeys =\n typeof weeks === 'number' && weeks > 0 ? allWeekKeys.slice(-weeks) : allWeekKeys;\n\n if (shownKeys.length === 0 || hoursByPersonWeek.size === 0) {\n return {\n rows: [],\n weekKeys: [],\n weekLabels: [],\n weekRanges: [],\n personUtil: [],\n hasData: false,\n };\n }\n\n const weekLabels = shownKeys.map((_key, index) => `W${index + 1}`);\n const weekRanges = shownKeys.map((key) => isoWeekRangeLabel(key));\n\n const people: PersonUtil[] = [];\n for (const [employeeId, weekMap] of hoursByPersonWeek) {\n const weekly = shownKeys.map((key) => {\n const hours = weekMap.get(key);\n if (hours === undefined) {\n return null;\n }\n return round((hours / WEEKLY_CAPACITY_HOURS) * 100);\n });\n const logged = weekly.filter((value): value is number => value !== null);\n if (logged.length === 0) {\n continue; // person has entries, but none within the shown window\n }\n const avg = round(logged.reduce((sum, value) => sum + value, 0) / logged.length);\n let latest = logged[logged.length - 1];\n for (let i = weekly.length - 1; i >= 0; i -= 1) {\n const value = weekly[i];\n if (value !== null) {\n latest = value;\n break;\n }\n }\n const employee = employeeById.get(employeeId);\n const name = employee\n ? employeeDisplayName(employee)\n : (timeEntries.find((entry) => entry.employeeId === employeeId)?.employeeName ?? 'Unknown');\n people.push({ employeeId, name, weekly, avg, latest });\n }\n\n people.sort((a, b) => b.avg - a.avg);\n\n const rows: HeatmapRow[] = people.map((person) => ({\n label: person.name,\n cells: person.weekly.map((value) => ({ value: value ?? 0 })),\n }));\n\n return {\n rows,\n weekKeys: shownKeys,\n weekLabels,\n weekRanges,\n personUtil: people,\n hasData: rows.length > 0,\n };\n}\n\nexport interface ResourcingKpis {\n /** Mean utilization across allocated people (%). */\n avgUtilization: number;\n /** Active employees with no logged time in the window. */\n bench: number;\n /** Allocated people whose most recent week exceeds 100%. */\n overAllocated: number;\n /** People with any logged time in the window. */\n allocatedCount: number;\n}\n\n/**\n * KPI counts derived from a utilization matrix + the employee roster. `bench`\n * needs the full active roster (people absent from the matrix have no\n * allocation), so the roster is passed alongside the matrix.\n */\nexport function benchAndOverallocated(\n matrix: UtilizationMatrix,\n employees: Employee[]\n): ResourcingKpis {\n const allocatedIds = new Set(matrix.personUtil.map((person) => person.employeeId));\n const activeEmployees = employees.filter((employee) => employee.status === 'active');\n const bench = activeEmployees.filter((employee) => !allocatedIds.has(employee.id)).length;\n const overAllocated = matrix.personUtil.filter((person) => person.latest > 100).length;\n const avgUtilization =\n matrix.personUtil.length > 0\n ? round(\n matrix.personUtil.reduce((sum, person) => sum + person.avg, 0) / matrix.personUtil.length\n )\n : 0;\n return {\n avgUtilization,\n bench,\n overAllocated,\n allocatedCount: matrix.personUtil.length,\n };\n}\n\n// ── Billable split ───────────────────────────────────────────────────────────\n\n/**\n * Billable vs non-billable hours donut, summing real `totalHours` split by the\n * real `TimeEntry.billable` flag. Returns null (callers render an empty state)\n * when no logged hours exist — never a fabricated ratio.\n */\nexport function billableSplit(timeEntries: TimeEntry[]): Segment[] | null {\n let billableHours = 0;\n let nonBillableHours = 0;\n for (const entry of timeEntries) {\n const hours =\n typeof entry.totalHours === 'number' && !Number.isNaN(entry.totalHours)\n ? entry.totalHours\n : 0;\n if (entry.billable) {\n billableHours += hours;\n } else {\n nonBillableHours += hours;\n }\n }\n\n if (billableHours + nonBillableHours <= 0) {\n return null;\n }\n\n return [\n { label: 'Billable', value: billableHours, color: STATUS.success },\n { label: 'Non-billable', value: nonBillableHours, color: CHART_GRID },\n ];\n}\n\n// ── Per-person allocation (latest week) ──────────────────────────────────────\n\nexport interface AllocationRow {\n employeeId: string;\n name: string;\n /** One segment per project the person logged time against this week. */\n segments: Segment[];\n /** Utilization % for the week (Σ hours ÷ 40h). */\n utilization: number;\n /** Human label for the week the allocation covers. */\n weekLabel: string;\n}\n\n/**\n * Per-person project allocation for the most recent ISO week that has real time\n * entries: a stacked bar of hours-by-project plus a utilization %. Empty when\n * no time entries exist. Sorted by utilization (busiest first).\n */\nexport function allocationByPerson(\n timeEntries: TimeEntry[],\n employees: Employee[]\n): AllocationRow[] {\n const dated = timeEntries\n .map((entry) => ({ entry, date: parseDate(entry.date) }))\n .filter(\n (item): item is { entry: TimeEntry; date: Date } =>\n item.date !== null &&\n typeof item.entry.totalHours === 'number' &&\n !Number.isNaN(item.entry.totalHours)\n );\n\n if (dated.length === 0) {\n return [];\n }\n\n const latestKey = dated\n .map((item) => isoWeekKey(item.date))\n .sort()\n .slice(-1)[0];\n const weekLabel = isoWeekRangeLabel(latestKey);\n const employeeById = new Map(employees.map((employee) => [employee.id, employee]));\n\n // employeeId -> project -> hours\n const byPerson = new Map<string, Map<string, number>>();\n const nameByPerson = new Map<string, string>();\n\n for (const { entry, date } of dated) {\n if (isoWeekKey(date) !== latestKey) {\n continue;\n }\n let projectMap = byPerson.get(entry.employeeId);\n if (!projectMap) {\n projectMap = new Map<string, number>();\n byPerson.set(entry.employeeId, projectMap);\n }\n const project = entry.project || 'Unassigned';\n projectMap.set(project, (projectMap.get(project) ?? 0) + entry.totalHours);\n const employee = employeeById.get(entry.employeeId);\n nameByPerson.set(\n entry.employeeId,\n employee ? employeeDisplayName(employee) : entry.employeeName || 'Unknown'\n );\n }\n\n const rows: AllocationRow[] = [];\n for (const [employeeId, projectMap] of byPerson) {\n const segments: Segment[] = [...projectMap.entries()].map(([label, value]) => ({\n label,\n value,\n }));\n const totalHours = segments.reduce((sum, segment) => sum + segment.value, 0);\n rows.push({\n employeeId,\n name: nameByPerson.get(employeeId) ?? 'Unknown',\n segments,\n utilization: round((totalHours / WEEKLY_CAPACITY_HOURS) * 100),\n weekLabel,\n });\n }\n\n rows.sort((a, b) => b.utilization - a.utilization);\n return rows;\n}\n\n// ── Skills match ─────────────────────────────────────────────────────────────\n\n/** Which real pool the skill matches were drawn from. */\nexport type SkillMatchSource = 'employee' | 'candidate';\n\nexport interface SkillMatchResult {\n id: string;\n name: string;\n matchPercent: number;\n matchedSkills: string[];\n missingSkills: string[];\n /** Short descriptor: role · department (employees) or experience · status (candidates). */\n detail: string;\n}\n\nexport interface SkillMatchPanel {\n roleTitle: string;\n /** Real skill tags the role calls for, derived from the matched pool's skill universe. */\n requiredSkills: string[];\n matches: SkillMatchResult[];\n /** Whether matches came from internal employees or the candidate pool. */\n source: SkillMatchSource;\n}\n\n/** A person (employee or candidate) reduced to what skill matching needs. */\ninterface MatchablePerson {\n id: string;\n name: string;\n skills: string[];\n detail: string;\n}\n\n/** \"3 yrs experience · Interview\" descriptor for a candidate (no UI helpers in this pure module). */\nfunction candidateDetail(candidate: Candidate): string {\n const years = candidate.experience;\n const experience = `${years} yr${years === 1 ? '' : 's'} experience`;\n const status = candidate.status.replace(/_/g, ' ');\n return `${experience} · ${status}`;\n}\n\n/**\n * Rank people by how well their real skill tags overlap a role's required\n * skills. Prefers internal employees (`Employee.skills`); falls back to the\n * candidate pool when no employee has skills recorded. The role's required\n * skills are themselves derived from real data — the matched pool's skill\n * universe intersected with the job's title + requirement text — so nothing is\n * invented, and the panel is empty when the data is absent.\n */\nexport function skillMatch(\n job: JobPosting,\n employees: Employee[],\n candidates: Candidate[],\n limit = 6\n): SkillMatchPanel {\n const employeePeople: MatchablePerson[] = employees\n .filter((employee) => employee.skills.length > 0)\n .map((employee) => ({\n id: employee.id,\n name: `${employee.firstName} ${employee.lastName}`.trim(),\n skills: employee.skills,\n detail: [employee.position, employee.department].filter(Boolean).join(' · '),\n }));\n\n const source: SkillMatchSource = employeePeople.length > 0 ? 'employee' : 'candidate';\n const people: MatchablePerson[] =\n source === 'employee'\n ? employeePeople\n : candidates.map((candidate) => ({\n id: candidate.id,\n name: `${candidate.firstName} ${candidate.lastName}`.trim(),\n skills: candidate.skills,\n detail: candidateDetail(candidate),\n }));\n\n // Distinct skill tags across the chosen pool, preserving first-seen casing.\n const universe = new Map<string, string>();\n for (const person of people) {\n for (const skill of person.skills) {\n const normalized = skill.trim();\n if (normalized.length >= 2 && !universe.has(normalized.toLowerCase())) {\n universe.set(normalized.toLowerCase(), normalized);\n }\n }\n }\n\n const haystack = `${job.title} ${job.requirements.join(' ')}`.toLowerCase();\n const requiredSkills = [...universe.entries()]\n .filter(([lower]) => haystack.includes(lower))\n .map(([, original]) => original);\n\n if (requiredSkills.length === 0) {\n return { roleTitle: job.title, requiredSkills: [], matches: [], source };\n }\n\n const requiredLower = requiredSkills.map((skill) => skill.toLowerCase());\n\n const matches: SkillMatchResult[] = people\n .map((person) => {\n const personLower = new Set(person.skills.map((skill) => skill.toLowerCase()));\n const matchedSkills = requiredSkills.filter((_skill, index) =>\n personLower.has(requiredLower[index])\n );\n const missingSkills = requiredSkills.filter(\n (_skill, index) => !personLower.has(requiredLower[index])\n );\n return {\n id: person.id,\n name: person.name,\n matchPercent: round((matchedSkills.length / requiredSkills.length) * 100),\n matchedSkills,\n missingSkills,\n detail: person.detail,\n };\n })\n .filter((result) => result.matchedSkills.length > 0)\n .sort(\n (a, b) => b.matchPercent - a.matchPercent || b.matchedSkills.length - a.matchedSkills.length\n )\n .slice(0, limit);\n\n return { roleTitle: job.title, requiredSkills, matches, source };\n}\n\n// ── Finance / payroll trends (best-effort) ───────────────────────────────────\n\nexport interface PayrollPeriodTotal {\n period: string;\n gross: number;\n net: number;\n deductions: number;\n}\n\n/**\n * Gross / net / deduction totals per payroll period, in first-seen period order\n * (period is a free-text label so it is not re-sorted). Empty when there are no\n * records.\n */\nexport function payrollTotals(payrollRecords: PayrollRecord[]): PayrollPeriodTotal[] {\n const order: string[] = [];\n const totals = new Map<string, PayrollPeriodTotal>();\n for (const record of payrollRecords) {\n let bucket = totals.get(record.period);\n if (!bucket) {\n bucket = { period: record.period, gross: 0, net: 0, deductions: 0 };\n totals.set(record.period, bucket);\n order.push(record.period);\n }\n bucket.gross += record.grossPay;\n bucket.net += record.netPay;\n bucket.deductions += record.deductions;\n }\n return order.map((period) => totals.get(period) as PayrollPeriodTotal);\n}\n\n/**\n * Monthly attrition: the count of employee exits per month, derived from the\n * real `Employee.terminationDate`, over the most recent `monthsWindow` months\n * that contain an exit (through the latest exit month, inclusive). Months with\n * no exits in that span read zero. Empty when no employee has a termination\n * date — never a fabricated exit month.\n */\nexport function attritionTrend(employees: Employee[], monthsWindow = 12): LabeledValue[] {\n const exitIndices = employees\n .map((employee) => parseDate(employee.terminationDate))\n .filter((date): date is Date => date !== null)\n .map(monthIndex);\n\n if (exitIndices.length === 0) {\n return [];\n }\n\n const maxIndex = Math.max(...exitIndices);\n const minIndex = Math.max(Math.min(...exitIndices), maxIndex - (monthsWindow - 1));\n const includeYear = Math.floor(minIndex / 12) !== Math.floor(maxIndex / 12);\n\n const series: LabeledValue[] = [];\n for (let index = minIndex; index <= maxIndex; index += 1) {\n const count = exitIndices.filter((exit) => exit === index).length;\n series.push({ label: monthLabelFromIndex(index, includeYear), value: count });\n }\n return series;\n}\n\nexport interface TimeToHire {\n averageDays: number;\n sampleSize: number;\n}\n\n/**\n * Average time-to-hire: mean elapsed days from `appliedDate` to the real\n * `Candidate.hiredDate`, across candidates that carry both dates. Non-negative\n * spans only. Returns null (callers render an empty state) when no candidate\n * has a hired date — the elapsed time is never estimated.\n */\nexport function timeToHire(candidates: Candidate[]): TimeToHire | null {\n let totalDays = 0;\n let sampleSize = 0;\n for (const candidate of candidates) {\n const hired = parseDate(candidate.hiredDate);\n const applied = parseDate(candidate.appliedDate);\n if (!hired || !applied) {\n continue;\n }\n const days = Math.round((hired.getTime() - applied.getTime()) / 86_400_000);\n if (days < 0) {\n continue;\n }\n totalDays += days;\n sampleSize += 1;\n }\n\n if (sampleSize === 0) {\n return null;\n }\n\n return { averageDays: Math.round(totalDays / sampleSize), sampleSize };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"analytics.js","names":[],"sources":["../../../src/crewfoundry/lib/analytics.ts"],"sourcesContent":["/**\n * CrewFoundry real-data analytics helpers.\n *\n * Pure, dependency-free (no React, no store access) transforms that turn REAL\n * store entities into viz-ready data structures for the visualization kit.\n * Every function takes its data as arguments and returns plain data — nothing\n * here reaches for the network, the DOM, or global state, so the results are\n * deterministic and unit-testable.\n *\n * Guiding rule: NEVER fabricate. When a needed field is absent from the backend\n * schema, the corresponding helper returns an empty/blank result (and carries a\n * `// needs backend field: …` note) rather than inventing values.\n */\n\nimport type {\n Candidate,\n Department,\n Employee,\n PayrollRecord,\n TimeEntry,\n} from '../store/crewfoundryStore';\nimport type { HeatmapRow, LabeledValue, Segment } from '../components/viz';\n// Tokenized colors from the (pure, React-free) palette module — importing the\n// viz index here would pull React components into this dependency-free module.\nimport { CHART_GRID, STATUS } from '../components/viz/lib/palette';\n\n// ── Small pure date helpers ──────────────────────────────────────────────────\n\nconst MONTHS_SHORT = [\n 'Jan',\n 'Feb',\n 'Mar',\n 'Apr',\n 'May',\n 'Jun',\n 'Jul',\n 'Aug',\n 'Sep',\n 'Oct',\n 'Nov',\n 'Dec',\n] as const;\n\n/** Parse a date-only or ISO string; returns null for empty / \"TBD\" / invalid. */\nfunction parseDate(value?: string | null): Date | null {\n if (!value) {\n return null;\n }\n const date = new Date(value);\n return Number.isNaN(date.getTime()) ? null : date;\n}\n\n/** Absolute month index (year * 12 + month) for iterating month ranges. */\nfunction monthIndex(date: Date): number {\n return date.getUTCFullYear() * 12 + date.getUTCMonth();\n}\n\nfunction monthLabelFromIndex(index: number, includeYear: boolean): string {\n const year = Math.floor(index / 12);\n const month = index % 12;\n const short = MONTHS_SHORT[month];\n return includeYear ? `${short} ${String(year).slice(2)}` : short;\n}\n\nfunction round(value: number): number {\n return Math.round(value);\n}\n\n// ── ISO week helpers (used by utilization + allocation) ──────────────────────\n\ninterface IsoWeek {\n year: number;\n week: number;\n}\n\n/** ISO-8601 week number for a date (weeks start Monday; week 1 owns Jan 4th). */\nfunction isoWeek(date: Date): IsoWeek {\n const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));\n const dayNum = d.getUTCDay() || 7; // Sunday → 7\n d.setUTCDate(d.getUTCDate() + 4 - dayNum); // shift to the week's Thursday\n const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));\n const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);\n return { year: d.getUTCFullYear(), week };\n}\n\nfunction isoWeekKey(date: Date): string {\n const { year, week } = isoWeek(date);\n return `${year}-W${String(week).padStart(2, '0')}`;\n}\n\n/** Monday (UTC) that opens the given ISO week key. */\nfunction isoWeekMonday(key: string): Date | null {\n const match = /^(\\d{4})-W(\\d{2})$/.exec(key);\n if (!match) {\n return null;\n }\n const year = Number(match[1]);\n const week = Number(match[2]);\n const jan4 = new Date(Date.UTC(year, 0, 4));\n const jan4Day = jan4.getUTCDay() || 7;\n const week1Monday = new Date(jan4);\n week1Monday.setUTCDate(jan4.getUTCDate() - (jan4Day - 1));\n const monday = new Date(week1Monday);\n monday.setUTCDate(week1Monday.getUTCDate() + (week - 1) * 7);\n return monday;\n}\n\n/** Compact \"Jul 6–12\" / \"Jun 29 – Jul 5\" range label for an ISO week key. */\nfunction isoWeekRangeLabel(key: string): string {\n const monday = isoWeekMonday(key);\n if (!monday) {\n return key;\n }\n const sunday = new Date(monday);\n sunday.setUTCDate(monday.getUTCDate() + 6);\n const startMonth = MONTHS_SHORT[monday.getUTCMonth()];\n const endMonth = MONTHS_SHORT[sunday.getUTCMonth()];\n if (monday.getUTCMonth() === sunday.getUTCMonth()) {\n return `${startMonth} ${monday.getUTCDate()}–${sunday.getUTCDate()}`;\n }\n return `${startMonth} ${monday.getUTCDate()} – ${endMonth} ${sunday.getUTCDate()}`;\n}\n\nconst WEEKLY_CAPACITY_HOURS = 40;\n\nfunction employeeDisplayName(employee: Employee): string {\n return `${employee.firstName} ${employee.lastName}`.trim();\n}\n\n// ── Headcount ────────────────────────────────────────────────────────────────\n\n/**\n * Cumulative headcount series by month, derived from each employee's `startDate`.\n * Spans the first hire month through the last hire month (inclusive); months\n * with no hires keep the running total flat. Empty when no employee has a\n * parseable start date.\n */\nexport function headcountByMonth(employees: Employee[]): LabeledValue[] {\n const startIndices = employees\n .map((employee) => parseDate(employee.startDate))\n .filter((date): date is Date => date !== null)\n .map(monthIndex);\n\n if (startIndices.length === 0) {\n return [];\n }\n\n const minIndex = Math.min(...startIndices);\n const maxIndex = Math.max(...startIndices);\n const includeYear = Math.floor(minIndex / 12) !== Math.floor(maxIndex / 12);\n\n const series: LabeledValue[] = [];\n for (let index = minIndex; index <= maxIndex; index += 1) {\n const cumulative = startIndices.filter((start) => start <= index).length;\n series.push({ label: monthLabelFromIndex(index, includeYear), value: cumulative });\n }\n return series;\n}\n\nexport interface MomDelta {\n current: number;\n previous: number;\n delta: number;\n deltaPercent: number;\n}\n\n/** Last-vs-prior comparison of a series, for KPI trend indicators. */\nexport function momDelta(series: LabeledValue[]): MomDelta {\n if (series.length === 0) {\n return { current: 0, previous: 0, delta: 0, deltaPercent: 0 };\n }\n const current = series[series.length - 1].value;\n const previous = series.length > 1 ? series[series.length - 2].value : current;\n const delta = current - previous;\n const deltaPercent = previous > 0 ? round((delta / previous) * 100) : 0;\n return { current, previous, delta, deltaPercent };\n}\n\n// ── Hiring funnel ────────────────────────────────────────────────────────────\n\nconst FUNNEL_STAGES: { key: Candidate['status']; label: string }[] = [\n { key: 'new', label: 'New' },\n { key: 'screening', label: 'Screening' },\n { key: 'interview', label: 'Interview' },\n { key: 'assessment', label: 'Assessment' },\n { key: 'offer', label: 'Offer' },\n { key: 'hired', label: 'Hired' },\n];\n\nexport interface FunnelConversion {\n from: string;\n to: string;\n percent: number;\n}\n\nexport interface HiringFunnel {\n stages: LabeledValue[];\n conversions: FunnelConversion[];\n}\n\n/**\n * Ordered hiring pipeline stages (new → hired) with the count of candidates\n * currently at each status, plus the stage-to-stage conversion ratio. Counts\n * reflect current status (the backend stores no per-candidate stage history).\n */\nexport function hiringFunnel(candidates: Candidate[]): HiringFunnel {\n const stages: LabeledValue[] = FUNNEL_STAGES.map((stage) => ({\n label: stage.label,\n value: candidates.filter((candidate) => candidate.status === stage.key).length,\n }));\n\n const conversions: FunnelConversion[] = [];\n for (let i = 0; i < stages.length - 1; i += 1) {\n const from = stages[i];\n const to = stages[i + 1];\n conversions.push({\n from: from.label,\n to: to.label,\n percent: from.value > 0 ? round((to.value / from.value) * 100) : 0,\n });\n }\n\n return { stages, conversions };\n}\n\n// ── Performance distribution ─────────────────────────────────────────────────\n\n/**\n * Employee performance-score histogram in four bands derived from the real\n * `performanceScore` (0–5). Employees without a numeric score are excluded.\n */\nexport function performanceDistribution(employees: Employee[]): LabeledValue[] {\n const scores = employees\n .map((employee) => employee.performanceScore)\n .filter((score): score is number => typeof score === 'number' && !Number.isNaN(score));\n\n const buckets: LabeledValue[] = [\n { label: '< 2.5', value: scores.filter((s) => s < 2.5).length },\n { label: '2.5–3.5', value: scores.filter((s) => s >= 2.5 && s < 3.5).length },\n { label: '3.5–4.5', value: scores.filter((s) => s >= 3.5 && s < 4.5).length },\n { label: '4.5+', value: scores.filter((s) => s >= 4.5).length },\n ];\n return buckets;\n}\n\n// ── Department headcount ─────────────────────────────────────────────────────\n\n/** Headcount per department (sorted high → low) from the real `employeeCount`. */\nexport function deptHeadcount(departments: Department[]): LabeledValue[] {\n return departments\n .map((department) => ({ label: department.name, value: department.employeeCount }))\n .sort((a, b) => b.value - a.value);\n}\n\n// ── Utilization heatmap ──────────────────────────────────────────────────────\n\nexport interface PersonUtil {\n employeeId: string;\n name: string;\n /** Utilization % per shown week; null where the person logged no time. */\n weekly: (number | null)[];\n /** Mean utilization across the weeks the person actually logged time. */\n avg: number;\n /** Utilization % of the most recent week the person logged time. */\n latest: number;\n}\n\nexport interface UtilizationMatrix {\n /** Heatmap rows (people × weeks) ready for the `Heatmap` component. */\n rows: HeatmapRow[];\n /** ISO week keys for the shown columns (ascending). */\n weekKeys: string[];\n /** Short column labels, e.g. \"W1\" … \"W8\". */\n weekLabels: string[];\n /** Human date-range labels aligned to `weekKeys`, e.g. \"Jul 6–12\". */\n weekRanges: string[];\n /** Per-person utilization, aligned to `rows`. */\n personUtil: PersonUtil[];\n hasData: boolean;\n}\n\n/**\n * Per-person, per-ISO-week utilization (Σ totalHours ÷ 40h capacity) built from\n * real time entries. Columns are only the weeks that actually contain entries\n * (optionally capped to the most recent `weeks`); a person's cell is left blank\n * for any week they logged no time — never fabricated. Rows are sorted by\n * average utilization (busiest first).\n */\nexport function utilizationByPersonWeek(\n timeEntries: TimeEntry[],\n employees: Employee[],\n weeks?: number\n): UtilizationMatrix {\n const employeeById = new Map(employees.map((employee) => [employee.id, employee]));\n\n // employeeId -> weekKey -> summed hours\n const hoursByPersonWeek = new Map<string, Map<string, number>>();\n const weekKeySet = new Set<string>();\n\n for (const entry of timeEntries) {\n const date = parseDate(entry.date);\n if (!date || typeof entry.totalHours !== 'number' || Number.isNaN(entry.totalHours)) {\n continue;\n }\n const key = isoWeekKey(date);\n weekKeySet.add(key);\n let weekMap = hoursByPersonWeek.get(entry.employeeId);\n if (!weekMap) {\n weekMap = new Map<string, number>();\n hoursByPersonWeek.set(entry.employeeId, weekMap);\n }\n weekMap.set(key, (weekMap.get(key) ?? 0) + entry.totalHours);\n }\n\n const allWeekKeys = [...weekKeySet].sort();\n const shownKeys =\n typeof weeks === 'number' && weeks > 0 ? allWeekKeys.slice(-weeks) : allWeekKeys;\n\n if (shownKeys.length === 0 || hoursByPersonWeek.size === 0) {\n return {\n rows: [],\n weekKeys: [],\n weekLabels: [],\n weekRanges: [],\n personUtil: [],\n hasData: false,\n };\n }\n\n const weekLabels = shownKeys.map((_key, index) => `W${index + 1}`);\n const weekRanges = shownKeys.map((key) => isoWeekRangeLabel(key));\n\n const people: PersonUtil[] = [];\n for (const [employeeId, weekMap] of hoursByPersonWeek) {\n const weekly = shownKeys.map((key) => {\n const hours = weekMap.get(key);\n if (hours === undefined) {\n return null;\n }\n return round((hours / WEEKLY_CAPACITY_HOURS) * 100);\n });\n const logged = weekly.filter((value): value is number => value !== null);\n if (logged.length === 0) {\n continue; // person has entries, but none within the shown window\n }\n const avg = round(logged.reduce((sum, value) => sum + value, 0) / logged.length);\n let latest = logged[logged.length - 1];\n for (let i = weekly.length - 1; i >= 0; i -= 1) {\n const value = weekly[i];\n if (value !== null) {\n latest = value;\n break;\n }\n }\n const employee = employeeById.get(employeeId);\n const name = employee\n ? employeeDisplayName(employee)\n : (timeEntries.find((entry) => entry.employeeId === employeeId)?.employeeName ?? 'Unknown');\n people.push({ employeeId, name, weekly, avg, latest });\n }\n\n people.sort((a, b) => b.avg - a.avg);\n\n const rows: HeatmapRow[] = people.map((person) => ({\n label: person.name,\n cells: person.weekly.map((value) => ({ value: value ?? 0 })),\n }));\n\n return {\n rows,\n weekKeys: shownKeys,\n weekLabels,\n weekRanges,\n personUtil: people,\n hasData: rows.length > 0,\n };\n}\n\nexport interface ResourcingKpis {\n /** Mean utilization across allocated people (%). */\n avgUtilization: number;\n /** Active employees with no logged time in the window. */\n bench: number;\n /** Allocated people whose most recent week exceeds 100%. */\n overAllocated: number;\n /** People with any logged time in the window. */\n allocatedCount: number;\n}\n\n/**\n * KPI counts derived from a utilization matrix + the employee roster. `bench`\n * needs the full active roster (people absent from the matrix have no\n * allocation), so the roster is passed alongside the matrix.\n */\nexport function benchAndOverallocated(\n matrix: UtilizationMatrix,\n employees: Employee[]\n): ResourcingKpis {\n const allocatedIds = new Set(matrix.personUtil.map((person) => person.employeeId));\n const activeEmployees = employees.filter((employee) => employee.status === 'active');\n const bench = activeEmployees.filter((employee) => !allocatedIds.has(employee.id)).length;\n const overAllocated = matrix.personUtil.filter((person) => person.latest > 100).length;\n const avgUtilization =\n matrix.personUtil.length > 0\n ? round(\n matrix.personUtil.reduce((sum, person) => sum + person.avg, 0) / matrix.personUtil.length\n )\n : 0;\n return {\n avgUtilization,\n bench,\n overAllocated,\n allocatedCount: matrix.personUtil.length,\n };\n}\n\n// ── Billable split ───────────────────────────────────────────────────────────\n\n/**\n * Billable vs non-billable hours donut, summing real `totalHours` split by the\n * real `TimeEntry.billable` flag. Returns null (callers render an empty state)\n * when no logged hours exist — never a fabricated ratio.\n */\nexport function billableSplit(timeEntries: TimeEntry[]): Segment[] | null {\n let billableHours = 0;\n let nonBillableHours = 0;\n for (const entry of timeEntries) {\n const hours =\n typeof entry.totalHours === 'number' && !Number.isNaN(entry.totalHours)\n ? entry.totalHours\n : 0;\n if (entry.billable) {\n billableHours += hours;\n } else {\n nonBillableHours += hours;\n }\n }\n\n if (billableHours + nonBillableHours <= 0) {\n return null;\n }\n\n return [\n { label: 'Billable', value: billableHours, color: STATUS.success },\n { label: 'Non-billable', value: nonBillableHours, color: CHART_GRID },\n ];\n}\n\n// ── Per-person allocation (latest week) ──────────────────────────────────────\n\nexport interface AllocationRow {\n employeeId: string;\n name: string;\n /** One segment per project the person logged time against this week. */\n segments: Segment[];\n /** Utilization % for the week (Σ hours ÷ 40h). */\n utilization: number;\n /** Human label for the week the allocation covers. */\n weekLabel: string;\n}\n\n/**\n * Per-person project allocation for the most recent ISO week that has real time\n * entries: a stacked bar of hours-by-project plus a utilization %. Empty when\n * no time entries exist. Sorted by utilization (busiest first).\n */\nexport function allocationByPerson(\n timeEntries: TimeEntry[],\n employees: Employee[]\n): AllocationRow[] {\n const dated = timeEntries\n .map((entry) => ({ entry, date: parseDate(entry.date) }))\n .filter(\n (item): item is { entry: TimeEntry; date: Date } =>\n item.date !== null &&\n typeof item.entry.totalHours === 'number' &&\n !Number.isNaN(item.entry.totalHours)\n );\n\n if (dated.length === 0) {\n return [];\n }\n\n const latestKey = dated\n .map((item) => isoWeekKey(item.date))\n .sort()\n .slice(-1)[0];\n const weekLabel = isoWeekRangeLabel(latestKey);\n const employeeById = new Map(employees.map((employee) => [employee.id, employee]));\n\n // employeeId -> project -> hours\n const byPerson = new Map<string, Map<string, number>>();\n const nameByPerson = new Map<string, string>();\n\n for (const { entry, date } of dated) {\n if (isoWeekKey(date) !== latestKey) {\n continue;\n }\n let projectMap = byPerson.get(entry.employeeId);\n if (!projectMap) {\n projectMap = new Map<string, number>();\n byPerson.set(entry.employeeId, projectMap);\n }\n const project = entry.project || 'Unassigned';\n projectMap.set(project, (projectMap.get(project) ?? 0) + entry.totalHours);\n const employee = employeeById.get(entry.employeeId);\n nameByPerson.set(\n entry.employeeId,\n employee ? employeeDisplayName(employee) : entry.employeeName || 'Unknown'\n );\n }\n\n const rows: AllocationRow[] = [];\n for (const [employeeId, projectMap] of byPerson) {\n const segments: Segment[] = [...projectMap.entries()].map(([label, value]) => ({\n label,\n value,\n }));\n const totalHours = segments.reduce((sum, segment) => sum + segment.value, 0);\n rows.push({\n employeeId,\n name: nameByPerson.get(employeeId) ?? 'Unknown',\n segments,\n utilization: round((totalHours / WEEKLY_CAPACITY_HOURS) * 100),\n weekLabel,\n });\n }\n\n rows.sort((a, b) => b.utilization - a.utilization);\n return rows;\n}\n\n// ── Finance / payroll trends (best-effort) ───────────────────────────────────\n\nexport interface PayrollPeriodTotal {\n period: string;\n gross: number;\n net: number;\n deductions: number;\n}\n\n/**\n * Gross / net / deduction totals per payroll period, in first-seen period order\n * (period is a free-text label so it is not re-sorted). Empty when there are no\n * records.\n */\nexport function payrollTotals(payrollRecords: PayrollRecord[]): PayrollPeriodTotal[] {\n const order: string[] = [];\n const totals = new Map<string, PayrollPeriodTotal>();\n for (const record of payrollRecords) {\n let bucket = totals.get(record.period);\n if (!bucket) {\n bucket = { period: record.period, gross: 0, net: 0, deductions: 0 };\n totals.set(record.period, bucket);\n order.push(record.period);\n }\n bucket.gross += record.grossPay;\n bucket.net += record.netPay;\n bucket.deductions += record.deductions;\n }\n return order.map((period) => totals.get(period) as PayrollPeriodTotal);\n}\n\n/**\n * Monthly attrition: the count of employee exits per month, derived from the\n * real `Employee.terminationDate`, over the most recent `monthsWindow` months\n * that contain an exit (through the latest exit month, inclusive). Months with\n * no exits in that span read zero. Empty when no employee has a termination\n * date — never a fabricated exit month.\n */\nexport function attritionTrend(employees: Employee[], monthsWindow = 12): LabeledValue[] {\n const exitIndices = employees\n .map((employee) => parseDate(employee.terminationDate))\n .filter((date): date is Date => date !== null)\n .map(monthIndex);\n\n if (exitIndices.length === 0) {\n return [];\n }\n\n const maxIndex = Math.max(...exitIndices);\n const minIndex = Math.max(Math.min(...exitIndices), maxIndex - (monthsWindow - 1));\n const includeYear = Math.floor(minIndex / 12) !== Math.floor(maxIndex / 12);\n\n const series: LabeledValue[] = [];\n for (let index = minIndex; index <= maxIndex; index += 1) {\n const count = exitIndices.filter((exit) => exit === index).length;\n series.push({ label: monthLabelFromIndex(index, includeYear), value: count });\n }\n return series;\n}\n\nexport interface TimeToHire {\n averageDays: number;\n sampleSize: number;\n}\n\n/**\n * Average time-to-hire: mean elapsed days from `appliedDate` to the real\n * `Candidate.hiredDate`, across candidates that carry both dates. Non-negative\n * spans only. Returns null (callers render an empty state) when no candidate\n * has a hired date — the elapsed time is never estimated.\n */\nexport function timeToHire(candidates: Candidate[]): TimeToHire | null {\n let totalDays = 0;\n let sampleSize = 0;\n for (const candidate of candidates) {\n const hired = parseDate(candidate.hiredDate);\n const applied = parseDate(candidate.appliedDate);\n if (!hired || !applied) {\n continue;\n }\n const days = Math.round((hired.getTime() - applied.getTime()) / 86_400_000);\n if (days < 0) {\n continue;\n }\n totalDays += days;\n sampleSize += 1;\n }\n\n if (sampleSize === 0) {\n return null;\n }\n\n return { averageDays: Math.round(totalDays / sampleSize), sampleSize };\n}\n"],"mappings":"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"}