@exxatdesignux/ui 0.0.6 → 0.0.8

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