@exxatdesignux/ui 0.2.18 → 0.2.19

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 (140) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/consumer-extras/AGENTS.md +76 -0
  3. package/consumer-extras/README.md +5 -1
  4. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +14 -3
  5. package/consumer-extras/cursor-skills/exxat-consumer-app/SKILL.md +37 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +21 -6
  7. package/consumer-extras/cursor-skills/exxat-focused-workflow-page/SKILL.md +57 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +4 -2
  9. package/consumer-extras/patterns/consumer-app-pattern.md +39 -0
  10. package/consumer-extras/patterns/consumer-upgrade-checklist.md +20 -0
  11. package/consumer-extras/patterns/data-views-pattern.md +40 -3
  12. package/consumer-extras/patterns/focused-workflow-page-pattern.md +84 -0
  13. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +5 -3
  14. package/package.json +2 -1
  15. package/src/components/ui/button-group.tsx +81 -0
  16. package/src/components/ui/button.tsx +4 -4
  17. package/src/globals.css +7 -1858
  18. package/src/theme.css +10 -1126
  19. package/src/tokens/README.md +15 -0
  20. package/src/tokens/base.css +337 -0
  21. package/src/tokens/high-contrast.css +1195 -0
  22. package/src/tokens/layers.css +224 -0
  23. package/src/tokens/tailwind-bridge.css +118 -0
  24. package/src/tokens/themes.css +201 -0
  25. package/template/AGENTS.md +60 -22
  26. package/template/app/(app)/dashboard/loading.tsx +3 -15
  27. package/template/app/(app)/dashboard/page.tsx +2 -14
  28. package/template/app/(app)/data-list/layout.tsx +43 -0
  29. package/template/app/(app)/data-list/page.tsx +2 -2
  30. package/template/app/(app)/examples/focused-workflow/page.tsx +5 -0
  31. package/template/app/(app)/examples/page.tsx +1 -0
  32. package/template/app/(app)/loading.tsx +1 -18
  33. package/template/app/(app)/question-bank/find/page.tsx +2 -1
  34. package/template/app/(app)/question-bank/library/page.tsx +2 -1
  35. package/template/app/(app)/question-bank/list/page.tsx +2 -1
  36. package/template/app/(app)/question-bank/new/page.tsx +15 -23
  37. package/template/app/(app)/question-bank/page.tsx +2 -1
  38. package/template/app/(app)/settings/page.tsx +4 -5
  39. package/template/app/globals.css +7 -1964
  40. package/template/components/app-route-loading.tsx +14 -0
  41. package/template/components/app-sidebar.tsx +70 -55
  42. package/template/components/data-views/index.ts +37 -9
  43. package/template/components/data-views/list-page-calendar-view.tsx +593 -0
  44. package/template/components/data-views/list-page-connected-view-body.tsx +66 -0
  45. package/template/components/data-views/list-page-folder-columns-panel.tsx +345 -0
  46. package/template/components/data-views/list-page-split-hub-chrome.tsx +8 -0
  47. package/template/components/examples/focused-workflow-showcase.tsx +183 -0
  48. package/template/components/list-hub-board-view.tsx +68 -0
  49. package/template/components/list-hub-client.tsx +186 -0
  50. package/template/components/list-hub-list-view.tsx +36 -0
  51. package/template/components/list-hub-panel-activator.tsx +8 -0
  52. package/template/components/list-hub-secondary-nav.tsx +121 -0
  53. package/template/components/list-hub-table.tsx +336 -0
  54. package/template/components/new-question-composer.tsx +6 -24
  55. package/template/components/product-switcher.tsx +3 -2
  56. package/template/components/question-bank-client.tsx +4 -1
  57. package/template/components/question-bank-folder-columns-panel.tsx +104 -0
  58. package/template/components/question-bank-table.tsx +143 -485
  59. package/template/components/secondary-panel/nav-link-rows.tsx +83 -0
  60. package/template/components/secondary-panel.tsx +4 -44
  61. package/template/components/secondary-panels/list-hub-panel.tsx +39 -0
  62. package/template/components/secondary-panels/question-bank-panel.tsx +39 -0
  63. package/template/components/secondary-panels/registry.tsx +15 -0
  64. package/template/components/settings-appearance-card.tsx +3 -2
  65. package/template/components/settings-client.tsx +59 -15
  66. package/template/components/settings-form-row.tsx +9 -4
  67. package/template/components/table-properties/drawer-button.tsx +13 -0
  68. package/template/components/table-properties/drawer.tsx +65 -4
  69. package/template/components/templates/focused-workflow-layouts.tsx +448 -0
  70. package/template/components/templates/focused-workflow-page-template.tsx +69 -0
  71. package/template/components/templates/list-page.tsx +29 -5
  72. package/template/components/templates/nested-secondary-panel-shell.tsx +2 -1
  73. package/template/components/templates/page-loading-shell.tsx +262 -0
  74. package/template/components/ui/button-group.tsx +1 -0
  75. package/template/docs/consumer-app-pattern.md +39 -0
  76. package/template/docs/data-views-pattern.md +40 -3
  77. package/template/docs/drawer-vs-dialog-pattern.md +3 -1
  78. package/template/docs/focused-workflow-page-pattern.md +84 -0
  79. package/template/docs/shell-surface-elevation-pattern.md +5 -3
  80. package/template/lib/command-menu-search-data.ts +11 -27
  81. package/template/lib/data-list-display-options.ts +16 -2
  82. package/template/lib/data-list-view-registry.ts +104 -0
  83. package/template/lib/data-list-view-surface.ts +15 -1
  84. package/template/lib/data-list-view.ts +10 -1
  85. package/template/lib/data-view-dashboard-storage.ts +38 -35
  86. package/template/lib/hub-connected-view-renderers.ts +58 -0
  87. package/template/lib/list-hub-nav.ts +121 -0
  88. package/template/lib/list-hub-supported-views.ts +10 -0
  89. package/template/lib/list-page-table-properties.ts +3 -7
  90. package/template/lib/list-status-badges.ts +4 -97
  91. package/template/lib/mock/list-hub-directory.ts +27 -0
  92. package/template/lib/mock/list-hub-kpi.ts +27 -0
  93. package/template/lib/mock/navigation.tsx +1 -0
  94. package/template/lib/page-loading-variant.ts +40 -0
  95. package/template/lib/question-bank-supported-views.ts +13 -0
  96. package/template/lib/table-state-lifecycle.ts +2 -2
  97. package/template/app/(app)/data-list/[id]/page.tsx +0 -44
  98. package/template/app/(app)/data-list/new/page.tsx +0 -34
  99. package/template/components/compliance-board-view.tsx +0 -142
  100. package/template/components/compliance-client.tsx +0 -92
  101. package/template/components/compliance-list-view.tsx +0 -54
  102. package/template/components/compliance-page-header.tsx +0 -89
  103. package/template/components/compliance-table.tsx +0 -612
  104. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  105. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  106. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  107. package/template/components/new-placement-back-btn.tsx +0 -28
  108. package/template/components/new-placement-form.tsx +0 -1068
  109. package/template/components/placement-board-card.tsx +0 -262
  110. package/template/components/placement-detail.tsx +0 -438
  111. package/template/components/placements-board-view.tsx +0 -404
  112. package/template/components/placements-client.tsx +0 -252
  113. package/template/components/placements-list-view.tsx +0 -171
  114. package/template/components/placements-page-header.tsx +0 -166
  115. package/template/components/placements-table-cells.test.tsx +0 -22
  116. package/template/components/placements-table-cells.tsx +0 -173
  117. package/template/components/placements-table-columns.tsx +0 -640
  118. package/template/components/placements-table.tsx +0 -1642
  119. package/template/components/rotations-empty-state.tsx +0 -50
  120. package/template/components/rotations-panel-activator.tsx +0 -8
  121. package/template/components/sites-all-client.tsx +0 -154
  122. package/template/components/sites-board-view.tsx +0 -67
  123. package/template/components/sites-list-view.tsx +0 -42
  124. package/template/components/sites-table.tsx +0 -382
  125. package/template/components/team-board-view.tsx +0 -122
  126. package/template/components/team-client.tsx +0 -100
  127. package/template/components/team-list-view.tsx +0 -59
  128. package/template/components/team-page-header.tsx +0 -92
  129. package/template/components/team-table.tsx +0 -693
  130. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  131. package/template/lib/mock/compliance-kpi.ts +0 -61
  132. package/template/lib/mock/compliance.ts +0 -146
  133. package/template/lib/mock/placements-kpi.ts +0 -134
  134. package/template/lib/mock/placements.ts +0 -183
  135. package/template/lib/mock/sites-directory.ts +0 -16
  136. package/template/lib/mock/sites-kpi.ts +0 -25
  137. package/template/lib/mock/team-kpi.ts +0 -60
  138. package/template/lib/mock/team.ts +0 -118
  139. package/template/lib/placement-board-card-layout.ts +0 -79
  140. package/template/lib/placement-lifecycle.ts +0 -5
@@ -1,693 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * Team roster — DataTable + TablePropertiesDrawer + table/list/board/panel/dashboard (shared `tableState.rows`).
5
- * Dashboard view uses `TeamDashboardChartsSection` (customise on canvas) + lib/mock/team-kpi.
6
- * Panel view uses `FinderPanelView` with status-based groups (Active, Away, Invited).
7
- */
8
-
9
- import * as React from "react"
10
- import { AvatarInitials } from "@/components/ui/avatar"
11
- import {
12
- TEAM_MEMBER_STATUS_BADGE_CLASS,
13
- TEAM_MEMBER_STATUS_ICON,
14
- TEAM_MEMBER_STATUS_LABEL,
15
- } from "@/lib/list-status-badges"
16
- import { mailtoHref } from "@/lib/mailto"
17
- import type { TeamMember } from "@/lib/mock/team"
18
- import { DataTable, DataTableToolbar } from "@/components/data-table"
19
- import {
20
- TeamDashboardChartsSection,
21
- DEFAULT_TEAM_CHART_TYPES,
22
- DEFAULT_TEAM_SPANS,
23
- ALL_TEAM_DASHBOARD_CARDS,
24
- loadTeamDashboardLayout,
25
- mergeTeamDashboardLayout,
26
- saveTeamDashboardLayout,
27
- } from "@/components/data-view-dashboard-charts-team"
28
- import { KEY_METRICS_KPI_COUNT_DEFAULT } from "@/lib/dashboard-layout-merge"
29
- import type { ChartType, DashboardLayout } from "@/lib/data-view-dashboard-placements-layout"
30
- import { TeamListView } from "@/components/team-list-view"
31
- import { TeamBoardView, TEAM_BOARD_GROUP_OPTIONS } from "@/components/team-board-view"
32
- import { FinderPanelView, type FinderGroup } from "@/components/data-views/finder-panel-view"
33
- import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
34
- import { teamKpiInsight, teamKpiMetrics } from "@/lib/mock/team-kpi"
35
- import { useRouter } from "next/navigation"
36
- import { cn } from "@/lib/utils"
37
- import type { DataListViewType } from "@/lib/data-list-view"
38
- import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
39
- import type { ColumnDef } from "@/components/data-table/types"
40
- import { useTableState } from "@/components/data-table/use-table-state"
41
- import { TablePropertiesDrawerButton } from "@/components/table-properties"
42
- import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
43
- import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
44
- import { Button } from "@/components/ui/button"
45
- import {
46
- DropdownMenu,
47
- DropdownMenuContent,
48
- DropdownMenuItem,
49
- DropdownMenuTrigger,
50
- } from "@/components/ui/dropdown-menu"
51
- import { Tip } from "@/components/ui/tip"
52
- import { CoachMark } from "@/components/ui/coach-mark"
53
- import { useCoachMark } from "@/hooks/use-coach-mark"
54
- import { DASHBOARD_CUSTOMIZE_COACH_STEPS } from "@/lib/dashboard-customize-coach-mark"
55
- import {
56
- DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
57
- type DataListDisplayOptions,
58
- } from "@/lib/data-list-display-options"
59
-
60
- function uniqueRoles(members: TeamMember[]) {
61
- return [...new Set(members.map(m => m.role))].sort().map(r => ({ value: r, label: r }))
62
- }
63
-
64
- function formatUsPhoneDigits(digits: string) {
65
- const d = digits.replace(/\D/g, "").slice(0, 10)
66
- if (d.length !== 10) return digits
67
- return `(${d.slice(0, 3)}) ${d.slice(3, 6)}-${d.slice(6)}`
68
- }
69
-
70
- const STATUS_FILTER_OPTS = [
71
- { value: "active", label: TEAM_MEMBER_STATUS_LABEL.active },
72
- { value: "away", label: TEAM_MEMBER_STATUS_LABEL.away },
73
- { value: "invited", label: TEAM_MEMBER_STATUS_LABEL.invited },
74
- ]
75
-
76
- // ─── Team-specific panel view helpers ──────────────────────────────────────
77
-
78
- function TeamFinderListRow({
79
- member,
80
- isSelected,
81
- }: {
82
- member: TeamMember
83
- isSelected: boolean
84
- }) {
85
- return (
86
- <div
87
- className={`flex w-full min-w-0 items-center gap-3 transition-colors duration-75 ${
88
- isSelected ? "bg-transparent text-accent-foreground" : "text-foreground"
89
- }`}
90
- >
91
- <AvatarInitials
92
- initials={member.initials}
93
- className={cn(
94
- "size-8 shrink-0 rounded-full text-[11px] font-semibold",
95
- isSelected ? "ring-2 ring-accent-foreground/35" : "",
96
- )}
97
- />
98
- <div className="min-w-0 flex-1">
99
- <p className={cn("truncate text-[13px] font-medium leading-tight", isSelected ? "text-accent-foreground" : "text-foreground")}>
100
- {member.name}
101
- </p>
102
- <p className={cn("mt-0.5 truncate text-[11px] leading-tight", isSelected ? "text-accent-foreground/80" : "text-muted-foreground")}>
103
- {member.role}
104
- </p>
105
- </div>
106
- {!isSelected && (
107
- <ListHubStatusBadge
108
- label={TEAM_MEMBER_STATUS_LABEL[member.status]}
109
- tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
110
- icon={TEAM_MEMBER_STATUS_ICON[member.status]}
111
- />
112
- )}
113
- </div>
114
- )
115
- }
116
-
117
- function TeamFinderDetail({
118
- member,
119
- }: {
120
- member: TeamMember
121
- }) {
122
- const router = useRouter()
123
-
124
- return (
125
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
126
- {/* Header */}
127
- <div className="flex shrink-0 items-start gap-4 border-b border-border px-5 py-4">
128
- <AvatarInitials initials={member.initials} className="size-14 shrink-0 rounded-full text-lg font-semibold" />
129
- <div className="min-w-0 flex-1">
130
- <h2 className="text-base font-semibold text-foreground leading-tight">{member.name}</h2>
131
- <p className="mt-0.5 text-[13px] text-muted-foreground">{member.role}</p>
132
- <div className="mt-2">
133
- <ListHubStatusBadge
134
- label={TEAM_MEMBER_STATUS_LABEL[member.status]}
135
- tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[member.status]}
136
- icon={TEAM_MEMBER_STATUS_ICON[member.status]}
137
- />
138
- </div>
139
- </div>
140
- <Tip side="bottom" label="Open full profile">
141
- <Button type="button" variant="outline" size="sm" className="shrink-0"
142
- onClick={() => router.push(`/team/${member.id}`)}
143
- aria-label={`Open full profile for ${member.name}`}>
144
- <i className="fa-light fa-arrow-up-right-from-square text-[12px]" aria-hidden="true" />
145
- Open
146
- </Button>
147
- </Tip>
148
- </div>
149
-
150
- {/* Fields */}
151
- <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
152
- <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
153
- <div className="flex flex-col gap-0.5">
154
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
155
- <i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
156
- </dt>
157
- <dd className="text-[13px]">
158
- <a href={mailtoHref(member.email)} className="text-interactive-foreground hover:underline">{member.email}</a>
159
- </dd>
160
- </div>
161
- <div className="flex flex-col gap-0.5">
162
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
163
- <i className="fa-light fa-briefcase text-[10px]" aria-hidden="true" /> Role
164
- </dt>
165
- <dd className="text-[13px] text-foreground">{member.role}</dd>
166
- </div>
167
- <div className="flex flex-col gap-0.5">
168
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
169
- <i className="fa-light fa-phone text-[10px]" aria-hidden="true" /> Phone
170
- </dt>
171
- <dd className="text-[13px] text-foreground">{member.phone}</dd>
172
- </div>
173
- </dl>
174
- </div>
175
- </div>
176
- )
177
- }
178
-
179
- // ─── Status groups for team panel view ────────────────────────────────────
180
-
181
- const TEAM_STATUS_GROUPS: Array<{ id: string; label: string; accent: string }> = [
182
- { id: "all", label: "All", accent: "bg-muted-foreground" },
183
- { id: "active", label: "Active", accent: "bg-success" },
184
- { id: "away", label: "Away", accent: "bg-warning" },
185
- { id: "invited", label: "Invited", accent: "bg-brand" },
186
- ]
187
-
188
- function buildTeamStatusGroups(members: TeamMember[]): FinderGroup[] {
189
- return TEAM_STATUS_GROUPS.map(sg => ({
190
- id: sg.id,
191
- label: sg.label,
192
- accent: sg.accent,
193
- count: sg.id === "all" ? members.length : members.filter(m => m.status === sg.id).length,
194
- }))
195
- }
196
-
197
- function columnToFilterFieldDef(c: ColumnDef<TeamMember>): FilterFieldDef | null {
198
- if (!c.filter) return null
199
- const f = c.filter
200
- const defaultOps: FilterOperator[] =
201
- f.type === "select" || f.type === "date"
202
- ? ["is", "is_not"]
203
- : ["contains", "not_contains"]
204
- return {
205
- key: c.key,
206
- label: c.label,
207
- icon: f.icon ?? "fa-filter",
208
- type: f.type,
209
- operators: (f.operators ?? defaultOps) as FilterOperator[],
210
- options: f.options,
211
- ...(f.textMask ? { textMask: f.textMask } : {}),
212
- }
213
- }
214
-
215
- function columnsToFilterFields(cols: ColumnDef<TeamMember>[]) {
216
- return cols.map(columnToFilterFieldDef).filter((x): x is FilterFieldDef => x !== null)
217
- }
218
-
219
- function buildTeamColumns(members: TeamMember[]): ColumnDef<TeamMember>[] {
220
- const roleOpts = uniqueRoles(members)
221
-
222
- const COLUMN_SELECT: ColumnDef<TeamMember> = {
223
- key: "select",
224
- label: "",
225
- width: 40,
226
- minWidth: 40,
227
- defaultPin: "left",
228
- lockPin: true,
229
- }
230
-
231
- const cols: ColumnDef<TeamMember>[] = [
232
- COLUMN_SELECT,
233
- {
234
- key: "name",
235
- label: "Name",
236
- width: 240,
237
- minWidth: 160,
238
- sortable: true,
239
- sortKey: "name",
240
- defaultPin: "left",
241
- filter: {
242
- type: "text",
243
- icon: "fa-user",
244
- operators: ["contains", "not_contains"],
245
- },
246
- cell: row => (
247
- <div className="flex items-center gap-2.5 min-w-0">
248
- <AvatarInitials initials={row.initials} className="size-8 shrink-0 text-xs" />
249
- <span className="truncate text-sm font-medium text-foreground">{row.name}</span>
250
- </div>
251
- ),
252
- },
253
- {
254
- key: "role",
255
- label: "Role",
256
- width: 200,
257
- minWidth: 140,
258
- sortable: true,
259
- sortKey: "role",
260
- filter: {
261
- type: "select",
262
- icon: "fa-briefcase",
263
- operators: ["is", "is_not"],
264
- options: roleOpts,
265
- },
266
- cell: row => <span className="text-sm text-foreground/90">{row.role}</span>,
267
- },
268
- {
269
- key: "email",
270
- label: "Email",
271
- width: 260,
272
- minWidth: 180,
273
- sortable: true,
274
- sortKey: "email",
275
- filter: {
276
- type: "text",
277
- icon: "fa-envelope",
278
- operators: ["contains", "not_contains"],
279
- },
280
- cell: row => (
281
- <a href={mailtoHref(row.email)} className="text-sm text-primary hover:underline truncate block">
282
- {row.email}
283
- </a>
284
- ),
285
- },
286
- {
287
- key: "phone",
288
- label: "Phone",
289
- width: 148,
290
- minWidth: 132,
291
- sortable: true,
292
- sortKey: "phone",
293
- filter: {
294
- type: "text",
295
- icon: "fa-phone",
296
- operators: ["contains", "not_contains"],
297
- textMask: "phone",
298
- },
299
- cell: row => (
300
- <a
301
- href={`tel:+1${row.phone}`}
302
- className="text-sm tabular-nums text-foreground/90 hover:text-primary hover:underline truncate block"
303
- >
304
- {formatUsPhoneDigits(row.phone)}
305
- </a>
306
- ),
307
- },
308
- {
309
- key: "status",
310
- label: "Status",
311
- width: 120,
312
- minWidth: 100,
313
- sortable: true,
314
- sortKey: "status",
315
- filter: {
316
- type: "select",
317
- icon: "fa-circle-dot",
318
- operators: ["is", "is_not"],
319
- options: STATUS_FILTER_OPTS,
320
- },
321
- cell: row => (
322
- <ListHubStatusBadge
323
- label={TEAM_MEMBER_STATUS_LABEL[row.status]}
324
- tintClassName={TEAM_MEMBER_STATUS_BADGE_CLASS[row.status]}
325
- icon={TEAM_MEMBER_STATUS_ICON[row.status]}
326
- />
327
- ),
328
- },
329
- {
330
- key: "actions",
331
- label: "",
332
- width: 48,
333
- minWidth: 48,
334
- defaultPin: "right",
335
- lockPin: true,
336
- cell: row => (
337
- <div className="flex items-center justify-center">
338
- <DropdownMenu>
339
- <DropdownMenuTrigger asChild>
340
- <Button size="icon-sm" variant="ghost" aria-label={`Actions for ${row.name}`}>
341
- <i className="fa-light fa-ellipsis text-sm" aria-hidden="true" />
342
- </Button>
343
- </DropdownMenuTrigger>
344
- <DropdownMenuContent align="end">
345
- <DropdownMenuItem onClick={() => window.open(mailtoHref(row.email))}>
346
- <i className="fa-light fa-envelope" aria-hidden="true" />
347
- Email
348
- </DropdownMenuItem>
349
- <DropdownMenuItem disabled>
350
- <i className="fa-light fa-user-gear" aria-hidden="true" />
351
- Manage access
352
- </DropdownMenuItem>
353
- </DropdownMenuContent>
354
- </DropdownMenu>
355
- </div>
356
- ),
357
- },
358
- ]
359
-
360
- return cols
361
- }
362
-
363
-
364
- export type TeamTableHandle = OpenTablePropertiesHandle
365
-
366
- export const TeamTable = React.forwardRef<
367
- TeamTableHandle,
368
- { members: TeamMember[]; view?: DataListViewType; onViewChange?: (v: DataListViewType) => void }
369
- >(function TeamTable({ members, view = "table", onViewChange }, ref) {
370
- const columns = React.useMemo(() => buildTeamColumns(members), [members])
371
- const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns])
372
- const fieldDefinitionsForDrawer = React.useMemo(
373
- () =>
374
- columns
375
- .filter(c => c.key !== "select" && c.key !== "actions")
376
- .map(c => ({ key: c.key, label: c.label, sortable: !!(c.sortable && (c.sortKey ?? c.key)) })),
377
- [columns],
378
- )
379
-
380
- const resolveColumnLabel = React.useCallback(
381
- (key: string) => columns.find(c => c.key === key)?.label ?? key,
382
- [columns],
383
- )
384
-
385
- const [displayOptions, setDisplayOptions] = React.useState<DataListDisplayOptions>(DEFAULT_DATA_LIST_DISPLAY_OPTIONS)
386
- const patchDisplay = React.useCallback((patch: Partial<DataListDisplayOptions>) => {
387
- setDisplayOptions(prev => ({ ...prev, ...patch }))
388
- }, [])
389
-
390
- const [conditionalRules, setConditionalRules] = React.useState<ConditionalRule[]>([])
391
-
392
- const addConditionalRule = React.useCallback((rule: Omit<ConditionalRule, "id">) => {
393
- setConditionalRules(prev => [...prev, { ...rule, id: `cr-${Date.now()}` }])
394
- }, [])
395
- const removeConditionalRule = React.useCallback((id: string) => {
396
- setConditionalRules(prev => prev.filter(r => r.id !== id))
397
- }, [])
398
- const updateConditionalRule = React.useCallback((id: string, patch: Partial<ConditionalRule>) => {
399
- setConditionalRules(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r))
400
- }, [])
401
-
402
- const tableState = useTableState(members, columns, { key: "name", dir: "asc" })
403
-
404
- const dashboardKpi = React.useMemo(
405
- () => ({
406
- metrics: teamKpiMetrics(tableState.rows),
407
- insight: teamKpiInsight(tableState.rows),
408
- }),
409
- [tableState.rows],
410
- )
411
-
412
- const [visibleTeamCards, setVisibleTeamCards] = React.useState<string[]>(() => ALL_TEAM_DASHBOARD_CARDS.map(c => c.id))
413
- const [teamCardOrder, setTeamCardOrder] = React.useState<string[]>(() => ALL_TEAM_DASHBOARD_CARDS.map(c => c.id))
414
- const [teamCardSpans, setTeamCardSpans] = React.useState<Record<string, 1 | 2>>(() => ({ ...DEFAULT_TEAM_SPANS }))
415
- const [teamCardChartTypes, setTeamCardChartTypes] = React.useState<Record<string, ChartType>>(() => ({ ...DEFAULT_TEAM_CHART_TYPES }))
416
- const [teamKeyMetricsKpiCount, setTeamKeyMetricsKpiCount] = React.useState<number>(KEY_METRICS_KPI_COUNT_DEFAULT)
417
- const [teamDashboardLayoutEdit, setTeamDashboardLayoutEdit] = React.useState(false)
418
- const teamDashboardLayoutHydrated = React.useRef(false)
419
- const teamDashboardLayoutEditBaselineRef = React.useRef<DashboardLayout | null>(null)
420
-
421
- React.useEffect(() => {
422
- const saved = loadTeamDashboardLayout()
423
- const m = mergeTeamDashboardLayout(saved)
424
- setVisibleTeamCards(m.visible)
425
- setTeamCardOrder(m.order)
426
- setTeamCardSpans(m.spans ?? { ...DEFAULT_TEAM_SPANS })
427
- setTeamCardChartTypes(m.chartTypes ?? { ...DEFAULT_TEAM_CHART_TYPES })
428
- setTeamKeyMetricsKpiCount(m.keyMetricsKpiCount ?? KEY_METRICS_KPI_COUNT_DEFAULT)
429
- teamDashboardLayoutHydrated.current = true
430
- }, [])
431
-
432
- React.useEffect(() => {
433
- if (!teamDashboardLayoutHydrated.current) return
434
- saveTeamDashboardLayout({
435
- visible: visibleTeamCards,
436
- order: teamCardOrder,
437
- spans: teamCardSpans,
438
- chartTypes: teamCardChartTypes,
439
- keyMetricsKpiCount: teamKeyMetricsKpiCount,
440
- })
441
- }, [visibleTeamCards, teamCardOrder, teamCardSpans, teamCardChartTypes, teamKeyMetricsKpiCount])
442
-
443
- const handleTeamVisibleChange = React.useCallback((v: string[]) => {
444
- setVisibleTeamCards(v)
445
- }, [])
446
-
447
- const handleTeamOrderChange = React.useCallback((o: string[]) => {
448
- setTeamCardOrder(o)
449
- }, [])
450
-
451
- const handleTeamSpanChange = React.useCallback((id: string, span: 1 | 2) => {
452
- setTeamCardSpans(prev => ({ ...prev, [id]: span }))
453
- }, [])
454
-
455
- const handleTeamChartTypeChange = React.useCallback((id: string, t: ChartType) => {
456
- setTeamCardChartTypes(prev => ({ ...prev, [id]: t }))
457
- }, [])
458
-
459
- const handleResetTeamDashboardLayout = React.useCallback(() => {
460
- setVisibleTeamCards(ALL_TEAM_DASHBOARD_CARDS.map(c => c.id))
461
- setTeamCardOrder(ALL_TEAM_DASHBOARD_CARDS.map(c => c.id))
462
- setTeamCardSpans({ ...DEFAULT_TEAM_SPANS })
463
- setTeamCardChartTypes({ ...DEFAULT_TEAM_CHART_TYPES })
464
- setTeamKeyMetricsKpiCount(KEY_METRICS_KPI_COUNT_DEFAULT)
465
- }, [])
466
-
467
- const handleTeamDashboardLayoutEditStart = React.useCallback(() => {
468
- teamDashboardLayoutEditBaselineRef.current = {
469
- visible: [...visibleTeamCards],
470
- order: [...teamCardOrder],
471
- spans: { ...teamCardSpans },
472
- chartTypes: { ...teamCardChartTypes },
473
- keyMetricsKpiCount: teamKeyMetricsKpiCount,
474
- }
475
- setTeamDashboardLayoutEdit(true)
476
- }, [visibleTeamCards, teamCardOrder, teamCardSpans, teamCardChartTypes, teamKeyMetricsKpiCount])
477
-
478
- const handleTeamDashboardLayoutEditDone = React.useCallback(() => {
479
- setTeamDashboardLayoutEdit(false)
480
- }, [])
481
-
482
- const handleTeamDashboardLayoutEditCancel = React.useCallback(() => {
483
- const b = teamDashboardLayoutEditBaselineRef.current
484
- if (b) {
485
- setVisibleTeamCards(b.visible)
486
- setTeamCardOrder(b.order)
487
- setTeamCardSpans(b.spans ?? { ...DEFAULT_TEAM_SPANS })
488
- setTeamCardChartTypes(b.chartTypes ?? { ...DEFAULT_TEAM_CHART_TYPES })
489
- setTeamKeyMetricsKpiCount(b.keyMetricsKpiCount ?? KEY_METRICS_KPI_COUNT_DEFAULT)
490
- }
491
- setTeamDashboardLayoutEdit(false)
492
- }, [])
493
-
494
- const dashboardCustomizeCoach = useCoachMark({
495
- flowId: "team-dashboard-customize",
496
- steps: DASHBOARD_CUSTOMIZE_COACH_STEPS,
497
- delay: 700,
498
- enabled: view === "dashboard",
499
- })
500
-
501
- React.useImperativeHandle(ref, () => ({
502
- openPropertiesDrawer: () => {
503
- tableState.setSheetOpen(true)
504
- },
505
- // `tableState` is freshly returned each render by useTableState; depending on
506
- // it would re-create the imperative handle on every render. Only the React
507
- // setter is needed (and is referentially stable).
508
- // eslint-disable-next-line react-hooks/exhaustive-deps
509
- }), [tableState.setSheetOpen])
510
-
511
- const teamPanelFinderGroups = React.useMemo(
512
- () => buildTeamStatusGroups(tableState.rows),
513
- [tableState.rows],
514
- )
515
-
516
- const teamBoardGroupKey = TEAM_BOARD_GROUP_OPTIONS.some(
517
- o => o.key === displayOptions.boardGroupByColumnKey,
518
- )
519
- ? displayOptions.boardGroupByColumnKey
520
- : "status"
521
-
522
- const drawerToolbarProps = {
523
- totalRows: members.length,
524
- filterFields,
525
- fieldDefinitions: fieldDefinitionsForDrawer,
526
- resolveColumnLabel,
527
- displayOptions,
528
- onDisplayOptionsChange: patchDisplay,
529
- conditionalRules,
530
- onAddConditionalRule: addConditionalRule,
531
- onRemoveConditionalRule: removeConditionalRule,
532
- onUpdateConditionalRule: updateConditionalRule,
533
- currentView: view,
534
- onViewChange,
535
- lifecycleTabLabel: "Team",
536
- boardGroupByColumnOptions: [...TEAM_BOARD_GROUP_OPTIONS],
537
- }
538
-
539
- const tableProps = {
540
- data: members,
541
- columns,
542
- getRowId: (row: TeamMember) => row.id,
543
- getRowSelectionLabel: (row: TeamMember) => row.name,
544
- selectable: true,
545
- searchable: displayOptions.showToolbarSearch,
546
- showColumnHeaders: displayOptions.showColumnLabels,
547
- groupable: true,
548
- defaultSort: { key: "name", dir: "asc" as const },
549
- emptyState: <p className="text-sm text-muted-foreground">No team members.</p>,
550
- conditionalRules,
551
- state: tableState,
552
- toolbarSlot: (s: ReturnType<typeof useTableState<TeamMember>>) => (
553
- <TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />
554
- ),
555
- bulkActionsSlot: (selected: Set<string | number>) => {
556
- const n = selected.size
557
- if (n === 0) return null
558
- return (
559
- <>
560
- <span className="sr-only">{n} selected</span>
561
- <Tip label="Export selection (demo)">
562
- <Button size="sm" variant="outline" type="button">
563
- <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
564
- Export
565
- </Button>
566
- </Tip>
567
- </>
568
- )
569
- },
570
- }
571
-
572
- if (view === "table") {
573
- return (
574
- <div className="pb-6">
575
- <DataTable<TeamMember> {...tableProps} />
576
- </div>
577
- )
578
- }
579
-
580
- const sharedToolbar = (
581
- <DataTableToolbar
582
- state={tableState}
583
- columns={columns}
584
- searchable={displayOptions.showToolbarSearch}
585
- searchAriaLabel="Search team members"
586
- toolbarSlot={s => <TablePropertiesDrawerButton {...drawerToolbarProps} state={s} />}
587
- />
588
- )
589
-
590
- if (view === "list") {
591
- return (
592
- <div className="flex min-h-0 flex-1 flex-col">
593
- {sharedToolbar}
594
- <TeamListView members={tableState.rows} onRowActivate={m => tableState.toggleRow(m.id)} />
595
- </div>
596
- )
597
- }
598
-
599
- if (view === "board") {
600
- return (
601
- <div className="flex min-h-0 flex-1 flex-col">
602
- {sharedToolbar}
603
- <TeamBoardView
604
- members={tableState.rows}
605
- groupByColumnKey={teamBoardGroupKey}
606
- onRowActivate={m => tableState.toggleRow(m.id)}
607
- />
608
- </div>
609
- )
610
- }
611
-
612
- if (view === "panel") {
613
- return (
614
- <div className="flex min-h-0 flex-1 flex-col">
615
- {sharedToolbar}
616
- <ListPageSplitHubChrome aria-label="Team members panel view">
617
- <FinderPanelView<TeamMember>
618
- embedded
619
- groupsColumnTitle="Status"
620
- groups={teamPanelFinderGroups}
621
- rows={tableState.rows}
622
- getRowId={r => r.id}
623
- getRowGroupId={r => r.status}
624
- defaultGroupId="all"
625
- autoSaveId="team-panel-view"
626
- ariaLabel="Team members panel view"
627
- emptyList={<p>No team members found</p>}
628
- renderListRow={(member, isSelected) => (
629
- <TeamFinderListRow member={member} isSelected={isSelected} />
630
- )}
631
- renderDetail={member => (
632
- <TeamFinderDetail member={member} />
633
- )}
634
- />
635
- </ListPageSplitHubChrome>
636
- </div>
637
- )
638
- }
639
-
640
- return (
641
- <div className="flex min-h-0 flex-1 flex-col">
642
- <CoachMark state={dashboardCustomizeCoach} />
643
- {!teamDashboardLayoutEdit ? (
644
- <DataTableToolbar
645
- state={tableState}
646
- columns={columns}
647
- searchable={displayOptions.showToolbarSearch}
648
- searchAriaLabel="Search team members"
649
- toolbarSlot={s => (
650
- <TablePropertiesDrawerButton
651
- {...drawerToolbarProps}
652
- state={s}
653
- extraActions={
654
- <Tip side="bottom" label="Edit dashboard layout on canvas">
655
- <Button
656
- type="button"
657
- variant="ghost"
658
- size="icon-sm"
659
- aria-label="Edit dashboard layout"
660
- onClick={handleTeamDashboardLayoutEditStart}
661
- className="text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover"
662
- >
663
- <i className="fa-light fa-pen-ruler text-[13px]" aria-hidden="true" />
664
- </Button>
665
- </Tip>
666
- }
667
- />
668
- )}
669
- />
670
- ) : null}
671
- <TeamDashboardChartsSection
672
- members={tableState.rows as TeamMember[]}
673
- keyMetrics={dashboardKpi}
674
- visibleCards={visibleTeamCards}
675
- cardOrder={teamCardOrder}
676
- cardSpans={teamCardSpans}
677
- cardChartTypes={teamCardChartTypes}
678
- keyMetricsKpiCount={teamKeyMetricsKpiCount}
679
- layoutEditMode={teamDashboardLayoutEdit}
680
- onVisibleChange={handleTeamVisibleChange}
681
- onOrderChange={handleTeamOrderChange}
682
- onSpanChange={handleTeamSpanChange}
683
- onChartTypeChange={handleTeamChartTypeChange}
684
- onKeyMetricsKpiCountChange={setTeamKeyMetricsKpiCount}
685
- onResetLayout={handleResetTeamDashboardLayout}
686
- onLayoutEditDone={handleTeamDashboardLayoutEditDone}
687
- onLayoutEditCancel={handleTeamDashboardLayoutEditCancel}
688
- />
689
- </div>
690
- )
691
- })
692
-
693
- TeamTable.displayName = "TeamTable"