@exxatdesignux/ui 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +701 -6
  2. package/README.md +138 -0
  3. package/bin/init.mjs +134 -31
  4. package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
  5. package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
  6. package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
  7. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
  8. package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
  9. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
  10. package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
  11. package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
  12. package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
  13. package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
  14. package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
  15. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
  16. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
  17. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
  18. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
  19. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
  20. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
  21. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
  22. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
  23. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
  24. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
  25. package/consumer-extras/handbook/HANDBOOK.md +2 -0
  26. package/consumer-extras/handbook/glossary.md +2 -1
  27. package/consumer-extras/handbook/reference-implementations.md +31 -4
  28. package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
  29. package/consumer-extras/patterns/data-views-pattern.md +18 -16
  30. package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
  31. package/dist/components/data-table/index.js +2 -2
  32. package/dist/components/data-table/index.js.map +1 -1
  33. package/dist/components/data-table/pagination.js +3 -3
  34. package/dist/components/data-table/pagination.js.map +1 -1
  35. package/dist/components/data-table/use-table-state.d.ts +1 -1
  36. package/dist/components/data-table/use-table-state.js.map +1 -1
  37. package/dist/components/data-views/data-row-list.js.map +1 -1
  38. package/dist/components/data-views/finder-panel-view.d.ts +1 -1
  39. package/dist/components/data-views/finder-panel-view.js.map +1 -1
  40. package/dist/components/data-views/hub-table.d.ts +9 -3
  41. package/dist/components/data-views/hub-table.js +262 -40
  42. package/dist/components/data-views/hub-table.js.map +1 -1
  43. package/dist/components/data-views/index.js +262 -40
  44. package/dist/components/data-views/index.js.map +1 -1
  45. package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
  46. package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
  47. package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
  48. package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
  49. package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
  50. package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
  51. package/dist/components/data-views/os-folder-glyph.js.map +1 -1
  52. package/dist/components/ui/avatar.d.ts +1 -1
  53. package/dist/components/ui/key-metrics.js.map +1 -1
  54. package/dist/index.js +136 -39
  55. package/dist/index.js.map +1 -1
  56. package/package.json +3 -2
  57. package/src/components/data-table/index.tsx +2 -2
  58. package/src/components/data-table/pagination.tsx +5 -1
  59. package/src/components/data-table/use-table-state.ts +1 -1
  60. package/src/components/data-views/data-row-list.tsx +1 -1
  61. package/src/components/data-views/finder-panel-view.tsx +2 -2
  62. package/src/components/data-views/hub-table.tsx +149 -41
  63. package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
  64. package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
  65. package/src/components/data-views/os-folder-glyph.tsx +1 -1
  66. package/src/components/ui/key-metrics.tsx +1 -1
  67. package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
  68. package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
  69. package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
  70. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
  71. package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
  72. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
  73. package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
  74. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
  75. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
  76. package/template/AGENTS.md +43 -37
  77. package/template/app/(app)/columns/page.tsx +11 -0
  78. package/template/app/(app)/library/all/page.tsx +11 -0
  79. package/template/app/(app)/library/find/page.tsx +12 -0
  80. package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
  81. package/template/app/(app)/library/list/page.tsx +12 -0
  82. package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
  83. package/template/app/(app)/library/page.tsx +11 -0
  84. package/template/app/(app)/tokens-themes/page.tsx +11 -0
  85. package/template/components/ask-leo-composer.tsx +2 -2
  86. package/template/components/columns-client.tsx +158 -0
  87. package/template/components/columns-showcase.tsx +541 -0
  88. package/template/components/data-views/index.ts +32 -6
  89. package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
  90. package/template/components/data-views/table-cells.tsx +673 -0
  91. package/template/components/folder-details-shell.tsx +11 -11
  92. package/template/components/hub-tree-panel-view.tsx +24 -24
  93. package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
  94. package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
  95. package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
  96. package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
  97. package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
  98. package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
  99. package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
  100. package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
  101. package/template/components/library-panel-activator.tsx +8 -0
  102. package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
  103. package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
  104. package/template/components/list-hub-status-badge.tsx +2 -2
  105. package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
  106. package/template/components/sidebar/app-sidebar.tsx +61 -5
  107. package/template/components/sidebar/secondary-panel.tsx +109 -56
  108. package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
  109. package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
  110. package/template/components/table-properties/types.ts +1 -1
  111. package/template/components/templates/discovery-hub-template.tsx +1 -1
  112. package/template/components/templates/new-focus-template.tsx +2 -2
  113. package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
  114. package/template/components/tokens-secondary-nav.tsx +192 -0
  115. package/template/components/tokens-themes-client.tsx +476 -0
  116. package/template/components/tokens-themes-section.tsx +386 -0
  117. package/template/docs/HANDBOOK.md +187 -0
  118. package/template/docs/blueprints/README.md +1 -1
  119. package/template/docs/blueprints/board-card.md +1 -1
  120. package/template/docs/blueprints/data-table.md +2 -2
  121. package/template/docs/blueprints/list-page-template.md +3 -3
  122. package/template/docs/blueprints/page-header.md +4 -4
  123. package/template/docs/collaboration-access-pattern.md +7 -7
  124. package/template/docs/component-selection-guide.md +1 -1
  125. package/template/docs/data-views-pattern.md +18 -16
  126. package/template/docs/glossary.md +58 -0
  127. package/template/docs/kpi-flat-band-pattern.md +3 -3
  128. package/template/docs/kpi-trend-pattern.md +18 -3
  129. package/template/docs/large-dataset-strategy.md +155 -0
  130. package/template/docs/library-hub-header-pattern.md +25 -0
  131. package/template/docs/migrations/_template.md +1 -1
  132. package/template/docs/reference-implementations.md +151 -0
  133. package/template/docs/token-taxonomy.md +1 -1
  134. package/template/docs/voice-and-tone.md +262 -0
  135. package/template/eslint.config.mjs +9 -39
  136. package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
  137. package/template/lib/ask-leo-route-context.ts +6 -18
  138. package/template/lib/coach-mark-registry.ts +0 -16
  139. package/template/lib/command-menu-config.ts +5 -12
  140. package/template/lib/command-menu-search-data.ts +8 -39
  141. package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
  142. package/template/lib/library-dedicated-search.ts +19 -0
  143. package/template/lib/library-hub-search.ts +90 -0
  144. package/template/lib/library-nav.ts +477 -0
  145. package/template/lib/library-recent-searches.ts +22 -0
  146. package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
  147. package/template/lib/list-status-badges.ts +16 -104
  148. package/template/lib/mock/dashboard.ts +1 -1
  149. package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
  150. package/template/lib/mock/library-header-collaborators.ts +54 -0
  151. package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
  152. package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
  153. package/template/lib/mock/library.ts +249 -0
  154. package/template/lib/mock/navigation.tsx +32 -26
  155. package/template/lib/table-state-lifecycle.ts +1 -1
  156. package/template/next.config.mjs +7 -4
  157. package/template/package.json +0 -1
  158. package/tokens/hooks-index.json +2874 -0
  159. package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
  160. package/template/app/(app)/examples/page.tsx +0 -41
  161. package/template/app/(app)/question-bank/find/page.tsx +0 -12
  162. package/template/app/(app)/question-bank/library/page.tsx +0 -11
  163. package/template/app/(app)/question-bank/list/page.tsx +0 -12
  164. package/template/app/(app)/question-bank/page.tsx +0 -11
  165. package/template/components/compliance-board-view.tsx +0 -142
  166. package/template/components/compliance-client.tsx +0 -92
  167. package/template/components/compliance-page-header.tsx +0 -89
  168. package/template/components/compliance-table.tsx +0 -468
  169. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  170. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  171. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  172. package/template/components/new-placement-back-btn.tsx +0 -28
  173. package/template/components/new-placement-form.tsx +0 -942
  174. package/template/components/placement-board-card.tsx +0 -250
  175. package/template/components/placement-detail.tsx +0 -438
  176. package/template/components/placements-board-view.tsx +0 -397
  177. package/template/components/placements-client.tsx +0 -220
  178. package/template/components/placements-list-view.tsx +0 -124
  179. package/template/components/placements-page-header.tsx +0 -166
  180. package/template/components/placements-table-cells.test.tsx +0 -22
  181. package/template/components/placements-table-cells.tsx +0 -173
  182. package/template/components/placements-table-columns.tsx +0 -210
  183. package/template/components/placements-table.tsx +0 -934
  184. package/template/components/question-bank-panel-activator.tsx +0 -8
  185. package/template/components/rotations-empty-state.tsx +0 -50
  186. package/template/components/rotations-panel-activator.tsx +0 -8
  187. package/template/components/sites-board-view.tsx +0 -67
  188. package/template/components/sites-client.tsx +0 -154
  189. package/template/components/sites-table.tsx +0 -249
  190. package/template/components/team-board-view.tsx +0 -122
  191. package/template/components/team-client.tsx +0 -100
  192. package/template/components/team-page-header.tsx +0 -92
  193. package/template/components/team-table.tsx +0 -553
  194. package/template/docs/question-bank-hub-header-pattern.md +0 -25
  195. package/template/lib/compliance-supported-views.ts +0 -10
  196. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  197. package/template/lib/mock/compliance-kpi.ts +0 -61
  198. package/template/lib/mock/compliance.ts +0 -146
  199. package/template/lib/mock/placements-kpi.ts +0 -134
  200. package/template/lib/mock/placements.ts +0 -176
  201. package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
  202. package/template/lib/mock/question-bank.ts +0 -249
  203. package/template/lib/mock/sites-directory.ts +0 -16
  204. package/template/lib/mock/sites-kpi.ts +0 -25
  205. package/template/lib/mock/team-kpi.ts +0 -60
  206. package/template/lib/mock/team.ts +0 -118
  207. package/template/lib/placement-board-card-layout.ts +0 -79
  208. package/template/lib/question-bank-dedicated-search.ts +0 -19
  209. package/template/lib/question-bank-hub-search.ts +0 -90
  210. package/template/lib/question-bank-nav.ts +0 -477
  211. package/template/lib/question-bank-recent-searches.ts +0 -22
  212. package/template/lib/question-bank-supported-views.ts +0 -12
  213. package/template/lib/sites-supported-views.ts +0 -10
  214. package/template/lib/team-supported-views.ts +0 -10
@@ -1,1503 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * Data view dashboard charts — contextual charts for the **Data** view tab (Placements hub).
5
- *
6
- * Tells a story about the placements data:
7
- * 1. Status pipeline — where are placements in the workflow?
8
- * 2. Program mix — which programs have the most activity?
9
- * 3. Compliance — how ready are upcoming placements?
10
- * 4. Progress tracker — how far along are ongoing placements?
11
- * 5. Site utilisation — which sites are busiest?
12
- * 6. Readiness — how prepared are students?
13
- * 7. Completion outcomes — pass rate + ratings for completed placements
14
- * 8. Timeline — upcoming start dates over the next 8 weeks
15
- *
16
- * Each chart is a `DashboardCard` — users can show/hide, reorder, change
17
- * column span, and switch chart type from the canvas (edit layout mode).
18
- *
19
- * ── WCAG AA ────────────────────────────────────────────────────────────────
20
- * Every chart is wrapped in `<ChartFigure>` (keyboard navigable, announced)
21
- * and includes `<ChartDataTable>` (sr-only fallback table) — matching the
22
- * accessibility pattern established in `charts-overview.tsx`.
23
- */
24
-
25
- import * as React from "react"
26
- import {
27
- closestCorners,
28
- DndContext,
29
- type DragEndEvent,
30
- KeyboardSensor,
31
- PointerSensor,
32
- useSensor,
33
- useSensors,
34
- } from "@dnd-kit/core"
35
- import {
36
- arrayMove,
37
- rectSortingStrategy,
38
- SortableContext,
39
- sortableKeyboardCoordinates,
40
- useSortable,
41
- } from "@dnd-kit/sortable"
42
- import { CSS } from "@dnd-kit/utilities"
43
- import {
44
- Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis,
45
- Area, AreaChart, RadialBar, RadialBarChart, Line, LineChart,
46
- } from "recharts"
47
- import {
48
- ChartCard,
49
- ChartFigure,
50
- ChartDataTable,
51
- ChartLeoPlotInsightOverlay,
52
- type ChartLeoInsight,
53
- } from "@/components/charts-overview"
54
- import { useChartVariant } from "@/contexts/chart-variant-context"
55
- import { KeyMetrics, type MetricInsight, type MetricItem } from "@/components/key-metrics"
56
- import { Button } from "@/components/ui/button"
57
- import {
58
- DropdownMenu,
59
- DropdownMenuContent,
60
- DropdownMenuItem,
61
- DropdownMenuTrigger,
62
- } from "@/components/ui/dropdown-menu"
63
- import { ViewSegmentedControl } from "@/components/ui/view-segmented-control"
64
- import { Tip } from "@/components/ui/tip"
65
- import { DragHandleGripIcon } from "@/components/ui/drag-handle-grip"
66
- import {
67
- KEY_METRICS_KPI_COUNT_DEFAULT,
68
- KEY_METRICS_KPI_COUNT_MAX,
69
- KEY_METRICS_KPI_COUNT_MIN,
70
- } from "@/lib/dashboard-layout-merge"
71
- import {
72
- type ChartType,
73
- type DashboardCardDef,
74
- KEY_METRICS_CARD_ID,
75
- ALL_DASHBOARD_CARDS,
76
- DEFAULT_SPANS,
77
- DEFAULT_CHART_TYPES,
78
- applyVisibleReorder,
79
- } from "@/lib/data-view-dashboard-placements-layout"
80
- import { cn } from "@/lib/utils"
81
- import {
82
- ChartContainer,
83
- ChartTooltip,
84
- chartTooltipKeyboardSyncProps,
85
- ChartTooltipContent,
86
- type ChartConfig,
87
- } from "@/components/ui/chart"
88
- import type { Placement } from "@/lib/mock/placements"
89
- import {
90
- CHART_KBD_ACTIVE_BAR,
91
- CHART_KBD_ACTIVE_PIE_SHAPE,
92
- } from "@/lib/chart-keyboard-selection"
93
-
94
- export type { ChartType, ChartTypeOption, DashboardCardDef, DashboardLayout } from "@/lib/data-view-dashboard-placements-layout"
95
- export {
96
- KEY_METRICS_CARD_ID,
97
- ALL_DASHBOARD_CARDS,
98
- DEFAULT_VISIBLE_CARDS,
99
- DEFAULT_SPANS,
100
- DEFAULT_CHART_TYPES,
101
- loadDashboardLayout,
102
- mergeDashboardLayout,
103
- saveDashboardLayout,
104
- applyVisibleReorder,
105
- } from "@/lib/data-view-dashboard-placements-layout"
106
-
107
- /* ── Chart colour tokens ───────────────────────────────────────────────── */
108
-
109
- const STATUS_COLORS: Record<string, string> = {
110
- confirmed: "var(--color-chart-2)",
111
- pending: "var(--color-chart-4)",
112
- "under-review": "var(--color-chart-3)",
113
- rejected: "var(--destructive)",
114
- completed: "var(--primary)",
115
- }
116
-
117
- const COMPLIANCE_COLORS: Record<string, string> = {
118
- Complete: "var(--color-chart-2)",
119
- "In progress": "var(--color-chart-3)",
120
- "Pending documents": "var(--color-chart-4)",
121
- Review: "var(--color-chart-1)",
122
- Incomplete: "var(--destructive)",
123
- }
124
-
125
- const READINESS_COLORS: Record<string, string> = {
126
- Ready: "var(--color-chart-2)",
127
- "In review": "var(--color-chart-3)",
128
- "At risk": "var(--color-chart-4)",
129
- Blocked: "var(--destructive)",
130
- }
131
-
132
- /* ── Chart configs ─────────────────────────────────────────────────────── */
133
-
134
- const BAR_CFG: ChartConfig = { value: { label: "Placements", color: "var(--primary)" } }
135
- const AREA_CFG: ChartConfig = { count: { label: "Starting", color: "var(--color-chart-1)" } }
136
-
137
- const CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const
138
- const CHART_MARGIN_HORIZONTAL = { top: 8, right: 8, left: 4, bottom: 0 } as const
139
-
140
- const PALETTE_COLORS = [
141
- "var(--color-chart-1)",
142
- "var(--color-chart-2)",
143
- "var(--color-chart-3)",
144
- "var(--color-chart-4)",
145
- "var(--color-chart-5)",
146
- ] as const
147
-
148
- /** Demo Leo “smart scan” copy per chart (swap for model output when wired). */
149
- const PLACEMENTS_CHART_LEO_INSIGHTS: Partial<Record<string, ChartLeoInsight>> = {
150
- "status-pipeline": {
151
- headline: "Under review is stacking up",
152
- explanation:
153
- "A larger share of placements sits in under-review than in confirmed. That usually means paperwork or coordinator capacity is lagging, and start dates can slip if the queue keeps growing.",
154
- },
155
- "compliance-status": {
156
- headline: "Pending documents cluster",
157
- explanation:
158
- "Several items still show pending documents. Gaps close to rotation dates are the most common reason placements get paused—worth prioritizing the oldest due dates first.",
159
- },
160
- "site-utilisation": {
161
- headline: "Load may be uneven across sites",
162
- explanation:
163
- "A few sites carry most of the volume while others are light. That can hide fatigue or preceptor limits on the busiest sites even when totals look fine.",
164
- },
165
- "upcoming-timeline": {
166
- headline: "Starts bunch in the same window",
167
- explanation:
168
- "Multiple placements begin in adjacent weeks. Check orientation, onboarding staff, and site capacity so you are not compressing the same resources.",
169
- anchor: {
170
- xValue: "This week",
171
- yDataKeys: ["count"],
172
- yCombine: "max",
173
- },
174
- },
175
- }
176
-
177
- /* ── Individual chart renderers (ChartFigure + ChartDataTable from charts-overview) ─ */
178
- /* Keyboard highlight: `CHART_KBD_*` — same ring-on-active pattern as `charts-overview`. */
179
-
180
- function StatusPipelineChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
181
- const data = React.useMemo(() => {
182
- const map = new Map<string, number>()
183
- for (const r of rows) map.set(r.status, (map.get(r.status) ?? 0) + 1)
184
- return ["confirmed", "pending", "under-review", "completed", "rejected"]
185
- .filter(s => map.has(s))
186
- .map(s => ({
187
- name: s === "under-review" ? "Under Review" : s.charAt(0).toUpperCase() + s.slice(1),
188
- value: map.get(s)!,
189
- fill: STATUS_COLORS[s] ?? "var(--primary)",
190
- }))
191
- }, [rows])
192
-
193
- if (data.length === 0) return <EmptyChart />
194
-
195
- const summary = `${data.length} status categories. Largest: ${data.reduce((a, b) => a.value > b.value ? a : b).name} with ${data.reduce((a, b) => a.value > b.value ? a : b).value} placements.`
196
-
197
- return (
198
- <ChartFigure label="Status Pipeline" summary={summary} dataLength={data.length}>
199
- {(activeIndex) => (
200
- <>
201
- {chartType === "pie" ? (
202
- <PieChartRenderer data={data} activeIndex={activeIndex} />
203
- ) : chartType === "horizontal-bar" ? (
204
- <HBarChartRenderer data={data} colored activeIndex={activeIndex} />
205
- ) : (
206
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
207
- <BarChart data={data} margin={CHART_MARGIN}>
208
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
209
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
210
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
211
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
212
- <Bar
213
- dataKey="value"
214
- radius={[4, 4, 0, 0]}
215
- maxBarSize={48}
216
- activeBar={CHART_KBD_ACTIVE_BAR}
217
- activeIndex={activeIndex ?? undefined}
218
- >
219
- {data.map((d, i) => (
220
- <Cell key={i} fill={d.fill} />
221
- ))}
222
- </Bar>
223
- </BarChart>
224
- </ChartContainer>
225
- )}
226
- <ChartDataTable
227
- caption="Status Pipeline data"
228
- headers={["Status", "Placements"]}
229
- rows={data.map(d => [d.name, d.value])}
230
- />
231
- </>
232
- )}
233
- </ChartFigure>
234
- )
235
- }
236
-
237
- function ProgramMixChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
238
- const data = React.useMemo(() => {
239
- const map = new Map<string, number>()
240
- for (const r of rows) map.set(r.program, (map.get(r.program) ?? 0) + 1)
241
- return [...map.entries()]
242
- .map(([name, value]) => ({ name, value }))
243
- .sort((a, b) => b.value - a.value)
244
- }, [rows])
245
-
246
- const colors = PALETTE_COLORS
247
- const coloredData = React.useMemo(
248
- () => data.map((d, i) => ({ ...d, fill: colors[i % colors.length] })),
249
- [data, colors],
250
- )
251
-
252
- if (data.length === 0) return <EmptyChart />
253
-
254
- const summary = `${data.length} programs. Largest: ${data[0].name} with ${data[0].value} placements.`
255
-
256
- return (
257
- <ChartFigure label="Placements by Program" summary={summary} dataLength={data.length}>
258
- {(activeIndex) => (
259
- <>
260
- {chartType === "bar" ? (
261
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
262
- <BarChart data={coloredData} margin={CHART_MARGIN}>
263
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
264
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
265
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
266
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
267
- <Bar
268
- dataKey="value"
269
- radius={[4, 4, 0, 0]}
270
- maxBarSize={48}
271
- activeBar={CHART_KBD_ACTIVE_BAR}
272
- activeIndex={activeIndex ?? undefined}
273
- >
274
- {coloredData.map((d, i) => (
275
- <Cell key={i} fill={d.fill} />
276
- ))}
277
- </Bar>
278
- </BarChart>
279
- </ChartContainer>
280
- ) : chartType === "horizontal-bar" ? (
281
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
282
- <BarChart data={coloredData} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
283
- <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
284
- <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
285
- <YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
286
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
287
- <Bar
288
- dataKey="value"
289
- radius={[0, 4, 4, 0]}
290
- maxBarSize={22}
291
- activeBar={CHART_KBD_ACTIVE_BAR}
292
- activeIndex={activeIndex ?? undefined}
293
- >
294
- {coloredData.map((d, i) => (
295
- <Cell key={i} fill={d.fill} />
296
- ))}
297
- </Bar>
298
- </BarChart>
299
- </ChartContainer>
300
- ) : (
301
- <PieChartRenderer data={coloredData} activeIndex={activeIndex} />
302
- )}
303
- <ChartDataTable
304
- caption="Placements by Program data"
305
- headers={["Program", "Placements"]}
306
- rows={data.map(d => [d.name, d.value])}
307
- />
308
- </>
309
- )}
310
- </ChartFigure>
311
- )
312
- }
313
-
314
- function ComplianceChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
315
- const upcoming = rows.filter(r => r.placementPhase === "upcoming")
316
- const data = React.useMemo(() => {
317
- const map = new Map<string, number>()
318
- for (const r of upcoming) map.set(r.compliance, (map.get(r.compliance) ?? 0) + 1)
319
- return ["Complete", "In progress", "Pending documents", "Review", "Incomplete"]
320
- .filter(s => map.has(s))
321
- .map(s => ({
322
- name: s,
323
- value: map.get(s)!,
324
- fill: COMPLIANCE_COLORS[s] ?? "var(--primary)",
325
- }))
326
- }, [upcoming])
327
-
328
- if (data.length === 0) return <EmptyChart message="No upcoming placements to show compliance." />
329
-
330
- const summary = `${data.length} compliance states. ${data.map(d => `${d.name}: ${d.value}`).join(", ")}.`
331
-
332
- return (
333
- <ChartFigure label="Compliance Status" summary={summary} dataLength={data.length}>
334
- {(activeIndex) => (
335
- <>
336
- {chartType === "bar" ? (
337
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
338
- <BarChart data={data} margin={CHART_MARGIN}>
339
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
340
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
341
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
342
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
343
- <Bar
344
- dataKey="value"
345
- radius={[4, 4, 0, 0]}
346
- maxBarSize={48}
347
- activeBar={CHART_KBD_ACTIVE_BAR}
348
- activeIndex={activeIndex ?? undefined}
349
- >
350
- {data.map((d, i) => (
351
- <Cell key={i} fill={d.fill} />
352
- ))}
353
- </Bar>
354
- </BarChart>
355
- </ChartContainer>
356
- ) : chartType === "pie" ? (
357
- <PieChartRenderer data={data} activeIndex={activeIndex} />
358
- ) : (
359
- <HBarChartRenderer data={data} colored activeIndex={activeIndex} />
360
- )}
361
- <ChartDataTable
362
- caption="Compliance Status data"
363
- headers={["Status", "Placements"]}
364
- rows={data.map(d => [d.name, d.value])}
365
- />
366
- </>
367
- )}
368
- </ChartFigure>
369
- )
370
- }
371
-
372
- function ReadinessChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
373
- const upcoming = rows.filter(r => r.placementPhase === "upcoming")
374
- const data = React.useMemo(() => {
375
- const map = new Map<string, number>()
376
- for (const r of upcoming) map.set(r.readiness, (map.get(r.readiness) ?? 0) + 1)
377
- return ["Ready", "In review", "At risk", "Blocked"]
378
- .filter(s => map.has(s))
379
- .map(s => ({
380
- name: s,
381
- value: map.get(s)!,
382
- fill: READINESS_COLORS[s] ?? "var(--primary)",
383
- }))
384
- }, [upcoming])
385
-
386
- if (data.length === 0) return <EmptyChart message="No upcoming placements to show readiness." />
387
-
388
- const summary = `${data.length} readiness states. ${data.map(d => `${d.name}: ${d.value}`).join(", ")}.`
389
-
390
- return (
391
- <ChartFigure label="Student Readiness" summary={summary} dataLength={data.length}>
392
- {(activeIndex) => (
393
- <>
394
- {chartType === "pie" ? (
395
- <PieChartRenderer data={data} activeIndex={activeIndex} />
396
- ) : chartType === "horizontal-bar" ? (
397
- <HBarChartRenderer data={data} colored activeIndex={activeIndex} />
398
- ) : (
399
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
400
- <BarChart data={data} margin={CHART_MARGIN}>
401
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
402
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
403
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
404
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
405
- <Bar
406
- dataKey="value"
407
- radius={[4, 4, 0, 0]}
408
- maxBarSize={48}
409
- activeBar={CHART_KBD_ACTIVE_BAR}
410
- activeIndex={activeIndex ?? undefined}
411
- >
412
- {data.map((d, i) => (
413
- <Cell key={i} fill={d.fill} />
414
- ))}
415
- </Bar>
416
- </BarChart>
417
- </ChartContainer>
418
- )}
419
- <ChartDataTable
420
- caption="Student Readiness data"
421
- headers={["State", "Placements"]}
422
- rows={data.map(d => [d.name, d.value])}
423
- />
424
- </>
425
- )}
426
- </ChartFigure>
427
- )
428
- }
429
-
430
- function ProgressTrackerChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
431
- const ongoing = rows.filter(r => r.placementPhase === "ongoing")
432
- const data = React.useMemo(() => {
433
- return ongoing.map(r => ({
434
- name: r.student.split(" ")[0],
435
- done: r.progressWeeksDone,
436
- remaining: r.progressWeeksTotal - r.progressWeeksDone,
437
- pct: Math.round((r.progressWeeksDone / r.progressWeeksTotal) * 100),
438
- }))
439
- }, [ongoing])
440
-
441
- if (data.length === 0) return <EmptyChart message="No ongoing placements to track progress." />
442
-
443
- const avgPct = Math.round(data.reduce((s, d) => s + d.pct, 0) / data.length)
444
- const summary = `${data.length} ongoing placements. Average progress: ${avgPct}%.`
445
-
446
- const cfg: ChartConfig = {
447
- done: { label: "Completed", color: "var(--color-chart-2)" },
448
- remaining: { label: "Remaining", color: "var(--muted)" },
449
- }
450
-
451
- return (
452
- <ChartFigure label="Ongoing Progress" summary={summary} dataLength={data.length}>
453
- {(activeIndex) => (
454
- <>
455
- {chartType === "bar" ? (
456
- <ChartContainer config={cfg} className="h-[220px] w-full">
457
- <BarChart data={data} margin={CHART_MARGIN}>
458
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
459
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
460
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
461
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
462
- <Bar
463
- dataKey="done"
464
- stackId="progress"
465
- fill="var(--color-chart-2)"
466
- radius={[0, 0, 0, 0]}
467
- maxBarSize={32}
468
- activeBar={CHART_KBD_ACTIVE_BAR}
469
- activeIndex={activeIndex ?? undefined}
470
- />
471
- <Bar
472
- dataKey="remaining"
473
- stackId="progress"
474
- fill="var(--muted)"
475
- radius={[4, 4, 0, 0]}
476
- maxBarSize={32}
477
- activeBar={CHART_KBD_ACTIVE_BAR}
478
- activeIndex={activeIndex ?? undefined}
479
- />
480
- </BarChart>
481
- </ChartContainer>
482
- ) : (
483
- <ChartContainer config={cfg} className="h-[220px] w-full">
484
- <BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
485
- <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
486
- <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
487
- <YAxis type="category" dataKey="name" width={72} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
488
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
489
- <Bar
490
- dataKey="done"
491
- stackId="progress"
492
- fill="var(--color-chart-2)"
493
- radius={[0, 0, 0, 0]}
494
- maxBarSize={20}
495
- activeBar={CHART_KBD_ACTIVE_BAR}
496
- activeIndex={activeIndex ?? undefined}
497
- />
498
- <Bar
499
- dataKey="remaining"
500
- stackId="progress"
501
- fill="var(--muted)"
502
- radius={[0, 4, 4, 0]}
503
- maxBarSize={20}
504
- activeBar={CHART_KBD_ACTIVE_BAR}
505
- activeIndex={activeIndex ?? undefined}
506
- />
507
- </BarChart>
508
- </ChartContainer>
509
- )}
510
- <ChartDataTable
511
- caption="Ongoing Progress data"
512
- headers={["Student", "Weeks Done", "Weeks Remaining", "Progress %"]}
513
- rows={data.map(d => [d.name, d.done, d.remaining, `${d.pct}%`])}
514
- />
515
- </>
516
- )}
517
- </ChartFigure>
518
- )
519
- }
520
-
521
- function SiteUtilisationChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
522
- const data = React.useMemo(() => {
523
- const map = new Map<string, number>()
524
- for (const r of rows) map.set(r.site, (map.get(r.site) ?? 0) + 1)
525
- return [...map.entries()]
526
- .map(([name, value]) => ({
527
- name: name.length > 24 ? `${name.slice(0, 22)}…` : name,
528
- value,
529
- }))
530
- .sort((a, b) => b.value - a.value)
531
- .slice(0, 8)
532
- }, [rows])
533
-
534
- const colors = PALETTE_COLORS
535
- const coloredData = React.useMemo(
536
- () => data.map((d, i) => ({ ...d, fill: colors[i % colors.length] })),
537
- [data, colors],
538
- )
539
-
540
- if (data.length === 0) return <EmptyChart />
541
-
542
- const summary = `Top ${data.length} sites. Busiest: ${data[0].name} with ${data[0].value} placements.`
543
-
544
- return (
545
- <ChartFigure label="Site Utilisation" summary={summary} dataLength={data.length}>
546
- {(activeIndex) => (
547
- <>
548
- {chartType === "bar" ? (
549
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
550
- <BarChart data={data} margin={CHART_MARGIN}>
551
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
552
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
553
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
554
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
555
- <Bar
556
- dataKey="value"
557
- fill="var(--color-chart-1)"
558
- radius={[4, 4, 0, 0]}
559
- maxBarSize={48}
560
- activeBar={CHART_KBD_ACTIVE_BAR}
561
- activeIndex={activeIndex ?? undefined}
562
- >
563
- {data.map((_, i) => (
564
- <Cell key={i} fill="var(--color-chart-1)" />
565
- ))}
566
- </Bar>
567
- </BarChart>
568
- </ChartContainer>
569
- ) : chartType === "pie" ? (
570
- <PieChartRenderer data={coloredData} activeIndex={activeIndex} />
571
- ) : (
572
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
573
- <BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
574
- <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
575
- <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
576
- <YAxis type="category" dataKey="name" width={130} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
577
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
578
- <Bar
579
- dataKey="value"
580
- fill="var(--color-chart-1)"
581
- radius={[0, 4, 4, 0]}
582
- maxBarSize={20}
583
- activeBar={CHART_KBD_ACTIVE_BAR}
584
- activeIndex={activeIndex ?? undefined}
585
- >
586
- {data.map((_, i) => (
587
- <Cell key={i} fill="var(--color-chart-1)" />
588
- ))}
589
- </Bar>
590
- </BarChart>
591
- </ChartContainer>
592
- )}
593
- <ChartDataTable
594
- caption="Site Utilisation data"
595
- headers={["Site", "Placements"]}
596
- rows={data.map(d => [d.name, d.value])}
597
- />
598
- </>
599
- )}
600
- </ChartFigure>
601
- )
602
- }
603
-
604
- function CompletionOutcomesChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
605
- const completed = rows.filter(r => r.placementPhase === "completed")
606
- const stats = React.useMemo(() => {
607
- if (completed.length === 0) return null
608
- const passed = completed.filter(r => r.finalStatus === "Passed").length
609
- const passRate = Math.round((passed / completed.length) * 100)
610
- const avgRating = completed.reduce((sum, r) => sum + r.rating, 0) / completed.length
611
- const hireYes = completed.filter(r => r.suggestedToHire === "Yes").length
612
- return { passRate, avgRating: avgRating.toFixed(1), hireRate: Math.round((hireYes / completed.length) * 100), total: completed.length }
613
- }, [completed])
614
-
615
- if (!stats) return <EmptyChart message="No completed placements yet." />
616
-
617
- const summary = `${stats.total} completed placements. Pass rate: ${stats.passRate}%, Average rating: ${stats.avgRating}/5.0, Suggested to hire: ${stats.hireRate}%.`
618
-
619
- if (chartType === "bar") {
620
- const barData = [
621
- { name: "Pass Rate", value: stats.passRate, fill: "var(--color-chart-2)" },
622
- { name: "Avg Rating", value: Math.round(parseFloat(stats.avgRating) * 20), fill: "var(--color-chart-1)" },
623
- { name: "Hire Rate", value: stats.hireRate, fill: "var(--color-chart-3)" },
624
- ]
625
- return (
626
- <ChartFigure label="Completion Outcomes" summary={summary} dataLength={3}>
627
- {(activeIndex) => (
628
- <>
629
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
630
- <BarChart data={barData} margin={CHART_MARGIN}>
631
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
632
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
633
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} domain={[0, 100]} />
634
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
635
- <Bar
636
- dataKey="value"
637
- radius={[4, 4, 0, 0]}
638
- maxBarSize={48}
639
- activeBar={CHART_KBD_ACTIVE_BAR}
640
- activeIndex={activeIndex ?? undefined}
641
- >
642
- {barData.map((d, i) => (
643
- <Cell key={i} fill={d.fill} />
644
- ))}
645
- </Bar>
646
- </BarChart>
647
- </ChartContainer>
648
- <ChartDataTable
649
- caption="Completion Outcomes data"
650
- headers={["Metric", "Value"]}
651
- rows={[["Pass Rate", `${stats.passRate}%`], ["Average Rating", `${stats.avgRating}/5.0`], ["Suggested to Hire", `${stats.hireRate}%`]]}
652
- />
653
- </>
654
- )}
655
- </ChartFigure>
656
- )
657
- }
658
-
659
- const radialData = [{ name: "Pass Rate", value: stats.passRate, fill: "var(--color-chart-2)" }]
660
-
661
- return (
662
- <ChartFigure label="Completion Outcomes" summary={summary} dataLength={3}>
663
- {(activeIndex) => (
664
- <>
665
- <div className="flex items-start gap-6">
666
- <div
667
- className={cn(
668
- "shrink-0 rounded-md",
669
- activeIndex === 0 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
670
- )}
671
- >
672
- <ChartContainer config={BAR_CFG} className="h-[180px] w-[180px]">
673
- <RadialBarChart
674
- cx="50%" cy="50%" innerRadius={55} outerRadius={80}
675
- data={radialData} startAngle={90} endAngle={-270}
676
- barSize={14}
677
- >
678
- <RadialBar
679
- dataKey="value"
680
- background
681
- cornerRadius={10}
682
- activeIndex={activeIndex === 0 ? 0 : undefined}
683
- >
684
- {radialData.map((d) => (
685
- <Cell key={d.name} fill={d.fill} />
686
- ))}
687
- </RadialBar>
688
- </RadialBarChart>
689
- </ChartContainer>
690
- </div>
691
- <div className="flex min-w-0 flex-1 flex-col gap-3">
692
- <div
693
- className={cn(
694
- "rounded-md px-1 py-0.5",
695
- activeIndex === 0 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
696
- )}
697
- >
698
- <p className="text-2xl font-semibold text-foreground tabular-nums">{stats.passRate}%</p>
699
- <p className="text-xs text-muted-foreground">Pass rate ({stats.total} placements)</p>
700
- </div>
701
- <div
702
- className={cn(
703
- "rounded-md px-1 py-0.5",
704
- activeIndex === 1 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
705
- )}
706
- >
707
- <p className="text-lg font-semibold text-foreground tabular-nums">{stats.avgRating} <span className="text-xs text-muted-foreground font-normal">/ 5.0</span></p>
708
- <p className="text-xs text-muted-foreground">Average rating</p>
709
- </div>
710
- <div
711
- className={cn(
712
- "rounded-md px-1 py-0.5",
713
- activeIndex === 2 && "ring-2 ring-ring ring-offset-2 ring-offset-background",
714
- )}
715
- >
716
- <p className="text-lg font-semibold text-foreground tabular-nums">{stats.hireRate}%</p>
717
- <p className="text-xs text-muted-foreground">Suggested to hire</p>
718
- </div>
719
- </div>
720
- </div>
721
- <ChartDataTable
722
- caption="Completion Outcomes data"
723
- headers={["Metric", "Value"]}
724
- rows={[["Pass Rate", `${stats.passRate}%`], ["Average Rating", `${stats.avgRating}/5.0`], ["Suggested to Hire", `${stats.hireRate}%`]]}
725
- />
726
- </>
727
- )}
728
- </ChartFigure>
729
- )
730
- }
731
-
732
- function UpcomingTimelineChart({ rows, chartType }: { rows: Placement[]; chartType: ChartType }) {
733
- const upcoming = rows.filter(r => r.placementPhase === "upcoming" && r.daysUntilStart > 0)
734
- const data = React.useMemo(() => {
735
- const buckets = [
736
- { name: "This week", min: 0, max: 7, count: 0 },
737
- { name: "Week 2", min: 8, max: 14, count: 0 },
738
- { name: "Week 3", min: 15, max: 21, count: 0 },
739
- { name: "Week 4", min: 22, max: 28, count: 0 },
740
- { name: "Week 5–6", min: 29, max: 42, count: 0 },
741
- { name: "Week 7–8", min: 43, max: 56, count: 0 },
742
- ]
743
- for (const r of upcoming) {
744
- const b = buckets.find(b => r.daysUntilStart >= b.min && r.daysUntilStart <= b.max)
745
- if (b) b.count++
746
- }
747
- return buckets
748
- }, [upcoming])
749
-
750
- if (upcoming.length === 0) return <EmptyChart message="No upcoming start dates in the next 8 weeks." />
751
-
752
- const summary = `${upcoming.length} upcoming placements across 8 weeks. Most starts: ${data.reduce((a, b) => a.count > b.count ? a : b).name}.`
753
-
754
- return (
755
- <ChartFigure label="Upcoming Start Dates" summary={summary} dataLength={data.length}>
756
- {(activeIndex) => (
757
- <>
758
- {chartType === "line" ? (
759
- <div className="relative w-full">
760
- <ChartContainer config={AREA_CFG} className="h-[200px] w-full">
761
- <LineChart data={data} margin={CHART_MARGIN}>
762
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
763
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
764
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
765
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
766
- <Line
767
- type="monotone"
768
- dataKey="count"
769
- stroke="var(--color-chart-1)"
770
- strokeWidth={2}
771
- dot={(props: { cx?: number; cy?: number; index?: number; stroke?: string; key?: string }) => {
772
- const idx = props.index ?? 0
773
- const isSel = activeIndex === idx
774
- return (
775
- <circle
776
- key={props.key}
777
- cx={props.cx}
778
- cy={props.cy}
779
- r={isSel ? 5 : 3}
780
- fill={props.stroke ?? "var(--color-chart-1)"}
781
- stroke="var(--ring)"
782
- strokeWidth={isSel ? 2 : 0}
783
- />
784
- )
785
- }}
786
- activeDot={false}
787
- />
788
- </LineChart>
789
- </ChartContainer>
790
- <ChartLeoPlotInsightOverlay
791
- data={data}
792
- xDataKey="name"
793
- insetPct={{ left: 6, right: 6, top: 10, bottom: 14 }}
794
- />
795
- </div>
796
- ) : chartType === "bar" ? (
797
- <div className="relative w-full">
798
- <ChartContainer config={AREA_CFG} className="h-[200px] w-full">
799
- <BarChart data={data} margin={CHART_MARGIN}>
800
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
801
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
802
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
803
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
804
- <Bar
805
- dataKey="count"
806
- fill="var(--color-chart-1)"
807
- radius={[4, 4, 0, 0]}
808
- maxBarSize={48}
809
- activeBar={CHART_KBD_ACTIVE_BAR}
810
- activeIndex={activeIndex ?? undefined}
811
- >
812
- {data.map((_, i) => (
813
- <Cell key={i} fill="var(--color-chart-1)" />
814
- ))}
815
- </Bar>
816
- </BarChart>
817
- </ChartContainer>
818
- <ChartLeoPlotInsightOverlay
819
- data={data}
820
- xDataKey="name"
821
- insetPct={{ left: 6, right: 6, top: 10, bottom: 14 }}
822
- />
823
- </div>
824
- ) : (
825
- <div className="relative w-full">
826
- <ChartContainer config={AREA_CFG} className="h-[200px] w-full">
827
- <AreaChart data={data} margin={CHART_MARGIN}>
828
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
829
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
830
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
831
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
832
- <defs>
833
- <linearGradient id="timeline-fill" x1="0" y1="0" x2="0" y2="1">
834
- <stop offset="5%" stopColor="var(--color-chart-1)" stopOpacity={0.3} />
835
- <stop offset="95%" stopColor="var(--color-chart-1)" stopOpacity={0} />
836
- </linearGradient>
837
- </defs>
838
- <Area
839
- key="timeline-count"
840
- type="monotone"
841
- dataKey="count"
842
- stroke="var(--color-chart-1)"
843
- fill="url(#timeline-fill)"
844
- strokeWidth={2}
845
- dot={(props: { cx?: number; cy?: number; index?: number; key?: string }) => {
846
- const idx = props.index ?? 0
847
- const isSel = activeIndex === idx
848
- return (
849
- <circle
850
- key={props.key}
851
- cx={props.cx}
852
- cy={props.cy}
853
- r={isSel ? 5 : 3}
854
- fill="var(--color-chart-1)"
855
- stroke="var(--ring)"
856
- strokeWidth={isSel ? 2 : 0}
857
- />
858
- )
859
- }}
860
- />
861
- </AreaChart>
862
- </ChartContainer>
863
- <ChartLeoPlotInsightOverlay
864
- data={data}
865
- xDataKey="name"
866
- insetPct={{ left: 6, right: 6, top: 10, bottom: 14 }}
867
- />
868
- </div>
869
- )}
870
- <ChartDataTable
871
- caption="Upcoming Start Dates data"
872
- headers={["Period", "Starting"]}
873
- rows={data.map(d => [d.name, d.count])}
874
- />
875
- </>
876
- )}
877
- </ChartFigure>
878
- )
879
- }
880
-
881
- /* ── Shared chart renderers for type switching ────────────────────────── */
882
-
883
- function PieChartRenderer({
884
- data,
885
- activeIndex = null,
886
- }: {
887
- data: { name: string; value: number; fill?: string }[]
888
- activeIndex?: number | null
889
- }) {
890
- const colors = PALETTE_COLORS
891
- return (
892
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
893
- <PieChart>
894
- <Pie
895
- data={data}
896
- dataKey="value"
897
- nameKey="name"
898
- cx="50%"
899
- cy="50%"
900
- innerRadius={50}
901
- outerRadius={80}
902
- strokeWidth={2}
903
- stroke="var(--card)"
904
- activeIndex={activeIndex ?? undefined}
905
- activeShape={CHART_KBD_ACTIVE_PIE_SHAPE}
906
- labelLine={false}
907
- >
908
- {data.map((d, i) => (
909
- <Cell key={i} fill={d.fill ?? colors[i % colors.length]} />
910
- ))}
911
- </Pie>
912
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
913
- </PieChart>
914
- </ChartContainer>
915
- )
916
- }
917
-
918
- function HBarChartRenderer({
919
- data,
920
- colored,
921
- activeIndex = null,
922
- }: {
923
- data: { name: string; value: number; fill?: string }[]
924
- colored?: boolean
925
- activeIndex?: number | null
926
- }) {
927
- return (
928
- <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
929
- <BarChart data={data} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
930
- <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
931
- <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
932
- <YAxis type="category" dataKey="name" width={120} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
933
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
934
- <Bar
935
- dataKey="value"
936
- radius={[0, 4, 4, 0]}
937
- maxBarSize={22}
938
- activeBar={CHART_KBD_ACTIVE_BAR}
939
- activeIndex={activeIndex ?? undefined}
940
- >
941
- {colored
942
- ? data.map((d, i) => (
943
- <Cell key={i} fill={d.fill ?? "var(--primary)"} />
944
- ))
945
- : data.map((_, i) => (
946
- <Cell key={i} fill="var(--primary)" />
947
- ))}
948
- </Bar>
949
- </BarChart>
950
- </ChartContainer>
951
- )
952
- }
953
-
954
- function EmptyChart({ message = "No data matches the current filters." }: { message?: string }) {
955
- return (
956
- <div className="flex items-center justify-center h-[200px] text-sm text-muted-foreground" role="status">
957
- {message}
958
- </div>
959
- )
960
- }
961
-
962
- /* ── Card renderer map ─────────────────────────────────────────────────── */
963
-
964
- const CHART_RENDERERS: Record<string, React.FC<{ rows: Placement[]; chartType: ChartType }>> = {
965
- "status-pipeline": StatusPipelineChart,
966
- "program-mix": ProgramMixChart,
967
- "compliance-status": ComplianceChart,
968
- "readiness-overview": ReadinessChart,
969
- "progress-tracker": ProgressTrackerChart,
970
- "site-utilisation": SiteUtilisationChart,
971
- "completion-outcomes": CompletionOutcomesChart,
972
- "upcoming-timeline": UpcomingTimelineChart,
973
- }
974
-
975
- /* ── Canvas layout (edit mode) ─────────────────────────────────────────── */
976
-
977
- function SortableDashboardChartCard({
978
- card,
979
- placements,
980
- span,
981
- chartType,
982
- cardIndex,
983
- totalCards,
984
- onSpanChange,
985
- onChartTypeChange,
986
- onRemove,
987
- onMoveStep,
988
- keyMetrics,
989
- keyMetricsKpiCount,
990
- onKeyMetricsKpiCountChange,
991
- }: {
992
- card: DashboardCardDef
993
- placements: Placement[]
994
- span: 1 | 2
995
- chartType: ChartType
996
- cardIndex: number
997
- totalCards: number
998
- onSpanChange: (id: string, span: 1 | 2) => void
999
- onChartTypeChange: (id: string, t: ChartType) => void
1000
- onRemove: (id: string) => void
1001
- onMoveStep: (direction: -1 | 1) => void
1002
- keyMetrics?: { metrics: MetricItem[]; insight: MetricInsight } | null
1003
- keyMetricsKpiCount: number
1004
- onKeyMetricsKpiCountChange?: (n: number) => void
1005
- }) {
1006
- const {
1007
- attributes,
1008
- listeners,
1009
- setNodeRef,
1010
- setActivatorNodeRef,
1011
- transform,
1012
- transition,
1013
- isDragging,
1014
- } = useSortable({ id: card.id })
1015
- const { chartVariant } = useChartVariant()
1016
-
1017
- /* Only apply transform while dragging — idle `transform` breaks grid row sizing + stacking. */
1018
- const style: React.CSSProperties = {
1019
- ...(transform ? { transform: CSS.Transform.toString(transform) } : {}),
1020
- transition,
1021
- }
1022
-
1023
- const isKeyMetrics = card.id === KEY_METRICS_CARD_ID
1024
- const Renderer = isKeyMetrics ? null : CHART_RENDERERS[card.id]
1025
- if (!isKeyMetrics && !Renderer) return null
1026
- if (isKeyMetrics && !keyMetrics) return null
1027
-
1028
- const chartLeoInsight = PLACEMENTS_CHART_LEO_INSIGHTS[card.id]
1029
-
1030
- const canMoveEarlier = cardIndex > 0
1031
- const canMoveLater = cardIndex < totalCards - 1
1032
-
1033
- return (
1034
- <div
1035
- ref={setNodeRef}
1036
- style={style}
1037
- className={cn(
1038
- "group flex min-h-0 w-full min-w-0 flex-col self-start rounded-xl border-2 border-dashed border-border bg-transparent p-2",
1039
- span === 2 ? "lg:col-span-2" : undefined,
1040
- isDragging && "z-20 opacity-95 ring-2 ring-ring",
1041
- )}
1042
- >
1043
- <div
1044
- className="mb-2 flex w-full min-w-0 flex-wrap items-center gap-2"
1045
- role="toolbar"
1046
- aria-label={`${card.title} layout controls`}
1047
- >
1048
- <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
1049
- <Tip label="Drag to reorder" side="top">
1050
- <button
1051
- type="button"
1052
- ref={setActivatorNodeRef}
1053
- className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1054
- aria-label={`Drag to reorder ${card.title}`}
1055
- {...attributes}
1056
- {...listeners}
1057
- >
1058
- <DragHandleGripIcon className="text-[15px]" />
1059
- </button>
1060
- </Tip>
1061
- {card.chartTypes.length > 0 ? (
1062
- <ViewSegmentedControl
1063
- aria-label={`Chart type for ${card.title}`}
1064
- iconOnly
1065
- value={chartType}
1066
- onValueChange={v => onChartTypeChange(card.id, v as ChartType)}
1067
- options={card.chartTypes.map(opt => ({
1068
- value: opt.type,
1069
- label: opt.label,
1070
- icon: opt.icon,
1071
- }))}
1072
- />
1073
- ) : null}
1074
- {isKeyMetrics && onKeyMetricsKpiCountChange ? (
1075
- <ViewSegmentedControl
1076
- aria-label="Number of KPIs to show"
1077
- iconOnly={false}
1078
- value={String(keyMetricsKpiCount)}
1079
- onValueChange={v => onKeyMetricsKpiCountChange(Number(v))}
1080
- options={Array.from(
1081
- { length: KEY_METRICS_KPI_COUNT_MAX - KEY_METRICS_KPI_COUNT_MIN + 1 },
1082
- (_, i) => {
1083
- const n = KEY_METRICS_KPI_COUNT_MIN + i
1084
- return { value: String(n), label: String(n) }
1085
- },
1086
- )}
1087
- />
1088
- ) : null}
1089
- <ViewSegmentedControl
1090
- aria-label={`Width for ${card.title}`}
1091
- iconOnly
1092
- value={String(span) as "1" | "2"}
1093
- onValueChange={v => onSpanChange(card.id, Number(v) as 1 | 2)}
1094
- options={[
1095
- { value: "1", label: "Half width", icon: "fa-light fa-table-columns" },
1096
- { value: "2", label: "Full width (all columns)", icon: "fa-light fa-maximize" },
1097
- ]}
1098
- />
1099
- </div>
1100
- <div className="ms-auto flex shrink-0 items-center gap-1">
1101
- <div
1102
- className="pointer-events-none flex items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100"
1103
- role="group"
1104
- aria-label={`Reorder ${card.title}`}
1105
- >
1106
- <div className="flex items-center gap-0.5 lg:hidden">
1107
- <Tip label="Move up" side="top">
1108
- <Button
1109
- type="button"
1110
- variant="ghost"
1111
- size="icon-sm"
1112
- className="size-7 shrink-0"
1113
- disabled={!canMoveEarlier}
1114
- aria-label={`Move ${card.title} up`}
1115
- onClick={() => onMoveStep(-1)}
1116
- >
1117
- <i className="fa-light fa-chevron-up text-xs" aria-hidden="true" />
1118
- </Button>
1119
- </Tip>
1120
- <Tip label="Move down" side="top">
1121
- <Button
1122
- type="button"
1123
- variant="ghost"
1124
- size="icon-sm"
1125
- className="size-7 shrink-0"
1126
- disabled={!canMoveLater}
1127
- aria-label={`Move ${card.title} down`}
1128
- onClick={() => onMoveStep(1)}
1129
- >
1130
- <i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
1131
- </Button>
1132
- </Tip>
1133
- </div>
1134
- <div className="hidden items-center gap-0.5 lg:flex">
1135
- <Tip label="Move left" side="top">
1136
- <Button
1137
- type="button"
1138
- variant="ghost"
1139
- size="icon-sm"
1140
- className="size-7 shrink-0"
1141
- disabled={!canMoveEarlier}
1142
- aria-label={`Move ${card.title} left`}
1143
- onClick={() => onMoveStep(-1)}
1144
- >
1145
- <i className="fa-light fa-chevron-left text-xs" aria-hidden="true" />
1146
- </Button>
1147
- </Tip>
1148
- <Tip label="Move right" side="top">
1149
- <Button
1150
- type="button"
1151
- variant="ghost"
1152
- size="icon-sm"
1153
- className="size-7 shrink-0"
1154
- disabled={!canMoveLater}
1155
- aria-label={`Move ${card.title} right`}
1156
- onClick={() => onMoveStep(1)}
1157
- >
1158
- <i className="fa-light fa-chevron-right text-xs" aria-hidden="true" />
1159
- </Button>
1160
- </Tip>
1161
- </div>
1162
- </div>
1163
- <Tip label={`Remove ${card.title}`} side="top">
1164
- <Button
1165
- type="button"
1166
- variant="ghost"
1167
- size="icon-sm"
1168
- className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
1169
- aria-label={`Remove ${card.title} from dashboard`}
1170
- onClick={() => onRemove(card.id)}
1171
- >
1172
- <i className="fa-light fa-trash text-[13px]" aria-hidden="true" />
1173
- </Button>
1174
- </Tip>
1175
- </div>
1176
- </div>
1177
- {isKeyMetrics && keyMetrics ? (
1178
- <KeyMetrics
1179
- variant="card"
1180
- title={card.title}
1181
- description={card.description}
1182
- metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
1183
- insight={keyMetrics.insight}
1184
- metricsSingleRow
1185
- metricsHalfWidthLayout={span === 1}
1186
- className="w-full min-w-0"
1187
- />
1188
- ) : (
1189
- <ChartCard
1190
- variant={chartVariant}
1191
- title={card.title}
1192
- description={card.description}
1193
- className="!h-auto min-h-0 shrink-0"
1194
- leoInsight={chartLeoInsight}
1195
- >
1196
- {Renderer ? <Renderer rows={placements} chartType={chartType} /> : null}
1197
- </ChartCard>
1198
- )}
1199
- </div>
1200
- )
1201
- }
1202
-
1203
- /* ── Main export ───────────────────────────────────────────────────────── */
1204
-
1205
- export interface PlacementsDashboardChartsSectionProps {
1206
- placements: Placement[]
1207
- /** KPI strip rendered as the `key-metrics` dashboard card (same slot as customise canvas). */
1208
- keyMetrics: { metrics: MetricItem[]; insight: MetricInsight }
1209
- visibleCards: string[]
1210
- cardOrder: string[]
1211
- /** Column span per card (1 = half row on large screens, 2 = full width). Defaults merged from saved layout. */
1212
- cardSpans?: Record<string, 1 | 2>
1213
- /** Chart visualization per card. Defaults merged from saved layout. */
1214
- cardChartTypes?: Record<string, ChartType>
1215
- /** How many KPIs to show on the key-metrics card (1–4). */
1216
- keyMetricsKpiCount?: number
1217
- /** When true, show canvas controls: drag, remove, width, chart type (no side panel). */
1218
- layoutEditMode?: boolean
1219
- onVisibleChange?: (visible: string[]) => void
1220
- onOrderChange?: (order: string[]) => void
1221
- onSpanChange?: (id: string, span: 1 | 2) => void
1222
- onChartTypeChange?: (id: string, chartType: ChartType) => void
1223
- onKeyMetricsKpiCountChange?: (count: number) => void
1224
- onResetLayout?: () => void
1225
- /** Exit edit mode (layout already persisted). */
1226
- onLayoutEditDone?: () => void
1227
- /** Exit edit mode and restore layout from when edit started. */
1228
- onLayoutEditCancel?: () => void
1229
- }
1230
-
1231
- export function PlacementsDashboardChartsSection({
1232
- placements,
1233
- keyMetrics,
1234
- visibleCards,
1235
- cardOrder,
1236
- cardSpans = DEFAULT_SPANS,
1237
- cardChartTypes = DEFAULT_CHART_TYPES,
1238
- keyMetricsKpiCount = KEY_METRICS_KPI_COUNT_DEFAULT,
1239
- layoutEditMode = false,
1240
- onVisibleChange,
1241
- onOrderChange,
1242
- onSpanChange,
1243
- onChartTypeChange,
1244
- onKeyMetricsKpiCountChange,
1245
- onResetLayout,
1246
- onLayoutEditDone,
1247
- onLayoutEditCancel,
1248
- }: PlacementsDashboardChartsSectionProps) {
1249
- const { chartVariant } = useChartVariant()
1250
- const orderedCards = React.useMemo(() => {
1251
- const defs = new Map(ALL_DASHBOARD_CARDS.map(c => [c.id, c]))
1252
- return cardOrder
1253
- .filter(id => visibleCards.includes(id) && defs.has(id))
1254
- .map(id => defs.get(id)!)
1255
- }, [visibleCards, cardOrder])
1256
-
1257
- const hiddenCardDefs = React.useMemo(
1258
- () => ALL_DASHBOARD_CARDS.filter(c => !visibleCards.includes(c.id)),
1259
- [visibleCards],
1260
- )
1261
-
1262
- const sortableIds = React.useMemo(() => orderedCards.map(c => c.id), [orderedCards])
1263
-
1264
- const sensors = useSensors(
1265
- useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
1266
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
1267
- )
1268
-
1269
- const handleDragEnd = React.useCallback(
1270
- (event: DragEndEvent) => {
1271
- if (!onOrderChange) return
1272
- const { active, over } = event
1273
- if (!over || active.id === over.id) return
1274
- const oldIndex = sortableIds.indexOf(String(active.id))
1275
- const newIndex = sortableIds.indexOf(String(over.id))
1276
- if (oldIndex < 0 || newIndex < 0) return
1277
- const nextVisibleOrder = arrayMove(sortableIds, oldIndex, newIndex)
1278
- const visibleSet = new Set(visibleCards)
1279
- onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
1280
- },
1281
- [cardOrder, onOrderChange, sortableIds, visibleCards],
1282
- )
1283
-
1284
- const moveStep = React.useCallback(
1285
- (id: string, direction: -1 | 1) => {
1286
- if (!onOrderChange) return
1287
- const idx = sortableIds.indexOf(id)
1288
- if (idx < 0) return
1289
- const newIdx = idx + direction
1290
- if (newIdx < 0 || newIdx >= sortableIds.length) return
1291
- const nextVisibleOrder = arrayMove(sortableIds, idx, newIdx)
1292
- const visibleSet = new Set(visibleCards)
1293
- onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
1294
- },
1295
- [cardOrder, onOrderChange, sortableIds, visibleCards],
1296
- )
1297
-
1298
- const addCard = React.useCallback(
1299
- (id: string) => {
1300
- if (!onVisibleChange) return
1301
- if (visibleCards.includes(id)) return
1302
- onVisibleChange([...visibleCards, id])
1303
- },
1304
- [onVisibleChange, visibleCards],
1305
- )
1306
-
1307
- const removeCard = React.useCallback(
1308
- (id: string) => {
1309
- if (!onVisibleChange) return
1310
- onVisibleChange(visibleCards.filter(v => v !== id))
1311
- },
1312
- [onVisibleChange, visibleCards],
1313
- )
1314
-
1315
- if (orderedCards.length === 0) {
1316
- return (
1317
- <div className="flex flex-col items-center justify-center gap-3 py-12 px-4 text-center">
1318
- <i className="fa-light fa-chart-column text-2xl text-muted-foreground/40" aria-hidden="true" />
1319
- <p className="text-sm text-muted-foreground">
1320
- No charts on the dashboard.
1321
- {layoutEditMode && hiddenCardDefs.length > 0
1322
- ? " Add a chart below."
1323
- : " Turn on Edit layout and add charts back."}
1324
- </p>
1325
- {layoutEditMode && hiddenCardDefs.length > 0 && onVisibleChange ? (
1326
- <DropdownMenu>
1327
- <DropdownMenuTrigger asChild>
1328
- <Button type="button" variant="outline" size="sm" className="size-9 p-0" aria-label="Add chart">
1329
- <i className="fa-light fa-plus text-sm" aria-hidden="true" />
1330
- </Button>
1331
- </DropdownMenuTrigger>
1332
- <DropdownMenuContent align="center">
1333
- {hiddenCardDefs.map(c => (
1334
- <DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
1335
- {c.title}
1336
- </DropdownMenuItem>
1337
- ))}
1338
- </DropdownMenuContent>
1339
- </DropdownMenu>
1340
- ) : null}
1341
- </div>
1342
- )
1343
- }
1344
-
1345
- const grid = (
1346
- <div
1347
- className={cn(
1348
- "grid grid-cols-1 gap-4 lg:grid-cols-2",
1349
- /* stretch + ChartCard h-full + sortable transform causes row overlap — pin to content height */
1350
- layoutEditMode && "lg:items-start lg:content-start lg:auto-rows-min",
1351
- )}
1352
- >
1353
- {orderedCards.map((card, cardIndex) => {
1354
- const isKeyMetricsCard = card.id === KEY_METRICS_CARD_ID
1355
- const Renderer = isKeyMetricsCard ? null : CHART_RENDERERS[card.id]
1356
- if (!isKeyMetricsCard && !Renderer) return null
1357
- const span = cardSpans[card.id] ?? card.defaultSpan
1358
- const requestedType = cardChartTypes[card.id] ?? card.defaultChartType
1359
- const allowedTypes = card.chartTypes.map(o => o.type)
1360
- const chartType =
1361
- allowedTypes.length === 0
1362
- ? card.defaultChartType
1363
- : allowedTypes.includes(requestedType)
1364
- ? requestedType
1365
- : card.defaultChartType
1366
-
1367
- if (
1368
- layoutEditMode &&
1369
- onOrderChange &&
1370
- onSpanChange &&
1371
- onChartTypeChange &&
1372
- onVisibleChange
1373
- ) {
1374
- return (
1375
- <SortableDashboardChartCard
1376
- key={card.id}
1377
- card={card}
1378
- placements={placements}
1379
- span={span}
1380
- chartType={chartType}
1381
- cardIndex={cardIndex}
1382
- totalCards={orderedCards.length}
1383
- onSpanChange={onSpanChange}
1384
- onChartTypeChange={onChartTypeChange}
1385
- onRemove={removeCard}
1386
- onMoveStep={dir => moveStep(card.id, dir)}
1387
- keyMetrics={isKeyMetricsCard ? keyMetrics : null}
1388
- keyMetricsKpiCount={keyMetricsKpiCount}
1389
- onKeyMetricsKpiCountChange={
1390
- isKeyMetricsCard ? onKeyMetricsKpiCountChange : undefined
1391
- }
1392
- />
1393
- )
1394
- }
1395
-
1396
- return (
1397
- <div
1398
- key={card.id}
1399
- className={cn(span === 2 ? "lg:col-span-2" : undefined)}
1400
- >
1401
- {isKeyMetricsCard ? (
1402
- <KeyMetrics
1403
- variant="card"
1404
- title={card.title}
1405
- description={card.description}
1406
- metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
1407
- insight={keyMetrics.insight}
1408
- metricsSingleRow
1409
- metricsHalfWidthLayout={span === 1}
1410
- className="w-full min-w-0"
1411
- />
1412
- ) : (
1413
- <ChartCard
1414
- variant={chartVariant}
1415
- title={card.title}
1416
- description={card.description}
1417
- leoInsight={PLACEMENTS_CHART_LEO_INSIGHTS[card.id]}
1418
- >
1419
- {Renderer ? <Renderer rows={placements} chartType={chartType} /> : null}
1420
- </ChartCard>
1421
- )}
1422
- </div>
1423
- )
1424
- })}
1425
- </div>
1426
- )
1427
-
1428
- const editToolbar =
1429
- layoutEditMode && onVisibleChange && onResetLayout ? (
1430
- <div
1431
- className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-transparent px-3 py-2"
1432
- role="region"
1433
- aria-label="Dashboard layout options"
1434
- >
1435
- <p className="text-xs text-muted-foreground">
1436
- Drag cards to reorder. Changes save automatically.
1437
- </p>
1438
- <div className="flex flex-wrap items-center justify-end gap-2">
1439
- <Button type="button" size="sm" variant="ghost" className="h-8 text-xs" onClick={() => onVisibleChange(ALL_DASHBOARD_CARDS.map(c => c.id))}>
1440
- Show all
1441
- </Button>
1442
- <Button type="button" size="sm" variant="ghost" className="h-8 text-xs" onClick={() => onVisibleChange([])}>
1443
- Hide all
1444
- </Button>
1445
- <Tip side="bottom" label="Reset visibility, order, widths, and chart types">
1446
- <Button type="button" size="sm" variant="ghost" className="h-8 px-2 text-xs" onClick={onResetLayout}>
1447
- <i className="fa-light fa-rotate-left me-1 text-xs" aria-hidden="true" />
1448
- Reset
1449
- </Button>
1450
- </Tip>
1451
- {hiddenCardDefs.length > 0 ? (
1452
- <DropdownMenu>
1453
- <DropdownMenuTrigger asChild>
1454
- <Button type="button" variant="outline" size="sm" className="size-8 p-0" aria-label="Add chart">
1455
- <i className="fa-light fa-plus text-[13px]" aria-hidden="true" />
1456
- </Button>
1457
- </DropdownMenuTrigger>
1458
- <DropdownMenuContent align="end">
1459
- {hiddenCardDefs.map(c => (
1460
- <DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
1461
- {c.title}
1462
- </DropdownMenuItem>
1463
- ))}
1464
- </DropdownMenuContent>
1465
- </DropdownMenu>
1466
- ) : null}
1467
- {onLayoutEditCancel ? (
1468
- <Button type="button" size="sm" variant="outline" className="h-8 text-xs" onClick={onLayoutEditCancel}>
1469
- Cancel
1470
- </Button>
1471
- ) : null}
1472
- {onLayoutEditDone ? (
1473
- <Button type="button" size="sm" className="h-8 text-xs" onClick={onLayoutEditDone}>
1474
- Done
1475
- </Button>
1476
- ) : null}
1477
- </div>
1478
- </div>
1479
- ) : null
1480
-
1481
- const gridBody =
1482
- layoutEditMode && onOrderChange ? (
1483
- <DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
1484
- <SortableContext items={sortableIds} strategy={rectSortingStrategy}>
1485
- {grid}
1486
- </SortableContext>
1487
- </DndContext>
1488
- ) : (
1489
- grid
1490
- )
1491
-
1492
- return (
1493
- <div
1494
- className={cn(
1495
- "flex flex-col gap-4 px-4 pb-2 lg:px-6",
1496
- layoutEditMode && "rounded-xl border border-dashed border-border/80 bg-transparent py-3",
1497
- )}
1498
- >
1499
- {editToolbar}
1500
- {gridBody}
1501
- </div>
1502
- )
1503
- }