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