@burdenoff/microfe-crewfoundry 2026.707.1 → 2026.707.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crewfoundry/components/viz/BarChart.js.map +1 -1
- package/dist/crewfoundry/components/viz/CertificateCard.js.map +1 -1
- package/dist/crewfoundry/components/viz/DonutChart.js.map +1 -1
- package/dist/crewfoundry/components/viz/Heatmap.js.map +1 -1
- package/dist/crewfoundry/components/viz/LineChart.js.map +1 -1
- package/dist/crewfoundry/components/viz/PathSteps.js.map +1 -1
- package/dist/crewfoundry/components/viz/RatingStars.js.map +1 -1
- package/dist/crewfoundry/components/viz/SegmentedTabs.js.map +1 -1
- package/dist/crewfoundry/components/viz/StatCard.js.map +1 -1
- package/dist/crewfoundry/components/viz/lib/Avatar.js.map +1 -1
- package/dist/crewfoundry/components/viz/lib/CartesianFrame.js.map +1 -1
- package/dist/crewfoundry/components/viz/lib/geometry.js.map +1 -1
- package/dist/crewfoundry/lib/analytics.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsEmployeeAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsFinancialAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/AnalyticsHiringAnalyticsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/CommunicationsMessagesPagePage.js +2 -1
- package/dist/crewfoundry/pages/CommunicationsMessagesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/CompanyClientManagementPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/DashboardDashboardPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringApplicationsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringCandidatesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringCreateJobPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringJobDetailsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HiringJobsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrDepartmentsPagePage.js +8 -4
- package/dist/crewfoundry/pages/HrDepartmentsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrLeaveManagementPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrPayrollPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/HrTimeTrackingPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningCertificatesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningCourseLearningPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningCoursesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningLearningPathsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/LearningMyLearningPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/MarketplaceMarketplacePagePage.js.map +1 -1
- package/dist/crewfoundry/pages/MarketplaceServiceDetailsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/PaymentsInvoicesPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/ProjectsProjectsPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/ProjectsResourcingPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/TeamsTeamDetailPagePage.js.map +1 -1
- package/dist/crewfoundry/pages/TeamsTeamsPagePage.js.map +1 -1
- package/dist/crewfoundry/utils/courseProgress.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 Invoice,\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.map((item) => isoWeekKey(item.date)).sort().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((a, b) => b.matchPercent - a.matchPercent || b.matchedSkills.length - a.matchedSkills.length)\n .slice(0, limit);\n\n return { roleTitle: job.title, requiredSkills, matches, source };\n}\n\n// ── Finance / payroll trends (best-effort) ───────────────────────────────────\n\n/**\n * Monthly recognised revenue from PAID invoices, bucketed by `issueDate`.\n * Empty when no paid invoice has a parseable issue date.\n */\nexport function revenueTrend(invoices: Invoice[]): LabeledValue[] {\n const paid = invoices.filter((invoice) => invoice.status === 'paid');\n const totals = new Map<number, number>();\n for (const invoice of paid) {\n const date = parseDate(invoice.issueDate);\n if (!date) {\n continue;\n }\n const index = monthIndex(date);\n totals.set(index, (totals.get(index) ?? 0) + invoice.amount);\n }\n if (totals.size === 0) {\n return [];\n }\n const indices = [...totals.keys()].sort((a, b) => a - b);\n const includeYear = Math.floor(indices[0] / 12) !== Math.floor(indices[indices.length - 1] / 12);\n return indices.map((index) => ({\n label: monthLabelFromIndex(index, includeYear),\n value: totals.get(index) ?? 0,\n }));\n}\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 Invoice,\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\n/**\n * Monthly recognised revenue from PAID invoices, bucketed by `issueDate`.\n * Empty when no paid invoice has a parseable issue date.\n */\nexport function revenueTrend(invoices: Invoice[]): LabeledValue[] {\n const paid = invoices.filter((invoice) => invoice.status === 'paid');\n const totals = new Map<number, number>();\n for (const invoice of paid) {\n const date = parseDate(invoice.issueDate);\n if (!date) {\n continue;\n }\n const index = monthIndex(date);\n totals.set(index, (totals.get(index) ?? 0) + invoice.amount);\n }\n if (totals.size === 0) {\n return [];\n }\n const indices = [...totals.keys()].sort((a, b) => a - b);\n const includeYear = Math.floor(indices[0] / 12) !== Math.floor(indices[indices.length - 1] / 12);\n return indices.map((index) => ({\n label: monthLabelFromIndex(index, includeYear),\n value: totals.get(index) ?? 0,\n }));\n}\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 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnalyticsAnalyticsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/AnalyticsAnalyticsPagePage.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport { Activity, BarChart3, Download, PieChart, Users } from 'lucide-react';\nimport {\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n} from '@burdenoff/fe-libs/ui';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport {\n AreaChart,\n BarChart,\n DonutChart,\n HBarFunnel,\n LineChart,\n SegmentedTabs,\n StatCard,\n} from '../components/viz';\nimport type { LabeledValue, Segment } from '../components/viz';\nimport {\n attritionTrend,\n benchAndOverallocated,\n deptHeadcount,\n headcountByMonth,\n momDelta,\n performanceDistribution,\n timeToHire,\n utilizationByPersonWeek,\n} from '../lib/analytics';\n\nconst STAGE_ORDER = ['new', 'screening', 'interview', 'assessment', 'offer', 'hired'] as const;\nconst PERF_LABELS = ['Needs', 'Meets', 'Exceeds', 'Outstanding'];\n\nfunction csvEscape(value: string | number): string {\n return `\"${String(value).replace(/\"/g, '\"\"')}\"`;\n}\n\nexport function AnalyticsAnalyticsPagePage() {\n const { employees, departments, candidates, projects, timeEntries } = useCrewFoundryStore();\n const [periodMonths, setPeriodMonths] = useState<'6' | '12'>('12');\n\n // ── KPI strip ──────────────────────────────────────────────────────────────\n const headcountSeries = useMemo(() => headcountByMonth(employees), [employees]);\n const headcountDelta = momDelta(headcountSeries).deltaPercent;\n const activeProjects = projects.filter((project) => project.status === 'active').length;\n\n // ── Headcount by department ─────────────────────────────────────────────────\n const deptSeries = useMemo<LabeledValue[]>(\n () => deptHeadcount(departments).slice(0, 8),\n [departments]\n );\n\n // ── Hiring funnel conversion (candidates who reached ≥ each stage) ──────────\n const windowedCandidates = useMemo(() => {\n if (candidates.length === 0) {\n return candidates;\n }\n const times = candidates\n .map((candidate) => new Date(candidate.appliedDate).getTime())\n .filter((time) => !Number.isNaN(time));\n if (times.length === 0) {\n return candidates;\n }\n const anchor = Math.max(...times);\n const windowStart = anchor - Number(periodMonths) * 30 * 24 * 60 * 60 * 1000;\n const filtered = candidates.filter((candidate) => {\n const time = new Date(candidate.appliedDate).getTime();\n return Number.isNaN(time) || time >= windowStart;\n });\n return filtered.length > 0 ? filtered : candidates;\n }, [candidates, periodMonths]);\n\n const funnel = useMemo<LabeledValue[]>(() => {\n const rankOf = (status: string) => STAGE_ORDER.indexOf(status as (typeof STAGE_ORDER)[number]);\n const reached = (minRank: number) =>\n windowedCandidates.filter((candidate) => rankOf(candidate.status) >= minRank).length;\n return [\n { label: 'Applied', value: windowedCandidates.length },\n { label: 'Screened', value: reached(1) },\n { label: 'Interviewed', value: reached(2) },\n { label: 'Offer', value: reached(4) },\n { label: 'Hired', value: reached(5) },\n ];\n }, [windowedCandidates]);\n\n // ── Performance distribution histogram ──────────────────────────────────────\n const perfSeries = useMemo<Segment[]>(() => {\n const buckets = performanceDistribution(employees);\n return buckets.map((bucket, index) => ({ label: PERF_LABELS[index], value: bucket.value }));\n }, [employees]);\n\n // ── Attrition trend (monthly exit counts from real Employee.terminationDate) ─\n const attrition = useMemo(() => attritionTrend(employees), [employees]);\n\n // ── Utilization donut (real logged time ÷ 40h capacity) ─────────────────────\n const utilization = useMemo(() => {\n const matrix = utilizationByPersonWeek(timeEntries, employees);\n if (!matrix.hasData) {\n return null;\n }\n const kpis = benchAndOverallocated(matrix, employees);\n const utilized = Math.min(Math.max(kpis.avgUtilization, 0), 100);\n return { utilized, available: Math.max(0, 100 - utilized) };\n }, [timeEntries, employees]);\n\n const utilizationSegments: Segment[] | null = utilization\n ? [\n { label: 'Utilized', value: utilization.utilized },\n { label: 'Available', value: utilization.available, color: 'var(--color-chart-grid)' },\n ]\n : null;\n\n // ── Time-to-hire (avg applied→hired days from real Candidate.hiredDate) ─────\n const timeToHireResult = useMemo(() => timeToHire(candidates), [candidates]);\n\n const exportCsv = () => {\n const lines = ['Section,Label,Value'];\n for (const row of deptSeries) {\n lines.push(`Headcount by department,${csvEscape(row.label)},${row.value}`);\n }\n for (const stage of funnel) {\n lines.push(`Hiring funnel,${csvEscape(stage.label)},${stage.value}`);\n }\n for (const bucket of perfSeries) {\n lines.push(`Performance distribution,${csvEscape(bucket.label)},${bucket.value}`);\n }\n if (utilization) {\n lines.push(`Utilization,Utilized %,${utilization.utilized}`);\n lines.push(`Utilization,Available %,${utilization.available}`);\n }\n const blob = new Blob([lines.join('\\n')], { type: 'text/csv;charset=utf-8;' });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement('a');\n anchor.href = url;\n anchor.download = 'workforce-analytics.csv';\n anchor.click();\n URL.revokeObjectURL(url);\n };\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Workforce Analytics</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n Comprehensive analytics across all HR domains\n </p>\n <PagePurpose className=\"mt-1\">\n Your workforce analytics hub — track headcount, hiring conversion, performance, and\n utilization here to spot what's working and where your teams need attention.\n </PagePurpose>\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n <SegmentedTabs\n aria-label=\"Reporting period\"\n tabs={[\n { id: '6', label: 'Last 6 months' },\n { id: '12', label: 'Last 12 months' },\n ]}\n value={periodMonths}\n onChange={(id) => setPeriodMonths(id === '6' ? '6' : '12')}\n />\n <Button variant=\"outline\" size=\"sm\" onClick={exportCsv}>\n <Download className=\"mr-1 h-4 w-4\" />\n Export\n </Button>\n </div>\n </div>\n\n {/* KPI strip */}\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n <StatCard\n label=\"Total Workforce\"\n value={employees.length}\n delta={headcountDelta || undefined}\n deltaLabel=\"from last month\"\n icon={<Users className=\"h-4 w-4\" />}\n sparkline={headcountSeries.map((point) => point.value)}\n sparklineVariant=\"area\"\n />\n <StatCard\n label=\"Active Departments\"\n value={departments.length}\n icon={<PieChart className=\"h-4 w-4\" />}\n />\n <StatCard\n label=\"Active Projects\"\n value={activeProjects}\n icon={<Activity className=\"h-4 w-4\" />}\n />\n <StatCard\n label=\"Hiring Pipeline\"\n value={candidates.length}\n icon={<BarChart3 className=\"h-4 w-4\" />}\n />\n </div>\n\n {/* Six-card analytics grid */}\n <div className=\"grid gap-4 lg:grid-cols-3\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Headcount by department</CardTitle>\n </CardHeader>\n <CardContent>\n {deptSeries.length > 0 ? (\n <BarChart series={deptSeries} colorful height={230} title=\"Headcount by department\" />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No departments yet\"\n description=\"Department headcount appears once departments exist.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Hiring funnel conversion</CardTitle>\n </CardHeader>\n <CardContent>\n {windowedCandidates.length > 0 ? (\n <HBarFunnel stages={funnel} valueFormatter={(value) => `${value}`} showConversion />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No candidates yet\"\n description=\"The hiring funnel and stage-to-stage conversion appear once candidates apply.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Performance distribution</CardTitle>\n </CardHeader>\n <CardContent>\n {employees.length > 0 ? (\n <BarChart series={perfSeries} height={230} title=\"Performance score distribution\" />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No employees yet\"\n description=\"Performance bands appear once employees have scores.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Attrition trend</CardTitle>\n </CardHeader>\n <CardContent>\n {attrition.length > 0 ? (\n <LineChart\n series={attrition}\n height={230}\n valueFormatter={(v) => `${v}`}\n title=\"Employee exits by month\"\n />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No exits recorded\"\n description=\"Monthly attrition appears once employees have termination dates. Nothing is shown while no exits are on record — estimated exits are never charted.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Utilization</CardTitle>\n </CardHeader>\n <CardContent>\n {utilizationSegments ? (\n <DonutChart\n segments={utilizationSegments}\n centerLabel={`${utilization?.utilized ?? 0}%`}\n centerCaption=\"Utilized\"\n valueFormatter={(value) => `${value}%`}\n />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No logged time yet\"\n description=\"Utilization is computed from real logged hours against a 40h weekly capacity.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Time-to-hire</CardTitle>\n </CardHeader>\n <CardContent>\n {timeToHireResult ? (\n <StatCard\n label=\"Average time-to-hire\"\n value={`${timeToHireResult.averageDays} days`}\n deltaLabel={`from ${timeToHireResult.sampleSize} hires`}\n />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No hires recorded yet\"\n description=\"Average time-to-hire appears once candidates carry a hired date. The elapsed applied-to-hired time is measured, never estimated.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n {/* Headcount growth (real cumulative series) */}\n {headcountSeries.length > 1 && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Headcount growth</CardTitle>\n </CardHeader>\n <CardContent>\n <AreaChart series={headcountSeries} height={220} title=\"Cumulative headcount by month\" />\n </CardContent>\n </Card>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AnalyticsAnalyticsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/AnalyticsAnalyticsPagePage.tsx"],"sourcesContent":["import { useMemo, useState } from 'react';\nimport { Activity, BarChart3, Download, PieChart, Users } from 'lucide-react';\nimport {\n Button,\n Card,\n CardContent,\n CardHeader,\n CardTitle,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n} from '@burdenoff/fe-libs/ui';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport {\n AreaChart,\n BarChart,\n DonutChart,\n HBarFunnel,\n LineChart,\n SegmentedTabs,\n StatCard,\n} from '../components/viz';\nimport type { LabeledValue, Segment } from '../components/viz';\nimport {\n attritionTrend,\n benchAndOverallocated,\n deptHeadcount,\n headcountByMonth,\n momDelta,\n performanceDistribution,\n timeToHire,\n utilizationByPersonWeek,\n} from '../lib/analytics';\n\nconst STAGE_ORDER = ['new', 'screening', 'interview', 'assessment', 'offer', 'hired'] as const;\nconst PERF_LABELS = ['Needs', 'Meets', 'Exceeds', 'Outstanding'];\n\nfunction csvEscape(value: string | number): string {\n return `\"${String(value).replace(/\"/g, '\"\"')}\"`;\n}\n\nexport function AnalyticsAnalyticsPagePage() {\n const { employees, departments, candidates, projects, timeEntries } = useCrewFoundryStore();\n const [periodMonths, setPeriodMonths] = useState<'6' | '12'>('12');\n\n // ── KPI strip ──────────────────────────────────────────────────────────────\n const headcountSeries = useMemo(() => headcountByMonth(employees), [employees]);\n const headcountDelta = momDelta(headcountSeries).deltaPercent;\n const activeProjects = projects.filter((project) => project.status === 'active').length;\n\n // ── Headcount by department ─────────────────────────────────────────────────\n const deptSeries = useMemo<LabeledValue[]>(\n () => deptHeadcount(departments).slice(0, 8),\n [departments]\n );\n\n // ── Hiring funnel conversion (candidates who reached ≥ each stage) ──────────\n const windowedCandidates = useMemo(() => {\n if (candidates.length === 0) {\n return candidates;\n }\n const times = candidates\n .map((candidate) => new Date(candidate.appliedDate).getTime())\n .filter((time) => !Number.isNaN(time));\n if (times.length === 0) {\n return candidates;\n }\n const anchor = Math.max(...times);\n const windowStart = anchor - Number(periodMonths) * 30 * 24 * 60 * 60 * 1000;\n const filtered = candidates.filter((candidate) => {\n const time = new Date(candidate.appliedDate).getTime();\n return Number.isNaN(time) || time >= windowStart;\n });\n return filtered.length > 0 ? filtered : candidates;\n }, [candidates, periodMonths]);\n\n const funnel = useMemo<LabeledValue[]>(() => {\n const rankOf = (status: string) => STAGE_ORDER.indexOf(status as (typeof STAGE_ORDER)[number]);\n const reached = (minRank: number) =>\n windowedCandidates.filter((candidate) => rankOf(candidate.status) >= minRank).length;\n return [\n { label: 'Applied', value: windowedCandidates.length },\n { label: 'Screened', value: reached(1) },\n { label: 'Interviewed', value: reached(2) },\n { label: 'Offer', value: reached(4) },\n { label: 'Hired', value: reached(5) },\n ];\n }, [windowedCandidates]);\n\n // ── Performance distribution histogram ──────────────────────────────────────\n const perfSeries = useMemo<Segment[]>(() => {\n const buckets = performanceDistribution(employees);\n return buckets.map((bucket, index) => ({ label: PERF_LABELS[index], value: bucket.value }));\n }, [employees]);\n\n // ── Attrition trend (monthly exit counts from real Employee.terminationDate) ─\n const attrition = useMemo(() => attritionTrend(employees), [employees]);\n\n // ── Utilization donut (real logged time ÷ 40h capacity) ─────────────────────\n const utilization = useMemo(() => {\n const matrix = utilizationByPersonWeek(timeEntries, employees);\n if (!matrix.hasData) {\n return null;\n }\n const kpis = benchAndOverallocated(matrix, employees);\n const utilized = Math.min(Math.max(kpis.avgUtilization, 0), 100);\n return { utilized, available: Math.max(0, 100 - utilized) };\n }, [timeEntries, employees]);\n\n const utilizationSegments: Segment[] | null = utilization\n ? [\n { label: 'Utilized', value: utilization.utilized },\n { label: 'Available', value: utilization.available, color: 'var(--color-chart-grid)' },\n ]\n : null;\n\n // ── Time-to-hire (avg applied→hired days from real Candidate.hiredDate) ─────\n const timeToHireResult = useMemo(() => timeToHire(candidates), [candidates]);\n\n const exportCsv = () => {\n const lines = ['Section,Label,Value'];\n for (const row of deptSeries) {\n lines.push(`Headcount by department,${csvEscape(row.label)},${row.value}`);\n }\n for (const stage of funnel) {\n lines.push(`Hiring funnel,${csvEscape(stage.label)},${stage.value}`);\n }\n for (const bucket of perfSeries) {\n lines.push(`Performance distribution,${csvEscape(bucket.label)},${bucket.value}`);\n }\n if (utilization) {\n lines.push(`Utilization,Utilized %,${utilization.utilized}`);\n lines.push(`Utilization,Available %,${utilization.available}`);\n }\n const blob = new Blob([lines.join('\\n')], { type: 'text/csv;charset=utf-8;' });\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement('a');\n anchor.href = url;\n anchor.download = 'workforce-analytics.csv';\n anchor.click();\n URL.revokeObjectURL(url);\n };\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Workforce Analytics</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n Comprehensive analytics across all HR domains\n </p>\n <PagePurpose className=\"mt-1\">\n Your workforce analytics hub — track headcount, hiring conversion, performance, and\n utilization here to spot what's working and where your teams need attention.\n </PagePurpose>\n </div>\n <div className=\"flex flex-wrap items-center gap-2\">\n <SegmentedTabs\n aria-label=\"Reporting period\"\n tabs={[\n { id: '6', label: 'Last 6 months' },\n { id: '12', label: 'Last 12 months' },\n ]}\n value={periodMonths}\n onChange={(id) => setPeriodMonths(id === '6' ? '6' : '12')}\n />\n <Button variant=\"outline\" size=\"sm\" onClick={exportCsv}>\n <Download className=\"mr-1 h-4 w-4\" />\n Export\n </Button>\n </div>\n </div>\n\n {/* KPI strip */}\n <div className=\"grid gap-4 sm:grid-cols-2 lg:grid-cols-4\">\n <StatCard\n label=\"Total Workforce\"\n value={employees.length}\n delta={headcountDelta || undefined}\n deltaLabel=\"from last month\"\n icon={<Users className=\"h-4 w-4\" />}\n sparkline={headcountSeries.map((point) => point.value)}\n sparklineVariant=\"area\"\n />\n <StatCard\n label=\"Active Departments\"\n value={departments.length}\n icon={<PieChart className=\"h-4 w-4\" />}\n />\n <StatCard\n label=\"Active Projects\"\n value={activeProjects}\n icon={<Activity className=\"h-4 w-4\" />}\n />\n <StatCard\n label=\"Hiring Pipeline\"\n value={candidates.length}\n icon={<BarChart3 className=\"h-4 w-4\" />}\n />\n </div>\n\n {/* Six-card analytics grid */}\n <div className=\"grid gap-4 lg:grid-cols-3\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Headcount by department</CardTitle>\n </CardHeader>\n <CardContent>\n {deptSeries.length > 0 ? (\n <BarChart series={deptSeries} colorful height={230} title=\"Headcount by department\" />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No departments yet\"\n description=\"Department headcount appears once departments exist.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Hiring funnel conversion</CardTitle>\n </CardHeader>\n <CardContent>\n {windowedCandidates.length > 0 ? (\n <HBarFunnel stages={funnel} valueFormatter={(value) => `${value}`} showConversion />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No candidates yet\"\n description=\"The hiring funnel and stage-to-stage conversion appear once candidates apply.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Performance distribution</CardTitle>\n </CardHeader>\n <CardContent>\n {employees.length > 0 ? (\n <BarChart series={perfSeries} height={230} title=\"Performance score distribution\" />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No employees yet\"\n description=\"Performance bands appear once employees have scores.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Attrition trend</CardTitle>\n </CardHeader>\n <CardContent>\n {attrition.length > 0 ? (\n <LineChart\n series={attrition}\n height={230}\n valueFormatter={(v) => `${v}`}\n title=\"Employee exits by month\"\n />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No exits recorded\"\n description=\"Monthly attrition appears once employees have termination dates. Nothing is shown while no exits are on record — estimated exits are never charted.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Utilization</CardTitle>\n </CardHeader>\n <CardContent>\n {utilizationSegments ? (\n <DonutChart\n segments={utilizationSegments}\n centerLabel={`${utilization?.utilized ?? 0}%`}\n centerCaption=\"Utilized\"\n valueFormatter={(value) => `${value}%`}\n />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No logged time yet\"\n description=\"Utilization is computed from real logged hours against a 40h weekly capacity.\"\n />\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Time-to-hire</CardTitle>\n </CardHeader>\n <CardContent>\n {timeToHireResult ? (\n <StatCard\n label=\"Average time-to-hire\"\n value={`${timeToHireResult.averageDays} days`}\n deltaLabel={`from ${timeToHireResult.sampleSize} hires`}\n />\n ) : (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No hires recorded yet\"\n description=\"Average time-to-hire appears once candidates carry a hired date. The elapsed applied-to-hired time is measured, never estimated.\"\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n {/* Headcount growth (real cumulative series) */}\n {headcountSeries.length > 1 && (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-base\">Headcount growth</CardTitle>\n </CardHeader>\n <CardContent>\n <AreaChart\n series={headcountSeries}\n height={220}\n title=\"Cumulative headcount by month\"\n />\n </CardContent>\n </Card>\n )}\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnalyticsEmployeeAnalyticsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/AnalyticsEmployeeAnalyticsPagePage.tsx"],"sourcesContent":["import {\n Badge,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n ResponsiveTable,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { Award, BarChart3, Clock, MapPin, Star, UserCheck } from 'lucide-react';\nimport { useMemo } from 'react';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { BarChart } from '../components/viz';\nimport type { Segment } from '../components/viz';\n\ntype DeptPerformanceRow = {\n name: string;\n headcount: number;\n avgScore: string;\n avgSalary: number;\n};\n\nexport function AnalyticsEmployeeAnalyticsPagePage() {\n const { employees, departments } = useCrewFoundryStore();\n\n const tenureDistribution = useMemo(() => {\n const total = Math.max(employees.length, 1);\n const getYears = (startDate: string) =>\n Math.max(\n 0,\n (new Date('2026-03-01').getTime() - new Date(startDate).getTime()) /\n (1000 * 60 * 60 * 24 * 365)\n );\n const buckets = [\n { range: '< 1 year', min: 0, max: 1 },\n { range: '1-2 years', min: 1, max: 2 },\n { range: '2-3 years', min: 2, max: 3 },\n { range: '3-4 years', min: 3, max: 4 },\n { range: '4+ years', min: 4, max: Infinity },\n ];\n return buckets.map((bucket) => {\n const count = employees.filter((employee) => {\n const years = getYears(employee.startDate);\n return years >= bucket.min && years < bucket.max;\n }).length;\n return { range: bucket.range, count, percentage: Math.round((count / total) * 100) };\n });\n }, [employees]);\n\n // Real average and median tenure derived from employee start dates.\n const { avgTenure, medianTenure } = useMemo(() => {\n if (employees.length === 0) {\n return { avgTenure: 0, medianTenure: 0 };\n }\n const getYears = (startDate: string) =>\n Math.max(\n 0,\n (new Date('2026-03-01').getTime() - new Date(startDate).getTime()) /\n (1000 * 60 * 60 * 24 * 365)\n );\n const years = employees.map((employee) => getYears(employee.startDate)).sort((a, b) => a - b);\n const avg = years.reduce((sum, value) => sum + value, 0) / years.length;\n const mid = Math.floor(years.length / 2);\n const median = years.length % 2 === 1 ? years[mid] : (years[mid - 1] + years[mid]) / 2;\n return { avgTenure: avg, medianTenure: median };\n }, [employees]);\n\n const tenureSeries: Segment[] = tenureDistribution.map((bucket) => ({\n label: bucket.range.replace(' years', 'y').replace(' year', 'y'),\n value: bucket.count,\n }));\n\n const avgPerformance = (\n employees.reduce((sum, e) => sum + e.performanceScore, 0) / Math.max(employees.length, 1)\n ).toFixed(1);\n const avgSalary = Math.round(\n employees.reduce((sum, e) => sum + e.salary, 0) / Math.max(employees.length, 1)\n );\n const activeCount = employees.filter((e) => e.status === 'active').length;\n const onLeaveCount = employees.filter((e) => e.status === 'on_leave').length;\n\n const deptPerformance: DeptPerformanceRow[] = departments.map((dept) => {\n const deptEmps = employees.filter((e) => e.department === dept.name);\n const avgScore =\n deptEmps.length > 0\n ? (deptEmps.reduce((s, e) => s + e.performanceScore, 0) / deptEmps.length).toFixed(1)\n : '0';\n const avgSal =\n deptEmps.length > 0\n ? Math.round(deptEmps.reduce((s, e) => s + e.salary, 0) / deptEmps.length)\n : 0;\n return { name: dept.name, headcount: deptEmps.length, avgScore, avgSalary: avgSal };\n });\n\n const sortedDeptPerformance = [...deptPerformance].sort(\n (a, b) => Number(b.avgScore) - Number(a.avgScore)\n );\n\n // Real key-insight figures.\n const topDept = sortedDeptPerformance[0];\n const flightRiskCount = employees.filter((e) => e.performanceScore < 3.5).length;\n const deptsWithStaff = deptPerformance.filter((d) => d.headcount > 0);\n const sortedSalaries = [...deptsWithStaff].map((d) => d.avgSalary).sort((a, b) => a - b);\n const medianDeptSalary =\n sortedSalaries.length > 0 ? sortedSalaries[Math.floor(sortedSalaries.length / 2)] : 0;\n const belowMedianDeptCount = deptsWithStaff.filter((d) => d.avgSalary < medianDeptSalary).length;\n\n const deptPerformanceColumns: ResponsiveTableColumn<DeptPerformanceRow>[] = [\n {\n key: 'name',\n header: 'Department',\n priority: 'primary',\n cell: (dept) => <span className=\"font-medium\">{dept.name}</span>,\n },\n {\n key: 'headcount',\n header: 'Headcount',\n priority: 'secondary',\n cell: (dept) => <span className=\"md:block md:text-right\">{dept.headcount}</span>,\n },\n {\n key: 'avgScore',\n header: 'Avg Score',\n priority: 'primary',\n cell: (dept) => (\n <span className=\"md:block md:text-right\">\n <Badge variant={Number(dept.avgScore) >= 4.5 ? 'default' : 'secondary'}>\n {dept.avgScore}\n </Badge>\n </span>\n ),\n },\n {\n key: 'avgSalary',\n header: 'Avg Salary',\n priority: 'secondary',\n cell: (dept) => (\n <span className=\"md:block md:text-right\">${dept.avgSalary.toLocaleString()}</span>\n ),\n },\n ];\n\n const locationBreakdown = employees.reduce<Record<string, number>>((acc, e) => {\n acc[e.location] = (acc[e.location] || 0) + 1;\n return acc;\n }, {});\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Employee Analytics</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n Deep-dive into employee performance, engagement, and demographics\n </p>\n <PagePurpose className=\"mt-1\">\n See how individual employees and teams are performing over time — use these insights for\n reviews, recognition, and targeted coaching.\n </PagePurpose>\n </div>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-4\">\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Active Employees</CardTitle>\n <UserCheck className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{activeCount}</div>\n <p className=\"text-xs text-text-secondary\">{onLeaveCount} currently on leave</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Avg Performance</CardTitle>\n <Star className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{avgPerformance}/5.0</div>\n <p className=\"text-xs text-text-secondary\">Across {employees.length} employees</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Avg Compensation</CardTitle>\n <BarChart3 className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">${avgSalary.toLocaleString()}</div>\n <p className=\"text-xs text-text-secondary\">Across all departments</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Avg Tenure</CardTitle>\n <Clock className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{avgTenure.toFixed(1)} yrs</div>\n <p className=\"text-xs text-text-secondary\">Median: {medianTenure.toFixed(1)} years</p>\n </CardContent>\n </GlassCard>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>Department Performance</CardTitle>\n <CardDescription>\n Average performance score and compensation by department\n </CardDescription>\n </CardHeader>\n <CardContent>\n <ResponsiveTable\n columns={deptPerformanceColumns}\n data={sortedDeptPerformance}\n rowKey={(dept) => dept.name}\n />\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle>Engagement Drivers</CardTitle>\n <CardDescription>Employee satisfaction by category</CardDescription>\n </CardHeader>\n <CardContent>\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"Engagement surveys coming soon\"\n description=\"Satisfaction and engagement drivers will appear here once the employee survey integration is connected — we don't show estimated scores.\"\n />\n </CardContent>\n </Card>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Clock className=\"h-4 w-4\" />\n Tenure Distribution\n </CardTitle>\n </CardHeader>\n <CardContent>\n {employees.length > 0 ? (\n <BarChart series={tenureSeries} colorful height={220} title=\"Employee tenure distribution\" />\n ) : (\n <p className=\"py-6 text-center text-sm text-text-secondary\">\n No employees yet.\n </p>\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <MapPin className=\"h-4 w-4\" />\n Location Distribution\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {Object.entries(locationBreakdown)\n .sort(([, a], [, b]) => b - a)\n .map(([loc, count]) => (\n <div key={loc} className=\"flex items-center justify-between py-1\">\n <span className=\"text-sm\">{loc}</span>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-medium\">{count}</span>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {Math.round((count / Math.max(employees.length, 1)) * 100)}%\n </Badge>\n </div>\n </div>\n ))}\n </CardContent>\n </Card>\n </div>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Award className=\"h-4 w-4 text-status-warning-text\" />\n Key Insights\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"grid gap-3 md:grid-cols-3\">\n <div className=\"rounded border p-3\">\n <p className=\"text-sm font-medium\">Highest Performance</p>\n <p className=\"text-xs text-text-secondary mt-1\">\n {topDept\n ? `${topDept.name} leads with an average score of ${topDept.avgScore}`\n : 'No department performance data yet'}\n </p>\n </div>\n <div className=\"rounded border p-3\">\n <p className=\"text-sm font-medium\">Retention Risk</p>\n <p className=\"text-xs text-text-secondary mt-1\">\n {flightRiskCount} employee{flightRiskCount === 1 ? '' : 's'} below a 3.5 performance\n score — a potential flight risk\n </p>\n </div>\n <div className=\"rounded border p-3\">\n <p className=\"text-sm font-medium\">Compensation Gap</p>\n <p className=\"text-xs text-text-secondary mt-1\">\n {belowMedianDeptCount} department{belowMedianDeptCount === 1 ? '' : 's'} below the\n median department salary\n </p>\n </div>\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AnalyticsEmployeeAnalyticsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/AnalyticsEmployeeAnalyticsPagePage.tsx"],"sourcesContent":["import {\n Badge,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n ResponsiveTable,\n type ResponsiveTableColumn,\n} from '@burdenoff/fe-libs/ui';\nimport { Award, BarChart3, Clock, MapPin, Star, UserCheck } from 'lucide-react';\nimport { useMemo } from 'react';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { BarChart } from '../components/viz';\nimport type { Segment } from '../components/viz';\n\ntype DeptPerformanceRow = {\n name: string;\n headcount: number;\n avgScore: string;\n avgSalary: number;\n};\n\nexport function AnalyticsEmployeeAnalyticsPagePage() {\n const { employees, departments } = useCrewFoundryStore();\n\n const tenureDistribution = useMemo(() => {\n const total = Math.max(employees.length, 1);\n const getYears = (startDate: string) =>\n Math.max(\n 0,\n (new Date('2026-03-01').getTime() - new Date(startDate).getTime()) /\n (1000 * 60 * 60 * 24 * 365)\n );\n const buckets = [\n { range: '< 1 year', min: 0, max: 1 },\n { range: '1-2 years', min: 1, max: 2 },\n { range: '2-3 years', min: 2, max: 3 },\n { range: '3-4 years', min: 3, max: 4 },\n { range: '4+ years', min: 4, max: Infinity },\n ];\n return buckets.map((bucket) => {\n const count = employees.filter((employee) => {\n const years = getYears(employee.startDate);\n return years >= bucket.min && years < bucket.max;\n }).length;\n return { range: bucket.range, count, percentage: Math.round((count / total) * 100) };\n });\n }, [employees]);\n\n // Real average and median tenure derived from employee start dates.\n const { avgTenure, medianTenure } = useMemo(() => {\n if (employees.length === 0) {\n return { avgTenure: 0, medianTenure: 0 };\n }\n const getYears = (startDate: string) =>\n Math.max(\n 0,\n (new Date('2026-03-01').getTime() - new Date(startDate).getTime()) /\n (1000 * 60 * 60 * 24 * 365)\n );\n const years = employees.map((employee) => getYears(employee.startDate)).sort((a, b) => a - b);\n const avg = years.reduce((sum, value) => sum + value, 0) / years.length;\n const mid = Math.floor(years.length / 2);\n const median = years.length % 2 === 1 ? years[mid] : (years[mid - 1] + years[mid]) / 2;\n return { avgTenure: avg, medianTenure: median };\n }, [employees]);\n\n const tenureSeries: Segment[] = tenureDistribution.map((bucket) => ({\n label: bucket.range.replace(' years', 'y').replace(' year', 'y'),\n value: bucket.count,\n }));\n\n const avgPerformance = (\n employees.reduce((sum, e) => sum + e.performanceScore, 0) / Math.max(employees.length, 1)\n ).toFixed(1);\n const avgSalary = Math.round(\n employees.reduce((sum, e) => sum + e.salary, 0) / Math.max(employees.length, 1)\n );\n const activeCount = employees.filter((e) => e.status === 'active').length;\n const onLeaveCount = employees.filter((e) => e.status === 'on_leave').length;\n\n const deptPerformance: DeptPerformanceRow[] = departments.map((dept) => {\n const deptEmps = employees.filter((e) => e.department === dept.name);\n const avgScore =\n deptEmps.length > 0\n ? (deptEmps.reduce((s, e) => s + e.performanceScore, 0) / deptEmps.length).toFixed(1)\n : '0';\n const avgSal =\n deptEmps.length > 0\n ? Math.round(deptEmps.reduce((s, e) => s + e.salary, 0) / deptEmps.length)\n : 0;\n return { name: dept.name, headcount: deptEmps.length, avgScore, avgSalary: avgSal };\n });\n\n const sortedDeptPerformance = [...deptPerformance].sort(\n (a, b) => Number(b.avgScore) - Number(a.avgScore)\n );\n\n // Real key-insight figures.\n const topDept = sortedDeptPerformance[0];\n const flightRiskCount = employees.filter((e) => e.performanceScore < 3.5).length;\n const deptsWithStaff = deptPerformance.filter((d) => d.headcount > 0);\n const sortedSalaries = [...deptsWithStaff].map((d) => d.avgSalary).sort((a, b) => a - b);\n const medianDeptSalary =\n sortedSalaries.length > 0 ? sortedSalaries[Math.floor(sortedSalaries.length / 2)] : 0;\n const belowMedianDeptCount = deptsWithStaff.filter((d) => d.avgSalary < medianDeptSalary).length;\n\n const deptPerformanceColumns: ResponsiveTableColumn<DeptPerformanceRow>[] = [\n {\n key: 'name',\n header: 'Department',\n priority: 'primary',\n cell: (dept) => <span className=\"font-medium\">{dept.name}</span>,\n },\n {\n key: 'headcount',\n header: 'Headcount',\n priority: 'secondary',\n cell: (dept) => <span className=\"md:block md:text-right\">{dept.headcount}</span>,\n },\n {\n key: 'avgScore',\n header: 'Avg Score',\n priority: 'primary',\n cell: (dept) => (\n <span className=\"md:block md:text-right\">\n <Badge variant={Number(dept.avgScore) >= 4.5 ? 'default' : 'secondary'}>\n {dept.avgScore}\n </Badge>\n </span>\n ),\n },\n {\n key: 'avgSalary',\n header: 'Avg Salary',\n priority: 'secondary',\n cell: (dept) => (\n <span className=\"md:block md:text-right\">${dept.avgSalary.toLocaleString()}</span>\n ),\n },\n ];\n\n const locationBreakdown = employees.reduce<Record<string, number>>((acc, e) => {\n acc[e.location] = (acc[e.location] || 0) + 1;\n return acc;\n }, {});\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Employee Analytics</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n Deep-dive into employee performance, engagement, and demographics\n </p>\n <PagePurpose className=\"mt-1\">\n See how individual employees and teams are performing over time — use these insights for\n reviews, recognition, and targeted coaching.\n </PagePurpose>\n </div>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-4\">\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Active Employees</CardTitle>\n <UserCheck className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{activeCount}</div>\n <p className=\"text-xs text-text-secondary\">{onLeaveCount} currently on leave</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Avg Performance</CardTitle>\n <Star className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{avgPerformance}/5.0</div>\n <p className=\"text-xs text-text-secondary\">Across {employees.length} employees</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Avg Compensation</CardTitle>\n <BarChart3 className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">${avgSalary.toLocaleString()}</div>\n <p className=\"text-xs text-text-secondary\">Across all departments</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Avg Tenure</CardTitle>\n <Clock className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">{avgTenure.toFixed(1)} yrs</div>\n <p className=\"text-xs text-text-secondary\">Median: {medianTenure.toFixed(1)} years</p>\n </CardContent>\n </GlassCard>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>Department Performance</CardTitle>\n <CardDescription>\n Average performance score and compensation by department\n </CardDescription>\n </CardHeader>\n <CardContent>\n <ResponsiveTable\n columns={deptPerformanceColumns}\n data={sortedDeptPerformance}\n rowKey={(dept) => dept.name}\n />\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle>Engagement Drivers</CardTitle>\n <CardDescription>Employee satisfaction by category</CardDescription>\n </CardHeader>\n <CardContent>\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"Engagement surveys coming soon\"\n description=\"Satisfaction and engagement drivers will appear here once the employee survey integration is connected — we don't show estimated scores.\"\n />\n </CardContent>\n </Card>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Clock className=\"h-4 w-4\" />\n Tenure Distribution\n </CardTitle>\n </CardHeader>\n <CardContent>\n {employees.length > 0 ? (\n <BarChart\n series={tenureSeries}\n colorful\n height={220}\n title=\"Employee tenure distribution\"\n />\n ) : (\n <p className=\"py-6 text-center text-sm text-text-secondary\">No employees yet.</p>\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <MapPin className=\"h-4 w-4\" />\n Location Distribution\n </CardTitle>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {Object.entries(locationBreakdown)\n .sort(([, a], [, b]) => b - a)\n .map(([loc, count]) => (\n <div key={loc} className=\"flex items-center justify-between py-1\">\n <span className=\"text-sm\">{loc}</span>\n <div className=\"flex items-center gap-2\">\n <span className=\"text-sm font-medium\">{count}</span>\n <Badge variant=\"outline\" className=\"text-[10px]\">\n {Math.round((count / Math.max(employees.length, 1)) * 100)}%\n </Badge>\n </div>\n </div>\n ))}\n </CardContent>\n </Card>\n </div>\n\n <Card>\n <CardHeader>\n <CardTitle className=\"flex items-center gap-2\">\n <Award className=\"h-4 w-4 text-status-warning-text\" />\n Key Insights\n </CardTitle>\n </CardHeader>\n <CardContent>\n <div className=\"grid gap-3 md:grid-cols-3\">\n <div className=\"rounded border p-3\">\n <p className=\"text-sm font-medium\">Highest Performance</p>\n <p className=\"text-xs text-text-secondary mt-1\">\n {topDept\n ? `${topDept.name} leads with an average score of ${topDept.avgScore}`\n : 'No department performance data yet'}\n </p>\n </div>\n <div className=\"rounded border p-3\">\n <p className=\"text-sm font-medium\">Retention Risk</p>\n <p className=\"text-xs text-text-secondary mt-1\">\n {flightRiskCount} employee{flightRiskCount === 1 ? '' : 's'} below a 3.5 performance\n score — a potential flight risk\n </p>\n </div>\n <div className=\"rounded border p-3\">\n <p className=\"text-sm font-medium\">Compensation Gap</p>\n <p className=\"text-xs text-text-secondary mt-1\">\n {belowMedianDeptCount} department{belowMedianDeptCount === 1 ? '' : 's'} below the\n median department salary\n </p>\n </div>\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AnalyticsFinancialAnalyticsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/AnalyticsFinancialAnalyticsPagePage.tsx"],"sourcesContent":["import {\n Badge,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Pagination,\n ResponsiveTable,\n type ResponsiveTableColumn,\n Separator,\n} from '@burdenoff/fe-libs/ui';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { DollarSign, FileText, Receipt, TrendingUp, Wallet } from 'lucide-react';\nimport { useMemo } from 'react';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { DonutChart, LineChart } from '../components/viz';\nimport type { LabeledValue, Segment } from '../components/viz';\n\ntype DeptCostRow = {\n name: string;\n salary: number;\n budget: number;\n headcount: number;\n};\n\nexport function AnalyticsFinancialAnalyticsPagePage() {\n const { employees, departments, payrollRecords, invoices, projects } = useCrewFoundryStore();\n\n const monthlyPayrollTrend = useMemo(() => {\n const byPeriod = payrollRecords.reduce<\n Record<string, { gross: number; net: number; deductions: number }>\n >((acc, record) => {\n const month = new Date(record.payDate).toLocaleString('en-US', { month: 'short' });\n const current = acc[month] || { gross: 0, net: 0, deductions: 0 };\n current.gross += record.grossPay;\n current.net += record.netPay;\n current.deductions += record.deductions;\n acc[month] = current;\n return acc;\n }, {});\n return Object.entries(byPeriod)\n .map(([month, value]) => ({ month, ...value }))\n .slice(-6);\n }, [payrollRecords]);\n\n // Real spend lines only (no invented budget coefficients).\n const spendByCategory = useMemo(() => {\n const items = [\n {\n category: 'Gross Payroll',\n amount: payrollRecords.reduce((sum, record) => sum + record.grossPay, 0),\n icon: Wallet,\n },\n {\n category: 'Deductions',\n amount: payrollRecords.reduce((sum, record) => sum + record.deductions, 0),\n icon: Receipt,\n },\n {\n category: 'Project Spend',\n amount: projects.reduce((sum, project) => sum + project.spent, 0),\n icon: TrendingUp,\n },\n {\n category: 'Collected Invoices',\n amount: invoices\n .filter((invoice) => invoice.status === 'paid')\n .reduce((sum, invoice) => sum + invoice.amount, 0),\n icon: FileText,\n },\n ];\n const total = items.reduce((sum, item) => sum + item.amount, 0) || 1;\n return items.map((item) => ({ ...item, percentage: Math.round((item.amount / total) * 100) }));\n }, [invoices, payrollRecords, projects]);\n\n const totalPayroll = employees.reduce((sum, e) => sum + e.salary, 0);\n const monthlyPayroll = Math.round(totalPayroll / 12);\n const totalInvoiced = invoices.reduce((sum, inv) => sum + inv.amount, 0);\n const paidInvoices = invoices\n .filter((i) => i.status === 'paid')\n .reduce((s, i) => s + i.amount, 0);\n const totalProjectBudget = projects.reduce((s, p) => s + p.budget, 0);\n const totalProjectSpent = projects.reduce((s, p) => s + p.spent, 0);\n\n const grossSeries: LabeledValue[] = monthlyPayrollTrend.map((m) => ({\n label: m.month,\n value: m.gross,\n }));\n const netCompare = monthlyPayrollTrend.map((m) => m.net);\n const spendSegments: Segment[] = spendByCategory\n .filter((category) => category.amount > 0)\n .map((category) => ({ label: category.category, value: category.amount }));\n const projectUtilization =\n totalProjectBudget > 0 ? Math.round((totalProjectSpent / totalProjectBudget) * 100) : 0;\n\n const deptCost: DeptCostRow[] = departments.map((d) => {\n const deptEmps = employees.filter((e) => e.department === d.name);\n const totalSal = deptEmps.reduce((s, e) => s + e.salary, 0);\n return { name: d.name, salary: totalSal, budget: d.budget, headcount: deptEmps.length };\n });\n const sortedDeptCost = [...deptCost].sort((a, b) => b.salary - a.salary);\n const totalDeptBudget = departments.reduce((s, d) => s + d.budget, 0);\n\n // Outstanding invoices = anything not fully paid, matching the card title.\n const outstandingInvoices = [...invoices]\n .filter((invoice) => invoice.status !== 'paid')\n .sort((a, b) => a.dueDate.localeCompare(b.dueDate));\n const {\n page: invoicePage,\n pageSize: invoicePageSize,\n offset: invoiceOffset,\n setPage: setInvoicePage,\n } = usePagination({ initialPageSize: 6 });\n const invoicePageItems = outstandingInvoices.slice(\n invoiceOffset,\n invoiceOffset + invoicePageSize\n );\n\n const deptCostColumns: ResponsiveTableColumn<DeptCostRow>[] = [\n {\n key: 'name',\n header: 'Department',\n priority: 'primary',\n cell: (d) => <span className=\"font-medium\">{d.name}</span>,\n },\n {\n key: 'headcount',\n header: 'Headcount',\n priority: 'secondary',\n cell: (d) => <span className=\"md:block md:text-right\">{d.headcount}</span>,\n },\n {\n key: 'salary',\n header: 'Total Salary',\n priority: 'secondary',\n cell: (d) => <span className=\"md:block md:text-right\">${(d.salary / 1000).toFixed(0)}K</span>,\n },\n {\n key: 'budget',\n header: 'Dept Budget',\n priority: 'secondary',\n cell: (d) => (\n <span className=\"md:block md:text-right\">${(d.budget / 1000000).toFixed(1)}M</span>\n ),\n },\n {\n key: 'ratio',\n header: 'Salary/Budget %',\n priority: 'primary',\n cell: (d) => {\n const ratio = d.budget > 0 ? Math.round((d.salary / d.budget) * 100) : 0;\n return (\n <span className=\"md:block md:text-right\">\n <Badge variant={ratio > 50 ? 'destructive' : 'default'} className=\"text-[10px]\">\n {ratio}%\n </Badge>\n </span>\n );\n },\n },\n ];\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Financial Analytics</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n Workforce financial metrics and cost analysis\n </p>\n <PagePurpose className=\"mt-1\">\n Understand the money side of your workforce — payroll, project budgets, and spend trends\n — so you can plan headcount and control costs with confidence.\n </PagePurpose>\n </div>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-4\">\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Monthly Payroll</CardTitle>\n <DollarSign className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(monthlyPayroll / 1000).toFixed(0)}K\n </div>\n <p className=\"text-xs text-text-secondary\">Annual salary ÷ 12 months</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Annual Salary Cost</CardTitle>\n <Wallet className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(totalPayroll / 1000000).toFixed(1)}M\n </div>\n <p className=\"text-xs text-text-secondary\">{employees.length} employees</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Total Invoiced</CardTitle>\n <FileText className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(totalInvoiced / 1000).toFixed(1)}K\n </div>\n <p className=\"text-xs text-text-secondary\">\n ${(paidInvoices / 1000).toFixed(1)}K collected\n </p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Project Budget</CardTitle>\n <TrendingUp className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(totalProjectBudget / 1000000).toFixed(1)}M\n </div>\n <p className=\"text-xs text-text-secondary\">{projectUtilization}% utilized</p>\n </CardContent>\n </GlassCard>\n </div>\n\n <Card>\n <CardHeader>\n <CardTitle>Payroll Trend</CardTitle>\n <CardDescription>Monthly gross payroll over the last 6 months</CardDescription>\n </CardHeader>\n <CardContent>\n {grossSeries.length > 0 ? (\n <LineChart\n series={grossSeries}\n compare={netCompare}\n seriesLabel=\"Gross pay\"\n compareLabel=\"Net pay\"\n height={220}\n valueFormatter={(value) => `$${(value / 1000).toFixed(0)}K`}\n />\n ) : (\n <p className=\"py-6 text-center text-sm text-text-secondary\">\n No payroll records yet.\n </p>\n )}\n </CardContent>\n </Card>\n\n <div className=\"grid gap-4 md:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>Spend by Category</CardTitle>\n <CardDescription>Share of tracked workforce spend</CardDescription>\n </CardHeader>\n <CardContent>\n {spendSegments.length > 0 ? (\n <DonutChart\n segments={spendSegments}\n centerLabel={`$${(spendSegments.reduce((sum, s) => sum + s.value, 0) / 1000).toFixed(0)}K`}\n centerCaption=\"Total spend\"\n valueFormatter={(value) => `$${(value / 1000).toFixed(0)}K`}\n />\n ) : (\n <p className=\"py-6 text-center text-sm text-text-secondary\">\n No tracked spend yet.\n </p>\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle>Outstanding Invoices</CardTitle>\n <CardDescription>Unpaid invoices requiring attention</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {outstandingInvoices.length === 0 && (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No outstanding invoices\"\n description=\"Every invoice has been paid. New unpaid invoices will appear here.\"\n />\n )}\n {invoicePageItems.map((inv) => (\n <div key={inv.id} className=\"flex items-center justify-between rounded border p-3\">\n <div>\n <p className=\"text-sm font-medium\">{inv.number}</p>\n <p className=\"text-xs text-text-secondary\">{inv.clientName}</p>\n </div>\n <div className=\"text-right\">\n <p className=\"text-sm font-medium\">${inv.amount.toLocaleString()}</p>\n <Badge\n variant={inv.status === 'overdue' ? 'destructive' : 'secondary'}\n className=\"text-[10px]\"\n >\n {formatEnumLabel(inv.status)}\n </Badge>\n </div>\n </div>\n ))}\n {outstandingInvoices.length > invoicePageSize && (\n <Pagination\n page={invoicePage}\n pageSize={invoicePageSize}\n totalItems={outstandingInvoices.length}\n onPageChange={setInvoicePage}\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n <Card>\n <CardHeader>\n <CardTitle>Department Cost Analysis</CardTitle>\n <CardDescription>Total salary cost and budget allocation by department</CardDescription>\n </CardHeader>\n <CardContent>\n <ResponsiveTable columns={deptCostColumns} data={sortedDeptCost} rowKey={(d) => d.name} />\n <Separator className=\"my-3\" />\n <div className=\"flex flex-wrap items-center justify-between gap-2 text-sm font-bold\">\n <span>Total</span>\n <span>{employees.length} employees</span>\n <span>${(totalPayroll / 1000000).toFixed(1)}M salary</span>\n <span>${(totalDeptBudget / 1000000).toFixed(1)}M budget</span>\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AnalyticsFinancialAnalyticsPagePage.js","names":[],"sources":["../../../src/crewfoundry/pages/AnalyticsFinancialAnalyticsPagePage.tsx"],"sourcesContent":["import {\n Badge,\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n GlassCard,\n GradientText,\n IllustratedEmptyState,\n PagePurpose,\n Pagination,\n ResponsiveTable,\n type ResponsiveTableColumn,\n Separator,\n} from '@burdenoff/fe-libs/ui';\nimport { usePagination } from '@burdenoff/fe-libs/shared/hooks';\nimport { DollarSign, FileText, Receipt, TrendingUp, Wallet } from 'lucide-react';\nimport { useMemo } from 'react';\nimport { useCrewFoundryStore } from '../store/crewfoundryStore';\nimport { formatEnumLabel } from '../utils/formatEnumLabel';\nimport { DonutChart, LineChart } from '../components/viz';\nimport type { LabeledValue, Segment } from '../components/viz';\n\ntype DeptCostRow = {\n name: string;\n salary: number;\n budget: number;\n headcount: number;\n};\n\nexport function AnalyticsFinancialAnalyticsPagePage() {\n const { employees, departments, payrollRecords, invoices, projects } = useCrewFoundryStore();\n\n const monthlyPayrollTrend = useMemo(() => {\n const byPeriod = payrollRecords.reduce<\n Record<string, { gross: number; net: number; deductions: number }>\n >((acc, record) => {\n const month = new Date(record.payDate).toLocaleString('en-US', { month: 'short' });\n const current = acc[month] || { gross: 0, net: 0, deductions: 0 };\n current.gross += record.grossPay;\n current.net += record.netPay;\n current.deductions += record.deductions;\n acc[month] = current;\n return acc;\n }, {});\n return Object.entries(byPeriod)\n .map(([month, value]) => ({ month, ...value }))\n .slice(-6);\n }, [payrollRecords]);\n\n // Real spend lines only (no invented budget coefficients).\n const spendByCategory = useMemo(() => {\n const items = [\n {\n category: 'Gross Payroll',\n amount: payrollRecords.reduce((sum, record) => sum + record.grossPay, 0),\n icon: Wallet,\n },\n {\n category: 'Deductions',\n amount: payrollRecords.reduce((sum, record) => sum + record.deductions, 0),\n icon: Receipt,\n },\n {\n category: 'Project Spend',\n amount: projects.reduce((sum, project) => sum + project.spent, 0),\n icon: TrendingUp,\n },\n {\n category: 'Collected Invoices',\n amount: invoices\n .filter((invoice) => invoice.status === 'paid')\n .reduce((sum, invoice) => sum + invoice.amount, 0),\n icon: FileText,\n },\n ];\n const total = items.reduce((sum, item) => sum + item.amount, 0) || 1;\n return items.map((item) => ({ ...item, percentage: Math.round((item.amount / total) * 100) }));\n }, [invoices, payrollRecords, projects]);\n\n const totalPayroll = employees.reduce((sum, e) => sum + e.salary, 0);\n const monthlyPayroll = Math.round(totalPayroll / 12);\n const totalInvoiced = invoices.reduce((sum, inv) => sum + inv.amount, 0);\n const paidInvoices = invoices\n .filter((i) => i.status === 'paid')\n .reduce((s, i) => s + i.amount, 0);\n const totalProjectBudget = projects.reduce((s, p) => s + p.budget, 0);\n const totalProjectSpent = projects.reduce((s, p) => s + p.spent, 0);\n\n const grossSeries: LabeledValue[] = monthlyPayrollTrend.map((m) => ({\n label: m.month,\n value: m.gross,\n }));\n const netCompare = monthlyPayrollTrend.map((m) => m.net);\n const spendSegments: Segment[] = spendByCategory\n .filter((category) => category.amount > 0)\n .map((category) => ({ label: category.category, value: category.amount }));\n const projectUtilization =\n totalProjectBudget > 0 ? Math.round((totalProjectSpent / totalProjectBudget) * 100) : 0;\n\n const deptCost: DeptCostRow[] = departments.map((d) => {\n const deptEmps = employees.filter((e) => e.department === d.name);\n const totalSal = deptEmps.reduce((s, e) => s + e.salary, 0);\n return { name: d.name, salary: totalSal, budget: d.budget, headcount: deptEmps.length };\n });\n const sortedDeptCost = [...deptCost].sort((a, b) => b.salary - a.salary);\n const totalDeptBudget = departments.reduce((s, d) => s + d.budget, 0);\n\n // Outstanding invoices = anything not fully paid, matching the card title.\n const outstandingInvoices = [...invoices]\n .filter((invoice) => invoice.status !== 'paid')\n .sort((a, b) => a.dueDate.localeCompare(b.dueDate));\n const {\n page: invoicePage,\n pageSize: invoicePageSize,\n offset: invoiceOffset,\n setPage: setInvoicePage,\n } = usePagination({ initialPageSize: 6 });\n const invoicePageItems = outstandingInvoices.slice(\n invoiceOffset,\n invoiceOffset + invoicePageSize\n );\n\n const deptCostColumns: ResponsiveTableColumn<DeptCostRow>[] = [\n {\n key: 'name',\n header: 'Department',\n priority: 'primary',\n cell: (d) => <span className=\"font-medium\">{d.name}</span>,\n },\n {\n key: 'headcount',\n header: 'Headcount',\n priority: 'secondary',\n cell: (d) => <span className=\"md:block md:text-right\">{d.headcount}</span>,\n },\n {\n key: 'salary',\n header: 'Total Salary',\n priority: 'secondary',\n cell: (d) => <span className=\"md:block md:text-right\">${(d.salary / 1000).toFixed(0)}K</span>,\n },\n {\n key: 'budget',\n header: 'Dept Budget',\n priority: 'secondary',\n cell: (d) => (\n <span className=\"md:block md:text-right\">${(d.budget / 1000000).toFixed(1)}M</span>\n ),\n },\n {\n key: 'ratio',\n header: 'Salary/Budget %',\n priority: 'primary',\n cell: (d) => {\n const ratio = d.budget > 0 ? Math.round((d.salary / d.budget) * 100) : 0;\n return (\n <span className=\"md:block md:text-right\">\n <Badge variant={ratio > 50 ? 'destructive' : 'default'} className=\"text-[10px]\">\n {ratio}%\n </Badge>\n </span>\n );\n },\n },\n ];\n\n return (\n <div className=\"space-y-6 p-6\">\n <div className=\"flex items-center justify-between\">\n <div>\n <h1 className=\"text-2xl font-semibold\">\n <GradientText>Financial Analytics</GradientText>\n </h1>\n <p className=\"text-sm text-text-secondary\">\n Workforce financial metrics and cost analysis\n </p>\n <PagePurpose className=\"mt-1\">\n Understand the money side of your workforce — payroll, project budgets, and spend trends\n — so you can plan headcount and control costs with confidence.\n </PagePurpose>\n </div>\n </div>\n\n <div className=\"grid gap-4 md:grid-cols-2 lg:grid-cols-4\">\n <GlassCard treatment=\"glass\" glow className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Monthly Payroll</CardTitle>\n <DollarSign className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(monthlyPayroll / 1000).toFixed(0)}K\n </div>\n <p className=\"text-xs text-text-secondary\">Annual salary ÷ 12 months</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Annual Salary Cost</CardTitle>\n <Wallet className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(totalPayroll / 1000000).toFixed(1)}M\n </div>\n <p className=\"text-xs text-text-secondary\">{employees.length} employees</p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Total Invoiced</CardTitle>\n <FileText className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(totalInvoiced / 1000).toFixed(1)}K\n </div>\n <p className=\"text-xs text-text-secondary\">\n ${(paidInvoices / 1000).toFixed(1)}K collected\n </p>\n </CardContent>\n </GlassCard>\n <GlassCard treatment=\"metric\" className=\"border-border-seam\">\n <CardHeader className=\"flex flex-row items-center justify-between space-y-0 pb-2\">\n <CardTitle className=\"text-sm font-medium\">Project Budget</CardTitle>\n <TrendingUp className=\"h-4 w-4 text-text-secondary\" />\n </CardHeader>\n <CardContent>\n <div className=\"text-2xl font-bold tabular-nums\">\n ${(totalProjectBudget / 1000000).toFixed(1)}M\n </div>\n <p className=\"text-xs text-text-secondary\">{projectUtilization}% utilized</p>\n </CardContent>\n </GlassCard>\n </div>\n\n <Card>\n <CardHeader>\n <CardTitle>Payroll Trend</CardTitle>\n <CardDescription>Monthly gross payroll over the last 6 months</CardDescription>\n </CardHeader>\n <CardContent>\n {grossSeries.length > 0 ? (\n <LineChart\n series={grossSeries}\n compare={netCompare}\n seriesLabel=\"Gross pay\"\n compareLabel=\"Net pay\"\n height={220}\n valueFormatter={(value) => `$${(value / 1000).toFixed(0)}K`}\n />\n ) : (\n <p className=\"py-6 text-center text-sm text-text-secondary\">No payroll records yet.</p>\n )}\n </CardContent>\n </Card>\n\n <div className=\"grid gap-4 md:grid-cols-2\">\n <Card>\n <CardHeader>\n <CardTitle>Spend by Category</CardTitle>\n <CardDescription>Share of tracked workforce spend</CardDescription>\n </CardHeader>\n <CardContent>\n {spendSegments.length > 0 ? (\n <DonutChart\n segments={spendSegments}\n centerLabel={`$${(spendSegments.reduce((sum, s) => sum + s.value, 0) / 1000).toFixed(0)}K`}\n centerCaption=\"Total spend\"\n valueFormatter={(value) => `$${(value / 1000).toFixed(0)}K`}\n />\n ) : (\n <p className=\"py-6 text-center text-sm text-text-secondary\">No tracked spend yet.</p>\n )}\n </CardContent>\n </Card>\n\n <Card>\n <CardHeader>\n <CardTitle>Outstanding Invoices</CardTitle>\n <CardDescription>Unpaid invoices requiring attention</CardDescription>\n </CardHeader>\n <CardContent className=\"space-y-3\">\n {outstandingInvoices.length === 0 && (\n <IllustratedEmptyState\n illustration=\"empty-data\"\n title=\"No outstanding invoices\"\n description=\"Every invoice has been paid. New unpaid invoices will appear here.\"\n />\n )}\n {invoicePageItems.map((inv) => (\n <div key={inv.id} className=\"flex items-center justify-between rounded border p-3\">\n <div>\n <p className=\"text-sm font-medium\">{inv.number}</p>\n <p className=\"text-xs text-text-secondary\">{inv.clientName}</p>\n </div>\n <div className=\"text-right\">\n <p className=\"text-sm font-medium\">${inv.amount.toLocaleString()}</p>\n <Badge\n variant={inv.status === 'overdue' ? 'destructive' : 'secondary'}\n className=\"text-[10px]\"\n >\n {formatEnumLabel(inv.status)}\n </Badge>\n </div>\n </div>\n ))}\n {outstandingInvoices.length > invoicePageSize && (\n <Pagination\n page={invoicePage}\n pageSize={invoicePageSize}\n totalItems={outstandingInvoices.length}\n onPageChange={setInvoicePage}\n />\n )}\n </CardContent>\n </Card>\n </div>\n\n <Card>\n <CardHeader>\n <CardTitle>Department Cost Analysis</CardTitle>\n <CardDescription>Total salary cost and budget allocation by department</CardDescription>\n </CardHeader>\n <CardContent>\n <ResponsiveTable columns={deptCostColumns} data={sortedDeptCost} rowKey={(d) => d.name} />\n <Separator className=\"my-3\" />\n <div className=\"flex flex-wrap items-center justify-between gap-2 text-sm font-bold\">\n <span>Total</span>\n <span>{employees.length} employees</span>\n <span>${(totalPayroll / 1000000).toFixed(1)}M salary</span>\n <span>${(totalDeptBudget / 1000000).toFixed(1)}M budget</span>\n </div>\n </CardContent>\n </Card>\n </div>\n );\n}\n"],"mappings":";;;;;;;;;;;AA+BA,SAAgB,IAAsC;CACpD,IAAM,EAAE,cAAW,gBAAa,mBAAgB,aAAU,gBAAa,GAAqB,EAEtF,IAAsB,QAAc;EACxC,IAAM,IAAW,EAAe,QAE7B,GAAK,MAAW;GACjB,IAAM,IAAQ,IAAI,KAAK,EAAO,QAAQ,CAAC,eAAe,SAAS,EAAE,OAAO,SAAS,CAAC,EAC5E,IAAU,EAAI,MAAU;IAAE,OAAO;IAAG,KAAK;IAAG,YAAY;IAAG;AAKjE,UAJA,EAAQ,SAAS,EAAO,UACxB,EAAQ,OAAO,EAAO,QACtB,EAAQ,cAAc,EAAO,YAC7B,EAAI,KAAS,GACN;KACN,EAAE,CAAC;AACN,SAAO,OAAO,QAAQ,EAAS,CAC5B,KAAK,CAAC,GAAO,QAAY;GAAE;GAAO,GAAG;GAAO,EAAE,CAC9C,MAAM,GAAG;IACX,CAAC,EAAe,CAAC,EAGd,IAAkB,QAAc;EACpC,IAAM,IAAQ;GACZ;IACE,UAAU;IACV,QAAQ,EAAe,QAAQ,GAAK,MAAW,IAAM,EAAO,UAAU,EAAE;IACxE,MAAM;IACP;GACD;IACE,UAAU;IACV,QAAQ,EAAe,QAAQ,GAAK,MAAW,IAAM,EAAO,YAAY,EAAE;IAC1E,MAAM;IACP;GACD;IACE,UAAU;IACV,QAAQ,EAAS,QAAQ,GAAK,MAAY,IAAM,EAAQ,OAAO,EAAE;IACjE,MAAM;IACP;GACD;IACE,UAAU;IACV,QAAQ,EACL,QAAQ,MAAY,EAAQ,WAAW,OAAO,CAC9C,QAAQ,GAAK,MAAY,IAAM,EAAQ,QAAQ,EAAE;IACpD,MAAM;IACP;GACF,EACK,IAAQ,EAAM,QAAQ,GAAK,MAAS,IAAM,EAAK,QAAQ,EAAE,IAAI;AACnE,SAAO,EAAM,KAAK,OAAU;GAAE,GAAG;GAAM,YAAY,KAAK,MAAO,EAAK,SAAS,IAAS,IAAI;GAAE,EAAE;IAC7F;EAAC;EAAU;EAAgB;EAAS,CAAC,EAElC,IAAe,EAAU,QAAQ,GAAK,MAAM,IAAM,EAAE,QAAQ,EAAE,EAC9D,IAAiB,KAAK,MAAM,IAAe,GAAG,EAC9C,IAAgB,EAAS,QAAQ,GAAK,MAAQ,IAAM,EAAI,QAAQ,EAAE,EAClE,IAAe,EAClB,QAAQ,MAAM,EAAE,WAAW,OAAO,CAClC,QAAQ,GAAG,MAAM,IAAI,EAAE,QAAQ,EAAE,EAC9B,IAAqB,EAAS,QAAQ,GAAG,MAAM,IAAI,EAAE,QAAQ,EAAE,EAC/D,IAAoB,EAAS,QAAQ,GAAG,MAAM,IAAI,EAAE,OAAO,EAAE,EAE7D,IAA8B,EAAoB,KAAK,OAAO;EAClE,OAAO,EAAE;EACT,OAAO,EAAE;EACV,EAAE,EACG,IAAa,EAAoB,KAAK,MAAM,EAAE,IAAI,EAClD,IAA2B,EAC9B,QAAQ,MAAa,EAAS,SAAS,EAAE,CACzC,KAAK,OAAc;EAAE,OAAO,EAAS;EAAU,OAAO,EAAS;EAAQ,EAAE,EACtE,IACJ,IAAqB,IAAI,KAAK,MAAO,IAAoB,IAAsB,IAAI,GAAG,GAOlF,IAAiB,CAAC,GALQ,EAAY,KAAK,MAAM;EACrD,IAAM,IAAW,EAAU,QAAQ,MAAM,EAAE,eAAe,EAAE,KAAK,EAC3D,IAAW,EAAS,QAAQ,GAAG,MAAM,IAAI,EAAE,QAAQ,EAAE;AAC3D,SAAO;GAAE,MAAM,EAAE;GAAM,QAAQ;GAAU,QAAQ,EAAE;GAAQ,WAAW,EAAS;GAAQ;GACvF,CACkC,CAAC,MAAM,GAAG,MAAM,EAAE,SAAS,EAAE,OAAO,EAClE,IAAkB,EAAY,QAAQ,GAAG,MAAM,IAAI,EAAE,QAAQ,EAAE,EAG/D,IAAsB,CAAC,GAAG,EAAS,CACtC,QAAQ,MAAY,EAAQ,WAAW,OAAO,CAC9C,MAAM,GAAG,MAAM,EAAE,QAAQ,cAAc,EAAE,QAAQ,CAAC,EAC/C,EACJ,MAAM,GACN,UAAU,GACV,QAAQ,GACR,SAAS,MACP,EAAc,EAAE,iBAAiB,GAAG,CAAC,EACnC,IAAmB,EAAoB,MAC3C,GACA,IAAgB,EACjB;AA8CD,QACE,kBAAC,OAAD;EAAK,WAAU;YAAf;GACE,kBAAC,OAAD;IAAK,WAAU;cACb,kBAAC,OAAD,EAAA,UAAA;KACE,kBAAC,MAAD;MAAI,WAAU;gBACZ,kBAAC,GAAD,EAAA,UAAc,uBAAkC,CAAA;MAC7C,CAAA;KACL,kBAAC,KAAD;MAAG,WAAU;gBAA8B;MAEvC,CAAA;KACJ,kBAAC,GAAD;MAAa,WAAU;gBAAO;MAGhB,CAAA;KACV,EAAA,CAAA;IACF,CAAA;GAEN,kBAAC,OAAD;IAAK,WAAU;cAAf;KACE,kBAAC,GAAD;MAAW,WAAU;MAAQ,MAAA;MAAK,WAAU;gBAA5C,CACE,kBAAC,GAAD;OAAY,WAAU;iBAAtB,CACE,kBAAC,GAAD;QAAW,WAAU;kBAAsB;QAA2B,CAAA,EACtE,kBAAC,GAAD,EAAY,WAAU,+BAAgC,CAAA,CAC3C;UACb,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QAAiD;SAC5C,IAAiB,KAAM,QAAQ,EAAE;QAAC;QACjC;UACN,kBAAC,KAAD;OAAG,WAAU;iBAA8B;OAA6B,CAAA,CAC5D,EAAA,CAAA,CACJ;;KACZ,kBAAC,GAAD;MAAW,WAAU;MAAS,WAAU;gBAAxC,CACE,kBAAC,GAAD;OAAY,WAAU;iBAAtB,CACE,kBAAC,GAAD;QAAW,WAAU;kBAAsB;QAA8B,CAAA,EACzE,kBAAC,GAAD,EAAQ,WAAU,+BAAgC,CAAA,CACvC;UACb,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QAAiD;SAC5C,IAAe,KAAS,QAAQ,EAAE;QAAC;QAClC;UACN,kBAAC,KAAD;OAAG,WAAU;iBAAb,CAA4C,EAAU,QAAO,aAAc;SAC/D,EAAA,CAAA,CACJ;;KACZ,kBAAC,GAAD;MAAW,WAAU;MAAS,WAAU;gBAAxC,CACE,kBAAC,GAAD;OAAY,WAAU;iBAAtB,CACE,kBAAC,GAAD;QAAW,WAAU;kBAAsB;QAA0B,CAAA,EACrE,kBAAC,GAAD,EAAU,WAAU,+BAAgC,CAAA,CACzC;UACb,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QAAiD;SAC5C,IAAgB,KAAM,QAAQ,EAAE;QAAC;QAChC;UACN,kBAAC,KAAD;OAAG,WAAU;iBAAb;QAA2C;SACtC,IAAe,KAAM,QAAQ,EAAE;QAAC;QACjC;SACQ,EAAA,CAAA,CACJ;;KACZ,kBAAC,GAAD;MAAW,WAAU;MAAS,WAAU;gBAAxC,CACE,kBAAC,GAAD;OAAY,WAAU;iBAAtB,CACE,kBAAC,GAAD;QAAW,WAAU;kBAAsB;QAA0B,CAAA,EACrE,kBAAC,GAAD,EAAY,WAAU,+BAAgC,CAAA,CAC3C;UACb,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,OAAD;OAAK,WAAU;iBAAf;QAAiD;SAC5C,IAAqB,KAAS,QAAQ,EAAE;QAAC;QACxC;UACN,kBAAC,KAAD;OAAG,WAAU;iBAAb,CAA4C,GAAmB,aAAc;SACjE,EAAA,CAAA,CACJ;;KACR;;GAEN,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAW,iBAAyB,CAAA,EACpC,kBAAC,GAAD,EAAA,UAAiB,gDAA8D,CAAA,CACpE,EAAA,CAAA,EACb,kBAAC,GAAD,EAAA,UACG,EAAY,SAAS,IACpB,kBAAC,GAAD;IACE,QAAQ;IACR,SAAS;IACT,aAAY;IACZ,cAAa;IACb,QAAQ;IACR,iBAAiB,MAAU,KAAK,IAAQ,KAAM,QAAQ,EAAE,CAAC;IACzD,CAAA,GAEF,kBAAC,KAAD;IAAG,WAAU;cAA+C;IAA2B,CAAA,EAE7E,CAAA,CACT,EAAA,CAAA;GAEP,kBAAC,OAAD;IAAK,WAAU;cAAf,CACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAW,qBAA6B,CAAA,EACxC,kBAAC,GAAD,EAAA,UAAiB,oCAAkD,CAAA,CACxD,EAAA,CAAA,EACb,kBAAC,GAAD,EAAA,UACG,EAAc,SAAS,IACtB,kBAAC,GAAD;KACE,UAAU;KACV,aAAa,KAAK,EAAc,QAAQ,GAAK,MAAM,IAAM,EAAE,OAAO,EAAE,GAAG,KAAM,QAAQ,EAAE,CAAC;KACxF,eAAc;KACd,iBAAiB,MAAU,KAAK,IAAQ,KAAM,QAAQ,EAAE,CAAC;KACzD,CAAA,GAEF,kBAAC,KAAD;KAAG,WAAU;eAA+C;KAAyB,CAAA,EAE3E,CAAA,CACT,EAAA,CAAA,EAEP,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAW,wBAAgC,CAAA,EAC3C,kBAAC,GAAD,EAAA,UAAiB,uCAAqD,CAAA,CAC3D,EAAA,CAAA,EACb,kBAAC,GAAD;KAAa,WAAU;eAAvB;MACG,EAAoB,WAAW,KAC9B,kBAAC,GAAD;OACE,cAAa;OACb,OAAM;OACN,aAAY;OACZ,CAAA;MAEH,EAAiB,KAAK,MACrB,kBAAC,OAAD;OAAkB,WAAU;iBAA5B,CACE,kBAAC,OAAD,EAAA,UAAA,CACE,kBAAC,KAAD;QAAG,WAAU;kBAAuB,EAAI;QAAW,CAAA,EACnD,kBAAC,KAAD;QAAG,WAAU;kBAA+B,EAAI;QAAe,CAAA,CAC3D,EAAA,CAAA,EACN,kBAAC,OAAD;QAAK,WAAU;kBAAf,CACE,kBAAC,KAAD;SAAG,WAAU;mBAAb,CAAmC,KAAE,EAAI,OAAO,gBAAgB,CAAK;YACrE,kBAAC,GAAD;SACE,SAAS,EAAI,WAAW,YAAY,gBAAgB;SACpD,WAAU;mBAET,EAAgB,EAAI,OAAO;SACtB,CAAA,CACJ;UACF;SAdI,EAAI,GAcR,CACN;MACD,EAAoB,SAAS,KAC5B,kBAAC,GAAD;OACE,MAAM;OACN,UAAU;OACV,YAAY,EAAoB;OAChC,cAAc;OACd,CAAA;MAEQ;OACT,EAAA,CAAA,CACH;;GAEN,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAA,CACE,kBAAC,GAAD,EAAA,UAAW,4BAAoC,CAAA,EAC/C,kBAAC,GAAD,EAAA,UAAiB,yDAAuE,CAAA,CAC7E,EAAA,CAAA,EACb,kBAAC,GAAD,EAAA,UAAA;IACE,kBAAC,GAAD;KAAiB,SA3MqC;MAC5D;OACE,KAAK;OACL,QAAQ;OACR,UAAU;OACV,OAAO,MAAM,kBAAC,QAAD;QAAM,WAAU;kBAAe,EAAE;QAAY,CAAA;OAC3D;MACD;OACE,KAAK;OACL,QAAQ;OACR,UAAU;OACV,OAAO,MAAM,kBAAC,QAAD;QAAM,WAAU;kBAA0B,EAAE;QAAiB,CAAA;OAC3E;MACD;OACE,KAAK;OACL,QAAQ;OACR,UAAU;OACV,OAAO,MAAM,kBAAC,QAAD;QAAM,WAAU;kBAAhB;SAAyC;UAAG,EAAE,SAAS,KAAM,QAAQ,EAAE;SAAC;SAAQ;;OAC9F;MACD;OACE,KAAK;OACL,QAAQ;OACR,UAAU;OACV,OAAO,MACL,kBAAC,QAAD;QAAM,WAAU;kBAAhB;SAAyC;UAAG,EAAE,SAAS,KAAS,QAAQ,EAAE;SAAC;SAAQ;;OAEtF;MACD;OACE,KAAK;OACL,QAAQ;OACR,UAAU;OACV,OAAO,MAAM;QACX,IAAM,IAAQ,EAAE,SAAS,IAAI,KAAK,MAAO,EAAE,SAAS,EAAE,SAAU,IAAI,GAAG;AACvE,eACE,kBAAC,QAAD;SAAM,WAAU;mBACd,kBAAC,GAAD;UAAO,SAAS,IAAQ,KAAK,gBAAgB;UAAW,WAAU;oBAAlE,CACG,GAAM,IACD;;SACH,CAAA;;OAGZ;MACF;KAiKkD,MAAM;KAAgB,SAAS,MAAM,EAAE;KAAQ,CAAA;IAC1F,kBAAC,GAAD,EAAW,WAAU,QAAS,CAAA;IAC9B,kBAAC,OAAD;KAAK,WAAU;eAAf;MACE,kBAAC,QAAD,EAAA,UAAM,SAAY,CAAA;MAClB,kBAAC,QAAD,EAAA,UAAA,CAAO,EAAU,QAAO,aAAiB,EAAA,CAAA;MACzC,kBAAC,QAAD,EAAA,UAAA;OAAM;QAAG,IAAe,KAAS,QAAQ,EAAE;OAAC;OAAe,EAAA,CAAA;MAC3D,kBAAC,QAAD,EAAA,UAAA;OAAM;QAAG,IAAkB,KAAS,QAAQ,EAAE;OAAC;OAAe,EAAA,CAAA;MAC1D;;IACM,EAAA,CAAA,CACT,EAAA,CAAA;GACH"}
|