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