@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,404 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * PlacementsBoardView — kanban-style board by lifecycle phase (domain-specific columns).
5
- * View chrome labels use `dataListViewLabel` from `@/lib/data-list-view` at the page level;
6
- * this component focuses on placement phase grouping + shared card primitives.
7
- */
8
-
9
- import * as React from "react"
10
- import { useRouter } from "next/navigation"
11
- import { cn } from "@/lib/utils"
12
- import type { Placement, PlacementPhase } from "@/lib/mock/placements"
13
- import { Input } from "@/components/ui/input"
14
- import { Tip } from "@/components/ui/tip"
15
- import {
16
- DropdownMenu,
17
- DropdownMenuContent,
18
- DropdownMenuItem,
19
- DropdownMenuSeparator,
20
- DropdownMenuSub,
21
- DropdownMenuSubContent,
22
- DropdownMenuSubTrigger,
23
- DropdownMenuTrigger,
24
- } from "@/components/ui/dropdown-menu"
25
- import { DEFAULT_DATA_LIST_DISPLAY_OPTIONS, type BoardLineCount } from "@/lib/data-list-display-options"
26
- import { type BoardCardLifecycleTabId } from "@/lib/placement-board-card-layout"
27
- import type { ConditionalRule } from "@/components/table-properties/types"
28
- import type { ColumnDef } from "@/components/data-table/types"
29
- import { Badge } from "@/components/ui/badge"
30
- import { BoardPlacementCard } from "@/components/placement-board-card"
31
- import { BoardNewCardPlaceholder } from "@/components/data-views/board-card-primitives"
32
-
33
- const PHASE_COLUMNS: { phase: PlacementPhase; label: string; description: string }[] = [
34
- { phase: "upcoming", label: "Upcoming", description: "Starting soon" },
35
- { phase: "ongoing", label: "Ongoing", description: "In progress" },
36
- { phase: "completed", label: "Completed", description: "Finished" },
37
- ]
38
-
39
- /** Substring match across visible card fields (per-phase quick search). */
40
- function rowMatchesPhaseSearch(row: Placement, q: string): boolean {
41
- if (!q.trim()) return true
42
- const lower = q.toLowerCase()
43
- const hay = [
44
- row.student,
45
- row.site,
46
- row.specialization,
47
- row.internship,
48
- row.program,
49
- row.status,
50
- row.supervisor,
51
- row.email,
52
- row.start,
53
- ]
54
- .map(v => String(v ?? "").toLowerCase())
55
- .join(" ")
56
- return hay.includes(lower)
57
- }
58
-
59
- export interface PlacementsBoardColumnMenu {
60
- filterableColumns: { key: string; label: string }[]
61
- sortableColumns: { key: string; label: string }[]
62
- groupableColumns: { key: string; label: string }[]
63
- groupBy: string | null
64
- onAddFilter: (fieldKey: string) => void
65
- onSortByField: (fieldKey: string, direction: "asc" | "desc") => void
66
- onToggleGroupBy: (fieldKey: string) => void
67
- onOpenProperties: () => void
68
- }
69
-
70
- export interface BoardDisplaySettings {
71
- lineCount: BoardLineCount
72
- showColumnLabels: boolean
73
- showColumnCounts: boolean
74
- newCardAbove: boolean
75
- }
76
-
77
- export interface PlacementsBoardViewProps {
78
- placements: Placement[]
79
- /** Current lifecycle filter tab — drives helper copy above the board. */
80
- lifecycleTabId: BoardCardLifecycleTabId
81
- /** When set, each phase column header shows the same actions as a DataTable column header. */
82
- boardColumnMenu?: PlacementsBoardColumnMenu
83
- /** Board display options (Properties → view display). */
84
- boardDisplay?: BoardDisplaySettings
85
- /** Column visibility from table state — hidden columns omit matching card fields. */
86
- hiddenColKeys?: Set<string>
87
- /** Same conditional formatting as the table (row background when a rule matches). */
88
- conditionalRules?: ConditionalRule[]
89
- /** Visible data columns (table order) — drives dates and other fields on the card. */
90
- boardColumns: ColumnDef<Placement>[]
91
- }
92
-
93
- function BoardPhaseColumnHeader({
94
- label,
95
- rawCount,
96
- filteredCount,
97
- searchValue,
98
- onSearchChange,
99
- menu,
100
- showLabels,
101
- showCounts,
102
- }: {
103
- label: string
104
- rawCount: number
105
- filteredCount: number
106
- searchValue: string
107
- onSearchChange: (value: string) => void
108
- menu: PlacementsBoardColumnMenu
109
- showLabels: boolean
110
- showCounts: boolean
111
- }) {
112
- const searchActive = Boolean(searchValue.trim())
113
- const countLabel =
114
- searchActive && filteredCount !== rawCount
115
- ? `${filteredCount} of ${rawCount} records`
116
- : `${filteredCount} ${filteredCount === 1 ? "record" : "records"}`
117
-
118
- const showLeft = showLabels || showCounts
119
-
120
- return (
121
- <div className="group/board-col border-b border-border px-3 py-2.5">
122
- <div className="flex items-center justify-between gap-2">
123
- {showLeft ? (
124
- <div className="flex min-w-0 flex-1 items-center gap-2">
125
- {showLabels ? (
126
- <p className="min-w-0 truncate text-sm font-semibold text-foreground">{label}</p>
127
- ) : null}
128
- {showCounts ? (
129
- <div className="flex shrink-0 items-center gap-1.5">
130
- <Badge
131
- variant="outline"
132
- className="inline-flex h-6 min-w-6 items-center justify-center border-0 bg-muted/70 px-2 text-xs font-semibold tabular-nums text-foreground"
133
- aria-label={countLabel}
134
- >
135
- {filteredCount}
136
- </Badge>
137
- {searchActive && filteredCount !== rawCount ? (
138
- <span className="text-xs font-medium tabular-nums text-muted-foreground" aria-hidden>
139
- / {rawCount}
140
- </span>
141
- ) : null}
142
- </div>
143
- ) : null}
144
- </div>
145
- ) : (
146
- <div className="min-w-0 flex-1" aria-hidden />
147
- )}
148
- <DropdownMenu>
149
- <Tip label="Column options" side="top">
150
- <DropdownMenuTrigger asChild>
151
- <button
152
- type="button"
153
- aria-label={`${label} column options`}
154
- onClick={e => e.stopPropagation()}
155
- className={cn(
156
- "opacity-0 group-hover/board-col:opacity-100 group-focus-within/board-col:opacity-100",
157
- "inline-flex shrink-0 items-center justify-center size-7 rounded-md",
158
- "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-row",
159
- "transition-opacity focus-visible:opacity-100",
160
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
161
- )}
162
- >
163
- <i className="fa-light fa-ellipsis-vertical text-xs" aria-hidden="true" />
164
- </button>
165
- </DropdownMenuTrigger>
166
- </Tip>
167
- <DropdownMenuContent align="end">
168
- <div className="px-2 pt-2 pb-1">
169
- <div className="relative">
170
- <i
171
- className="fa-light fa-magnifying-glass pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
172
- aria-hidden="true"
173
- />
174
- <Input
175
- placeholder={`Search ${label}…`}
176
- value={searchValue}
177
- onChange={e => onSearchChange(e.target.value)}
178
- onKeyDown={e => e.stopPropagation()}
179
- className="h-7 pl-6 text-xs"
180
- />
181
- {searchValue ? (
182
- <button
183
- type="button"
184
- aria-label="Clear search"
185
- onClick={() => onSearchChange("")}
186
- className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-interactive-hover-foreground"
187
- >
188
- <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
189
- </button>
190
- ) : null}
191
- </div>
192
- </div>
193
- <DropdownMenuSeparator />
194
-
195
- {menu.filterableColumns.length > 0 && (
196
- <>
197
- <DropdownMenuSub>
198
- <DropdownMenuSubTrigger>
199
- <i className="fa-light fa-filter" aria-hidden="true" />
200
- Filter by field…
201
- </DropdownMenuSubTrigger>
202
- <DropdownMenuSubContent className="max-h-[min(280px,60vh)] overflow-y-auto">
203
- {menu.filterableColumns.map(col => (
204
- <DropdownMenuItem key={col.key} onClick={() => menu.onAddFilter(col.key)}>
205
- {col.label}
206
- </DropdownMenuItem>
207
- ))}
208
- </DropdownMenuSubContent>
209
- </DropdownMenuSub>
210
- <DropdownMenuSeparator />
211
- </>
212
- )}
213
-
214
- {menu.sortableColumns.length > 0 && (
215
- <>
216
- <DropdownMenuSub>
217
- <DropdownMenuSubTrigger>
218
- <i className="fa-light fa-arrow-up-arrow-down" aria-hidden="true" />
219
- Sort by…
220
- </DropdownMenuSubTrigger>
221
- <DropdownMenuSubContent className="max-h-[min(320px,60vh)] overflow-y-auto">
222
- {menu.sortableColumns.map(col => (
223
- <React.Fragment key={col.key}>
224
- <DropdownMenuItem onClick={() => menu.onSortByField(col.key, "asc")}>
225
- <i className="fa-light fa-arrow-up-a-z" aria-hidden="true" />
226
- {col.label} — ascending
227
- </DropdownMenuItem>
228
- <DropdownMenuItem onClick={() => menu.onSortByField(col.key, "desc")}>
229
- <i className="fa-light fa-arrow-down-a-z" aria-hidden="true" />
230
- {col.label} — descending
231
- </DropdownMenuItem>
232
- </React.Fragment>
233
- ))}
234
- </DropdownMenuSubContent>
235
- </DropdownMenuSub>
236
- <DropdownMenuSeparator />
237
- </>
238
- )}
239
-
240
- {menu.groupableColumns.length > 0 && (
241
- <>
242
- <DropdownMenuSub>
243
- <DropdownMenuSubTrigger>
244
- <i className="fa-light fa-layer-group" aria-hidden="true" />
245
- Group by…
246
- </DropdownMenuSubTrigger>
247
- <DropdownMenuSubContent className="max-h-[min(280px,60vh)] overflow-y-auto">
248
- {menu.groupableColumns.map(col => (
249
- <DropdownMenuItem
250
- key={col.key}
251
- onClick={() => menu.onToggleGroupBy(col.key)}
252
- >
253
- {menu.groupBy === col.key ? (
254
- <>
255
- <i className="fa-light fa-check text-xs" aria-hidden="true" />
256
- Grouped by {col.label}
257
- </>
258
- ) : (
259
- <>
260
- <span className="inline-block w-3" aria-hidden />
261
- Group by {col.label}
262
- </>
263
- )}
264
- </DropdownMenuItem>
265
- ))}
266
- </DropdownMenuSubContent>
267
- </DropdownMenuSub>
268
- <DropdownMenuSeparator />
269
- </>
270
- )}
271
-
272
- <DropdownMenuItem onClick={menu.onOpenProperties}>
273
- <i className="fa-light fa-palette" aria-hidden="true" />
274
- Add conditional rule
275
- </DropdownMenuItem>
276
- </DropdownMenuContent>
277
- </DropdownMenu>
278
- </div>
279
- </div>
280
- )
281
- }
282
-
283
- export function PlacementsBoardView({
284
- placements,
285
- lifecycleTabId,
286
- boardColumnMenu,
287
- boardDisplay: boardDisplayProp,
288
- hiddenColKeys: hiddenColKeysProp,
289
- conditionalRules,
290
- boardColumns,
291
- }: PlacementsBoardViewProps) {
292
- const router = useRouter()
293
-
294
- const bd: BoardDisplaySettings = {
295
- lineCount: boardDisplayProp?.lineCount ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.boardLineCount,
296
- showColumnLabels: boardDisplayProp?.showColumnLabels ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.showColumnLabels,
297
- showColumnCounts: boardDisplayProp?.showColumnCounts ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.showBoardColumnCounts,
298
- newCardAbove: boardDisplayProp?.newCardAbove ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.boardNewCardAbove,
299
- }
300
- const hiddenColKeys = hiddenColKeysProp ?? new Set<string>()
301
-
302
- const [phaseSearch, setPhaseSearch] = React.useState<Record<PlacementPhase, string>>({
303
- upcoming: "",
304
- ongoing: "",
305
- completed: "",
306
- })
307
-
308
- const byPhase = React.useMemo(() => {
309
- const map: Record<PlacementPhase, Placement[]> = {
310
- upcoming: [],
311
- ongoing: [],
312
- completed: [],
313
- }
314
- for (const p of placements) {
315
- map[p.placementPhase].push(p)
316
- }
317
- return map
318
- }, [placements])
319
-
320
- const cardsByPhase = React.useMemo(() => {
321
- const out: Record<PlacementPhase, Placement[]> = {
322
- upcoming: [],
323
- ongoing: [],
324
- completed: [],
325
- }
326
- for (const phase of PHASE_COLUMNS.map(c => c.phase)) {
327
- const q = phaseSearch[phase]
328
- out[phase] = byPhase[phase].filter(row => rowMatchesPhaseSearch(row, q))
329
- }
330
- return out
331
- }, [byPhase, phaseSearch])
332
-
333
- return (
334
- <div className="px-4 pb-8 pt-2 lg:px-6">
335
- <p className="text-xs text-muted-foreground mb-4">
336
- {lifecycleTabId === "all"
337
- ? "Rows grouped by phase (same data as Table view and List view)."
338
- : `Filtered to ${lifecycleTabId} — cards shown in matching columns only.`}
339
- </p>
340
- <div className="grid grid-cols-1 gap-4 md:grid-cols-3 min-h-[min(480px,calc(100vh-14rem))]">
341
- {PHASE_COLUMNS.map(col => {
342
- const rawInPhase = byPhase[col.phase]
343
- const cards = cardsByPhase[col.phase]
344
-
345
- return (
346
- <div
347
- key={col.phase}
348
- className="group/board-col flex min-h-0 flex-col rounded-xl border border-border bg-muted/30"
349
- >
350
- {boardColumnMenu ? (
351
- <BoardPhaseColumnHeader
352
- label={col.label}
353
- rawCount={rawInPhase.length}
354
- filteredCount={cards.length}
355
- searchValue={phaseSearch[col.phase]}
356
- onSearchChange={v => setPhaseSearch(prev => ({ ...prev, [col.phase]: v }))}
357
- menu={boardColumnMenu}
358
- showLabels={bd.showColumnLabels}
359
- showCounts={bd.showColumnCounts}
360
- />
361
- ) : (
362
- <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
363
- {bd.showColumnLabels ? (
364
- <p className="min-w-0 truncate text-sm font-semibold text-foreground">{col.label}</p>
365
- ) : (
366
- <span className="min-w-0 flex-1" aria-hidden />
367
- )}
368
- {bd.showColumnCounts ? (
369
- <Badge
370
- variant="outline"
371
- className="inline-flex h-6 min-w-6 shrink-0 items-center justify-center border-0 bg-muted/70 px-2 text-xs font-semibold tabular-nums text-foreground"
372
- aria-label={`${rawInPhase.length} ${rawInPhase.length === 1 ? "record" : "records"}`}
373
- >
374
- {rawInPhase.length}
375
- </Badge>
376
- ) : null}
377
- </div>
378
- )}
379
- <div className="flex flex-1 flex-col gap-2 overflow-y-auto p-2">
380
- {bd.newCardAbove ? <BoardNewCardPlaceholder position="above" /> : null}
381
- {cards.length === 0 ? (
382
- <p className="px-2 py-6 text-center text-xs text-muted-foreground">No placements</p>
383
- ) : (
384
- cards.map(row => (
385
- <BoardPlacementCard
386
- key={row.id}
387
- row={row}
388
- lifecycleTabId={lifecycleTabId}
389
- hiddenColKeys={hiddenColKeys}
390
- lineCount={bd.lineCount}
391
- conditionalRules={conditionalRules}
392
- boardColumns={boardColumns}
393
- onOpen={id => router.push(`/data-list/${id}`)}
394
- />
395
- ))
396
- )}
397
- </div>
398
- </div>
399
- )
400
- })}
401
- </div>
402
- </div>
403
- )
404
- }
@@ -1,252 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * PlacementsClient — placements hub composition on the reusable
5
- * `ListPageTemplate`. Owns the per-page persisted layout (tabs, display
6
- * options, show-metrics toggle) and mounts `PlacementsTable` per tab.
7
- *
8
- * Uses centralized exports from `@/components/data-views`.
9
- */
10
-
11
- import * as React from "react"
12
- import { useRouter } from "next/navigation"
13
- import { useSidebar } from "@/components/ui/sidebar"
14
- import {
15
- ListPageTemplate,
16
- type ViewTab,
17
- PlacementsTable,
18
- type PlacementsTableHandle,
19
- type PlacementLifecycleTabId,
20
- type DataListViewType,
21
- dataListViewIcon,
22
- } from "@/components/data-views"
23
- import {
24
- emptyCopyForPlacementLifecycleTab,
25
- getPlacementColumnsForLifecycle,
26
- placementLifecycleDrawerLabels,
27
- } from "@/components/placements-table-columns"
28
- import { PlacementsPageHeader } from "@/components/placements-page-header"
29
- import {
30
- DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
31
- type DataListDisplayOptions,
32
- } from "@/lib/data-list-display-options"
33
- import { loadPageFromStorage, schedulePageSave } from "@/lib/data-list-persistence"
34
- import { KeyMetrics } from "@/components/key-metrics"
35
- import { placementsForPhase } from "@/lib/mock/placements"
36
- import { PLACEMENT_KPI_INSIGHT, PLACEMENT_KPI_METRICS } from "@/lib/mock/placements-kpi"
37
- import { useAskLeoPageContext } from "@/components/ask-leo-sidebar"
38
- import { CoachMark } from "@/components/ui/coach-mark"
39
- import { useCoachMark, type CoachMarkStep } from "@/hooks/use-coach-mark"
40
-
41
- /** Maps each view tab's `filterId` to the demo row segment — unknown ids fall back to all rows. */
42
- function segmentFilterToPhase(id: string): PlacementLifecycleTabId {
43
- if (id === "all" || id === "upcoming" || id === "ongoing" || id === "completed") {
44
- return id
45
- }
46
- return "all"
47
- }
48
-
49
- // ─────────────────────────────────────────────────────────────────────────────
50
- // Coach mark flow — Views & Properties tour
51
- // ─────────────────────────────────────────────────────────────────────────────
52
-
53
- const VIEWS_TOUR_STEPS: CoachMarkStep[] = [
54
- {
55
- id: "views-tabs",
56
- target: "[role='toolbar'][aria-label='Views']",
57
- side: "bottom",
58
- align: "start",
59
- title: "Switch Between Views",
60
- description:
61
- "Use these tabs to move between saved segments — All, Due soon, In progress, or Done. Each tab keeps its own layout and properties.",
62
- },
63
- {
64
- id: "views-settings",
65
- target: "[aria-label='View settings']",
66
- side: "bottom",
67
- align: "start",
68
- title: "Customise Each View",
69
- description:
70
- "Click the dropdown arrow to rename, duplicate, or edit a view. Choose between Table, List, Board, or Dashboard layouts.",
71
- },
72
- {
73
- id: "views-add",
74
- target: "button:has(.fa-plus) + .fa-plus, [aria-label='Views'] ~ button",
75
- side: "bottom",
76
- align: "start",
77
- title: "Create New Views",
78
- description:
79
- "Add custom views with different layouts and filters. Create a Board view for visual tracking, or a Dashboard for charts and KPIs.",
80
- },
81
- {
82
- id: "views-search",
83
- target: "button[aria-label='Search']",
84
- side: "bottom",
85
- align: "end",
86
- title: "Quick Search",
87
- description:
88
- "Instantly search across all visible columns. Press ⌘K to open search from anywhere on the page.",
89
- },
90
- {
91
- id: "views-filter",
92
- target: "button[aria-label='Add filter']:last-of-type, button:has(.fa-filter-list)",
93
- side: "bottom",
94
- align: "end",
95
- title: "Filter Your Data",
96
- description:
97
- "Add filters to narrow down results. Combine multiple conditions — filter by status, date, site, program, and more.",
98
- },
99
- {
100
- id: "views-properties",
101
- target: "button[aria-label='Properties']",
102
- side: "bottom",
103
- align: "end",
104
- title: "Table Properties",
105
- description:
106
- "Open the Properties panel to manage columns, sort order, conditional formatting, density, and gridlines. Everything is saved per view.",
107
- },
108
- ]
109
-
110
- // ─────────────────────────────────────────────────────────────────────────────
111
- // Config
112
- // ─────────────────────────────────────────────────────────────────────────────
113
-
114
- const DEFAULT_TABS: ViewTab[] = [
115
- { id: "all", label: "All", viewType: "table", icon: "fa-table", filterId: "all" },
116
- { id: "upcoming", label: "Due soon", viewType: "table", icon: "fa-calendar-clock", filterId: "upcoming" },
117
- { id: "ongoing", label: "In progress", viewType: "table", icon: "fa-circle-half-stroke", filterId: "ongoing" },
118
- { id: "completed", label: "Done", viewType: "table", icon: "fa-circle-check", filterId: "completed" },
119
- ]
120
-
121
- const LIFECYCLE_OPTIONS = [
122
- { id: "all", label: "All" },
123
- { id: "upcoming", label: "Due soon" },
124
- { id: "ongoing", label: "In progress" },
125
- { id: "completed", label: "Done" },
126
- ]
127
-
128
- // ─────────────────────────────────────────────────────────────────────────────
129
- // Component
130
- // ─────────────────────────────────────────────────────────────────────────────
131
-
132
- export function PlacementsClient() {
133
- const router = useRouter()
134
- const { setOpen } = useSidebar()
135
- const [showMetrics, setShowMetrics] = React.useState(true)
136
- const [exportOpen, setExportOpen] = React.useState(false)
137
- const [displayOptions, setDisplayOptions] = React.useState<DataListDisplayOptions>(DEFAULT_DATA_LIST_DISPLAY_OPTIONS)
138
- const [tabs, setTabs] = React.useState<ViewTab[]>(DEFAULT_TABS)
139
- const [activeTabId, setActiveTabId] = React.useState<string>(DEFAULT_TABS[0]?.id ?? "")
140
- const tableRef = React.useRef<PlacementsTableHandle>(null)
141
-
142
- const viewsTour = useCoachMark({
143
- flowId: "data-list-views-tour",
144
- steps: VIEWS_TOUR_STEPS,
145
- delay: 1200,
146
- })
147
-
148
- const activeTab = tabs.find((t) => t.id === activeTabId)
149
- const placementCount = activeTab
150
- ? placementsForPhase(activeTab.filterId as PlacementLifecycleTabId).length
151
- : 0
152
-
153
- useAskLeoPageContext(
154
- React.useMemo(
155
- () => ({
156
- title: "List hub",
157
- description: activeTab
158
- ? `${placementCount} row${placementCount === 1 ? "" : "s"} in “${activeTab.label}” · ${activeTab.viewType} view.`
159
- : undefined,
160
- suggestions: [
161
- "Which rows are due in the next 30 days?",
162
- "Summarize what is visible after my filters",
163
- "What columns help reviewers scan this grid quickly?",
164
- ],
165
- }),
166
- [activeTab, placementCount],
167
- ),
168
- )
169
-
170
- React.useLayoutEffect(() => {
171
- const p = loadPageFromStorage()
172
- if (!p) return
173
- setDisplayOptions({ ...DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ...p.displayOptions })
174
- setShowMetrics(p.showMetrics)
175
- setTabs(p.tabs)
176
- const nextActive = p.tabs.some(t => t.id === p.activeTabId) ? p.activeTabId : (p.tabs[0]?.id ?? "")
177
- setActiveTabId(nextActive)
178
- }, [])
179
-
180
- React.useEffect(() => {
181
- schedulePageSave({
182
- v: 1,
183
- displayOptions,
184
- showMetrics,
185
- tabs,
186
- activeTabId,
187
- })
188
- }, [displayOptions, showMetrics, tabs, activeTabId])
189
-
190
- function handleNewPlacement() {
191
- setOpen(false)
192
- setTimeout(() => router.push("/data-list/new"), 260)
193
- }
194
-
195
- return (
196
- <>
197
- <CoachMark state={viewsTour} />
198
- <div className="flex min-h-0 flex-1 flex-col">
199
- <ListPageTemplate
200
- tabs={tabs}
201
- onTabsChange={setTabs}
202
- activeTabId={activeTabId}
203
- onActiveTabChange={setActiveTabId}
204
- tablePropertiesRef={tableRef}
205
- header={
206
- <PlacementsPageHeader
207
- onNewPlacement={handleNewPlacement}
208
- onExport={() => setExportOpen(true)}
209
- showMetrics={showMetrics}
210
- onToggleMetrics={() => setShowMetrics(v => !v)}
211
- showTitleBlock={displayOptions.showViewTitle}
212
- />
213
- }
214
- metrics={
215
- <KeyMetrics
216
- variant="flat"
217
- metrics={PLACEMENT_KPI_METRICS}
218
- insight={PLACEMENT_KPI_INSIGHT}
219
- showHeader={false}
220
- metricsSingleRow
221
- />
222
- }
223
- showMetrics={showMetrics}
224
- defaultTabs={DEFAULT_TABS}
225
- filterOptions={LIFECYCLE_OPTIONS}
226
- filterLabel="Filter segment"
227
- getTabCount={(filterId) => placementsForPhase(segmentFilterToPhase(filterId)).length}
228
- renderContent={(tab, updateTab) => {
229
- const phase = segmentFilterToPhase(tab.filterId)
230
- return (
231
- <PlacementsTable
232
- key={tab.id}
233
- ref={tableRef}
234
- view={tab.viewType}
235
- onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
236
- lifecycleTabId={phase}
237
- getColumnsForLifecycle={getPlacementColumnsForLifecycle}
238
- emptyTableCopy={emptyCopyForPlacementLifecycleTab(phase)}
239
- lifecycleDrawerLabel={placementLifecycleDrawerLabels[phase]}
240
- displayOptions={displayOptions}
241
- onDisplayOptionsChange={patch =>
242
- setDisplayOptions(prev => ({ ...prev, ...patch }))}
243
- />
244
- )
245
- }}
246
- exportOpen={exportOpen}
247
- onExportOpenChange={setExportOpen}
248
- />
249
- </div>
250
- </>
251
- )
252
- }