@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,963 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * Compliance **Data** view dashboard — filtered compliance rows with the same canvas as Placements/Team.
5
- */
6
-
7
- import * as React from "react"
8
- import {
9
- closestCorners,
10
- DndContext,
11
- type DragEndEvent,
12
- KeyboardSensor,
13
- PointerSensor,
14
- useSensor,
15
- useSensors,
16
- } from "@dnd-kit/core"
17
- import {
18
- arrayMove,
19
- rectSortingStrategy,
20
- SortableContext,
21
- sortableKeyboardCoordinates,
22
- useSortable,
23
- } from "@dnd-kit/sortable"
24
- import { CSS } from "@dnd-kit/utilities"
25
- import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"
26
- import { ChartCard, ChartDataTable, ChartFigure, type ChartLeoInsight } from "@/components/charts-overview"
27
- import { useChartVariant } from "@/contexts/chart-variant-context"
28
- import { KeyMetrics, type MetricInsight, type MetricItem } from "@/components/key-metrics"
29
- import { Button } from "@/components/ui/button"
30
- import {
31
- DropdownMenu,
32
- DropdownMenuContent,
33
- DropdownMenuItem,
34
- DropdownMenuTrigger,
35
- } from "@/components/ui/dropdown-menu"
36
- import { ViewSegmentedControl } from "@/components/ui/view-segmented-control"
37
- import { Tip } from "@/components/ui/tip"
38
- import { DragHandleGripIcon } from "@/components/ui/drag-handle-grip"
39
- import {
40
- ChartContainer,
41
- ChartTooltip,
42
- chartTooltipKeyboardSyncProps,
43
- ChartTooltipContent,
44
- type ChartConfig,
45
- } from "@/components/ui/chart"
46
- import {
47
- KEY_METRICS_KPI_COUNT_DEFAULT,
48
- KEY_METRICS_KPI_COUNT_MAX,
49
- KEY_METRICS_KPI_COUNT_MIN,
50
- mergeDashboardLayoutGeneric,
51
- } from "@/lib/dashboard-layout-merge"
52
- import { cn } from "@/lib/utils"
53
- import type { ComplianceItem } from "@/lib/mock/compliance"
54
- import {
55
- KEY_METRICS_CARD_ID,
56
- applyVisibleReorder,
57
- type ChartType,
58
- type DashboardLayout,
59
- } from "@/lib/data-view-dashboard-placements-layout"
60
- import {
61
- CHART_KBD_ACTIVE_BAR,
62
- CHART_KBD_ACTIVE_PIE_SHAPE,
63
- } from "@/lib/chart-keyboard-selection"
64
- import {
65
- loadDataViewLayout,
66
- saveDataViewLayout,
67
- } from "@/lib/data-view-dashboard-storage"
68
-
69
- const STATUS_CFG: ChartConfig = {
70
- value: { label: "Items", color: "var(--primary)" },
71
- }
72
-
73
- const CAT_CFG: ChartConfig = {
74
- value: { label: "Items", color: "var(--primary)" },
75
- }
76
-
77
- const CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const
78
- const CHART_MARGIN_HORIZONTAL = { top: 8, right: 8, left: 4, bottom: 0 } as const
79
-
80
- interface ComplianceDashboardCardDef {
81
- id: string
82
- title: string
83
- description: string
84
- defaultSpan: 1 | 2
85
- defaultChartType: ChartType
86
- chartTypes: { type: ChartType; label: string; icon: string }[]
87
- }
88
-
89
- export const ALL_COMPLIANCE_DASHBOARD_CARDS: ComplianceDashboardCardDef[] = [
90
- {
91
- id: KEY_METRICS_CARD_ID,
92
- title: "Key metrics",
93
- description: "Summary KPIs for filtered obligations",
94
- defaultSpan: 2,
95
- defaultChartType: "bar",
96
- chartTypes: [],
97
- },
98
- {
99
- id: "compliance-by-status",
100
- title: "Items by status",
101
- description: "Filtered compliance obligations",
102
- defaultSpan: 1,
103
- defaultChartType: "bar",
104
- chartTypes: [
105
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
106
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
107
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
108
- ],
109
- },
110
- {
111
- id: "compliance-by-category",
112
- title: "Items by category",
113
- description: "Top categories in this view",
114
- defaultSpan: 1,
115
- defaultChartType: "horizontal-bar",
116
- chartTypes: [
117
- { type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
118
- { type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
119
- { type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
120
- ],
121
- },
122
- ]
123
-
124
- export const DEFAULT_COMPLIANCE_VISIBLE_CARDS = ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => c.id)
125
- export const DEFAULT_COMPLIANCE_SPANS: Record<string, 1 | 2> = Object.fromEntries(
126
- ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => [c.id, c.defaultSpan]),
127
- )
128
- export const DEFAULT_COMPLIANCE_CHART_TYPES: Record<string, ChartType> = Object.fromEntries(
129
- ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => [c.id, c.defaultChartType]),
130
- )
131
-
132
- const COMPLIANCE_CHART_LEO_INSIGHTS: Partial<Record<string, ChartLeoInsight>> = {
133
- "compliance-by-status": {
134
- headline: "Overdue and at-risk items stand out in this mix",
135
- explanation:
136
- "When overdue or expiring-soon slices are visible, renewal timing may be out of sync with the cohort. Tightening reminders or batch renewals often reduces last-minute spikes.",
137
- },
138
- "compliance-by-category": {
139
- headline: "Workload clusters in a few categories",
140
- explanation:
141
- "Heavy bars in one or two categories usually signal template or training gaps—not random noise. Leo can help outline how to spread load or simplify the noisiest obligation types.",
142
- },
143
- }
144
-
145
- export function loadComplianceDashboardLayout(): DashboardLayout | null {
146
- const v = loadDataViewLayout("compliance")
147
- if (!v) return null
148
- return {
149
- visible: v.visible,
150
- order: v.order,
151
- spans: v.spans,
152
- chartTypes: v.chartTypes as Record<string, ChartType> | undefined,
153
- keyMetricsKpiCount: v.keyMetricsKpiCount,
154
- }
155
- }
156
-
157
- export function mergeComplianceDashboardLayout(saved: DashboardLayout | null): DashboardLayout {
158
- const defaults = {
159
- visible: [...DEFAULT_COMPLIANCE_VISIBLE_CARDS],
160
- order: ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => c.id),
161
- spans: { ...DEFAULT_COMPLIANCE_SPANS },
162
- chartTypes: { ...DEFAULT_COMPLIANCE_CHART_TYPES } as Record<string, string>,
163
- keyMetricsKpiCount: KEY_METRICS_KPI_COUNT_DEFAULT,
164
- }
165
- const ids = ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => c.id)
166
- const m = mergeDashboardLayoutGeneric(saved, defaults, ids)
167
- return {
168
- visible: m.visible,
169
- order: m.order,
170
- spans: m.spans as Record<string, 1 | 2>,
171
- chartTypes: m.chartTypes as Record<string, ChartType>,
172
- keyMetricsKpiCount: m.keyMetricsKpiCount,
173
- }
174
- }
175
-
176
- export function saveComplianceDashboardLayout(layout: DashboardLayout) {
177
- saveDataViewLayout("compliance", {
178
- visible: layout.visible,
179
- order: layout.order,
180
- spans: layout.spans,
181
- chartTypes: layout.chartTypes as Record<string, string> | undefined,
182
- keyMetricsKpiCount: layout.keyMetricsKpiCount,
183
- })
184
- }
185
-
186
- function EmptyChart({ message = "No items in this view." }: { message?: string }) {
187
- return (
188
- <div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground" role="status">
189
- {message}
190
- </div>
191
- )
192
- }
193
-
194
- const PIE_FILLS = [
195
- "var(--color-chart-1)",
196
- "var(--color-chart-2)",
197
- "var(--color-chart-3)",
198
- "var(--color-chart-4)",
199
- "var(--color-chart-5)",
200
- ]
201
-
202
- function ComplianceByStatusChart({ rows, chartType }: { rows: ComplianceItem[]; chartType: ChartType }) {
203
- const byStatus = React.useMemo(() => {
204
- const c = { compliant: 0, due_soon: 0, overdue: 0, pending: 0 }
205
- for (const r of rows) c[r.status]++
206
- return [
207
- { name: "Compliant", value: c.compliant },
208
- { name: "Due soon", value: c.due_soon },
209
- { name: "Overdue", value: c.overdue },
210
- { name: "Pending", value: c.pending },
211
- ]
212
- }, [rows])
213
-
214
- if (rows.length === 0) return <EmptyChart />
215
-
216
- const statusSummary = `Compliance status: ${byStatus.map(d => `${d.name} ${d.value}`).join(", ")}. Total ${rows.length} items.`
217
-
218
- if (chartType === "pie") {
219
- return (
220
- <ChartFigure label="Items by status" summary={statusSummary} dataLength={byStatus.length}>
221
- {(activeIndex) => (
222
- <>
223
- <ChartContainer config={STATUS_CFG} className="mx-auto aspect-square max-h-[220px] w-full">
224
- <PieChart>
225
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
226
- <Pie
227
- data={byStatus}
228
- dataKey="value"
229
- nameKey="name"
230
- innerRadius={48}
231
- outerRadius={80}
232
- strokeWidth={2}
233
- stroke="var(--card)"
234
- activeIndex={activeIndex ?? undefined}
235
- activeShape={CHART_KBD_ACTIVE_PIE_SHAPE}
236
- >
237
- {byStatus.map((_, i) => (
238
- <Cell key={i} fill={PIE_FILLS[i % PIE_FILLS.length]} />
239
- ))}
240
- </Pie>
241
- </PieChart>
242
- </ChartContainer>
243
- <ChartDataTable
244
- caption="Items by status"
245
- headers={["Status", "Count"]}
246
- rows={byStatus.map(d => [d.name, d.value])}
247
- />
248
- </>
249
- )}
250
- </ChartFigure>
251
- )
252
- }
253
-
254
- if (chartType === "horizontal-bar") {
255
- return (
256
- <ChartFigure label="Items by status" summary={statusSummary} dataLength={byStatus.length}>
257
- {(activeIndex) => (
258
- <>
259
- <ChartContainer config={STATUS_CFG} className="h-[220px] w-full">
260
- <BarChart data={byStatus} layout="vertical" margin={CHART_MARGIN}>
261
- <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
262
- <XAxis type="number" allowDecimals={false} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
263
- <YAxis type="category" dataKey="name" width={88} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
264
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
265
- <Bar
266
- dataKey="value"
267
- fill="var(--color-chart-2)"
268
- radius={[0, 4, 4, 0]}
269
- maxBarSize={22}
270
- activeBar={CHART_KBD_ACTIVE_BAR}
271
- activeIndex={activeIndex ?? undefined}
272
- >
273
- {byStatus.map((_, i) => (
274
- <Cell key={i} fill="var(--color-chart-2)" />
275
- ))}
276
- </Bar>
277
- </BarChart>
278
- </ChartContainer>
279
- <ChartDataTable
280
- caption="Items by status"
281
- headers={["Status", "Count"]}
282
- rows={byStatus.map(d => [d.name, d.value])}
283
- />
284
- </>
285
- )}
286
- </ChartFigure>
287
- )
288
- }
289
-
290
- return (
291
- <ChartFigure label="Items by status" summary={statusSummary} dataLength={byStatus.length}>
292
- {(activeIndex) => (
293
- <>
294
- <ChartContainer config={STATUS_CFG} className="h-[220px] w-full">
295
- <BarChart data={byStatus} margin={CHART_MARGIN}>
296
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
297
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
298
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
299
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
300
- <Bar
301
- dataKey="value"
302
- fill="var(--color-chart-2)"
303
- radius={[4, 4, 0, 0]}
304
- maxBarSize={40}
305
- activeBar={CHART_KBD_ACTIVE_BAR}
306
- activeIndex={activeIndex ?? undefined}
307
- >
308
- {byStatus.map((_, i) => (
309
- <Cell key={i} fill="var(--color-chart-2)" />
310
- ))}
311
- </Bar>
312
- </BarChart>
313
- </ChartContainer>
314
- <ChartDataTable
315
- caption="Items by status"
316
- headers={["Status", "Count"]}
317
- rows={byStatus.map(d => [d.name, d.value])}
318
- />
319
- </>
320
- )}
321
- </ChartFigure>
322
- )
323
- }
324
-
325
- function ComplianceByCategoryChart({ rows, chartType }: { rows: ComplianceItem[]; chartType: ChartType }) {
326
- const byCategory = React.useMemo(() => {
327
- const map = new Map<string, number>()
328
- for (const r of rows) map.set(r.category, (map.get(r.category) ?? 0) + 1)
329
- return [...map.entries()]
330
- .map(([name, value]) => ({ name: name.length > 24 ? `${name.slice(0, 22)}…` : name, value }))
331
- .sort((a, b) => b.value - a.value)
332
- }, [rows])
333
-
334
- if (rows.length === 0) return <EmptyChart />
335
-
336
- const categorySummary = `${byCategory.length} categories. Total ${rows.length} items.`
337
-
338
- if (chartType === "pie") {
339
- return (
340
- <ChartFigure label="Items by category" summary={categorySummary} dataLength={byCategory.length}>
341
- {(activeIndex) => (
342
- <>
343
- <ChartContainer config={CAT_CFG} className="mx-auto aspect-square max-h-[220px] w-full">
344
- <PieChart>
345
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
346
- <Pie
347
- data={byCategory}
348
- dataKey="value"
349
- nameKey="name"
350
- innerRadius={48}
351
- outerRadius={80}
352
- strokeWidth={2}
353
- stroke="var(--card)"
354
- activeIndex={activeIndex ?? undefined}
355
- activeShape={CHART_KBD_ACTIVE_PIE_SHAPE}
356
- >
357
- {byCategory.map((_, i) => (
358
- <Cell key={i} fill={PIE_FILLS[i % PIE_FILLS.length]} />
359
- ))}
360
- </Pie>
361
- </PieChart>
362
- </ChartContainer>
363
- <ChartDataTable
364
- caption="Items by category"
365
- headers={["Category", "Count"]}
366
- rows={byCategory.map(d => [d.name, d.value])}
367
- />
368
- </>
369
- )}
370
- </ChartFigure>
371
- )
372
- }
373
-
374
- if (chartType === "bar") {
375
- return (
376
- <ChartFigure label="Items by category" summary={categorySummary} dataLength={byCategory.length}>
377
- {(activeIndex) => (
378
- <>
379
- <ChartContainer config={CAT_CFG} className="h-[220px] w-full">
380
- <BarChart data={byCategory} margin={CHART_MARGIN}>
381
- <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
382
- <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
383
- <YAxis allowDecimals={false} width={32} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
384
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
385
- <Bar
386
- dataKey="value"
387
- fill="var(--color-chart-4)"
388
- radius={[4, 4, 0, 0]}
389
- maxBarSize={40}
390
- activeBar={CHART_KBD_ACTIVE_BAR}
391
- activeIndex={activeIndex ?? undefined}
392
- >
393
- {byCategory.map((_, i) => (
394
- <Cell key={i} fill="var(--color-chart-4)" />
395
- ))}
396
- </Bar>
397
- </BarChart>
398
- </ChartContainer>
399
- <ChartDataTable
400
- caption="Items by category"
401
- headers={["Category", "Count"]}
402
- rows={byCategory.map(d => [d.name, d.value])}
403
- />
404
- </>
405
- )}
406
- </ChartFigure>
407
- )
408
- }
409
-
410
- return (
411
- <ChartFigure label="Items by category" summary={categorySummary} dataLength={byCategory.length}>
412
- {(activeIndex) => (
413
- <>
414
- <ChartContainer config={CAT_CFG} className="h-[220px] w-full">
415
- <BarChart data={byCategory} layout="vertical" margin={CHART_MARGIN_HORIZONTAL}>
416
- <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
417
- <XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
418
- <YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
419
- <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
420
- <Bar
421
- dataKey="value"
422
- fill="var(--color-chart-4)"
423
- radius={[0, 4, 4, 0]}
424
- maxBarSize={22}
425
- activeBar={CHART_KBD_ACTIVE_BAR}
426
- activeIndex={activeIndex ?? undefined}
427
- >
428
- {byCategory.map((_, i) => (
429
- <Cell key={i} fill="var(--color-chart-4)" />
430
- ))}
431
- </Bar>
432
- </BarChart>
433
- </ChartContainer>
434
- <ChartDataTable
435
- caption="Items by category"
436
- headers={["Category", "Count"]}
437
- rows={byCategory.map(d => [d.name, d.value])}
438
- />
439
- </>
440
- )}
441
- </ChartFigure>
442
- )
443
- }
444
-
445
- const COMPLIANCE_CHART_RENDERERS: Record<string, React.FC<{ rows: ComplianceItem[]; chartType: ChartType }>> = {
446
- "compliance-by-status": ComplianceByStatusChart,
447
- "compliance-by-category": ComplianceByCategoryChart,
448
- }
449
-
450
- function SortableComplianceDashboardCard({
451
- card,
452
- rows,
453
- span,
454
- chartType,
455
- cardIndex,
456
- totalCards,
457
- onSpanChange,
458
- onChartTypeChange,
459
- onRemove,
460
- onMoveStep,
461
- keyMetrics,
462
- keyMetricsKpiCount,
463
- onKeyMetricsKpiCountChange,
464
- }: {
465
- card: ComplianceDashboardCardDef
466
- rows: ComplianceItem[]
467
- span: 1 | 2
468
- chartType: ChartType
469
- cardIndex: number
470
- totalCards: number
471
- onSpanChange: (id: string, span: 1 | 2) => void
472
- onChartTypeChange: (id: string, t: ChartType) => void
473
- onRemove: (id: string) => void
474
- onMoveStep: (direction: -1 | 1) => void
475
- keyMetrics?: { metrics: MetricItem[]; insight: MetricInsight } | null
476
- keyMetricsKpiCount: number
477
- onKeyMetricsKpiCountChange?: (n: number) => void
478
- }) {
479
- const {
480
- attributes,
481
- listeners,
482
- setNodeRef,
483
- setActivatorNodeRef,
484
- transform,
485
- transition,
486
- isDragging,
487
- } = useSortable({ id: card.id })
488
- const { chartVariant } = useChartVariant()
489
-
490
- const style: React.CSSProperties = {
491
- ...(transform ? { transform: CSS.Transform.toString(transform) } : {}),
492
- transition,
493
- }
494
-
495
- const isKeyMetrics = card.id === KEY_METRICS_CARD_ID
496
- const Renderer = isKeyMetrics ? null : COMPLIANCE_CHART_RENDERERS[card.id]
497
- if (!isKeyMetrics && !Renderer) return null
498
- if (isKeyMetrics && !keyMetrics) return null
499
-
500
- const canMoveEarlier = cardIndex > 0
501
- const canMoveLater = cardIndex < totalCards - 1
502
- const chartLeoInsight = COMPLIANCE_CHART_LEO_INSIGHTS[card.id]
503
-
504
- return (
505
- <div
506
- ref={setNodeRef}
507
- style={style}
508
- className={cn(
509
- "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",
510
- span === 2 ? "lg:col-span-2" : undefined,
511
- isDragging && "z-20 opacity-95 ring-2 ring-ring",
512
- )}
513
- >
514
- <div className="mb-2 flex w-full min-w-0 flex-wrap items-center gap-2" role="toolbar" aria-label={`${card.title} layout controls`}>
515
- <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
516
- <Tip label="Drag to reorder" side="top">
517
- <button
518
- type="button"
519
- ref={setActivatorNodeRef}
520
- 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"
521
- aria-label={`Drag to reorder ${card.title}`}
522
- {...attributes}
523
- {...listeners}
524
- >
525
- <DragHandleGripIcon className="text-[15px]" />
526
- </button>
527
- </Tip>
528
- {card.chartTypes.length > 0 ? (
529
- <ViewSegmentedControl
530
- aria-label={`Chart type for ${card.title}`}
531
- iconOnly
532
- value={chartType}
533
- onValueChange={v => onChartTypeChange(card.id, v as ChartType)}
534
- options={card.chartTypes.map(opt => ({
535
- value: opt.type,
536
- label: opt.label,
537
- icon: opt.icon,
538
- }))}
539
- />
540
- ) : null}
541
- {isKeyMetrics && onKeyMetricsKpiCountChange ? (
542
- <ViewSegmentedControl
543
- aria-label="Number of KPIs to show"
544
- iconOnly={false}
545
- value={String(keyMetricsKpiCount)}
546
- onValueChange={v => onKeyMetricsKpiCountChange(Number(v))}
547
- options={Array.from(
548
- { length: KEY_METRICS_KPI_COUNT_MAX - KEY_METRICS_KPI_COUNT_MIN + 1 },
549
- (_, i) => {
550
- const n = KEY_METRICS_KPI_COUNT_MIN + i
551
- return { value: String(n), label: String(n) }
552
- },
553
- )}
554
- />
555
- ) : null}
556
- <ViewSegmentedControl
557
- aria-label={`Width for ${card.title}`}
558
- iconOnly
559
- value={String(span) as "1" | "2"}
560
- onValueChange={v => onSpanChange(card.id, Number(v) as 1 | 2)}
561
- options={[
562
- { value: "1", label: "Half width", icon: "fa-light fa-table-columns" },
563
- { value: "2", label: "Full width (all columns)", icon: "fa-light fa-maximize" },
564
- ]}
565
- />
566
- </div>
567
- <div className="ms-auto flex shrink-0 items-center gap-1">
568
- <div
569
- 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"
570
- role="group"
571
- aria-label={`Reorder ${card.title}`}
572
- >
573
- <div className="flex items-center gap-0.5 lg:hidden">
574
- <Tip label="Move up" side="top">
575
- <Button
576
- type="button"
577
- variant="ghost"
578
- size="icon-sm"
579
- className="size-7 shrink-0"
580
- disabled={!canMoveEarlier}
581
- aria-label={`Move ${card.title} up`}
582
- onClick={() => onMoveStep(-1)}
583
- >
584
- <i className="fa-light fa-chevron-up text-xs" aria-hidden="true" />
585
- </Button>
586
- </Tip>
587
- <Tip label="Move down" side="top">
588
- <Button
589
- type="button"
590
- variant="ghost"
591
- size="icon-sm"
592
- className="size-7 shrink-0"
593
- disabled={!canMoveLater}
594
- aria-label={`Move ${card.title} down`}
595
- onClick={() => onMoveStep(1)}
596
- >
597
- <i className="fa-light fa-chevron-down text-xs" aria-hidden="true" />
598
- </Button>
599
- </Tip>
600
- </div>
601
- <div className="hidden items-center gap-0.5 lg:flex">
602
- <Tip label="Move left" side="top">
603
- <Button
604
- type="button"
605
- variant="ghost"
606
- size="icon-sm"
607
- className="size-7 shrink-0"
608
- disabled={!canMoveEarlier}
609
- aria-label={`Move ${card.title} left`}
610
- onClick={() => onMoveStep(-1)}
611
- >
612
- <i className="fa-light fa-chevron-left text-xs" aria-hidden="true" />
613
- </Button>
614
- </Tip>
615
- <Tip label="Move right" side="top">
616
- <Button
617
- type="button"
618
- variant="ghost"
619
- size="icon-sm"
620
- className="size-7 shrink-0"
621
- disabled={!canMoveLater}
622
- aria-label={`Move ${card.title} right`}
623
- onClick={() => onMoveStep(1)}
624
- >
625
- <i className="fa-light fa-chevron-right text-xs" aria-hidden="true" />
626
- </Button>
627
- </Tip>
628
- </div>
629
- </div>
630
- <Tip label={`Remove ${card.title}`} side="top">
631
- <Button
632
- type="button"
633
- variant="ghost"
634
- size="icon-sm"
635
- className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
636
- aria-label={`Remove ${card.title} from dashboard`}
637
- onClick={() => onRemove(card.id)}
638
- >
639
- <i className="fa-light fa-trash text-[13px]" aria-hidden="true" />
640
- </Button>
641
- </Tip>
642
- </div>
643
- </div>
644
- {isKeyMetrics && keyMetrics ? (
645
- <KeyMetrics
646
- variant="card"
647
- title={card.title}
648
- description={card.description}
649
- metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
650
- insight={keyMetrics.insight}
651
- metricsSingleRow
652
- metricsHalfWidthLayout={span === 1}
653
- className="w-full min-w-0"
654
- />
655
- ) : (
656
- <ChartCard
657
- variant={chartVariant}
658
- title={card.title}
659
- description={card.description}
660
- className="!h-auto min-h-0 shrink-0"
661
- leoInsight={chartLeoInsight}
662
- >
663
- {Renderer ? <Renderer rows={rows} chartType={chartType} /> : null}
664
- </ChartCard>
665
- )}
666
- </div>
667
- )
668
- }
669
-
670
- export interface ComplianceDashboardChartsSectionProps {
671
- rows: ComplianceItem[]
672
- keyMetrics: { metrics: MetricItem[]; insight: MetricInsight }
673
- visibleCards: string[]
674
- cardOrder: string[]
675
- cardSpans?: Record<string, 1 | 2>
676
- cardChartTypes?: Record<string, ChartType>
677
- keyMetricsKpiCount?: number
678
- layoutEditMode?: boolean
679
- onVisibleChange?: (visible: string[]) => void
680
- onOrderChange?: (order: string[]) => void
681
- onSpanChange?: (id: string, span: 1 | 2) => void
682
- onChartTypeChange?: (id: string, chartType: ChartType) => void
683
- onKeyMetricsKpiCountChange?: (count: number) => void
684
- onResetLayout?: () => void
685
- onLayoutEditDone?: () => void
686
- onLayoutEditCancel?: () => void
687
- }
688
-
689
- export function ComplianceDashboardChartsSection({
690
- rows,
691
- keyMetrics,
692
- visibleCards,
693
- cardOrder,
694
- cardSpans = DEFAULT_COMPLIANCE_SPANS,
695
- cardChartTypes = DEFAULT_COMPLIANCE_CHART_TYPES,
696
- keyMetricsKpiCount = KEY_METRICS_KPI_COUNT_DEFAULT,
697
- layoutEditMode = false,
698
- onVisibleChange,
699
- onOrderChange,
700
- onSpanChange,
701
- onChartTypeChange,
702
- onKeyMetricsKpiCountChange,
703
- onResetLayout,
704
- onLayoutEditDone,
705
- onLayoutEditCancel,
706
- }: ComplianceDashboardChartsSectionProps) {
707
- const { chartVariant } = useChartVariant()
708
- const defs = React.useMemo(() => new Map(ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => [c.id, c])), [])
709
-
710
- const orderedCards = React.useMemo(() => {
711
- return cardOrder
712
- .filter(id => visibleCards.includes(id) && defs.has(id))
713
- .map(id => defs.get(id)!)
714
- }, [visibleCards, cardOrder, defs])
715
-
716
- const hiddenCardDefs = React.useMemo(
717
- () => ALL_COMPLIANCE_DASHBOARD_CARDS.filter(c => !visibleCards.includes(c.id)),
718
- [visibleCards],
719
- )
720
-
721
- const sortableIds = React.useMemo(() => orderedCards.map(c => c.id), [orderedCards])
722
-
723
- const sensors = useSensors(
724
- useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
725
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
726
- )
727
-
728
- const handleDragEnd = React.useCallback(
729
- (event: DragEndEvent) => {
730
- if (!onOrderChange) return
731
- const { active, over } = event
732
- if (!over || active.id === over.id) return
733
- const oldIndex = sortableIds.indexOf(String(active.id))
734
- const newIndex = sortableIds.indexOf(String(over.id))
735
- if (oldIndex < 0 || newIndex < 0) return
736
- const nextVisibleOrder = arrayMove(sortableIds, oldIndex, newIndex)
737
- const visibleSet = new Set(visibleCards)
738
- onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
739
- },
740
- [cardOrder, onOrderChange, sortableIds, visibleCards],
741
- )
742
-
743
- const moveStep = React.useCallback(
744
- (id: string, direction: -1 | 1) => {
745
- if (!onOrderChange) return
746
- const idx = sortableIds.indexOf(id)
747
- if (idx < 0) return
748
- const newIdx = idx + direction
749
- if (newIdx < 0 || newIdx >= sortableIds.length) return
750
- const nextVisibleOrder = arrayMove(sortableIds, idx, newIdx)
751
- const visibleSet = new Set(visibleCards)
752
- onOrderChange(applyVisibleReorder(cardOrder, visibleSet, nextVisibleOrder))
753
- },
754
- [cardOrder, onOrderChange, sortableIds, visibleCards],
755
- )
756
-
757
- const addCard = React.useCallback(
758
- (id: string) => {
759
- if (!onVisibleChange) return
760
- if (visibleCards.includes(id)) return
761
- onVisibleChange([...visibleCards, id])
762
- },
763
- [onVisibleChange, visibleCards],
764
- )
765
-
766
- const removeCard = React.useCallback(
767
- (id: string) => {
768
- if (!onVisibleChange) return
769
- onVisibleChange(visibleCards.filter(v => v !== id))
770
- },
771
- [onVisibleChange, visibleCards],
772
- )
773
-
774
- if (orderedCards.length === 0) {
775
- return (
776
- <div className="flex flex-col items-center justify-center gap-3 px-4 py-12 text-center lg:px-6">
777
- <i className="fa-light fa-chart-column text-2xl text-muted-foreground/40" aria-hidden="true" />
778
- <p className="text-sm text-muted-foreground">
779
- No widgets on the dashboard.
780
- {layoutEditMode && hiddenCardDefs.length > 0 ? " Add a widget below." : " Turn on Edit layout and add widgets back."}
781
- </p>
782
- {layoutEditMode && hiddenCardDefs.length > 0 && onVisibleChange ? (
783
- <DropdownMenu>
784
- <DropdownMenuTrigger asChild>
785
- <Button type="button" variant="outline" size="sm" className="size-9 p-0" aria-label="Add widget">
786
- <i className="fa-light fa-plus text-sm" aria-hidden="true" />
787
- </Button>
788
- </DropdownMenuTrigger>
789
- <DropdownMenuContent align="center">
790
- {hiddenCardDefs.map(c => (
791
- <DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
792
- {c.title}
793
- </DropdownMenuItem>
794
- ))}
795
- </DropdownMenuContent>
796
- </DropdownMenu>
797
- ) : null}
798
- </div>
799
- )
800
- }
801
-
802
- const grid = (
803
- <div
804
- className={cn(
805
- "grid grid-cols-1 gap-4 lg:grid-cols-2",
806
- layoutEditMode && "lg:items-start lg:content-start lg:auto-rows-min",
807
- )}
808
- >
809
- {orderedCards.map((card, cardIndex) => {
810
- const isKeyMetricsCard = card.id === KEY_METRICS_CARD_ID
811
- const Renderer = isKeyMetricsCard ? null : COMPLIANCE_CHART_RENDERERS[card.id]
812
- if (!isKeyMetricsCard && !Renderer) return null
813
- const span = cardSpans[card.id] ?? card.defaultSpan
814
- const requestedType = cardChartTypes[card.id] ?? card.defaultChartType
815
- const allowedTypes = card.chartTypes.map(o => o.type)
816
- const chartType =
817
- allowedTypes.length === 0
818
- ? card.defaultChartType
819
- : allowedTypes.includes(requestedType)
820
- ? requestedType
821
- : card.defaultChartType
822
-
823
- if (
824
- layoutEditMode &&
825
- onOrderChange &&
826
- onSpanChange &&
827
- onChartTypeChange &&
828
- onVisibleChange
829
- ) {
830
- return (
831
- <SortableComplianceDashboardCard
832
- key={card.id}
833
- card={card}
834
- rows={rows}
835
- span={span}
836
- chartType={chartType}
837
- cardIndex={cardIndex}
838
- totalCards={orderedCards.length}
839
- onSpanChange={onSpanChange}
840
- onChartTypeChange={onChartTypeChange}
841
- onRemove={removeCard}
842
- onMoveStep={dir => moveStep(card.id, dir)}
843
- keyMetrics={isKeyMetricsCard ? keyMetrics : null}
844
- keyMetricsKpiCount={keyMetricsKpiCount}
845
- onKeyMetricsKpiCountChange={
846
- isKeyMetricsCard ? onKeyMetricsKpiCountChange : undefined
847
- }
848
- />
849
- )
850
- }
851
-
852
- return (
853
- <div
854
- key={card.id}
855
- className={cn(span === 2 ? "lg:col-span-2" : undefined)}
856
- >
857
- {isKeyMetricsCard ? (
858
- <KeyMetrics
859
- variant="card"
860
- title={card.title}
861
- description={card.description}
862
- metrics={keyMetrics.metrics.slice(0, keyMetricsKpiCount)}
863
- insight={keyMetrics.insight}
864
- metricsSingleRow
865
- metricsHalfWidthLayout={span === 1}
866
- className="w-full min-w-0"
867
- />
868
- ) : (
869
- <ChartCard
870
- variant={chartVariant}
871
- title={card.title}
872
- description={card.description}
873
- leoInsight={COMPLIANCE_CHART_LEO_INSIGHTS[card.id]}
874
- >
875
- {Renderer ? <Renderer rows={rows} chartType={chartType} /> : null}
876
- </ChartCard>
877
- )}
878
- </div>
879
- )
880
- })}
881
- </div>
882
- )
883
-
884
- const editToolbar =
885
- layoutEditMode && onVisibleChange && onResetLayout ? (
886
- <div
887
- className="mb-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-transparent px-3 py-2"
888
- role="region"
889
- aria-label="Dashboard layout options"
890
- >
891
- <p className="text-xs text-muted-foreground">Drag cards to reorder. Changes save automatically.</p>
892
- <div className="flex flex-wrap items-center justify-end gap-2">
893
- <Button
894
- type="button"
895
- size="sm"
896
- variant="ghost"
897
- className="h-8 text-xs"
898
- onClick={() => onVisibleChange(ALL_COMPLIANCE_DASHBOARD_CARDS.map(c => c.id))}
899
- >
900
- Show all
901
- </Button>
902
- <Button type="button" size="sm" variant="ghost" className="h-8 text-xs" onClick={() => onVisibleChange([])}>
903
- Hide all
904
- </Button>
905
- <Tip side="bottom" label="Reset visibility, order, widths, and chart types">
906
- <Button type="button" size="sm" variant="ghost" className="h-8 px-2 text-xs" onClick={onResetLayout}>
907
- <i className="fa-light fa-rotate-left me-1 text-xs" aria-hidden="true" />
908
- Reset
909
- </Button>
910
- </Tip>
911
- {hiddenCardDefs.length > 0 ? (
912
- <DropdownMenu>
913
- <DropdownMenuTrigger asChild>
914
- <Button type="button" variant="outline" size="sm" className="size-8 p-0" aria-label="Add widget">
915
- <i className="fa-light fa-plus text-[13px]" aria-hidden="true" />
916
- </Button>
917
- </DropdownMenuTrigger>
918
- <DropdownMenuContent align="end">
919
- {hiddenCardDefs.map(c => (
920
- <DropdownMenuItem key={c.id} onSelect={() => addCard(c.id)}>
921
- {c.title}
922
- </DropdownMenuItem>
923
- ))}
924
- </DropdownMenuContent>
925
- </DropdownMenu>
926
- ) : null}
927
- {onLayoutEditCancel ? (
928
- <Button type="button" size="sm" variant="outline" className="h-8 text-xs" onClick={onLayoutEditCancel}>
929
- Cancel
930
- </Button>
931
- ) : null}
932
- {onLayoutEditDone ? (
933
- <Button type="button" size="sm" className="h-8 text-xs" onClick={onLayoutEditDone}>
934
- Done
935
- </Button>
936
- ) : null}
937
- </div>
938
- </div>
939
- ) : null
940
-
941
- const gridBody =
942
- layoutEditMode && onOrderChange ? (
943
- <DndContext sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleDragEnd}>
944
- <SortableContext items={sortableIds} strategy={rectSortingStrategy}>
945
- {grid}
946
- </SortableContext>
947
- </DndContext>
948
- ) : (
949
- grid
950
- )
951
-
952
- return (
953
- <div
954
- className={cn(
955
- "flex flex-col gap-4 px-4 pb-2 lg:px-6",
956
- layoutEditMode && "rounded-xl border border-dashed border-border/80 bg-transparent py-3",
957
- )}
958
- >
959
- {editToolbar}
960
- {gridBody}
961
- </div>
962
- )
963
- }