@burdenoff/microfe-crewfoundry 2026.1030.3 → 2026.1030.4

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 (51) 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/HiringCandidatesPagePage.d.ts.map +1 -1
  37. package/dist/crewfoundry/pages/HiringCandidatesPagePage.js +210 -177
  38. package/dist/crewfoundry/pages/HiringCandidatesPagePage.js.map +1 -1
  39. package/dist/crewfoundry/pages/HrEmployeeProfilePagePage.js +6 -6
  40. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.d.ts +9 -0
  41. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.d.ts.map +1 -0
  42. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.js +456 -0
  43. package/dist/crewfoundry/pages/HrSkillsTaxonomyPagePage.js.map +1 -0
  44. package/dist/crewfoundry/pages/ProjectsResourcingPagePage.d.ts.map +1 -1
  45. package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js +50 -102
  46. package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js.map +1 -1
  47. package/dist/crewfoundry/store/crewfoundryStore.d.ts +101 -0
  48. package/dist/crewfoundry/store/crewfoundryStore.d.ts.map +1 -1
  49. package/dist/crewfoundry/store/crewfoundryStore.js +161 -57
  50. package/dist/crewfoundry/store/crewfoundryStore.js.map +1 -1
  51. package/package.json +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"EmployeeSkillsPanel.js","names":[],"sources":["../../../src/crewfoundry/components/EmployeeSkillsPanel.tsx"],"sourcesContent":["import { useEffect, useRef, useState, type FormEvent } from 'react';\nimport { Badge, Button, IllustratedEmptyState, Input, Label, Spinner } from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { Plus, X } from 'lucide-react';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { SkillBar } from './viz';\n\nexport interface EmployeeSkillsPanelProps {\n employeeId: string;\n}\n\n/** Stable empty profile so the selector never hands React a fresh array. */\nconst NO_PROFILE: never[] = [];\n\n/**\n * Employee profile Skills tab (BOFF-3717, mock cf-03). Shows the employee's\n * canonical skills from the skills taxonomy with real proficiency bars.\n * Skills without an assessed proficiency (for example ones backfilled from\n * the old free-text tags) say so instead of showing an invented level.\n * Typed names resolve through taxonomy aliases, so \"ReactJS\" lands on React.\n */\nexport function EmployeeSkillsPanel({ employeeId }: EmployeeSkillsPanelProps) {\n const { showToast } = useGlobalUi();\n const profile = useCrewFoundryStore((state) => state.employeeSkillProfiles[employeeId]);\n const fetchEmployeeSkillProfile = useCrewFoundryStore((state) => state.fetchEmployeeSkillProfile);\n const attachEmployeeSkill = useCrewFoundryStore((state) => state.attachEmployeeSkill);\n const detachEmployeeSkill = useCrewFoundryStore((state) => state.detachEmployeeSkill);\n const [skillName, setSkillName] = useState('');\n const [proficiency, setProficiency] = useState('');\n const [submitting, setSubmitting] = useState(false);\n const [removingSkillId, setRemovingSkillId] = useState<string | null>(null);\n const [loadFailed, setLoadFailed] = useState(false);\n // Gates the one-time fetch per employee without triggering a re-render.\n const loadedForRef = useRef<string | null>(null);\n\n useEffect(() => {\n if (loadedForRef.current === employeeId) return;\n loadedForRef.current = employeeId;\n fetchEmployeeSkillProfile(employeeId).catch((error: unknown) => {\n setLoadFailed(true);\n showToast(error instanceof Error ? error.message : 'Unable to load skills', 'error');\n });\n }, [employeeId, fetchEmployeeSkillProfile, showToast]);\n\n const skills = profile ?? NO_PROFILE;\n const assessed = skills.filter((item) => item.proficiency !== null);\n const unassessed = skills.filter((item) => item.proficiency === null);\n\n const handleAdd = async (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n const name = skillName.trim();\n if (!name) {\n showToast('Enter a skill name', 'error');\n return;\n }\n let level: number | null = null;\n if (proficiency.trim() !== '') {\n const parsed = Number(proficiency);\n if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {\n showToast('Proficiency must be a whole number from 0 to 100', 'error');\n return;\n }\n level = parsed;\n }\n setSubmitting(true);\n try {\n await attachEmployeeSkill({ employeeId, skillName: name, proficiency: level });\n setSkillName('');\n setProficiency('');\n showToast('Skill saved', 'success');\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to add this skill', 'error');\n } finally {\n setSubmitting(false);\n }\n };\n\n const handleRemove = async (skillId: string, name: string) => {\n setRemovingSkillId(skillId);\n try {\n await detachEmployeeSkill(employeeId, skillId);\n showToast(`${name} removed`, 'success');\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to remove this skill', 'error');\n } finally {\n setRemovingSkillId(null);\n }\n };\n\n return (\n <div className=\"space-y-6\" data-testid=\"employee-skills-panel\">\n {profile === undefined && !loadFailed ? (\n <div className=\"flex justify-center py-8\">\n <Spinner />\n </div>\n ) : skills.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skills recorded\"\n description=\"Add this team member's skills below. Names are matched to the CrewFoundry skills taxonomy, so spellings like ReactJS resolve to React.\"\n />\n ) : (\n <div className=\"space-y-5\">\n {assessed.length > 0 ? (\n <ul className=\"space-y-4\" aria-label=\"Assessed skills\">\n {assessed.map((item) => (\n <li key={item.id} className=\"flex items-end gap-3\">\n <SkillBar\n className=\"flex-1\"\n label={item.skill.name}\n value={item.proficiency ?? 0}\n />\n <Badge variant=\"secondary\" className=\"shrink-0 text-xs\">\n {formatEnumLabel(item.verifiedFrom)}\n </Badge>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-label={`Remove ${item.skill.name}`}\n disabled={removingSkillId === item.skillId}\n onClick={() => void handleRemove(item.skillId, item.skill.name)}\n >\n <X className=\"h-3.5 w-3.5\" />\n </Button>\n </li>\n ))}\n </ul>\n ) : null}\n\n {unassessed.length > 0 ? (\n <div className=\"space-y-2\">\n <p className=\"text-xs font-medium uppercase tracking-wide text-text-muted\">\n Proficiency not assessed yet\n </p>\n <ul\n className=\"flex flex-wrap gap-2\"\n aria-label=\"Skills without an assessed proficiency\"\n >\n {unassessed.map((item) => (\n <li\n key={item.id}\n className=\"inline-flex items-center gap-1 rounded-full border border-border-subtle bg-bg-surface py-1 pl-3 pr-1 text-xs text-text-primary\"\n >\n <span>{item.skill.name}</span>\n <button\n type=\"button\"\n className=\"rounded-full p-1 text-text-muted hover:bg-bg-sunken hover:text-text-primary disabled:opacity-50\"\n aria-label={`Remove ${item.skill.name}`}\n disabled={removingSkillId === item.skillId}\n onClick={() => void handleRemove(item.skillId, item.skill.name)}\n >\n <X className=\"h-3 w-3\" />\n </button>\n </li>\n ))}\n </ul>\n </div>\n ) : null}\n </div>\n )}\n\n <form\n className=\"flex flex-col gap-3 border-t border-border-subtle pt-4 sm:flex-row sm:items-end\"\n onSubmit={(event) => void handleAdd(event)}\n aria-label=\"Add or update a skill\"\n >\n <div className=\"flex flex-1 flex-col gap-1.5\">\n <Label htmlFor={`skill-name-${employeeId}`}>Skill</Label>\n <Input\n id={`skill-name-${employeeId}`}\n value={skillName}\n disabled={submitting}\n onChange={(event) => setSkillName(event.target.value)}\n placeholder=\"e.g. Forklift Certification, Kubernetes, Site Safety\"\n />\n </div>\n <div className=\"flex flex-col gap-1.5 sm:w-36\">\n <Label htmlFor={`skill-proficiency-${employeeId}`}>Proficiency %</Label>\n <Input\n id={`skill-proficiency-${employeeId}`}\n type=\"number\"\n inputMode=\"numeric\"\n min={0}\n max={100}\n step={1}\n value={proficiency}\n disabled={submitting}\n onChange={(event) => setProficiency(event.target.value)}\n placeholder=\"0-100\"\n />\n </div>\n <Button type=\"submit\" disabled={submitting}>\n <Plus className=\"mr-1.5 h-3.5 w-3.5\" />\n {submitting ? 'Saving…' : 'Save skill'}\n </Button>\n </form>\n </div>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"EmployeeSkillsPanel.js","names":[],"sources":["../../../src/crewfoundry/components/EmployeeSkillsPanel.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';\nimport { Badge, Button, IllustratedEmptyState, Input, Label, Spinner } from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { Plus, X } from 'lucide-react';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { SkillBar } from './viz';\n\nexport interface EmployeeSkillsPanelProps {\n employeeId: string;\n}\n\nexport interface CandidateSkillsPanelProps {\n candidateId: string;\n}\n\n/** One held skill, normalized across employees (verifiedFrom) and candidates (source). */\ninterface HeldSkill {\n id: string;\n skillId: string;\n proficiency: number | null;\n provenance: string;\n skill: { name: string };\n}\n\n/** Stable empty profile so the selector never hands React a fresh array. */\nconst NO_PROFILE: never[] = [];\n\n/**\n * Employee profile Skills tab (BOFF-3717, mock cf-03). Shows the employee's\n * canonical skills from the skills taxonomy with real proficiency bars.\n * Skills without an assessed proficiency (for example ones backfilled from\n * the old free-text tags) say so instead of showing an invented level.\n * Typed names resolve through taxonomy aliases, so \"ReactJS\" lands on React.\n */\nexport function EmployeeSkillsPanel({ employeeId }: EmployeeSkillsPanelProps) {\n const profile = useCrewFoundryStore((state) => state.employeeSkillProfiles[employeeId]);\n const fetchEmployeeSkillProfile = useCrewFoundryStore((state) => state.fetchEmployeeSkillProfile);\n const attachEmployeeSkill = useCrewFoundryStore((state) => state.attachEmployeeSkill);\n const detachEmployeeSkill = useCrewFoundryStore((state) => state.detachEmployeeSkill);\n const held = useMemo<HeldSkill[] | undefined>(\n () => profile?.map((item) => ({ ...item, provenance: item.verifiedFrom })),\n [profile]\n );\n return (\n <HolderSkillsPanel\n holderId={employeeId}\n noun=\"team member\"\n testId=\"employee-skills-panel\"\n profile={held}\n fetchProfile={fetchEmployeeSkillProfile}\n attach={(skillName, proficiency) =>\n attachEmployeeSkill({ employeeId, skillName, proficiency })\n }\n detach={(skillId) => detachEmployeeSkill(employeeId, skillId)}\n />\n );\n}\n\n/**\n * Candidate skill profile (BOFF-10361): the same canonical skills with\n * proficiency for someone in the hiring pipeline, so matching sees what the\n * recruiter recorded rather than free-text tags.\n */\nexport function CandidateSkillsPanel({ candidateId }: CandidateSkillsPanelProps) {\n const profile = useCrewFoundryStore((state) => state.candidateSkillProfiles[candidateId]);\n const fetchCandidateSkillProfile = useCrewFoundryStore(\n (state) => state.fetchCandidateSkillProfile\n );\n const attachCandidateSkill = useCrewFoundryStore((state) => state.attachCandidateSkill);\n const detachCandidateSkill = useCrewFoundryStore((state) => state.detachCandidateSkill);\n const held = useMemo<HeldSkill[] | undefined>(\n () => profile?.map((item) => ({ ...item, provenance: item.source })),\n [profile]\n );\n return (\n <HolderSkillsPanel\n holderId={candidateId}\n noun=\"candidate\"\n testId=\"candidate-skills-panel\"\n profile={held}\n fetchProfile={fetchCandidateSkillProfile}\n attach={(skillName, proficiency) =>\n attachCandidateSkill({ candidateId, skillName, proficiency })\n }\n detach={(skillId) => detachCandidateSkill(candidateId, skillId)}\n />\n );\n}\n\ninterface HolderSkillsPanelProps {\n holderId: string;\n noun: string;\n testId: string;\n profile: HeldSkill[] | undefined;\n fetchProfile: (holderId: string) => Promise<void>;\n attach: (skillName: string, proficiency: number | null) => Promise<void>;\n detach: (skillId: string) => Promise<void>;\n}\n\nfunction HolderSkillsPanel({\n holderId,\n noun,\n testId,\n profile,\n fetchProfile,\n attach,\n detach,\n}: HolderSkillsPanelProps) {\n const { showToast } = useGlobalUi();\n const [skillName, setSkillName] = useState('');\n const [proficiency, setProficiency] = useState('');\n const [submitting, setSubmitting] = useState(false);\n const [removingSkillId, setRemovingSkillId] = useState<string | null>(null);\n const [loadFailed, setLoadFailed] = useState(false);\n // Gates the one-time fetch per employee without triggering a re-render.\n const loadedForRef = useRef<string | null>(null);\n\n useEffect(() => {\n if (loadedForRef.current === holderId) return;\n loadedForRef.current = holderId;\n fetchProfile(holderId).catch((error: unknown) => {\n setLoadFailed(true);\n showToast(error instanceof Error ? error.message : 'Unable to load skills', 'error');\n });\n }, [holderId, fetchProfile, showToast]);\n\n const skills = profile ?? NO_PROFILE;\n const assessed = skills.filter((item) => item.proficiency !== null);\n const unassessed = skills.filter((item) => item.proficiency === null);\n\n const handleAdd = async (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault();\n const name = skillName.trim();\n if (!name) {\n showToast('Enter a skill name', 'error');\n return;\n }\n let level: number | null = null;\n if (proficiency.trim() !== '') {\n const parsed = Number(proficiency);\n if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) {\n showToast('Proficiency must be a whole number from 0 to 100', 'error');\n return;\n }\n level = parsed;\n }\n setSubmitting(true);\n try {\n await attach(name, level);\n setSkillName('');\n setProficiency('');\n showToast('Skill saved', 'success');\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to add this skill', 'error');\n } finally {\n setSubmitting(false);\n }\n };\n\n const handleRemove = async (skillId: string, name: string) => {\n setRemovingSkillId(skillId);\n try {\n await detach(skillId);\n showToast(`${name} removed`, 'success');\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to remove this skill', 'error');\n } finally {\n setRemovingSkillId(null);\n }\n };\n\n return (\n <div className=\"space-y-6\" data-testid={testId}>\n {profile === undefined && !loadFailed ? (\n <div className=\"flex justify-center py-8\">\n <Spinner />\n </div>\n ) : skills.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skills recorded\"\n description={`Add this ${noun}'s skills below. Names are matched to the CrewFoundry skills taxonomy, so spellings like ReactJS resolve to React.`}\n />\n ) : (\n <div className=\"space-y-5\">\n {assessed.length > 0 ? (\n <ul className=\"space-y-4\" aria-label=\"Assessed skills\">\n {assessed.map((item) => (\n <li key={item.id} className=\"flex items-end gap-3\">\n <SkillBar\n className=\"flex-1\"\n label={item.skill.name}\n value={item.proficiency ?? 0}\n />\n <Badge variant=\"secondary\" className=\"shrink-0 text-xs\">\n {formatEnumLabel(item.provenance)}\n </Badge>\n <Button\n size=\"sm\"\n variant=\"ghost\"\n aria-label={`Remove ${item.skill.name}`}\n disabled={removingSkillId === item.skillId}\n onClick={() => void handleRemove(item.skillId, item.skill.name)}\n >\n <X className=\"h-3.5 w-3.5\" />\n </Button>\n </li>\n ))}\n </ul>\n ) : null}\n\n {unassessed.length > 0 ? (\n <div className=\"space-y-2\">\n <p className=\"text-xs font-medium uppercase tracking-wide text-text-muted\">\n Proficiency not assessed yet\n </p>\n <ul\n className=\"flex flex-wrap gap-2\"\n aria-label=\"Skills without an assessed proficiency\"\n >\n {unassessed.map((item) => (\n <li\n key={item.id}\n className=\"inline-flex items-center gap-1 rounded-full border border-border-subtle bg-bg-surface py-1 pl-3 pr-1 text-xs text-text-primary\"\n >\n <span>{item.skill.name}</span>\n <button\n type=\"button\"\n className=\"rounded-full p-1 text-text-muted hover:bg-bg-sunken hover:text-text-primary disabled:opacity-50\"\n aria-label={`Remove ${item.skill.name}`}\n disabled={removingSkillId === item.skillId}\n onClick={() => void handleRemove(item.skillId, item.skill.name)}\n >\n <X className=\"h-3 w-3\" />\n </button>\n </li>\n ))}\n </ul>\n </div>\n ) : null}\n </div>\n )}\n\n <form\n className=\"flex flex-col gap-3 border-t border-border-subtle pt-4 sm:flex-row sm:items-end\"\n onSubmit={(event) => void handleAdd(event)}\n aria-label=\"Add or update a skill\"\n >\n <div className=\"flex flex-1 flex-col gap-1.5\">\n <Label htmlFor={`skill-name-${holderId}`}>Skill</Label>\n <Input\n id={`skill-name-${holderId}`}\n value={skillName}\n disabled={submitting}\n onChange={(event) => setSkillName(event.target.value)}\n placeholder=\"e.g. Forklift Certification, Kubernetes, Site Safety\"\n />\n </div>\n <div className=\"flex flex-col gap-1.5 sm:w-36\">\n <Label htmlFor={`skill-proficiency-${holderId}`}>Proficiency %</Label>\n <Input\n id={`skill-proficiency-${holderId}`}\n type=\"number\"\n inputMode=\"numeric\"\n min={0}\n max={100}\n step={1}\n value={proficiency}\n disabled={submitting}\n onChange={(event) => setProficiency(event.target.value)}\n placeholder=\"0-100\"\n />\n </div>\n <Button type=\"submit\" disabled={submitting}>\n <Plus className=\"mr-1.5 h-3.5 w-3.5\" />\n {submitting ? 'Saving…' : 'Save skill'}\n </Button>\n </form>\n </div>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import { CrewTaxonomySkill } from '../../store/crewfoundryStore';
2
+
3
+ export interface FindPeopleBySkillCardProps {
4
+ skills: readonly CrewTaxonomySkill[];
5
+ taxonomyLoaded: boolean;
6
+ }
7
+ /**
8
+ * "Find people by skill" (BOFF-10361). Pick any skill in the taxonomy and
9
+ * see who can do it: exact holders first, then people whose credential
10
+ * supersedes it or whose skill implies it, each with the reason in words.
11
+ */
12
+ export declare function FindPeopleBySkillCard({ skills, taxonomyLoaded }: FindPeopleBySkillCardProps): import("react").JSX.Element;
13
+ //# sourceMappingURL=FindPeopleBySkillCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FindPeopleBySkillCard.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/skills/FindPeopleBySkillCard.tsx"],"names":[],"mappings":"AAaA,OAAO,EAGL,KAAK,iBAAiB,EACvB,MAAM,8BAA8B,CAAC;AActC,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACrC,cAAc,EAAE,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,EAAE,MAAM,EAAE,cAAc,EAAE,EAAE,0BAA0B,+BA0H3F"}
@@ -0,0 +1,128 @@
1
+ import { useCrewFoundryStore as e } from "../../store/crewfoundryStore.js";
2
+ import { formatEnumLabel as t } from "../../utils/formatEnumLabel.js";
3
+ import { SkillPicker as n } from "./SkillPicker.js";
4
+ import { useEffect as r, useState as i } from "react";
5
+ import { jsx as a, jsxs as o } from "react/jsx-runtime";
6
+ import { Badge as s, Card as c, CardContent as l, CardDescription as u, CardHeader as d, CardTitle as f, IllustratedEmptyState as p, Input as m, Label as h, Spinner as g } from "@burdenoff/fe-libs/ui";
7
+ //#region src/crewfoundry/components/skills/FindPeopleBySkillCard.tsx
8
+ var _ = 25;
9
+ function v({ skills: v, taxonomyLoaded: y }) {
10
+ let b = e((e) => e.fetchEmployeesBySkill), [x, S] = i(null), [C, w] = i(""), [T, E] = i(null), D = C.trim() === "" ? null : Number(C), O = D === null || Number.isInteger(D) && D >= 0 && D <= 100, k = x ? v.find((e) => e.id === x) : void 0, A = `${x ?? ""}:${D ?? ""}`;
11
+ r(() => {
12
+ if (!x || !O) return;
13
+ let e = !1;
14
+ return b(x, {
15
+ minProficiency: D,
16
+ limit: _
17
+ }).then((t) => {
18
+ e || E({
19
+ status: "ready",
20
+ matches: t,
21
+ key: A
22
+ });
23
+ }).catch((t) => {
24
+ e || E({
25
+ status: "error",
26
+ message: t instanceof Error ? t.message : "Unable to find people",
27
+ key: A
28
+ });
29
+ }), () => {
30
+ e = !0;
31
+ };
32
+ }, [
33
+ x,
34
+ D,
35
+ O,
36
+ A,
37
+ b
38
+ ]);
39
+ let j = T && T.key === A ? T : { status: "loading" };
40
+ return /* @__PURE__ */ o(c, {
41
+ "data-testid": "find-people-by-skill",
42
+ children: [/* @__PURE__ */ o(d, { children: [/* @__PURE__ */ a(f, {
43
+ className: "text-base",
44
+ children: "Find people by skill"
45
+ }), /* @__PURE__ */ a(u, { children: "Exact holders, plus people whose skills imply or supersede the one you need." })] }), /* @__PURE__ */ o(l, {
46
+ className: "space-y-4",
47
+ children: [
48
+ y ? /* @__PURE__ */ o("div", {
49
+ className: "grid gap-4 md:grid-cols-[1fr_10rem]",
50
+ children: [/* @__PURE__ */ a(n, {
51
+ skills: v,
52
+ label: "Skill",
53
+ selectedId: x,
54
+ onSelect: (e) => S(e.id)
55
+ }), /* @__PURE__ */ o("div", {
56
+ className: "space-y-2",
57
+ children: [
58
+ /* @__PURE__ */ a(h, {
59
+ htmlFor: "find-people-min-proficiency",
60
+ children: "Min. proficiency %"
61
+ }),
62
+ /* @__PURE__ */ a(m, {
63
+ id: "find-people-min-proficiency",
64
+ type: "number",
65
+ inputMode: "numeric",
66
+ min: 0,
67
+ max: 100,
68
+ step: 1,
69
+ value: C,
70
+ placeholder: "Any",
71
+ onChange: (e) => w(e.target.value)
72
+ }),
73
+ O ? null : /* @__PURE__ */ a("p", {
74
+ className: "text-xs text-status-error-text",
75
+ children: "Use a whole number from 0 to 100."
76
+ })
77
+ ]
78
+ })]
79
+ }) : /* @__PURE__ */ a("div", {
80
+ className: "flex min-h-[6rem] items-center justify-center",
81
+ children: /* @__PURE__ */ a(g, { size: "md" })
82
+ }),
83
+ k && O && j.status === "loading" ? /* @__PURE__ */ a("div", {
84
+ className: "flex min-h-[4rem] items-center justify-center",
85
+ children: /* @__PURE__ */ a(g, { size: "sm" })
86
+ }) : null,
87
+ k && j.status === "error" ? /* @__PURE__ */ a("p", {
88
+ className: "text-sm text-status-error-text",
89
+ children: j.message
90
+ }) : null,
91
+ k && j.status === "ready" ? j.matches.length > 0 ? /* @__PURE__ */ a("ul", {
92
+ className: "space-y-2",
93
+ "aria-label": `People who can do ${k.name}`,
94
+ children: j.matches.map((e) => /* @__PURE__ */ o("li", {
95
+ className: "flex flex-col gap-1 rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3 sm:flex-row sm:items-center sm:justify-between",
96
+ children: [/* @__PURE__ */ o("div", {
97
+ className: "min-w-0",
98
+ children: [/* @__PURE__ */ a("p", {
99
+ className: "text-sm font-medium text-text-primary",
100
+ children: `${e.employee.firstName} ${e.employee.lastName}`.trim()
101
+ }), /* @__PURE__ */ a("p", {
102
+ className: "text-xs text-text-secondary",
103
+ children: e.explanation
104
+ })]
105
+ }), /* @__PURE__ */ o("div", {
106
+ className: "flex shrink-0 flex-wrap items-center gap-1.5",
107
+ children: [/* @__PURE__ */ a(s, {
108
+ variant: e.matchType === "EXACT" ? "default" : "secondary",
109
+ children: t(e.matchType)
110
+ }), /* @__PURE__ */ a("span", {
111
+ className: "text-xs tabular-nums text-text-muted",
112
+ children: e.proficiency === null ? "Proficiency not assessed" : `${e.proficiency}%`
113
+ })]
114
+ })]
115
+ }, e.employee.id))
116
+ }) : /* @__PURE__ */ a(p, {
117
+ illustration: "empty-people",
118
+ title: `Nobody can do ${k.name} yet`,
119
+ description: "No one holds this skill, or a skill that implies or supersedes it, at that level."
120
+ }) : null
121
+ ]
122
+ })]
123
+ });
124
+ }
125
+ //#endregion
126
+ export { v as FindPeopleBySkillCard };
127
+
128
+ //# sourceMappingURL=FindPeopleBySkillCard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FindPeopleBySkillCard.js","names":[],"sources":["../../../../src/crewfoundry/components/skills/FindPeopleBySkillCard.tsx"],"sourcesContent":["import { useEffect, useState } from 'react';\nimport {\n Badge,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n IllustratedEmptyState,\n Input,\n Label,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport {\n useCrewFoundryStore,\n type CrewSkillHolderMatch,\n type CrewTaxonomySkill,\n} from '../../store/crewfoundryStore';\nimport { formatEnumLabel } from '../../utils/formatEnumLabel';\nimport { SkillPicker } from './SkillPicker';\n\nconst RESULT_LIMIT = 25;\n\ntype ResultState =\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'ready'; matches: CrewSkillHolderMatch[] };\n\n/** A finished lookup, tagged with the query it answers so a stale answer reads as loading. */\ntype LookupResult = Exclude<ResultState, { status: 'loading' }> & { key: string };\n\nexport interface FindPeopleBySkillCardProps {\n skills: readonly CrewTaxonomySkill[];\n taxonomyLoaded: boolean;\n}\n\n/**\n * \"Find people by skill\" (BOFF-10361). Pick any skill in the taxonomy and\n * see who can do it: exact holders first, then people whose credential\n * supersedes it or whose skill implies it, each with the reason in words.\n */\nexport function FindPeopleBySkillCard({ skills, taxonomyLoaded }: FindPeopleBySkillCardProps) {\n const fetchEmployeesBySkill = useCrewFoundryStore((state) => state.fetchEmployeesBySkill);\n const [skillId, setSkillId] = useState<string | null>(null);\n const [minProficiency, setMinProficiency] = useState('');\n const [lookup, setLookup] = useState<LookupResult | null>(null);\n\n const parsedMin = minProficiency.trim() === '' ? null : Number(minProficiency);\n const minIsValid =\n parsedMin === null || (Number.isInteger(parsedMin) && parsedMin >= 0 && parsedMin <= 100);\n const skill = skillId ? skills.find((item) => item.id === skillId) : undefined;\n const queryKey = `${skillId ?? ''}:${parsedMin ?? ''}`;\n\n useEffect(() => {\n if (!skillId || !minIsValid) return;\n let cancelled = false;\n fetchEmployeesBySkill(skillId, { minProficiency: parsedMin, limit: RESULT_LIMIT })\n .then((matches) => {\n if (!cancelled) setLookup({ status: 'ready', matches, key: queryKey });\n })\n .catch((error: unknown) => {\n if (!cancelled) {\n setLookup({\n status: 'error',\n message: error instanceof Error ? error.message : 'Unable to find people',\n key: queryKey,\n });\n }\n });\n return () => {\n cancelled = true;\n };\n }, [skillId, parsedMin, minIsValid, queryKey, fetchEmployeesBySkill]);\n\n const result: ResultState = lookup && lookup.key === queryKey ? lookup : { status: 'loading' };\n\n return (\n <Card data-testid=\"find-people-by-skill\">\n <CardHeader>\n <CardTitle className=\"text-base\">Find people by skill</CardTitle>\n <CardDescription>\n Exact holders, plus people whose skills imply or supersede the one you need.\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {!taxonomyLoaded ? (\n <div className=\"flex min-h-[6rem] items-center justify-center\">\n <Spinner size=\"md\" />\n </div>\n ) : (\n <div className=\"grid gap-4 md:grid-cols-[1fr_10rem]\">\n <SkillPicker\n skills={skills}\n label=\"Skill\"\n selectedId={skillId}\n onSelect={(picked) => setSkillId(picked.id)}\n />\n <div className=\"space-y-2\">\n <Label htmlFor=\"find-people-min-proficiency\">Min. proficiency %</Label>\n <Input\n id=\"find-people-min-proficiency\"\n type=\"number\"\n inputMode=\"numeric\"\n min={0}\n max={100}\n step={1}\n value={minProficiency}\n placeholder=\"Any\"\n onChange={(event) => setMinProficiency(event.target.value)}\n />\n {!minIsValid ? (\n <p className=\"text-xs text-status-error-text\">Use a whole number from 0 to 100.</p>\n ) : null}\n </div>\n </div>\n )}\n\n {skill && minIsValid && result.status === 'loading' ? (\n <div className=\"flex min-h-[4rem] items-center justify-center\">\n <Spinner size=\"sm\" />\n </div>\n ) : null}\n {skill && result.status === 'error' ? (\n <p className=\"text-sm text-status-error-text\">{result.message}</p>\n ) : null}\n {skill && result.status === 'ready' ? (\n result.matches.length > 0 ? (\n <ul className=\"space-y-2\" aria-label={`People who can do ${skill.name}`}>\n {result.matches.map((match) => (\n <li\n key={match.employee.id}\n className=\"flex flex-col gap-1 rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3 sm:flex-row sm:items-center sm:justify-between\"\n >\n <div className=\"min-w-0\">\n <p className=\"text-sm font-medium text-text-primary\">\n {`${match.employee.firstName} ${match.employee.lastName}`.trim()}\n </p>\n <p className=\"text-xs text-text-secondary\">{match.explanation}</p>\n </div>\n <div className=\"flex shrink-0 flex-wrap items-center gap-1.5\">\n <Badge variant={match.matchType === 'EXACT' ? 'default' : 'secondary'}>\n {formatEnumLabel(match.matchType)}\n </Badge>\n <span className=\"text-xs tabular-nums text-text-muted\">\n {match.proficiency !== null\n ? `${match.proficiency}%`\n : 'Proficiency not assessed'}\n </span>\n </div>\n </li>\n ))}\n </ul>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-people\"\n title={`Nobody can do ${skill.name} yet`}\n description=\"No one holds this skill, or a skill that implies or supersedes it, at that level.\"\n />\n )\n ) : null}\n </CardContent>\n </Card>\n );\n}\n"],"mappings":";;;;;;;AAqBA,IAAM,IAAe;AAoBrB,SAAgB,EAAsB,EAAE,WAAQ,qBAA8C;CAC5F,IAAM,IAAwB,GAAqB,MAAU,EAAM,sBAAsB,EACnF,CAAC,GAAS,KAAc,EAAwB,KAAK,EACrD,CAAC,GAAgB,KAAqB,EAAS,GAAG,EAClD,CAAC,GAAQ,KAAa,EAA8B,KAAK,EAEzD,IAAY,EAAe,MAAM,KAAK,KAAK,OAAO,OAAO,EAAe,EACxE,IACJ,MAAc,QAAS,OAAO,UAAU,EAAU,IAAI,KAAa,KAAK,KAAa,KACjF,IAAQ,IAAU,EAAO,MAAM,MAAS,EAAK,OAAO,EAAQ,GAAG,KAAA,GAC/D,IAAW,GAAG,KAAW,GAAG,GAAG,KAAa;AAElD,SAAgB;AACd,MAAI,CAAC,KAAW,CAAC,EAAY;EAC7B,IAAI,IAAY;AAchB,SAbA,EAAsB,GAAS;GAAE,gBAAgB;GAAW,OAAO;GAAc,CAAC,CAC/E,MAAM,MAAY;AACjB,GAAK,KAAW,EAAU;IAAE,QAAQ;IAAS;IAAS,KAAK;IAAU,CAAC;IACtE,CACD,OAAO,MAAmB;AACzB,GAAK,KACH,EAAU;IACR,QAAQ;IACR,SAAS,aAAiB,QAAQ,EAAM,UAAU;IAClD,KAAK;IACN,CAAC;IAEJ,QACS;AACX,OAAY;;IAEb;EAAC;EAAS;EAAW;EAAY;EAAU;EAAsB,CAAC;CAErE,IAAM,IAAsB,KAAU,EAAO,QAAQ,IAAW,IAAS,EAAE,QAAQ,WAAW;AAE9F,QACE,kBAAC,GAAD;EAAM,eAAY;YAAlB,CACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD;GAAW,WAAU;aAAY;GAAgC,CAAA,EACjE,kBAAC,GAAD,EAAA,UAAiB,gFAEC,CAAA,CACP,EAAA,CAAA,EACb,kBAAC,GAAD;GAAa,WAAU;aAAvB;IACI,IAKA,kBAAC,OAAD;KAAK,WAAU;eAAf,CACE,kBAAC,GAAD;MACU;MACR,OAAM;MACN,YAAY;MACZ,WAAW,MAAW,EAAW,EAAO,GAAG;MAC3C,CAAA,EACF,kBAAC,OAAD;MAAK,WAAU;gBAAf;OACE,kBAAC,GAAD;QAAO,SAAQ;kBAA8B;QAA0B,CAAA;OACvE,kBAAC,GAAD;QACE,IAAG;QACH,MAAK;QACL,WAAU;QACV,KAAK;QACL,KAAK;QACL,MAAM;QACN,OAAO;QACP,aAAY;QACZ,WAAW,MAAU,EAAkB,EAAM,OAAO,MAAM;QAC1D,CAAA;OACA,IAEE,OADF,kBAAC,KAAD;QAAG,WAAU;kBAAiC;QAAqC,CAAA;OAEjF;QACF;SA5BN,kBAAC,OAAD;KAAK,WAAU;eACb,kBAAC,GAAD,EAAS,MAAK,MAAO,CAAA;KACjB,CAAA;IA6BP,KAAS,KAAc,EAAO,WAAW,YACxC,kBAAC,OAAD;KAAK,WAAU;eACb,kBAAC,GAAD,EAAS,MAAK,MAAO,CAAA;KACjB,CAAA,GACJ;IACH,KAAS,EAAO,WAAW,UAC1B,kBAAC,KAAD;KAAG,WAAU;eAAkC,EAAO;KAAY,CAAA,GAChE;IACH,KAAS,EAAO,WAAW,UAC1B,EAAO,QAAQ,SAAS,IACtB,kBAAC,MAAD;KAAI,WAAU;KAAY,cAAY,qBAAqB,EAAM;eAC9D,EAAO,QAAQ,KAAK,MACnB,kBAAC,MAAD;MAEE,WAAU;gBAFZ,CAIE,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,KAAD;QAAG,WAAU;kBACV,GAAG,EAAM,SAAS,UAAU,GAAG,EAAM,SAAS,WAAW,MAAM;QAC9D,CAAA,EACJ,kBAAC,KAAD;QAAG,WAAU;kBAA+B,EAAM;QAAgB,CAAA,CAC9D;UACN,kBAAC,OAAD;OAAK,WAAU;iBAAf,CACE,kBAAC,GAAD;QAAO,SAAS,EAAM,cAAc,UAAU,YAAY;kBACvD,EAAgB,EAAM,UAAU;QAC3B,CAAA,EACR,kBAAC,QAAD;QAAM,WAAU;kBACb,EAAM,gBAAgB,OAEnB,6BADA,GAAG,EAAM,YAAY;QAEpB,CAAA,CACH;SACH;QAnBE,EAAM,SAAS,GAmBjB,CACL;KACC,CAAA,GAEL,kBAAC,GAAD;KACE,cAAa;KACb,OAAO,iBAAiB,EAAM,KAAK;KACnC,aAAY;KACZ,CAAA,GAEF;IACQ;KACT"}
@@ -0,0 +1,15 @@
1
+ import { CrewTaxonomySkill } from '../../store/crewfoundryStore';
2
+
3
+ export interface MergeSkillDialogProps {
4
+ /** The custom or backfill skill being merged away. */
5
+ source: CrewTaxonomySkill | null;
6
+ skills: readonly CrewTaxonomySkill[];
7
+ onOpenChange: (open: boolean) => void;
8
+ }
9
+ /**
10
+ * Merge a custom or BACKFILL skill into another skill (BOFF-10361): everyone
11
+ * holding it, every job posting asking for it and its relations move to the
12
+ * target, and its name becomes an alias so old spellings keep resolving.
13
+ */
14
+ export declare function MergeSkillDialog({ source, skills, onOpenChange }: MergeSkillDialogProps): import("react").JSX.Element;
15
+ //# sourceMappingURL=MergeSkillDialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MergeSkillDialog.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/skills/MergeSkillDialog.tsx"],"names":[],"mappings":"AAWA,OAAO,EAAuB,KAAK,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAG3F,MAAM,WAAW,qBAAqB;IACpC,sDAAsD;IACtD,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACrC,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;CACvC;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE,qBAAqB,+BA6DvF"}
@@ -0,0 +1,58 @@
1
+ import { useCrewFoundryStore as e } from "../../store/crewfoundryStore.js";
2
+ import { SkillPicker as t } from "./SkillPicker.js";
3
+ import { useState as n } from "react";
4
+ import { jsx as r, jsxs as i } from "react/jsx-runtime";
5
+ import { Button as a, ResponsiveDialog as o, ResponsiveDialogContent as s, ResponsiveDialogDescription as c, ResponsiveDialogFooter as l, ResponsiveDialogHeader as u, ResponsiveDialogTitle as d } from "@burdenoff/fe-libs/ui";
6
+ import { useGlobalUi as f } from "@burdenoff/fe-libs/shared/providers/shell";
7
+ //#region src/crewfoundry/components/skills/MergeSkillDialog.tsx
8
+ function p({ source: p, skills: m, onOpenChange: h }) {
9
+ let { showToast: g } = f(), _ = e((e) => e.mergeTaxonomySkills), [v, y] = n(null), [b, x] = n(!1), S = v ? m.find((e) => e.id === v) : void 0, C = (e) => {
10
+ e || y(null), h(e);
11
+ }, w = async () => {
12
+ if (!(!p || !S)) {
13
+ x(!0);
14
+ try {
15
+ let e = await _(p.id, S.id);
16
+ g(`${p.name} merged into ${e.targetName} (${e.employeesMoved} people, ${e.candidatesMoved} candidates, ${e.jobPostingsMoved} job postings moved)`, "success"), C(!1);
17
+ } catch (e) {
18
+ g(e instanceof Error ? e.message : "Unable to merge these skills", "error");
19
+ } finally {
20
+ x(!1);
21
+ }
22
+ }
23
+ };
24
+ return /* @__PURE__ */ r(o, {
25
+ open: p !== null,
26
+ onOpenChange: C,
27
+ children: /* @__PURE__ */ i(s, {
28
+ className: "sm:max-w-[520px]",
29
+ children: [
30
+ /* @__PURE__ */ i(u, { children: [/* @__PURE__ */ r(d, { children: p ? `Merge ${p.name}` : "Merge skill" }), /* @__PURE__ */ r(c, { children: p ? `${p.employeeCount} people and ${p.candidateCount} candidates hold ${p.name}. They, its job-posting requirements and its relations move to the skill you pick, and “${p.name}” becomes an alias of it.` : "" })] }),
31
+ /* @__PURE__ */ r(t, {
32
+ skills: m,
33
+ label: "Merge into",
34
+ excludeIds: p ? [p.id] : [],
35
+ selectedId: v,
36
+ onSelect: (e) => y(e.id),
37
+ disabled: b
38
+ }),
39
+ /* @__PURE__ */ i(l, { children: [/* @__PURE__ */ r(a, {
40
+ type: "button",
41
+ variant: "outline",
42
+ disabled: b,
43
+ onClick: () => C(!1),
44
+ children: "Cancel"
45
+ }), /* @__PURE__ */ r(a, {
46
+ type: "button",
47
+ disabled: b || !S,
48
+ onClick: () => void w(),
49
+ children: b ? "Merging…" : S ? `Merge into ${S.name}` : "Merge"
50
+ })] })
51
+ ]
52
+ })
53
+ });
54
+ }
55
+ //#endregion
56
+ export { p as MergeSkillDialog };
57
+
58
+ //# sourceMappingURL=MergeSkillDialog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MergeSkillDialog.js","names":[],"sources":["../../../../src/crewfoundry/components/skills/MergeSkillDialog.tsx"],"sourcesContent":["import { useState } from 'react';\nimport {\n Button,\n ResponsiveDialog,\n ResponsiveDialogContent,\n ResponsiveDialogDescription,\n ResponsiveDialogFooter,\n ResponsiveDialogHeader,\n ResponsiveDialogTitle,\n} from '@burdenoff/fe-libs/ui';\nimport { useGlobalUi } from '@burdenoff/fe-libs/shared/providers/shell';\nimport { useCrewFoundryStore, type CrewTaxonomySkill } from '../../store/crewfoundryStore';\nimport { SkillPicker } from './SkillPicker';\n\nexport interface MergeSkillDialogProps {\n /** The custom or backfill skill being merged away. */\n source: CrewTaxonomySkill | null;\n skills: readonly CrewTaxonomySkill[];\n onOpenChange: (open: boolean) => void;\n}\n\n/**\n * Merge a custom or BACKFILL skill into another skill (BOFF-10361): everyone\n * holding it, every job posting asking for it and its relations move to the\n * target, and its name becomes an alias so old spellings keep resolving.\n */\nexport function MergeSkillDialog({ source, skills, onOpenChange }: MergeSkillDialogProps) {\n const { showToast } = useGlobalUi();\n const mergeTaxonomySkills = useCrewFoundryStore((state) => state.mergeTaxonomySkills);\n const [targetId, setTargetId] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const target = targetId ? skills.find((skill) => skill.id === targetId) : undefined;\n\n const close = (open: boolean) => {\n if (!open) setTargetId(null);\n onOpenChange(open);\n };\n\n const handleMerge = async () => {\n if (!source || !target) return;\n setBusy(true);\n try {\n const outcome = await mergeTaxonomySkills(source.id, target.id);\n showToast(\n `${source.name} merged into ${outcome.targetName} (${outcome.employeesMoved} people, ${outcome.candidatesMoved} candidates, ${outcome.jobPostingsMoved} job postings moved)`,\n 'success'\n );\n close(false);\n } catch (error: unknown) {\n showToast(error instanceof Error ? error.message : 'Unable to merge these skills', 'error');\n } finally {\n setBusy(false);\n }\n };\n\n return (\n <ResponsiveDialog open={source !== null} onOpenChange={close}>\n <ResponsiveDialogContent className=\"sm:max-w-[520px]\">\n <ResponsiveDialogHeader>\n <ResponsiveDialogTitle>\n {source ? `Merge ${source.name}` : 'Merge skill'}\n </ResponsiveDialogTitle>\n <ResponsiveDialogDescription>\n {source\n ? `${source.employeeCount} people and ${source.candidateCount} candidates hold ${source.name}. They, its job-posting requirements and its relations move to the skill you pick, and “${source.name}” becomes an alias of it.`\n : ''}\n </ResponsiveDialogDescription>\n </ResponsiveDialogHeader>\n <SkillPicker\n skills={skills}\n label=\"Merge into\"\n excludeIds={source ? [source.id] : []}\n selectedId={targetId}\n onSelect={(picked) => setTargetId(picked.id)}\n disabled={busy}\n />\n <ResponsiveDialogFooter>\n <Button type=\"button\" variant=\"outline\" disabled={busy} onClick={() => close(false)}>\n Cancel\n </Button>\n <Button type=\"button\" disabled={busy || !target} onClick={() => void handleMerge()}>\n {busy ? 'Merging…' : target ? `Merge into ${target.name}` : 'Merge'}\n </Button>\n </ResponsiveDialogFooter>\n </ResponsiveDialogContent>\n </ResponsiveDialog>\n );\n}\n"],"mappings":";;;;;;;AA0BA,SAAgB,EAAiB,EAAE,WAAQ,WAAQ,mBAAuC;CACxF,IAAM,EAAE,iBAAc,GAAa,EAC7B,IAAsB,GAAqB,MAAU,EAAM,oBAAoB,EAC/E,CAAC,GAAU,KAAe,EAAwB,KAAK,EACvD,CAAC,GAAM,KAAW,EAAS,GAAM,EACjC,IAAS,IAAW,EAAO,MAAM,MAAU,EAAM,OAAO,EAAS,GAAG,KAAA,GAEpE,KAAS,MAAkB;AAE/B,EADK,KAAM,EAAY,KAAK,EAC5B,EAAa,EAAK;IAGd,IAAc,YAAY;AAC1B,SAAC,KAAU,CAAC,IAChB;KAAQ,GAAK;AACb,OAAI;IACF,IAAM,IAAU,MAAM,EAAoB,EAAO,IAAI,EAAO,GAAG;AAK/D,IAJA,EACE,GAAG,EAAO,KAAK,eAAe,EAAQ,WAAW,IAAI,EAAQ,eAAe,WAAW,EAAQ,gBAAgB,eAAe,EAAQ,iBAAiB,uBACvJ,UACD,EACD,EAAM,GAAM;YACL,GAAgB;AACvB,MAAU,aAAiB,QAAQ,EAAM,UAAU,gCAAgC,QAAQ;aACnF;AACR,MAAQ,GAAM;;;;AAIlB,QACE,kBAAC,GAAD;EAAkB,MAAM,MAAW;EAAM,cAAc;YACrD,kBAAC,GAAD;GAAyB,WAAU;aAAnC;IACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UACG,IAAS,SAAS,EAAO,SAAS,eACb,CAAA,EACxB,kBAAC,GAAD,EAAA,UACG,IACG,GAAG,EAAO,cAAc,cAAc,EAAO,eAAe,mBAAmB,EAAO,KAAK,0FAA0F,EAAO,KAAK,6BACjM,IACwB,CAAA,CACP,EAAA,CAAA;IACzB,kBAAC,GAAD;KACU;KACR,OAAM;KACN,YAAY,IAAS,CAAC,EAAO,GAAG,GAAG,EAAE;KACrC,YAAY;KACZ,WAAW,MAAW,EAAY,EAAO,GAAG;KAC5C,UAAU;KACV,CAAA;IACF,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD;KAAQ,MAAK;KAAS,SAAQ;KAAU,UAAU;KAAM,eAAe,EAAM,GAAM;eAAE;KAE5E,CAAA,EACT,kBAAC,GAAD;KAAQ,MAAK;KAAS,UAAU,KAAQ,CAAC;KAAQ,eAAe,KAAK,GAAa;eAC/E,IAAO,aAAa,IAAS,cAAc,EAAO,SAAS;KACrD,CAAA,CACc,EAAA,CAAA;IACD;;EACT,CAAA"}
@@ -0,0 +1,15 @@
1
+ import { JobPosting } from '../../store/crewfoundryStore';
2
+
3
+ export interface RoleSkillMatchPanelProps {
4
+ /** Open roles to choose from (active job postings). */
5
+ roles: readonly JobPosting[];
6
+ }
7
+ /**
8
+ * "Skills match for open role" (BOFF-10361, mock cf-19). Ranks the internal
9
+ * team against the role's canonical skill requirements. Each requirement is
10
+ * answered by `crewEmployeesBySkill`, which matches through aliases and
11
+ * IMPLIES / SUPERSEDES edges, so someone holding Kubernetes is offered for a
12
+ * Docker requirement — with the explanation shown, never a bare score.
13
+ */
14
+ export declare function RoleSkillMatchPanel({ roles }: RoleSkillMatchPanelProps): import("react").JSX.Element;
15
+ //# sourceMappingURL=RoleSkillMatchPanel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RoleSkillMatchPanel.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/skills/RoleSkillMatchPanel.tsx"],"names":[],"mappings":"AAiBA,OAAO,EAGL,KAAK,UAAU,EAChB,MAAM,8BAA8B,CAAC;AAYtC,MAAM,WAAW,wBAAwB;IACvC,uDAAuD;IACvD,KAAK,EAAE,SAAS,UAAU,EAAE,CAAC;CAC9B;AAiBD;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,EAAE,wBAAwB,+BAyFtE"}
@@ -0,0 +1,163 @@
1
+ import { useCrewFoundryStore as e } from "../../store/crewfoundryStore.js";
2
+ import { formatEnumLabel as t } from "../../utils/formatEnumLabel.js";
3
+ import { rankRoleMatches as n } from "../../lib/skillMatching.js";
4
+ import { useEffect as r, useMemo as i, useState as a } from "react";
5
+ import { Fragment as o, jsx as s, jsxs as c } from "react/jsx-runtime";
6
+ import { Badge as l, Card as u, CardContent as d, CardDescription as f, CardHeader as p, CardTitle as m, IllustratedEmptyState as h, Label as g, Select as _, SelectContent as v, SelectItem as y, SelectTrigger as b, SelectValue as x, Spinner as S } from "@burdenoff/fe-libs/ui";
7
+ //#region src/crewfoundry/components/skills/RoleSkillMatchPanel.tsx
8
+ var C = 12, w = 6, T = (e) => ({
9
+ skillId: e.skillId,
10
+ skillName: e.skill.name,
11
+ required: e.required,
12
+ minProficiency: e.minProficiency
13
+ });
14
+ function E({ roles: t }) {
15
+ let l = e((e) => e.fetchJobPostingSkillRequirements), S = e((e) => e.fetchEmployeesBySkill), [E, O] = a(null), [k, A] = a(null), j = E ?? t[0]?.id ?? null, M = i(() => t.find((e) => e.id === j) ?? null, [t, j]);
16
+ r(() => {
17
+ if (!j) return;
18
+ let e = !1;
19
+ return (async () => {
20
+ let t = (await l(j)).slice(0, C).map(T), r = await Promise.all(t.map(async (e) => [e.skillId, await S(e.skillId, {
21
+ minProficiency: e.minProficiency,
22
+ limit: 50
23
+ })])), i = n(t, new Map(r), w);
24
+ e || A({
25
+ status: "ready",
26
+ requirements: t,
27
+ matches: i,
28
+ roleId: j
29
+ });
30
+ })().catch((t) => {
31
+ e || A({
32
+ status: "error",
33
+ message: t instanceof Error ? t.message : "Unable to match this role",
34
+ roleId: j
35
+ });
36
+ }), () => {
37
+ e = !0;
38
+ };
39
+ }, [
40
+ j,
41
+ l,
42
+ S
43
+ ]);
44
+ let N = k && k.roleId === j ? k : { status: "loading" };
45
+ return /* @__PURE__ */ c(u, {
46
+ "data-testid": "role-skill-match-panel",
47
+ children: [/* @__PURE__ */ c(p, { children: [/* @__PURE__ */ s(m, {
48
+ className: "text-base",
49
+ children: "Skills match for open role"
50
+ }), /* @__PURE__ */ s(f, { children: M ? `${M.title} · internal team, matched through the skills taxonomy` : "Internal team, matched through the skills taxonomy" })] }), /* @__PURE__ */ s(d, {
51
+ className: "space-y-4",
52
+ children: t.length === 0 ? /* @__PURE__ */ s(h, {
53
+ illustration: "empty-search",
54
+ title: "No active roles",
55
+ description: "Publish a job posting to match your team against its required skills."
56
+ }) : /* @__PURE__ */ c(o, { children: [t.length > 1 ? /* @__PURE__ */ c("div", {
57
+ className: "space-y-1.5",
58
+ children: [/* @__PURE__ */ s(g, {
59
+ htmlFor: "resourcing-role",
60
+ children: "Open role"
61
+ }), /* @__PURE__ */ c(_, {
62
+ value: j ?? void 0,
63
+ onValueChange: O,
64
+ children: [/* @__PURE__ */ s(b, {
65
+ id: "resourcing-role",
66
+ className: "w-full",
67
+ children: /* @__PURE__ */ s(x, {})
68
+ }), /* @__PURE__ */ s(v, { children: t.map((e) => /* @__PURE__ */ s(y, {
69
+ value: e.id,
70
+ children: e.title
71
+ }, e.id)) })]
72
+ })]
73
+ }) : null, /* @__PURE__ */ s(D, { state: N })] })
74
+ })]
75
+ });
76
+ }
77
+ function D({ state: e }) {
78
+ return e.status === "loading" ? /* @__PURE__ */ s("div", {
79
+ className: "flex min-h-[8rem] items-center justify-center",
80
+ children: /* @__PURE__ */ s(S, { size: "md" })
81
+ }) : e.status === "error" ? /* @__PURE__ */ s(h, {
82
+ illustration: "empty-data",
83
+ title: "Matching is unavailable",
84
+ description: e.message
85
+ }) : e.requirements.length === 0 ? /* @__PURE__ */ s(h, {
86
+ illustration: "empty-data",
87
+ title: "No skills on this role",
88
+ description: "Add requirements to the job posting; they are matched to canonical skills automatically."
89
+ }) : /* @__PURE__ */ c(o, { children: [/* @__PURE__ */ s("ul", {
90
+ className: "flex flex-wrap gap-1.5",
91
+ "aria-label": "Skills this role needs",
92
+ children: e.requirements.map((e) => /* @__PURE__ */ s("li", { children: /* @__PURE__ */ c(l, {
93
+ variant: e.required ? "outline" : "secondary",
94
+ className: "text-[11px]",
95
+ children: [
96
+ e.skillName,
97
+ e.required ? "" : " · nice to have",
98
+ e.minProficiency === null ? "" : ` · ≥${e.minProficiency}%`
99
+ ]
100
+ }) }, e.skillId))
101
+ }), e.matches.length > 0 ? /* @__PURE__ */ s("ol", {
102
+ className: "space-y-3",
103
+ "aria-label": "Ranked matches",
104
+ children: e.matches.map((e, n) => /* @__PURE__ */ c("li", {
105
+ className: "rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3",
106
+ children: [
107
+ /* @__PURE__ */ c("div", {
108
+ className: "flex items-baseline justify-between gap-2",
109
+ children: [/* @__PURE__ */ c("span", {
110
+ className: "min-w-0 text-sm font-medium text-text-primary",
111
+ children: [
112
+ n + 1,
113
+ ". ",
114
+ e.name
115
+ ]
116
+ }), /* @__PURE__ */ c("span", {
117
+ className: "shrink-0 text-sm font-semibold tabular-nums text-status-success-text",
118
+ children: [e.matchPercent, "% match"]
119
+ })]
120
+ }),
121
+ e.detail ? /* @__PURE__ */ s("p", {
122
+ className: "text-xs text-text-muted",
123
+ children: e.detail
124
+ }) : null,
125
+ /* @__PURE__ */ s("ul", {
126
+ className: "mt-2 space-y-1",
127
+ children: e.matched.map((e) => /* @__PURE__ */ c("li", {
128
+ className: "text-xs text-text-secondary",
129
+ children: [
130
+ /* @__PURE__ */ s(l, {
131
+ variant: "secondary",
132
+ className: "mr-1.5 text-[10px]",
133
+ children: e.skillName
134
+ }),
135
+ /* @__PURE__ */ c("span", {
136
+ className: "text-text-muted",
137
+ children: [
138
+ t(e.matchType),
139
+ e.proficiency === null ? "" : ` · ${e.proficiency}%`,
140
+ " ·",
141
+ " "
142
+ ]
143
+ }),
144
+ e.explanation
145
+ ]
146
+ }, e.skillId))
147
+ }),
148
+ e.missingRequired.length > 0 ? /* @__PURE__ */ c("p", {
149
+ className: "mt-2 text-xs text-status-warning-text",
150
+ children: ["Missing: ", e.missingRequired.join(", ")]
151
+ }) : null
152
+ ]
153
+ }, e.employeeId))
154
+ }) : /* @__PURE__ */ s(h, {
155
+ illustration: "empty-people",
156
+ title: "Nobody matches yet",
157
+ description: "No one on the team holds these skills or a skill that implies or supersedes them."
158
+ })] });
159
+ }
160
+ //#endregion
161
+ export { E as RoleSkillMatchPanel };
162
+
163
+ //# sourceMappingURL=RoleSkillMatchPanel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RoleSkillMatchPanel.js","names":[],"sources":["../../../../src/crewfoundry/components/skills/RoleSkillMatchPanel.tsx"],"sourcesContent":["import { useEffect, useMemo, useState } from 'react';\nimport {\n Badge,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n IllustratedEmptyState,\n Label,\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n Spinner,\n} from '@burdenoff/fe-libs/ui';\nimport {\n useCrewFoundryStore,\n type CrewJobPostingSkillRequirement,\n type JobPosting,\n} from '../../store/crewfoundryStore';\nimport {\n rankRoleMatches,\n type RoleMatch,\n type RoleSkillRequirement,\n} from '../../lib/skillMatching';\nimport { formatEnumLabel } from '../../utils/formatEnumLabel';\n\n/** Requirements looked up per role; a role rarely lists more. */\nconst MAX_REQUIREMENTS = 12;\nconst MAX_MATCHES = 6;\n\nexport interface RoleSkillMatchPanelProps {\n /** Open roles to choose from (active job postings). */\n roles: readonly JobPosting[];\n}\n\ntype LoadState =\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'ready'; requirements: RoleSkillRequirement[]; matches: RoleMatch[] };\n\n/** A finished load, tagged with the role it answers so a stale answer reads as loading. */\ntype LoadResult = Exclude<LoadState, { status: 'loading' }> & { roleId: string };\n\nconst toRequirement = (row: CrewJobPostingSkillRequirement): RoleSkillRequirement => ({\n skillId: row.skillId,\n skillName: row.skill.name,\n required: row.required,\n minProficiency: row.minProficiency,\n});\n\n/**\n * \"Skills match for open role\" (BOFF-10361, mock cf-19). Ranks the internal\n * team against the role's canonical skill requirements. Each requirement is\n * answered by `crewEmployeesBySkill`, which matches through aliases and\n * IMPLIES / SUPERSEDES edges, so someone holding Kubernetes is offered for a\n * Docker requirement — with the explanation shown, never a bare score.\n */\nexport function RoleSkillMatchPanel({ roles }: RoleSkillMatchPanelProps) {\n const fetchJobPostingSkillRequirements = useCrewFoundryStore(\n (state) => state.fetchJobPostingSkillRequirements\n );\n const fetchEmployeesBySkill = useCrewFoundryStore((state) => state.fetchEmployeesBySkill);\n const [selectedRoleId, setSelectedRoleId] = useState<string | null>(null);\n const [loaded, setLoaded] = useState<LoadResult | null>(null);\n\n const roleId = selectedRoleId ?? roles[0]?.id ?? null;\n const role = useMemo(() => roles.find((item) => item.id === roleId) ?? null, [roles, roleId]);\n\n useEffect(() => {\n if (!roleId) return;\n let cancelled = false;\n (async () => {\n const rows = await fetchJobPostingSkillRequirements(roleId);\n const requirements = rows.slice(0, MAX_REQUIREMENTS).map(toRequirement);\n const answers = await Promise.all(\n requirements.map(\n async (requirement) =>\n [\n requirement.skillId,\n await fetchEmployeesBySkill(requirement.skillId, {\n minProficiency: requirement.minProficiency,\n limit: 50,\n }),\n ] as const\n )\n );\n const matches = rankRoleMatches(requirements, new Map(answers), MAX_MATCHES);\n if (!cancelled) setLoaded({ status: 'ready', requirements, matches, roleId });\n })().catch((error: unknown) => {\n if (!cancelled) {\n setLoaded({\n status: 'error',\n message: error instanceof Error ? error.message : 'Unable to match this role',\n roleId,\n });\n }\n });\n return () => {\n cancelled = true;\n };\n }, [roleId, fetchJobPostingSkillRequirements, fetchEmployeesBySkill]);\n\n const state: LoadState = loaded && loaded.roleId === roleId ? loaded : { status: 'loading' };\n\n return (\n <Card data-testid=\"role-skill-match-panel\">\n <CardHeader>\n <CardTitle className=\"text-base\">Skills match for open role</CardTitle>\n <CardDescription>\n {role\n ? `${role.title} · internal team, matched through the skills taxonomy`\n : 'Internal team, matched through the skills taxonomy'}\n </CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-4\">\n {roles.length === 0 ? (\n <IllustratedEmptyState\n illustration=\"empty-search\"\n title=\"No active roles\"\n description=\"Publish a job posting to match your team against its required skills.\"\n />\n ) : (\n <>\n {roles.length > 1 ? (\n <div className=\"space-y-1.5\">\n <Label htmlFor=\"resourcing-role\">Open role</Label>\n <Select value={roleId ?? undefined} onValueChange={setSelectedRoleId}>\n <SelectTrigger id=\"resourcing-role\" className=\"w-full\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {roles.map((item) => (\n <SelectItem key={item.id} value={item.id}>\n {item.title}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n ) : null}\n <RoleMatchBody state={state} />\n </>\n )}\n </CardContent>\n </Card>\n );\n}\n\nfunction RoleMatchBody({ state }: { state: LoadState }) {\n if (state.status === 'loading') {\n return (\n <div className=\"flex min-h-[8rem] items-center justify-center\">\n <Spinner size=\"md\" />\n </div>\n );\n }\n if (state.status === 'error') {\n return (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"Matching is unavailable\"\n description={state.message}\n />\n );\n }\n if (state.requirements.length === 0) {\n return (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No skills on this role\"\n description=\"Add requirements to the job posting; they are matched to canonical skills automatically.\"\n />\n );\n }\n return (\n <>\n <ul className=\"flex flex-wrap gap-1.5\" aria-label=\"Skills this role needs\">\n {state.requirements.map((requirement) => (\n <li key={requirement.skillId}>\n <Badge variant={requirement.required ? 'outline' : 'secondary'} className=\"text-[11px]\">\n {requirement.skillName}\n {requirement.required ? '' : ' · nice to have'}\n {requirement.minProficiency !== null ? ` · ≥${requirement.minProficiency}%` : ''}\n </Badge>\n </li>\n ))}\n </ul>\n {state.matches.length > 0 ? (\n <ol className=\"space-y-3\" aria-label=\"Ranked matches\">\n {state.matches.map((match, index) => (\n <li\n key={match.employeeId}\n className=\"rounded-[var(--radius-card)] border border-border-subtle bg-bg-surface p-3\"\n >\n <div className=\"flex items-baseline justify-between gap-2\">\n <span className=\"min-w-0 text-sm font-medium text-text-primary\">\n {index + 1}. {match.name}\n </span>\n <span className=\"shrink-0 text-sm font-semibold tabular-nums text-status-success-text\">\n {match.matchPercent}% match\n </span>\n </div>\n {match.detail ? <p className=\"text-xs text-text-muted\">{match.detail}</p> : null}\n <ul className=\"mt-2 space-y-1\">\n {match.matched.map((item) => (\n <li key={item.skillId} className=\"text-xs text-text-secondary\">\n <Badge variant=\"secondary\" className=\"mr-1.5 text-[10px]\">\n {item.skillName}\n </Badge>\n <span className=\"text-text-muted\">\n {formatEnumLabel(item.matchType)}\n {item.proficiency !== null ? ` · ${item.proficiency}%` : ''} ·{' '}\n </span>\n {item.explanation}\n </li>\n ))}\n </ul>\n {match.missingRequired.length > 0 ? (\n <p className=\"mt-2 text-xs text-status-warning-text\">\n Missing: {match.missingRequired.join(', ')}\n </p>\n ) : null}\n </li>\n ))}\n </ol>\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-people\"\n title=\"Nobody matches yet\"\n description=\"No one on the team holds these skills or a skill that implies or supersedes them.\"\n />\n )}\n </>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ import { CrewTaxonomySkill } from '../../store/crewfoundryStore';
2
+
3
+ export interface SkillPickerProps {
4
+ skills: readonly CrewTaxonomySkill[];
5
+ label: string;
6
+ placeholder?: string;
7
+ /** Skills that cannot be picked (e.g. the skill being merged or related). */
8
+ excludeIds?: readonly string[];
9
+ selectedId?: string | null;
10
+ onSelect: (skill: CrewTaxonomySkill) => void;
11
+ disabled?: boolean;
12
+ }
13
+ /**
14
+ * Type-ahead over the workspace skills taxonomy (BOFF-10361). Matches names
15
+ * and aliases, so typing "K8s" offers Kubernetes. Shows at most eight
16
+ * suggestions as a list of buttons, which works the same with a mouse, a
17
+ * keyboard and a 390px touch screen.
18
+ */
19
+ export declare function SkillPicker({ skills, label, placeholder, excludeIds, selectedId, onSelect, disabled, }: SkillPickerProps): import("react").JSX.Element;
20
+ //# sourceMappingURL=SkillPicker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SkillPicker.d.ts","sourceRoot":"","sources":["../../../../src/crewfoundry/components/skills/SkillPicker.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAItE,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,QAAQ,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAC7C,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAC1B,MAAM,EACN,KAAK,EACL,WAAqD,EACrD,UAAU,EACV,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,EAAE,gBAAgB,+BAiElB"}