@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,1675 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * PlacementsTable — placements hub composition on top of the generic
5
- * `DataTable`. Owns: placement-specific column defs, board column grouping,
6
- * KPI dashboards, the "open table properties" imperative handle, and the
7
- * lifecycle persistence wiring (via `useTableStateLifecycle`).
8
- *
9
- * NOTE: this is hub composition, NOT a parallel table primitive. Every hub
10
- * has its own `*-table.tsx` of the same shape (`team-table.tsx`,
11
- * `compliance-table.tsx`, …); all of them render `<DataTable>` from
12
- * `@/components/data-table`.
13
- *
14
- * View tabs drive `view` (table | list | board | …). `lifecycleTabId` selects which **demo row
15
- * segment** (columns + filtered rows) to use — keep in sync with each tab's `filterId`, or pass
16
- * `"all"` for tabs that only change layout.
17
- */
18
-
19
- import * as React from "react"
20
- import dynamic from "next/dynamic"
21
- import { cn } from "@/lib/utils"
22
- import { mailtoHref } from "@/lib/mailto"
23
- import { useRouter } from "next/navigation"
24
- import { Button } from "@/components/ui/button"
25
- import { Tip } from "@/components/ui/tip"
26
- import { Skeleton } from "@/components/ui/skeleton"
27
- import { KEY_METRICS_KPI_COUNT_DEFAULT } from "@/lib/dashboard-layout-merge"
28
- import {
29
- ALL_DASHBOARD_CARDS,
30
- DEFAULT_VISIBLE_CARDS,
31
- DEFAULT_SPANS,
32
- DEFAULT_CHART_TYPES,
33
- loadDashboardLayout,
34
- mergeDashboardLayout,
35
- saveDashboardLayout,
36
- type ChartType,
37
- type DashboardLayout,
38
- } from "@/lib/data-view-dashboard-placements-layout"
39
- import { CoachMark } from "@/components/ui/coach-mark"
40
- import { useCoachMark } from "@/hooks/use-coach-mark"
41
- import { DASHBOARD_CUSTOMIZE_COACH_STEPS } from "@/lib/dashboard-customize-coach-mark"
42
- import { PlacementsBoardView, type PlacementsBoardColumnMenu } from "@/components/placements-board-view"
43
- import { PlacementsListView } from "@/components/placements-list-view"
44
- import { FolderGridView, ListPageTreePanelShell } from "@/components/data-views"
45
- import { ListPageSplitHubChrome } from "@/components/data-views/list-page-split-hub-chrome"
46
- import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
47
- import { FinderPanelView, type FinderGroup } from "@/components/data-views/finder-panel-view"
48
- import { ListPageSplitDetailsPlaceholder } from "@/components/data-views/list-page-split-details-placeholder"
49
- import { AvatarInitials } from "@/components/ui/avatar"
50
- import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
51
- import { isBoardFieldActive } from "@/lib/placement-board-card-layout"
52
- import type { BoardCardLifecycleTabId } from "@/lib/placement-board-card-layout"
53
- import { TablePropertiesDrawerButton } from "@/components/table-properties"
54
- import type { FilterFieldDef } from "@/components/table-properties/types"
55
- import type { DataListViewType } from "@/lib/data-list-view"
56
- import {
57
- DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
58
- type DataListDisplayOptions,
59
- } from "@/lib/data-list-display-options"
60
- import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
61
- import type { PlacementsLifecycleExtras } from "@/lib/data-list-persistence"
62
-
63
- /** Storage namespace for the placements hub. Keep `"data-list"` so existing
64
- * user payloads in localStorage remain readable. */
65
- const PLACEMENTS_LIFECYCLE_NAMESPACE = "data-list"
66
- import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
67
- import { StatusBadge } from "@/components/placements-table-cells"
68
- import { columnsToFilterFields } from "@/components/placements-table-columns"
69
- import { DataTable, DataTableToolbar } from "@/components/data-table"
70
- import { CountSyncer, PaginationBar } from "@/components/data-table/pagination"
71
- import type { DataTableExtendedProps } from "@/components/data-table"
72
- import type { ColumnDef, ConditionalRule } from "@/components/data-table/types"
73
- import { useTableState } from "@/components/data-table/use-table-state"
74
- import { placementsForPhase, type Placement, type Status } from "@/lib/mock/placements"
75
- import type { PlacementLifecycleTabId } from "@/lib/placement-lifecycle"
76
- import { placementKpiInsightFromRows, placementKpiMetricsFromRows } from "@/lib/mock/placements-kpi"
77
-
78
- const PlacementsDashboardChartsSection = dynamic(
79
- () =>
80
- import("@/components/data-view-dashboard-charts").then(mod => ({
81
- default: mod.PlacementsDashboardChartsSection,
82
- })),
83
- {
84
- ssr: false,
85
- loading: () => (
86
- <div className="mx-4 mb-8 mt-2 flex flex-col gap-3 border border-border rounded-xl p-6 lg:mx-6">
87
- <Skeleton className="h-7 w-48 max-w-full" />
88
- <Skeleton className="min-h-[200px] w-full rounded-lg" />
89
- <Skeleton className="min-h-[200px] w-full rounded-lg" />
90
- </div>
91
- ),
92
- },
93
- )
94
-
95
- function DataListBoardShell({
96
- state,
97
- openDrawerRef,
98
- tableData,
99
- columns,
100
- lifecycleTabId,
101
- view,
102
- onViewChange,
103
- pagination,
104
- onPaginationChange,
105
- conditionalRules,
106
- onAddConditionalRule,
107
- onRemoveConditionalRule,
108
- onUpdateConditionalRule,
109
- filterFields,
110
- lifecycleDrawerLabel,
111
- fieldDefinitionsForDrawer,
112
- resolveColumnLabel,
113
- renderFilterOptionValue,
114
- displayOptions,
115
- onDisplayOptionsChange,
116
- }: {
117
- state: ReturnType<typeof useTableState<Placement>>
118
- openDrawerRef: React.MutableRefObject<() => void>
119
- tableData: Placement[]
120
- columns: ColumnDef<Placement>[]
121
- lifecycleTabId: PlacementLifecycleTabId
122
- view: DataListViewType
123
- onViewChange?: (view: DataListViewType) => void
124
- pagination: boolean
125
- onPaginationChange: (v: boolean) => void
126
- conditionalRules: ConditionalRule[]
127
- onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
128
- onRemoveConditionalRule: (id: string) => void
129
- onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
130
- filterFields: FilterFieldDef[]
131
- lifecycleDrawerLabel: string
132
- fieldDefinitionsForDrawer: { key: string; label: string; sortable?: boolean }[]
133
- resolveColumnLabel: (key: string) => string
134
- renderFilterOptionValue: (fieldKey: string, value: string) => React.ReactNode
135
- displayOptions: DataListDisplayOptions
136
- onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
137
- }) {
138
- // Store the "open properties drawer" callback on a stable ref so the parent
139
- // imperative handle can invoke it without re-rendering the whole table.
140
- // `state` is freshly returned each render by useTableState; only the React
141
- // setter is stable and needed here.
142
- React.useEffect(() => {
143
- openDrawerRef.current = () => state.setSheetOpen(true)
144
- // eslint-disable-next-line react-hooks/exhaustive-deps
145
- }, [openDrawerRef, state.setSheetOpen])
146
-
147
- const boardColumnMenu: PlacementsBoardColumnMenu = React.useMemo(
148
- () => ({
149
- filterableColumns: columns.filter(c => c.filter).map(c => ({ key: c.key, label: c.label })),
150
- sortableColumns: columns.filter(c => c.sortable && c.sortKey).map(c => ({ key: c.key, label: c.label })),
151
- groupableColumns: columns.filter(c => c.key !== "select" && c.key !== "actions").map(c => ({ key: c.key, label: c.label })),
152
- groupBy: state.groupBy,
153
- onAddFilter: state.addFilter,
154
- onSortByField: (fieldKey, direction) => {
155
- state.setSortRules(prev => {
156
- const filtered = prev.filter(r => r.fieldKey !== fieldKey)
157
- return [{ id: `sort-${Date.now()}`, fieldKey, direction }, ...filtered]
158
- })
159
- },
160
- onToggleGroupBy: (fieldKey: string) => {
161
- state.setGroupBy(prev => (prev === fieldKey ? null : fieldKey))
162
- },
163
- onOpenProperties: () => state.setSheetOpen(true),
164
- }),
165
- [columns, state],
166
- )
167
-
168
- return (
169
- <>
170
- <DataTableToolbar
171
- state={state}
172
- columns={columns}
173
- searchable
174
- renderFilterOptionValue={renderFilterOptionValue}
175
- searchAriaLabel="Search rows"
176
- toolbarSlot={(s) => (
177
- <TablePropertiesDrawerButton
178
- state={s}
179
- totalRows={tableData.length}
180
- pagination={pagination}
181
- onPaginationChange={onPaginationChange}
182
- conditionalRules={conditionalRules}
183
- onAddConditionalRule={onAddConditionalRule}
184
- onRemoveConditionalRule={onRemoveConditionalRule}
185
- onUpdateConditionalRule={onUpdateConditionalRule}
186
- filterFields={filterFields}
187
- currentView={view}
188
- onViewChange={onViewChange}
189
- lifecycleTabLabel={lifecycleDrawerLabel}
190
- fieldDefinitions={fieldDefinitionsForDrawer}
191
- resolveColumnLabel={resolveColumnLabel}
192
- displayOptions={displayOptions}
193
- onDisplayOptionsChange={onDisplayOptionsChange}
194
- renderFilterOptionValue={renderFilterOptionValue}
195
- />
196
- )}
197
- />
198
- <PlacementsBoardView
199
- placements={state.rows as Placement[]}
200
- lifecycleTabId={lifecycleTabId}
201
- boardColumnMenu={boardColumnMenu}
202
- boardDisplay={{
203
- lineCount: displayOptions.boardLineCount,
204
- showColumnLabels: displayOptions.showColumnLabels,
205
- showColumnCounts: displayOptions.showBoardColumnCounts,
206
- newCardAbove: displayOptions.boardNewCardAbove,
207
- }}
208
- hiddenColKeys={state.hiddenCols}
209
- conditionalRules={conditionalRules}
210
- boardColumns={state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")}
211
- />
212
- </>
213
- )
214
- }
215
-
216
- /** List / row view: shared table state + toolbar + full-width rows */
217
- function DataListListShell({
218
- state,
219
- openDrawerRef,
220
- tableData,
221
- columns,
222
- lifecycleTabId,
223
- view,
224
- onViewChange,
225
- pagination,
226
- onPaginationChange,
227
- conditionalRules,
228
- onAddConditionalRule,
229
- onRemoveConditionalRule,
230
- onUpdateConditionalRule,
231
- filterFields,
232
- lifecycleDrawerLabel,
233
- fieldDefinitionsForDrawer,
234
- resolveColumnLabel,
235
- renderFilterOptionValue,
236
- displayOptions,
237
- onDisplayOptionsChange,
238
- listRows,
239
- emptyTableCopy,
240
- }: {
241
- state: ReturnType<typeof useTableState<Placement>>
242
- openDrawerRef: React.MutableRefObject<() => void>
243
- tableData: Placement[]
244
- columns: ColumnDef<Placement>[]
245
- lifecycleTabId: PlacementLifecycleTabId
246
- view: DataListViewType
247
- onViewChange?: (view: DataListViewType) => void
248
- pagination: boolean
249
- onPaginationChange: (v: boolean) => void
250
- conditionalRules: ConditionalRule[]
251
- onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
252
- onRemoveConditionalRule: (id: string) => void
253
- onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
254
- filterFields: FilterFieldDef[]
255
- lifecycleDrawerLabel: string
256
- fieldDefinitionsForDrawer: { key: string; label: string; sortable?: boolean }[]
257
- resolveColumnLabel: (key: string) => string
258
- renderFilterOptionValue: (fieldKey: string, value: string) => React.ReactNode
259
- displayOptions: DataListDisplayOptions
260
- onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
261
- listRows: Placement[]
262
- emptyTableCopy: string
263
- }) {
264
- // Stable "open properties drawer" callback ref — see top of this file.
265
- React.useEffect(() => {
266
- openDrawerRef.current = () => state.setSheetOpen(true)
267
- // eslint-disable-next-line react-hooks/exhaustive-deps
268
- }, [openDrawerRef, state.setSheetOpen])
269
-
270
- return (
271
- <>
272
- <DataTableToolbar
273
- state={state}
274
- columns={columns}
275
- searchable
276
- renderFilterOptionValue={renderFilterOptionValue}
277
- searchAriaLabel="Search rows"
278
- toolbarSlot={s => (
279
- <TablePropertiesDrawerButton
280
- state={s}
281
- totalRows={tableData.length}
282
- pagination={pagination}
283
- onPaginationChange={onPaginationChange}
284
- conditionalRules={conditionalRules}
285
- onAddConditionalRule={onAddConditionalRule}
286
- onRemoveConditionalRule={onRemoveConditionalRule}
287
- onUpdateConditionalRule={onUpdateConditionalRule}
288
- filterFields={filterFields}
289
- currentView={view}
290
- onViewChange={onViewChange}
291
- lifecycleTabLabel={lifecycleDrawerLabel}
292
- fieldDefinitions={fieldDefinitionsForDrawer}
293
- resolveColumnLabel={resolveColumnLabel}
294
- displayOptions={displayOptions}
295
- onDisplayOptionsChange={onDisplayOptionsChange}
296
- renderFilterOptionValue={renderFilterOptionValue}
297
- />
298
- )}
299
- />
300
- <PlacementsListView
301
- rows={listRows}
302
- lifecycleTabId={lifecycleTabId}
303
- hiddenColKeys={state.hiddenCols}
304
- boardColumns={state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")}
305
- conditionalRules={conditionalRules}
306
- emptyCopy={emptyTableCopy}
307
- />
308
- </>
309
- )
310
- }
311
-
312
- /** Dashboard view tab: same toolbar + properties as list/board; KPIs from filtered rows. */
313
- function DataListDashboardShell({
314
- state,
315
- openDrawerRef,
316
- tableData,
317
- columns,
318
- view,
319
- onViewChange,
320
- pagination,
321
- onPaginationChange,
322
- conditionalRules,
323
- onAddConditionalRule,
324
- onRemoveConditionalRule,
325
- onUpdateConditionalRule,
326
- filterFields,
327
- lifecycleDrawerLabel,
328
- fieldDefinitionsForDrawer,
329
- resolveColumnLabel,
330
- renderFilterOptionValue,
331
- displayOptions,
332
- onDisplayOptionsChange,
333
- }: {
334
- state: ReturnType<typeof useTableState<Placement>>
335
- openDrawerRef: React.MutableRefObject<() => void>
336
- tableData: Placement[]
337
- columns: ColumnDef<Placement>[]
338
- view: DataListViewType
339
- onViewChange?: (view: DataListViewType) => void
340
- pagination: boolean
341
- onPaginationChange: (v: boolean) => void
342
- conditionalRules: ConditionalRule[]
343
- onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
344
- onRemoveConditionalRule: (id: string) => void
345
- onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
346
- filterFields: FilterFieldDef[]
347
- lifecycleDrawerLabel: string
348
- fieldDefinitionsForDrawer: { key: string; label: string; sortable?: boolean }[]
349
- resolveColumnLabel: (key: string) => string
350
- renderFilterOptionValue: (fieldKey: string, value: string) => React.ReactNode
351
- displayOptions: DataListDisplayOptions
352
- onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
353
- }) {
354
- // Stable "open properties drawer" callback ref — see top of this file.
355
- React.useEffect(() => {
356
- openDrawerRef.current = () => state.setSheetOpen(true)
357
- // eslint-disable-next-line react-hooks/exhaustive-deps
358
- }, [openDrawerRef, state.setSheetOpen])
359
-
360
- const dashboardKpi = React.useMemo(
361
- () => ({
362
- metrics: placementKpiMetricsFromRows(state.rows as Placement[]),
363
- insight: placementKpiInsightFromRows(state.rows as Placement[]),
364
- }),
365
- [state.rows],
366
- )
367
-
368
- /* Dashboard card layout — persisted to localStorage */
369
- const [visibleCards, setVisibleCards] = React.useState<string[]>(DEFAULT_VISIBLE_CARDS)
370
- const [cardOrder, setCardOrder] = React.useState<string[]>(ALL_DASHBOARD_CARDS.map(c => c.id))
371
- const [cardSpans, setCardSpans] = React.useState<Record<string, 1 | 2>>(() => ({ ...DEFAULT_SPANS }))
372
- const [cardChartTypes, setCardChartTypes] = React.useState<Record<string, ChartType>>(() => ({ ...DEFAULT_CHART_TYPES }))
373
- const [keyMetricsKpiCount, setKeyMetricsKpiCount] = React.useState<number>(KEY_METRICS_KPI_COUNT_DEFAULT)
374
- const [dashboardLayoutEdit, setDashboardLayoutEdit] = React.useState(false)
375
- const dashboardLayoutHydrated = React.useRef(false)
376
- const dashboardLayoutEditBaselineRef = React.useRef<DashboardLayout | null>(null)
377
-
378
- React.useEffect(() => {
379
- const saved = loadDashboardLayout()
380
- const m = mergeDashboardLayout(saved)
381
- setVisibleCards(m.visible)
382
- setCardOrder(m.order)
383
- setCardSpans(m.spans ?? { ...DEFAULT_SPANS })
384
- setCardChartTypes(m.chartTypes ?? { ...DEFAULT_CHART_TYPES })
385
- setKeyMetricsKpiCount(m.keyMetricsKpiCount ?? KEY_METRICS_KPI_COUNT_DEFAULT)
386
- dashboardLayoutHydrated.current = true
387
- }, [])
388
-
389
- React.useEffect(() => {
390
- if (!dashboardLayoutHydrated.current) return
391
- saveDashboardLayout({
392
- visible: visibleCards,
393
- order: cardOrder,
394
- spans: cardSpans,
395
- chartTypes: cardChartTypes,
396
- keyMetricsKpiCount,
397
- })
398
- }, [visibleCards, cardOrder, cardSpans, cardChartTypes, keyMetricsKpiCount])
399
-
400
- const handleVisibleChange = React.useCallback((v: string[]) => {
401
- setVisibleCards(v)
402
- }, [])
403
-
404
- const handleOrderChange = React.useCallback((o: string[]) => {
405
- setCardOrder(o)
406
- }, [])
407
-
408
- const handleSpanChange = React.useCallback((id: string, span: 1 | 2) => {
409
- setCardSpans(prev => ({ ...prev, [id]: span }))
410
- }, [])
411
-
412
- const handleChartTypeChange = React.useCallback((id: string, t: ChartType) => {
413
- setCardChartTypes(prev => ({ ...prev, [id]: t }))
414
- }, [])
415
-
416
- const handleResetDashboardLayout = React.useCallback(() => {
417
- setVisibleCards(ALL_DASHBOARD_CARDS.map(c => c.id))
418
- setCardOrder(ALL_DASHBOARD_CARDS.map(c => c.id))
419
- setCardSpans({ ...DEFAULT_SPANS })
420
- setCardChartTypes({ ...DEFAULT_CHART_TYPES })
421
- setKeyMetricsKpiCount(KEY_METRICS_KPI_COUNT_DEFAULT)
422
- }, [])
423
-
424
- const handleDashboardLayoutEditStart = React.useCallback(() => {
425
- dashboardLayoutEditBaselineRef.current = {
426
- visible: [...visibleCards],
427
- order: [...cardOrder],
428
- spans: { ...cardSpans },
429
- chartTypes: { ...cardChartTypes },
430
- keyMetricsKpiCount,
431
- }
432
- setDashboardLayoutEdit(true)
433
- }, [visibleCards, cardOrder, cardSpans, cardChartTypes, keyMetricsKpiCount])
434
-
435
- const handleDashboardLayoutEditDone = React.useCallback(() => {
436
- setDashboardLayoutEdit(false)
437
- }, [])
438
-
439
- const handleDashboardLayoutEditCancel = React.useCallback(() => {
440
- const b = dashboardLayoutEditBaselineRef.current
441
- if (b) {
442
- setVisibleCards(b.visible)
443
- setCardOrder(b.order)
444
- setCardSpans(b.spans ?? { ...DEFAULT_SPANS })
445
- setCardChartTypes(b.chartTypes ?? { ...DEFAULT_CHART_TYPES })
446
- setKeyMetricsKpiCount(b.keyMetricsKpiCount ?? KEY_METRICS_KPI_COUNT_DEFAULT)
447
- }
448
- setDashboardLayoutEdit(false)
449
- }, [])
450
-
451
- const dashboardCustomizeCoach = useCoachMark({
452
- flowId: "data-list-dashboard-customize",
453
- steps: DASHBOARD_CUSTOMIZE_COACH_STEPS,
454
- delay: 700,
455
- dependsOnDismissedFlowId: "data-list-views-tour",
456
- })
457
-
458
- return (
459
- <>
460
- <CoachMark state={dashboardCustomizeCoach} />
461
- {!dashboardLayoutEdit ? (
462
- <DataTableToolbar
463
- state={state}
464
- columns={columns}
465
- searchable={displayOptions.showToolbarSearch}
466
- renderFilterOptionValue={renderFilterOptionValue}
467
- searchAriaLabel="Search rows"
468
- toolbarSlot={s => (
469
- <TablePropertiesDrawerButton
470
- state={s}
471
- totalRows={tableData.length}
472
- pagination={pagination}
473
- onPaginationChange={onPaginationChange}
474
- conditionalRules={conditionalRules}
475
- onAddConditionalRule={onAddConditionalRule}
476
- onRemoveConditionalRule={onRemoveConditionalRule}
477
- onUpdateConditionalRule={onUpdateConditionalRule}
478
- filterFields={filterFields}
479
- currentView={view}
480
- onViewChange={onViewChange}
481
- lifecycleTabLabel={lifecycleDrawerLabel}
482
- fieldDefinitions={fieldDefinitionsForDrawer}
483
- resolveColumnLabel={resolveColumnLabel}
484
- displayOptions={displayOptions}
485
- onDisplayOptionsChange={onDisplayOptionsChange}
486
- renderFilterOptionValue={renderFilterOptionValue}
487
- extraActions={
488
- <Tip side="bottom" label="Edit dashboard layout on canvas">
489
- <Button
490
- type="button"
491
- variant="ghost"
492
- size="icon-sm"
493
- aria-label="Edit dashboard layout"
494
- onClick={handleDashboardLayoutEditStart}
495
- className="text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover"
496
- >
497
- <i className="fa-light fa-pen-ruler text-[13px]" aria-hidden="true" />
498
- </Button>
499
- </Tip>
500
- }
501
- />
502
- )}
503
- />
504
- ) : null}
505
-
506
- {/* Contextual placement charts + KPI card (customise on canvas) */}
507
- <PlacementsDashboardChartsSection
508
- placements={state.rows as Placement[]}
509
- keyMetrics={dashboardKpi}
510
- visibleCards={visibleCards}
511
- cardOrder={cardOrder}
512
- cardSpans={cardSpans}
513
- cardChartTypes={cardChartTypes}
514
- keyMetricsKpiCount={keyMetricsKpiCount}
515
- layoutEditMode={dashboardLayoutEdit}
516
- onVisibleChange={handleVisibleChange}
517
- onOrderChange={handleOrderChange}
518
- onSpanChange={handleSpanChange}
519
- onChartTypeChange={handleChartTypeChange}
520
- onKeyMetricsKpiCountChange={setKeyMetricsKpiCount}
521
- onResetLayout={handleResetDashboardLayout}
522
- onLayoutEditDone={handleDashboardLayoutEditDone}
523
- onLayoutEditCancel={handleDashboardLayoutEditCancel}
524
- />
525
- </>
526
- )
527
- }
528
-
529
- // ─── Placement-specific tile for FolderGridView ──────────────────────────────
530
-
531
- function PlacementFolderTile({
532
- row,
533
- tab,
534
- hiddenColKeys,
535
- boardColumns,
536
- conditionalRules,
537
- onClick,
538
- }: {
539
- row: Placement
540
- tab: BoardCardLifecycleTabId
541
- hiddenColKeys: Set<string>
542
- boardColumns: ColumnDef<Placement>[]
543
- conditionalRules?: ConditionalRule[]
544
- onClick: () => void
545
- }) {
546
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
547
- const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
548
- const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
549
- const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
550
- const showSpec = isBoardFieldActive("specialization", tab, hiddenColKeys, boardColumns)
551
- const showProgram = isBoardFieldActive("program", tab, hiddenColKeys, boardColumns)
552
- const name = showStudent ? row.student : `Placement ${row.id}`
553
-
554
- const statusDotClass: Record<Status, string> = {
555
- confirmed: "bg-success",
556
- pending: "bg-warning",
557
- "under-review": "bg-brand",
558
- completed: "bg-muted-foreground",
559
- rejected: "bg-destructive",
560
- }
561
-
562
- return (
563
- <button
564
- type="button"
565
- onClick={onClick}
566
- className={`group relative flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-left hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-all duration-100 cursor-pointer select-none w-full ${ruleBg}`}
567
- aria-label={`Open ${name}`}
568
- >
569
- <div className="relative">
570
- <AvatarInitials initials={row.initials} className="size-14 rounded-full text-lg font-semibold" />
571
- {showStatus && (
572
- <span className="absolute -bottom-0.5 -right-1 flex size-4 items-center justify-center rounded-full bg-card ring-2 ring-card" aria-hidden="true">
573
- <span className={`size-2.5 rounded-full ${statusDotClass[row.status]}`} />
574
- </span>
575
- )}
576
- </div>
577
- <p className="w-full text-center text-[13px] font-medium text-foreground leading-tight line-clamp-2">{name}</p>
578
- {showStatus && <StatusBadge status={row.status} />}
579
- {(showSite || showSpec || showProgram) && (
580
- <div className="flex w-full flex-col gap-0.5">
581
- {showSite && (
582
- <p className="truncate text-center text-[11px] text-muted-foreground leading-tight">
583
- <i className="fa-light fa-building mr-1" aria-hidden="true" />{row.site}
584
- </p>
585
- )}
586
- {showSpec && <p className="truncate text-center text-[11px] text-muted-foreground leading-tight">{row.specialization}</p>}
587
- {showProgram && <p className="truncate text-center text-[11px] text-muted-foreground leading-tight">{row.program}</p>}
588
- </div>
589
- )}
590
- </button>
591
- )
592
- }
593
-
594
-
595
- // ─── Folder view shell ────────────────────────────────────────────────────────
596
-
597
- /** Folder / icon-grid view shell */
598
- function DataListFolderShell({
599
- state,
600
- openDrawerRef,
601
- tableData,
602
- columns,
603
- lifecycleTabId,
604
- view,
605
- onViewChange,
606
- pagination,
607
- onPaginationChange,
608
- conditionalRules,
609
- onAddConditionalRule,
610
- onRemoveConditionalRule,
611
- onUpdateConditionalRule,
612
- filterFields,
613
- lifecycleDrawerLabel,
614
- fieldDefinitionsForDrawer,
615
- resolveColumnLabel,
616
- renderFilterOptionValue,
617
- displayOptions,
618
- onDisplayOptionsChange,
619
- listRows,
620
- emptyTableCopy,
621
- }: {
622
- state: ReturnType<typeof useTableState<Placement>>
623
- openDrawerRef: React.MutableRefObject<() => void>
624
- tableData: Placement[]
625
- columns: ColumnDef<Placement>[]
626
- lifecycleTabId: PlacementLifecycleTabId
627
- view: DataListViewType
628
- onViewChange?: (view: DataListViewType) => void
629
- pagination: boolean
630
- onPaginationChange: (v: boolean) => void
631
- conditionalRules: ConditionalRule[]
632
- onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
633
- onRemoveConditionalRule: (id: string) => void
634
- onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
635
- filterFields: FilterFieldDef[]
636
- lifecycleDrawerLabel: string
637
- fieldDefinitionsForDrawer: { key: string; label: string; sortable?: boolean }[]
638
- resolveColumnLabel: (key: string) => string
639
- renderFilterOptionValue: (fieldKey: string, value: string) => React.ReactNode
640
- displayOptions: DataListDisplayOptions
641
- onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
642
- listRows: Placement[]
643
- emptyTableCopy: string
644
- }) {
645
- const router = useRouter()
646
-
647
- // Stable "open properties drawer" callback ref — see top of this file.
648
- React.useEffect(() => {
649
- openDrawerRef.current = () => state.setSheetOpen(true)
650
- // eslint-disable-next-line react-hooks/exhaustive-deps
651
- }, [openDrawerRef, state.setSheetOpen])
652
-
653
- const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
654
-
655
- return (
656
- <>
657
- <DataTableToolbar
658
- state={state}
659
- columns={columns}
660
- searchable
661
- renderFilterOptionValue={renderFilterOptionValue}
662
- searchAriaLabel="Search rows"
663
- toolbarSlot={s => (
664
- <TablePropertiesDrawerButton
665
- state={s}
666
- totalRows={tableData.length}
667
- pagination={pagination}
668
- onPaginationChange={onPaginationChange}
669
- conditionalRules={conditionalRules}
670
- onAddConditionalRule={onAddConditionalRule}
671
- onRemoveConditionalRule={onRemoveConditionalRule}
672
- onUpdateConditionalRule={onUpdateConditionalRule}
673
- filterFields={filterFields}
674
- currentView={view}
675
- onViewChange={onViewChange}
676
- lifecycleTabLabel={lifecycleDrawerLabel}
677
- fieldDefinitions={fieldDefinitionsForDrawer}
678
- resolveColumnLabel={resolveColumnLabel}
679
- displayOptions={displayOptions}
680
- onDisplayOptionsChange={onDisplayOptionsChange}
681
- renderFilterOptionValue={renderFilterOptionValue}
682
- />
683
- )}
684
- />
685
- <FolderGridView<Placement>
686
- rows={listRows}
687
- getRowId={r => r.id}
688
- ariaLabel="Demo folder view"
689
- emptyContent={<p>{emptyTableCopy}</p>}
690
- renderTile={row => (
691
- <PlacementFolderTile
692
- row={row}
693
- tab={lifecycleTabId as BoardCardLifecycleTabId}
694
- hiddenColKeys={state.hiddenCols}
695
- boardColumns={boardColumns}
696
- conditionalRules={conditionalRules}
697
- onClick={() => router.push(`/data-list/${row.id}`)}
698
- />
699
- )}
700
- />
701
- </>
702
- )
703
- }
704
-
705
- // ─── Tree / outline + details shell ───────────────────────────────────────────
706
-
707
- function DataListTreeShell({
708
- state,
709
- openDrawerRef,
710
- tableData,
711
- columns,
712
- lifecycleTabId,
713
- view,
714
- onViewChange,
715
- pagination,
716
- onPaginationChange,
717
- conditionalRules,
718
- onAddConditionalRule,
719
- onRemoveConditionalRule,
720
- onUpdateConditionalRule,
721
- filterFields,
722
- lifecycleDrawerLabel,
723
- fieldDefinitionsForDrawer,
724
- resolveColumnLabel,
725
- renderFilterOptionValue,
726
- displayOptions,
727
- onDisplayOptionsChange,
728
- listRows,
729
- emptyTableCopy,
730
- }: {
731
- state: ReturnType<typeof useTableState<Placement>>
732
- openDrawerRef: React.MutableRefObject<() => void>
733
- tableData: Placement[]
734
- columns: ColumnDef<Placement>[]
735
- lifecycleTabId: PlacementLifecycleTabId
736
- view: DataListViewType
737
- onViewChange?: (view: DataListViewType) => void
738
- pagination: boolean
739
- onPaginationChange: (v: boolean) => void
740
- conditionalRules: ConditionalRule[]
741
- onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
742
- onRemoveConditionalRule: (id: string) => void
743
- onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
744
- filterFields: FilterFieldDef[]
745
- lifecycleDrawerLabel: string
746
- fieldDefinitionsForDrawer: { key: string; label: string; sortable?: boolean }[]
747
- resolveColumnLabel: (key: string) => string
748
- renderFilterOptionValue: (fieldKey: string, value: string) => React.ReactNode
749
- displayOptions: DataListDisplayOptions
750
- onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
751
- listRows: Placement[]
752
- emptyTableCopy: string
753
- }) {
754
- const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
755
- const [selectedId, setSelectedId] = React.useState<number | null>(() => listRows[0]?.id ?? null)
756
-
757
- // Stable "open properties drawer" callback ref — see top of this file.
758
- React.useEffect(() => {
759
- openDrawerRef.current = () => state.setSheetOpen(true)
760
- // eslint-disable-next-line react-hooks/exhaustive-deps
761
- }, [openDrawerRef, state.setSheetOpen])
762
-
763
- React.useEffect(() => {
764
- if (selectedId == null) {
765
- setSelectedId(listRows[0]?.id ?? null)
766
- return
767
- }
768
- if (!listRows.some(r => r.id === selectedId)) {
769
- setSelectedId(listRows[0]?.id ?? null)
770
- }
771
- }, [listRows, selectedId])
772
-
773
- const selected = listRows.find(r => r.id === selectedId) ?? null
774
-
775
- return (
776
- <>
777
- <DataTableToolbar
778
- state={state}
779
- columns={columns}
780
- searchable
781
- renderFilterOptionValue={renderFilterOptionValue}
782
- searchAriaLabel="Search rows"
783
- toolbarSlot={s => (
784
- <TablePropertiesDrawerButton
785
- state={s}
786
- totalRows={tableData.length}
787
- pagination={pagination}
788
- onPaginationChange={onPaginationChange}
789
- conditionalRules={conditionalRules}
790
- onAddConditionalRule={onAddConditionalRule}
791
- onRemoveConditionalRule={onRemoveConditionalRule}
792
- onUpdateConditionalRule={onUpdateConditionalRule}
793
- filterFields={filterFields}
794
- currentView={view}
795
- onViewChange={onViewChange}
796
- lifecycleTabLabel={lifecycleDrawerLabel}
797
- fieldDefinitions={fieldDefinitionsForDrawer}
798
- resolveColumnLabel={resolveColumnLabel}
799
- displayOptions={displayOptions}
800
- onDisplayOptionsChange={onDisplayOptionsChange}
801
- renderFilterOptionValue={renderFilterOptionValue}
802
- />
803
- )}
804
- />
805
- <ListPageTreePanelShell
806
- resizableGroupId={`data-list-tree-${lifecycleTabId}`}
807
- ariaLabel="Record outline and details"
808
- tree={
809
- <div className="flex min-h-0 flex-1 flex-col">
810
- <ListPageTreeColumnHeader title="Records" />
811
- {listRows.length === 0 ? (
812
- <p className="p-3 text-sm text-muted-foreground">{emptyTableCopy}</p>
813
- ) : (
814
- <ul
815
- role="tree"
816
- aria-label="Demo records"
817
- className="min-h-0 flex-1 list-none space-y-0.5 overflow-y-auto py-1"
818
- >
819
- {listRows.map(row => {
820
- const isSel = selectedId === row.id
821
- return (
822
- <li key={row.id} role="none" className="py-0.5">
823
- <button
824
- type="button"
825
- role="treeitem"
826
- aria-selected={isSel}
827
- tabIndex={isSel ? 0 : -1}
828
- onClick={() => setSelectedId(row.id)}
829
- className={cn(
830
- "flex w-full min-h-8 items-center rounded-md px-3 py-2 text-left text-sm transition-colors duration-75",
831
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
832
- isSel
833
- ? "bg-accent font-medium text-accent-foreground"
834
- : "text-foreground hover:bg-muted/50",
835
- )}
836
- >
837
- <span className="min-w-0 truncate">{row.student}</span>
838
- </button>
839
- </li>
840
- )
841
- })}
842
- </ul>
843
- )}
844
- </div>
845
- }
846
- details={
847
- selected ? (
848
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-card">
849
- <ListPageTreeColumnHeader title="Details" />
850
- <div className="min-h-0 flex-1 overflow-y-auto">
851
- <PlacementFinderDetail
852
- row={selected}
853
- tab={lifecycleTabId as BoardCardLifecycleTabId}
854
- hiddenColKeys={state.hiddenCols}
855
- boardColumns={boardColumns}
856
- />
857
- </div>
858
- </div>
859
- ) : (
860
- <ListPageSplitDetailsPlaceholder title="Nothing selected" />
861
- )
862
- }
863
- />
864
- </>
865
- )
866
- }
867
-
868
- // ─── Placement-specific list row for FinderPanelView ─────────────────────────
869
-
870
- function PlacementFinderListRow({
871
- row,
872
- isSelected,
873
- tab,
874
- hiddenColKeys,
875
- boardColumns,
876
- conditionalRules,
877
- }: {
878
- row: Placement
879
- isSelected: boolean
880
- tab: BoardCardLifecycleTabId
881
- hiddenColKeys: Set<string>
882
- boardColumns: ColumnDef<Placement>[]
883
- conditionalRules?: ConditionalRule[]
884
- }) {
885
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
886
- const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
887
- const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
888
- const name = showStudent ? row.student : `Placement ${row.id}`
889
-
890
- return (
891
- <div
892
- className={`flex w-full min-w-0 items-center gap-3 transition-colors duration-75 ${
893
- isSelected
894
- ? "bg-transparent text-accent-foreground"
895
- : cn("text-foreground", ruleBg)
896
- }`}
897
- >
898
- <AvatarInitials
899
- initials={row.initials}
900
- className={cn(
901
- "size-8 shrink-0 rounded-full text-[11px] font-semibold",
902
- isSelected ? "ring-2 ring-accent-foreground/35" : "",
903
- )}
904
- />
905
- <div className="min-w-0 flex-1">
906
- <p className={cn("truncate text-[13px] font-medium leading-tight", isSelected ? "text-accent-foreground" : "text-foreground")}>
907
- {name}
908
- </p>
909
- {showSite && (
910
- <p className={cn("mt-0.5 truncate text-[11px] leading-tight", isSelected ? "text-accent-foreground/80" : "text-muted-foreground")}>
911
- {row.site}
912
- </p>
913
- )}
914
- </div>
915
- {!isSelected && <StatusBadge status={row.status} />}
916
- </div>
917
- )
918
- }
919
-
920
- // ─── Placement-specific detail pane for FinderPanelView ──────────────────────
921
-
922
- function PlacementFinderDetail({
923
- row,
924
- tab,
925
- hiddenColKeys,
926
- boardColumns,
927
- }: {
928
- row: Placement
929
- tab: BoardCardLifecycleTabId
930
- hiddenColKeys: Set<string>
931
- boardColumns: ColumnDef<Placement>[]
932
- }) {
933
- const router = useRouter()
934
- const show = (key: string) => isBoardFieldActive(key, tab, hiddenColKeys, boardColumns)
935
-
936
- return (
937
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
938
- {/* Header */}
939
- <div className="flex shrink-0 items-start gap-4 border-b border-border px-5 py-4">
940
- <AvatarInitials initials={row.initials} className="size-14 shrink-0 rounded-full text-lg font-semibold" />
941
- <div className="min-w-0 flex-1">
942
- <h2 className="text-base font-semibold text-foreground leading-tight">{row.student}</h2>
943
- {show("program") && <p className="mt-0.5 text-[13px] text-muted-foreground">{row.program}</p>}
944
- {show("status") && <div className="mt-2"><StatusBadge status={row.status} /></div>}
945
- </div>
946
- <Tip side="bottom" label="Open full detail page">
947
- <Button type="button" variant="outline" size="sm" className="shrink-0"
948
- onClick={() => router.push(`/data-list/${row.id}`)}
949
- aria-label={`Open full detail for ${row.student}`}>
950
- <i className="fa-light fa-arrow-up-right-from-square text-[12px]" aria-hidden="true" />
951
- Open
952
- </Button>
953
- </Tip>
954
- </div>
955
-
956
- {/* Fields */}
957
- <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
958
- <dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
959
- {show("email") && (
960
- <div className="flex flex-col gap-0.5">
961
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
962
- <i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
963
- </dt>
964
- <dd className="text-[13px]">
965
- <a href={mailtoHref(row.email)} className="text-interactive-foreground hover:underline">{row.email}</a>
966
- </dd>
967
- </div>
968
- )}
969
- {show("site") && (
970
- <div className="flex flex-col gap-0.5">
971
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
972
- <i className="fa-light fa-building text-[10px]" aria-hidden="true" /> Site
973
- </dt>
974
- <dd className="text-[13px] text-foreground">{row.site}</dd>
975
- </div>
976
- )}
977
- {show("internship") && (
978
- <div className="flex flex-col gap-0.5">
979
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
980
- <i className="fa-light fa-briefcase text-[10px]" aria-hidden="true" /> Internship
981
- </dt>
982
- <dd className="text-[13px] text-foreground">{row.internship}</dd>
983
- </div>
984
- )}
985
- {show("specialization") && (
986
- <div className="flex flex-col gap-0.5">
987
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
988
- <i className="fa-light fa-stethoscope text-[10px]" aria-hidden="true" /> Specialization
989
- </dt>
990
- <dd className="text-[13px] text-foreground">{row.specialization}</dd>
991
- </div>
992
- )}
993
- {show("supervisor") && (
994
- <div className="flex flex-col gap-0.5">
995
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
996
- <i className="fa-light fa-user-tie text-[10px]" aria-hidden="true" /> Supervisor
997
- </dt>
998
- <dd className="text-[13px] text-foreground">{row.supervisor}</dd>
999
- </div>
1000
- )}
1001
- {show("start") && (
1002
- <div className="flex flex-col gap-0.5">
1003
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
1004
- <i className="fa-light fa-calendar text-[10px]" aria-hidden="true" /> Start Date
1005
- </dt>
1006
- <dd className="text-[13px] text-foreground">{row.start}</dd>
1007
- </div>
1008
- )}
1009
- {show("duration") && (
1010
- <div className="flex flex-col gap-0.5">
1011
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
1012
- <i className="fa-light fa-clock text-[10px]" aria-hidden="true" /> Duration
1013
- </dt>
1014
- <dd className="text-[13px] text-foreground">{row.duration}</dd>
1015
- </div>
1016
- )}
1017
- {tab === "ongoing" && (
1018
- <div className="flex flex-col gap-0.5 sm:col-span-2">
1019
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
1020
- <i className="fa-light fa-chart-line text-[10px]" aria-hidden="true" /> Progress
1021
- </dt>
1022
- <dd className="text-[13px] text-foreground flex flex-col gap-1.5">
1023
- <span>{row.progressWeeksDone} / {row.progressWeeksTotal} weeks</span>
1024
- <div role="progressbar" aria-valuenow={row.progressWeeksDone} aria-valuemin={0} aria-valuemax={row.progressWeeksTotal}
1025
- aria-label={`${row.progressWeeksDone} of ${row.progressWeeksTotal} weeks completed`}
1026
- className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
1027
- <div className="h-full rounded-full bg-primary transition-all"
1028
- style={{ width: `${Math.round((row.progressWeeksDone / Math.max(1, row.progressWeeksTotal)) * 100)}%` }} />
1029
- </div>
1030
- </dd>
1031
- </div>
1032
- )}
1033
- {row.siteAddress && (
1034
- <div className="flex flex-col gap-0.5 sm:col-span-2">
1035
- <dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
1036
- <i className="fa-light fa-location-dot text-[10px]" aria-hidden="true" /> Site Address
1037
- </dt>
1038
- <dd className="text-[13px] text-foreground">{row.siteAddress}</dd>
1039
- </div>
1040
- )}
1041
- </dl>
1042
- </div>
1043
- </div>
1044
- )
1045
- }
1046
-
1047
- // ─── Status groups for FinderPanelView ───────────────────────────────────────
1048
-
1049
- const STATUS_GROUPS: Array<{ id: Status | "all"; label: string; accent: string }> = [
1050
- { id: "all", label: "All", accent: "bg-muted-foreground" },
1051
- { id: "confirmed", label: "Confirmed", accent: "bg-success" },
1052
- { id: "pending", label: "Pending", accent: "bg-warning" },
1053
- { id: "under-review", label: "Under Review", accent: "bg-brand" },
1054
- { id: "rejected", label: "Rejected", accent: "bg-destructive" },
1055
- { id: "completed", label: "Completed", accent: "bg-muted-foreground/50" },
1056
- ]
1057
-
1058
- function buildStatusGroups(rows: Placement[]): FinderGroup[] {
1059
- return STATUS_GROUPS.map(sg => ({
1060
- id: sg.id,
1061
- label: sg.label,
1062
- accent: sg.accent,
1063
- count: sg.id === "all" ? rows.length : rows.filter(r => r.status === sg.id).length,
1064
- }))
1065
- }
1066
-
1067
- // ─── Panel view shell ────────────────────────────────────────────────────────
1068
-
1069
- /** Finder-style panel view shell with groups, list, and detail pane */
1070
- function DataListPanelShell({
1071
- state,
1072
- openDrawerRef,
1073
- tableData,
1074
- columns,
1075
- lifecycleTabId,
1076
- view,
1077
- onViewChange,
1078
- pagination,
1079
- onPaginationChange,
1080
- conditionalRules,
1081
- onAddConditionalRule,
1082
- onRemoveConditionalRule,
1083
- onUpdateConditionalRule,
1084
- filterFields,
1085
- lifecycleDrawerLabel,
1086
- fieldDefinitionsForDrawer,
1087
- resolveColumnLabel,
1088
- renderFilterOptionValue,
1089
- displayOptions,
1090
- onDisplayOptionsChange,
1091
- listRows,
1092
- emptyTableCopy,
1093
- panelGroupsBuilder,
1094
- panelRenderListRow,
1095
- panelRenderDetail,
1096
- }: {
1097
- state: ReturnType<typeof useTableState<Placement>>
1098
- openDrawerRef: React.MutableRefObject<() => void>
1099
- tableData: Placement[]
1100
- columns: ColumnDef<Placement>[]
1101
- lifecycleTabId: PlacementLifecycleTabId
1102
- view: DataListViewType
1103
- onViewChange?: (view: DataListViewType) => void
1104
- pagination: boolean
1105
- onPaginationChange: (v: boolean) => void
1106
- conditionalRules: ConditionalRule[]
1107
- onAddConditionalRule: (rule: Omit<ConditionalRule, "id">) => void
1108
- onRemoveConditionalRule: (id: string) => void
1109
- onUpdateConditionalRule: (id: string, patch: Partial<ConditionalRule>) => void
1110
- filterFields: FilterFieldDef[]
1111
- lifecycleDrawerLabel: string
1112
- fieldDefinitionsForDrawer: { key: string; label: string; sortable?: boolean }[]
1113
- resolveColumnLabel: (key: string) => string
1114
- renderFilterOptionValue: (fieldKey: string, value: string) => React.ReactNode
1115
- displayOptions: DataListDisplayOptions
1116
- onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
1117
- listRows: Placement[]
1118
- emptyTableCopy: string
1119
- panelGroupsBuilder?: (rows: Placement[]) => FinderGroup[]
1120
- panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
1121
- panelRenderDetail?: (row: Placement) => React.ReactNode
1122
- }) {
1123
- // Stable "open properties drawer" callback ref — see top of this file.
1124
- React.useEffect(() => {
1125
- openDrawerRef.current = () => state.setSheetOpen(true)
1126
- // eslint-disable-next-line react-hooks/exhaustive-deps
1127
- }, [openDrawerRef, state.setSheetOpen])
1128
-
1129
- const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
1130
- const groups = React.useMemo(
1131
- () => panelGroupsBuilder ? panelGroupsBuilder(listRows) : buildStatusGroups(listRows),
1132
- [listRows, panelGroupsBuilder],
1133
- )
1134
-
1135
- return (
1136
- <div className="flex min-h-0 flex-1 flex-col">
1137
- <DataTableToolbar
1138
- state={state}
1139
- columns={columns}
1140
- searchable
1141
- renderFilterOptionValue={renderFilterOptionValue}
1142
- searchAriaLabel="Search rows"
1143
- toolbarSlot={s => (
1144
- <TablePropertiesDrawerButton
1145
- state={s}
1146
- totalRows={tableData.length}
1147
- pagination={pagination}
1148
- onPaginationChange={onPaginationChange}
1149
- conditionalRules={conditionalRules}
1150
- onAddConditionalRule={onAddConditionalRule}
1151
- onRemoveConditionalRule={onRemoveConditionalRule}
1152
- onUpdateConditionalRule={onUpdateConditionalRule}
1153
- filterFields={filterFields}
1154
- currentView={view}
1155
- onViewChange={onViewChange}
1156
- lifecycleTabLabel={lifecycleDrawerLabel}
1157
- fieldDefinitions={fieldDefinitionsForDrawer}
1158
- resolveColumnLabel={resolveColumnLabel}
1159
- displayOptions={displayOptions}
1160
- onDisplayOptionsChange={onDisplayOptionsChange}
1161
- renderFilterOptionValue={renderFilterOptionValue}
1162
- />
1163
- )}
1164
- />
1165
- <ListPageSplitHubChrome aria-label={lifecycleDrawerLabel}>
1166
- <FinderPanelView<Placement>
1167
- embedded
1168
- groupsColumnTitle="Status"
1169
- groups={groups}
1170
- rows={listRows}
1171
- getRowId={r => r.id}
1172
- getRowGroupId={r => r.status}
1173
- defaultGroupId="all"
1174
- autoSaveId="finder-panel-view"
1175
- ariaLabel="Demo panel view"
1176
- emptyList={<p>{emptyTableCopy}</p>}
1177
- renderListRow={
1178
- panelRenderListRow
1179
- ? panelRenderListRow
1180
- : (row, isSelected) => (
1181
- <PlacementFinderListRow
1182
- row={row}
1183
- isSelected={isSelected}
1184
- tab={lifecycleTabId as BoardCardLifecycleTabId}
1185
- hiddenColKeys={state.hiddenCols}
1186
- boardColumns={boardColumns}
1187
- conditionalRules={conditionalRules}
1188
- />
1189
- )
1190
- }
1191
- renderDetail={
1192
- panelRenderDetail
1193
- ? panelRenderDetail
1194
- : row => (
1195
- <PlacementFinderDetail
1196
- row={row}
1197
- tab={lifecycleTabId as BoardCardLifecycleTabId}
1198
- hiddenColKeys={state.hiddenCols}
1199
- boardColumns={boardColumns}
1200
- />
1201
- )
1202
- }
1203
- />
1204
- </ListPageSplitHubChrome>
1205
- </div>
1206
- )
1207
- }
1208
-
1209
- // ─────────────────────────────────────────────────────────────────────────────
1210
- // Props
1211
- // ─────────────────────────────────────────────────────────────────────────────
1212
-
1213
- export interface PlacementsTableProps {
1214
- view?: DataListViewType
1215
- onViewChange?: (view: DataListViewType) => void
1216
- /** Demo row segment: drives filtered rows + column set (`all` | `upcoming` | `ongoing` | `completed`). */
1217
- lifecycleTabId?: PlacementLifecycleTabId
1218
- /** Shared display options (persist at page level — all view types). */
1219
- displayOptions?: DataListDisplayOptions
1220
- onDisplayOptionsChange?: (patch: Partial<DataListDisplayOptions>) => void
1221
- /** Lifecycle column set from the placements page (e.g. `getPlacementColumnsForLifecycle`). */
1222
- getColumnsForLifecycle: (tab: PlacementLifecycleTabId) => ColumnDef<Placement>[]
1223
- /** Empty-state copy for the active lifecycle tab — from the page. */
1224
- emptyTableCopy: string
1225
- /** Table Properties drawer lifecycle label — from the page. */
1226
- lifecycleDrawerLabel: string
1227
- /** Panel view: custom groups builder. If not provided, uses default placement status groups. */
1228
- panelGroupsBuilder?: (rows: Placement[]) => FinderGroup[]
1229
- /** Panel view: custom list row renderer. If not provided, uses default placement row rendering. */
1230
- panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
1231
- /** Panel view: custom detail pane renderer. If not provided, uses default placement detail rendering. */
1232
- panelRenderDetail?: (row: Placement) => React.ReactNode
1233
- }
1234
-
1235
- /** Imperative handle — open Table Properties (table view only). */
1236
- export type PlacementsTableHandle = OpenTablePropertiesHandle
1237
-
1238
- // ─────────────────────────────────────────────────────────────────────────────
1239
- // Main component
1240
- // ─────────────────────────────────────────────────────────────────────────────
1241
-
1242
- export const PlacementsTable = React.forwardRef<PlacementsTableHandle, PlacementsTableProps>(function PlacementsTable({
1243
- view = "table",
1244
- onViewChange,
1245
- lifecycleTabId = "all",
1246
- displayOptions: displayOptionsProp,
1247
- onDisplayOptionsChange,
1248
- getColumnsForLifecycle,
1249
- emptyTableCopy,
1250
- lifecycleDrawerLabel,
1251
- panelGroupsBuilder,
1252
- panelRenderListRow,
1253
- panelRenderDetail,
1254
- }, ref) {
1255
- const displayOptions = React.useMemo(
1256
- () => ({ ...DEFAULT_DATA_LIST_DISPLAY_OPTIONS, ...displayOptionsProp }),
1257
- [displayOptionsProp],
1258
- )
1259
-
1260
- const patchDisplayOptions = React.useCallback(
1261
- (patch: Partial<DataListDisplayOptions>) => {
1262
- onDisplayOptionsChange?.(patch)
1263
- },
1264
- [onDisplayOptionsChange],
1265
- )
1266
- const openDrawerRef = React.useRef<() => void>(() => {})
1267
-
1268
- React.useImperativeHandle(ref, () => ({
1269
- openPropertiesDrawer: () => {
1270
- openDrawerRef.current()
1271
- },
1272
- }), [])
1273
-
1274
- const router = useRouter()
1275
- const [pagination, setPagination] = React.useState(false)
1276
-
1277
- const columns = React.useMemo(
1278
- () => getColumnsForLifecycle(lifecycleTabId),
1279
- [getColumnsForLifecycle, lifecycleTabId],
1280
- )
1281
-
1282
- const tableData = React.useMemo(
1283
- () => placementsForPhase(lifecycleTabId),
1284
- [lifecycleTabId],
1285
- )
1286
-
1287
- const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns])
1288
-
1289
- const fieldDefinitionsForDrawer = React.useMemo(
1290
- () => columns
1291
- .filter(c => c.key !== "select" && c.key !== "actions")
1292
- .map(c => ({
1293
- key: c.key,
1294
- label: c.label,
1295
- sortable: !!(c.sortable && c.sortKey),
1296
- })),
1297
- [columns],
1298
- )
1299
-
1300
- const resolveColumnLabel = React.useCallback(
1301
- (key: string) => columns.find(c => c.key === key)?.label ?? key,
1302
- [columns],
1303
- )
1304
-
1305
- const [conditionalRules, setConditionalRules] = React.useState<ConditionalRule[]>([])
1306
-
1307
- function addConditionalRule(rule: Omit<ConditionalRule, "id">) {
1308
- setConditionalRules(prev => [...prev, { ...rule, id: `cr-${Date.now()}` }])
1309
- }
1310
- function removeConditionalRule(id: string) {
1311
- setConditionalRules(prev => prev.filter(r => r.id !== id))
1312
- }
1313
- function updateConditionalRule(id: string, patch: Partial<ConditionalRule>) {
1314
- setConditionalRules(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r))
1315
- }
1316
-
1317
- const renderFilterOptionValue = React.useCallback(
1318
- (fieldKey: string, value: string): React.ReactNode => {
1319
- if (fieldKey === "status") return <StatusBadge status={value as Status} />
1320
- const col = columns.find(c => c.key === fieldKey)
1321
- const opt = col?.filter?.options?.find(o => o.value === value)
1322
- return <span className="text-foreground">{opt?.label ?? value}</span>
1323
- },
1324
- [columns],
1325
- )
1326
-
1327
- const [paginationPage, setPaginationPage] = React.useState(1)
1328
- const [paginationPageSize, setPaginationPageSize] = React.useState(10)
1329
- const [filteredCount, setFilteredCount] = React.useState(tableData.length)
1330
-
1331
- React.useEffect(() => {
1332
- setFilteredCount(tableData.length)
1333
- }, [tableData])
1334
-
1335
- const totalPages = Math.max(1, Math.ceil(filteredCount / Math.max(1, paginationPageSize)))
1336
- const safePage = Math.min(paginationPage, totalPages)
1337
- const paginationOverride =
1338
- pagination && view !== "board" && view !== "dashboard" && view !== "folder" && view !== "panel" && view !== "tree-panel"
1339
- ? { page: safePage, pageSize: paginationPageSize }
1340
- : undefined
1341
-
1342
- const tableState = useTableState(tableData, columns, { key: "student", dir: "asc" }, paginationOverride)
1343
-
1344
- const columnKeys = React.useMemo(() => new Set(columns.map(c => c.key)), [columns])
1345
-
1346
- // Stable "open properties drawer" callback ref — see top of this file.
1347
- React.useEffect(() => {
1348
- openDrawerRef.current = () => tableState.setSheetOpen(true)
1349
- // eslint-disable-next-line react-hooks/exhaustive-deps
1350
- }, [openDrawerRef, tableState.setSheetOpen])
1351
-
1352
- // ── Lifecycle persistence ─────────────────────────────────────────────
1353
- // Centralised in `lib/table-state-lifecycle` — one hook wires both the
1354
- // load (layout effect) and the debounced save (effect) including all the
1355
- // table slices plus placements-specific extras. Hubs that don't want
1356
- // localStorage persistence simply don't call this hook.
1357
- useTableStateLifecycle<PlacementsLifecycleExtras>({
1358
- namespace: PLACEMENTS_LIFECYCLE_NAMESPACE,
1359
- tabId: lifecycleTabId,
1360
- tableState,
1361
- columnKeys,
1362
- extras: {
1363
- conditionalRules,
1364
- pagination,
1365
- paginationPage: safePage,
1366
- paginationPageSize,
1367
- },
1368
- onLoadExtras: e => {
1369
- if (!e) return
1370
- if (Array.isArray(e.conditionalRules)) setConditionalRules(e.conditionalRules as ConditionalRule[])
1371
- if (typeof e.pagination === "boolean") setPagination(e.pagination)
1372
- if (typeof e.paginationPage === "number") setPaginationPage(e.paginationPage)
1373
- if (typeof e.paginationPageSize === "number") setPaginationPageSize(e.paginationPageSize)
1374
- },
1375
- })
1376
-
1377
- function buildToolbarSlot(
1378
- s: ReturnType<typeof useTableState<Placement>>,
1379
- ): React.ReactNode {
1380
- return (
1381
- <TablePropertiesDrawerButton
1382
- state={s}
1383
- totalRows={tableData.length}
1384
- pagination={pagination}
1385
- onPaginationChange={setPagination}
1386
- conditionalRules={conditionalRules}
1387
- onAddConditionalRule={addConditionalRule}
1388
- onRemoveConditionalRule={removeConditionalRule}
1389
- onUpdateConditionalRule={updateConditionalRule}
1390
- filterFields={filterFields}
1391
- currentView={view}
1392
- onViewChange={onViewChange}
1393
- lifecycleTabLabel={lifecycleDrawerLabel}
1394
- fieldDefinitions={fieldDefinitionsForDrawer}
1395
- resolveColumnLabel={resolveColumnLabel}
1396
- displayOptions={displayOptions}
1397
- onDisplayOptionsChange={patchDisplayOptions}
1398
- renderFilterOptionValue={renderFilterOptionValue}
1399
- />
1400
- )
1401
- }
1402
-
1403
- function bulkActionsSlot(selected: Set<string | number>, _rows: Placement[]): React.ReactNode {
1404
- const count = selected.size
1405
- const contextId = "bulk-selection-context"
1406
- return (
1407
- <>
1408
- <span id={contextId} className="sr-only">
1409
- {count} {count === 1 ? "row" : "rows"} selected
1410
- </span>
1411
- <Button size="sm" variant="default" aria-describedby={contextId}>
1412
- <i className="fa-light fa-circle-check" aria-hidden="true" /> Confirm
1413
- </Button>
1414
- <Button size="sm" variant="outline" aria-describedby={contextId}>
1415
- <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" /> Export
1416
- </Button>
1417
- <Button size="sm" variant="destructive" aria-describedby={contextId}>
1418
- <i className="fa-light fa-trash" aria-hidden="true" /> Delete
1419
- </Button>
1420
- </>
1421
- )
1422
- }
1423
-
1424
- const tableProps: DataTableExtendedProps<Placement> = {
1425
- data: tableData,
1426
- columns,
1427
- getRowId: (row: Placement) => row.id,
1428
- getRowSelectionLabel: (row: Placement) => row.student,
1429
- selectable: true,
1430
- searchable: displayOptions.showToolbarSearch,
1431
- showColumnHeaders: displayOptions.showColumnLabels,
1432
- defaultSort: { key: "student" as const, dir: "asc" as const },
1433
- emptyState: emptyTableCopy,
1434
- toolbarSlot: buildToolbarSlot,
1435
- bulkActionsSlot,
1436
- renderFilterOptionValue,
1437
- conditionalRules,
1438
- onRowClick: (row: Placement) => router.push(`/data-list/${row.id}`),
1439
- state: tableState,
1440
- }
1441
-
1442
- if (view === "board") {
1443
- return (
1444
- <DataListBoardShell
1445
- state={tableState}
1446
- openDrawerRef={openDrawerRef}
1447
- tableData={tableData}
1448
- columns={columns}
1449
- lifecycleTabId={lifecycleTabId}
1450
- view={view}
1451
- onViewChange={onViewChange}
1452
- pagination={pagination}
1453
- onPaginationChange={setPagination}
1454
- conditionalRules={conditionalRules}
1455
- onAddConditionalRule={addConditionalRule}
1456
- onRemoveConditionalRule={removeConditionalRule}
1457
- onUpdateConditionalRule={updateConditionalRule}
1458
- filterFields={filterFields}
1459
- lifecycleDrawerLabel={lifecycleDrawerLabel}
1460
- fieldDefinitionsForDrawer={fieldDefinitionsForDrawer}
1461
- resolveColumnLabel={resolveColumnLabel}
1462
- renderFilterOptionValue={renderFilterOptionValue}
1463
- displayOptions={displayOptions}
1464
- onDisplayOptionsChange={patchDisplayOptions}
1465
- />
1466
- )
1467
- }
1468
-
1469
- if (view === "dashboard") {
1470
- return (
1471
- <DataListDashboardShell
1472
- state={tableState}
1473
- openDrawerRef={openDrawerRef}
1474
- tableData={tableData}
1475
- columns={columns}
1476
- view={view}
1477
- onViewChange={onViewChange}
1478
- pagination={pagination}
1479
- onPaginationChange={setPagination}
1480
- conditionalRules={conditionalRules}
1481
- onAddConditionalRule={addConditionalRule}
1482
- onRemoveConditionalRule={removeConditionalRule}
1483
- onUpdateConditionalRule={updateConditionalRule}
1484
- filterFields={filterFields}
1485
- lifecycleDrawerLabel={lifecycleDrawerLabel}
1486
- fieldDefinitionsForDrawer={fieldDefinitionsForDrawer}
1487
- resolveColumnLabel={resolveColumnLabel}
1488
- renderFilterOptionValue={renderFilterOptionValue}
1489
- displayOptions={displayOptions}
1490
- onDisplayOptionsChange={patchDisplayOptions}
1491
- />
1492
- )
1493
- }
1494
-
1495
- if (view === "list") {
1496
- return (
1497
- <React.Fragment key={lifecycleTabId}>
1498
- {pagination ? (
1499
- <CountSyncer
1500
- count={tableState.rows.length}
1501
- onSync={setFilteredCount}
1502
- onReset={() => setPaginationPage(1)}
1503
- />
1504
- ) : null}
1505
- <DataListListShell
1506
- state={tableState}
1507
- openDrawerRef={openDrawerRef}
1508
- tableData={tableData}
1509
- columns={columns}
1510
- lifecycleTabId={lifecycleTabId}
1511
- view={view}
1512
- onViewChange={onViewChange}
1513
- pagination={pagination}
1514
- onPaginationChange={setPagination}
1515
- conditionalRules={conditionalRules}
1516
- onAddConditionalRule={addConditionalRule}
1517
- onRemoveConditionalRule={removeConditionalRule}
1518
- onUpdateConditionalRule={updateConditionalRule}
1519
- filterFields={filterFields}
1520
- lifecycleDrawerLabel={lifecycleDrawerLabel}
1521
- fieldDefinitionsForDrawer={fieldDefinitionsForDrawer}
1522
- resolveColumnLabel={resolveColumnLabel}
1523
- renderFilterOptionValue={renderFilterOptionValue}
1524
- displayOptions={displayOptions}
1525
- onDisplayOptionsChange={patchDisplayOptions}
1526
- listRows={pagination ? tableState.pagedRows : tableState.rows}
1527
- emptyTableCopy={emptyTableCopy}
1528
- />
1529
- {pagination ? (
1530
- <div className="mx-4 lg:mx-6 border-x border-b border-border rounded-b-lg overflow-hidden">
1531
- <PaginationBar
1532
- page={safePage}
1533
- pageSize={paginationPageSize}
1534
- total={filteredCount}
1535
- pageSizeOptions={[10, 25, 50, 100]}
1536
- onPageChange={setPaginationPage}
1537
- onPageSizeChange={n => {
1538
- setPaginationPageSize(n)
1539
- setPaginationPage(1)
1540
- }}
1541
- />
1542
- </div>
1543
- ) : null}
1544
- </React.Fragment>
1545
- )
1546
- }
1547
-
1548
- if (view === "folder") {
1549
- return (
1550
- <DataListFolderShell
1551
- key={lifecycleTabId}
1552
- state={tableState}
1553
- openDrawerRef={openDrawerRef}
1554
- tableData={tableData}
1555
- columns={columns}
1556
- lifecycleTabId={lifecycleTabId}
1557
- view={view}
1558
- onViewChange={onViewChange}
1559
- pagination={pagination}
1560
- onPaginationChange={setPagination}
1561
- conditionalRules={conditionalRules}
1562
- onAddConditionalRule={addConditionalRule}
1563
- onRemoveConditionalRule={removeConditionalRule}
1564
- onUpdateConditionalRule={updateConditionalRule}
1565
- filterFields={filterFields}
1566
- lifecycleDrawerLabel={lifecycleDrawerLabel}
1567
- fieldDefinitionsForDrawer={fieldDefinitionsForDrawer}
1568
- resolveColumnLabel={resolveColumnLabel}
1569
- renderFilterOptionValue={renderFilterOptionValue}
1570
- displayOptions={displayOptions}
1571
- onDisplayOptionsChange={patchDisplayOptions}
1572
- listRows={tableState.rows}
1573
- emptyTableCopy={emptyTableCopy}
1574
- />
1575
- )
1576
- }
1577
-
1578
- if (view === "tree-panel") {
1579
- return (
1580
- <DataListTreeShell
1581
- key={lifecycleTabId}
1582
- state={tableState}
1583
- openDrawerRef={openDrawerRef}
1584
- tableData={tableData}
1585
- columns={columns}
1586
- lifecycleTabId={lifecycleTabId}
1587
- view={view}
1588
- onViewChange={onViewChange}
1589
- pagination={pagination}
1590
- onPaginationChange={setPagination}
1591
- conditionalRules={conditionalRules}
1592
- onAddConditionalRule={addConditionalRule}
1593
- onRemoveConditionalRule={removeConditionalRule}
1594
- onUpdateConditionalRule={updateConditionalRule}
1595
- filterFields={filterFields}
1596
- lifecycleDrawerLabel={lifecycleDrawerLabel}
1597
- fieldDefinitionsForDrawer={fieldDefinitionsForDrawer}
1598
- resolveColumnLabel={resolveColumnLabel}
1599
- renderFilterOptionValue={renderFilterOptionValue}
1600
- displayOptions={displayOptions}
1601
- onDisplayOptionsChange={patchDisplayOptions}
1602
- listRows={tableState.rows}
1603
- emptyTableCopy={emptyTableCopy}
1604
- />
1605
- )
1606
- }
1607
-
1608
- if (view === "panel") {
1609
- return (
1610
- <DataListPanelShell
1611
- key={lifecycleTabId}
1612
- state={tableState}
1613
- openDrawerRef={openDrawerRef}
1614
- tableData={tableData}
1615
- columns={columns}
1616
- lifecycleTabId={lifecycleTabId}
1617
- view={view}
1618
- onViewChange={onViewChange}
1619
- pagination={pagination}
1620
- onPaginationChange={setPagination}
1621
- conditionalRules={conditionalRules}
1622
- onAddConditionalRule={addConditionalRule}
1623
- onRemoveConditionalRule={removeConditionalRule}
1624
- onUpdateConditionalRule={updateConditionalRule}
1625
- filterFields={filterFields}
1626
- lifecycleDrawerLabel={lifecycleDrawerLabel}
1627
- fieldDefinitionsForDrawer={fieldDefinitionsForDrawer}
1628
- resolveColumnLabel={resolveColumnLabel}
1629
- renderFilterOptionValue={renderFilterOptionValue}
1630
- displayOptions={displayOptions}
1631
- onDisplayOptionsChange={patchDisplayOptions}
1632
- listRows={tableState.rows}
1633
- emptyTableCopy={emptyTableCopy}
1634
- panelGroupsBuilder={panelGroupsBuilder}
1635
- panelRenderListRow={panelRenderListRow}
1636
- panelRenderDetail={panelRenderDetail}
1637
- />
1638
- )
1639
- }
1640
-
1641
- if (pagination) {
1642
- return (
1643
- <React.Fragment key={lifecycleTabId}>
1644
- <CountSyncer
1645
- count={tableState.rows.length}
1646
- onSync={setFilteredCount}
1647
- onReset={() => setPaginationPage(1)}
1648
- />
1649
- <DataTable<Placement> {...tableProps} hasFooter />
1650
- <div className="mx-4 lg:mx-6 border-x border-b border-border rounded-b-lg overflow-hidden">
1651
- <PaginationBar
1652
- page={safePage}
1653
- pageSize={paginationPageSize}
1654
- total={filteredCount}
1655
- pageSizeOptions={[10, 25, 50, 100]}
1656
- onPageChange={setPaginationPage}
1657
- onPageSizeChange={n => {
1658
- setPaginationPageSize(n)
1659
- setPaginationPage(1)
1660
- }}
1661
- />
1662
- </div>
1663
- </React.Fragment>
1664
- )
1665
- }
1666
-
1667
- return <DataTable<Placement> key={lifecycleTabId} {...tableProps} />
1668
- })
1669
-
1670
- PlacementsTable.displayName = "PlacementsTable"
1671
-
1672
-
1673
- export type { DataListViewType } from "@/lib/data-list-view"
1674
- export { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
1675
- export type { DataListDisplayOptions } from "@/lib/data-list-display-options"