@exxatdesignux/ui 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +701 -6
  2. package/README.md +138 -0
  3. package/bin/init.mjs +134 -31
  4. package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
  5. package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
  6. package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
  7. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
  8. package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
  9. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
  10. package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
  11. package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
  12. package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
  13. package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
  14. package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
  15. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
  16. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
  17. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
  18. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
  19. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
  20. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
  21. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
  22. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
  23. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
  24. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
  25. package/consumer-extras/handbook/HANDBOOK.md +2 -0
  26. package/consumer-extras/handbook/glossary.md +2 -1
  27. package/consumer-extras/handbook/reference-implementations.md +31 -4
  28. package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
  29. package/consumer-extras/patterns/data-views-pattern.md +18 -16
  30. package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
  31. package/dist/components/data-table/index.js +2 -2
  32. package/dist/components/data-table/index.js.map +1 -1
  33. package/dist/components/data-table/pagination.js +3 -3
  34. package/dist/components/data-table/pagination.js.map +1 -1
  35. package/dist/components/data-table/use-table-state.d.ts +1 -1
  36. package/dist/components/data-table/use-table-state.js.map +1 -1
  37. package/dist/components/data-views/data-row-list.js.map +1 -1
  38. package/dist/components/data-views/finder-panel-view.d.ts +1 -1
  39. package/dist/components/data-views/finder-panel-view.js.map +1 -1
  40. package/dist/components/data-views/hub-table.d.ts +9 -3
  41. package/dist/components/data-views/hub-table.js +262 -40
  42. package/dist/components/data-views/hub-table.js.map +1 -1
  43. package/dist/components/data-views/index.js +262 -40
  44. package/dist/components/data-views/index.js.map +1 -1
  45. package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
  46. package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
  47. package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
  48. package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
  49. package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
  50. package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
  51. package/dist/components/data-views/os-folder-glyph.js.map +1 -1
  52. package/dist/components/ui/avatar.d.ts +1 -1
  53. package/dist/components/ui/key-metrics.js.map +1 -1
  54. package/dist/index.js +136 -39
  55. package/dist/index.js.map +1 -1
  56. package/package.json +3 -2
  57. package/src/components/data-table/index.tsx +2 -2
  58. package/src/components/data-table/pagination.tsx +5 -1
  59. package/src/components/data-table/use-table-state.ts +1 -1
  60. package/src/components/data-views/data-row-list.tsx +1 -1
  61. package/src/components/data-views/finder-panel-view.tsx +2 -2
  62. package/src/components/data-views/hub-table.tsx +149 -41
  63. package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
  64. package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
  65. package/src/components/data-views/os-folder-glyph.tsx +1 -1
  66. package/src/components/ui/key-metrics.tsx +1 -1
  67. package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
  68. package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
  69. package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
  70. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
  71. package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
  72. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
  73. package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
  74. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
  75. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
  76. package/template/AGENTS.md +43 -37
  77. package/template/app/(app)/columns/page.tsx +11 -0
  78. package/template/app/(app)/library/all/page.tsx +11 -0
  79. package/template/app/(app)/library/find/page.tsx +12 -0
  80. package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
  81. package/template/app/(app)/library/list/page.tsx +12 -0
  82. package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
  83. package/template/app/(app)/library/page.tsx +11 -0
  84. package/template/app/(app)/tokens-themes/page.tsx +11 -0
  85. package/template/components/ask-leo-composer.tsx +2 -2
  86. package/template/components/columns-client.tsx +158 -0
  87. package/template/components/columns-showcase.tsx +541 -0
  88. package/template/components/data-views/index.ts +32 -6
  89. package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
  90. package/template/components/data-views/table-cells.tsx +673 -0
  91. package/template/components/folder-details-shell.tsx +11 -11
  92. package/template/components/hub-tree-panel-view.tsx +24 -24
  93. package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
  94. package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
  95. package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
  96. package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
  97. package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
  98. package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
  99. package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
  100. package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
  101. package/template/components/library-panel-activator.tsx +8 -0
  102. package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
  103. package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
  104. package/template/components/list-hub-status-badge.tsx +2 -2
  105. package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
  106. package/template/components/sidebar/app-sidebar.tsx +61 -5
  107. package/template/components/sidebar/secondary-panel.tsx +109 -56
  108. package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
  109. package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
  110. package/template/components/table-properties/types.ts +1 -1
  111. package/template/components/templates/discovery-hub-template.tsx +1 -1
  112. package/template/components/templates/new-focus-template.tsx +2 -2
  113. package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
  114. package/template/components/tokens-secondary-nav.tsx +192 -0
  115. package/template/components/tokens-themes-client.tsx +476 -0
  116. package/template/components/tokens-themes-section.tsx +386 -0
  117. package/template/docs/HANDBOOK.md +187 -0
  118. package/template/docs/blueprints/README.md +1 -1
  119. package/template/docs/blueprints/board-card.md +1 -1
  120. package/template/docs/blueprints/data-table.md +2 -2
  121. package/template/docs/blueprints/list-page-template.md +3 -3
  122. package/template/docs/blueprints/page-header.md +4 -4
  123. package/template/docs/collaboration-access-pattern.md +7 -7
  124. package/template/docs/component-selection-guide.md +1 -1
  125. package/template/docs/data-views-pattern.md +18 -16
  126. package/template/docs/glossary.md +58 -0
  127. package/template/docs/kpi-flat-band-pattern.md +3 -3
  128. package/template/docs/kpi-trend-pattern.md +18 -3
  129. package/template/docs/large-dataset-strategy.md +155 -0
  130. package/template/docs/library-hub-header-pattern.md +25 -0
  131. package/template/docs/migrations/_template.md +1 -1
  132. package/template/docs/reference-implementations.md +151 -0
  133. package/template/docs/token-taxonomy.md +1 -1
  134. package/template/docs/voice-and-tone.md +262 -0
  135. package/template/eslint.config.mjs +9 -39
  136. package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
  137. package/template/lib/ask-leo-route-context.ts +6 -18
  138. package/template/lib/coach-mark-registry.ts +0 -16
  139. package/template/lib/command-menu-config.ts +5 -12
  140. package/template/lib/command-menu-search-data.ts +8 -39
  141. package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
  142. package/template/lib/library-dedicated-search.ts +19 -0
  143. package/template/lib/library-hub-search.ts +90 -0
  144. package/template/lib/library-nav.ts +477 -0
  145. package/template/lib/library-recent-searches.ts +22 -0
  146. package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
  147. package/template/lib/list-status-badges.ts +16 -104
  148. package/template/lib/mock/dashboard.ts +1 -1
  149. package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
  150. package/template/lib/mock/library-header-collaborators.ts +54 -0
  151. package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
  152. package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
  153. package/template/lib/mock/library.ts +249 -0
  154. package/template/lib/mock/navigation.tsx +32 -26
  155. package/template/lib/table-state-lifecycle.ts +1 -1
  156. package/template/next.config.mjs +7 -4
  157. package/template/package.json +0 -1
  158. package/tokens/hooks-index.json +2874 -0
  159. package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
  160. package/template/app/(app)/examples/page.tsx +0 -41
  161. package/template/app/(app)/question-bank/find/page.tsx +0 -12
  162. package/template/app/(app)/question-bank/library/page.tsx +0 -11
  163. package/template/app/(app)/question-bank/list/page.tsx +0 -12
  164. package/template/app/(app)/question-bank/page.tsx +0 -11
  165. package/template/components/compliance-board-view.tsx +0 -142
  166. package/template/components/compliance-client.tsx +0 -92
  167. package/template/components/compliance-page-header.tsx +0 -89
  168. package/template/components/compliance-table.tsx +0 -468
  169. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  170. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  171. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  172. package/template/components/new-placement-back-btn.tsx +0 -28
  173. package/template/components/new-placement-form.tsx +0 -942
  174. package/template/components/placement-board-card.tsx +0 -250
  175. package/template/components/placement-detail.tsx +0 -438
  176. package/template/components/placements-board-view.tsx +0 -397
  177. package/template/components/placements-client.tsx +0 -220
  178. package/template/components/placements-list-view.tsx +0 -124
  179. package/template/components/placements-page-header.tsx +0 -166
  180. package/template/components/placements-table-cells.test.tsx +0 -22
  181. package/template/components/placements-table-cells.tsx +0 -173
  182. package/template/components/placements-table-columns.tsx +0 -210
  183. package/template/components/placements-table.tsx +0 -934
  184. package/template/components/question-bank-panel-activator.tsx +0 -8
  185. package/template/components/rotations-empty-state.tsx +0 -50
  186. package/template/components/rotations-panel-activator.tsx +0 -8
  187. package/template/components/sites-board-view.tsx +0 -67
  188. package/template/components/sites-client.tsx +0 -154
  189. package/template/components/sites-table.tsx +0 -249
  190. package/template/components/team-board-view.tsx +0 -122
  191. package/template/components/team-client.tsx +0 -100
  192. package/template/components/team-page-header.tsx +0 -92
  193. package/template/components/team-table.tsx +0 -553
  194. package/template/docs/question-bank-hub-header-pattern.md +0 -25
  195. package/template/lib/compliance-supported-views.ts +0 -10
  196. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  197. package/template/lib/mock/compliance-kpi.ts +0 -61
  198. package/template/lib/mock/compliance.ts +0 -146
  199. package/template/lib/mock/placements-kpi.ts +0 -134
  200. package/template/lib/mock/placements.ts +0 -176
  201. package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
  202. package/template/lib/mock/question-bank.ts +0 -249
  203. package/template/lib/mock/sites-directory.ts +0 -16
  204. package/template/lib/mock/sites-kpi.ts +0 -25
  205. package/template/lib/mock/team-kpi.ts +0 -60
  206. package/template/lib/mock/team.ts +0 -118
  207. package/template/lib/placement-board-card-layout.ts +0 -79
  208. package/template/lib/question-bank-dedicated-search.ts +0 -19
  209. package/template/lib/question-bank-hub-search.ts +0 -90
  210. package/template/lib/question-bank-nav.ts +0 -477
  211. package/template/lib/question-bank-recent-searches.ts +0 -22
  212. package/template/lib/question-bank-supported-views.ts +0 -12
  213. package/template/lib/sites-supported-views.ts +0 -10
  214. package/template/lib/team-supported-views.ts +0 -10
@@ -1,124 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * Placement-specific list row body — exported as `PlacementListRowContent`.
5
- *
6
- * Shell (DataRowList wrapping, empty state, virtualization) is now provided by
7
- * `HubTable.renderListRow` (see `placements-table.tsx`) — this file owns only the
8
- * placement-specific row body (avatar + title + status badge + schedule summary).
9
- *
10
- * Previously this file also exported `PlacementsListView` (which wrapped `DataRowList`);
11
- * that wrapper was inlined into `placements-table.tsx` when `HubTable.renderListRow`
12
- * landed, so all `BoardCardLifecycleTabId` / `scheduleKeysForTab` imports are gone.
13
- */
14
-
15
- import type { Placement } from "@/lib/mock/placements"
16
- import { StatusBadge } from "@/components/placements-table-cells"
17
- import { Badge } from "@/components/ui/badge"
18
- import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
19
- import {
20
- isBoardFieldActive,
21
- SCHEDULE_KEYS,
22
- } from "@/lib/placement-board-card-layout"
23
- import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
24
- import type { ConditionalRule } from "@/components/table-properties/types"
25
- import type { ColumnDef } from "@/components/data-table/types"
26
-
27
- /** Above this count, `DataRowList` virtualizes against the window scroll. */
28
- export const PLACEMENT_LIST_VIRTUAL_ROWS_THRESHOLD = 80
29
- /** Initial row height guess (px); `measureElement` refines for variable content. */
30
- export const PLACEMENT_LIST_ESTIMATE_ROW_PX = 100
31
-
32
- function scheduleSummary(row: Placement): string | null {
33
- switch (row.placementPhase) {
34
- case "upcoming":
35
- return row.daysUntilStart > 0
36
- ? `${row.start} · Starts in ${row.daysUntilStart} days`
37
- : row.start
38
- case "ongoing":
39
- return `${row.progressWeeksDone} / ${row.progressWeeksTotal} wks · Ends ${row.endDate}`
40
- case "completed":
41
- return [row.completionDate, row.finalStatus].filter(v => v && v !== "—").join(" · ") || null
42
- default:
43
- return [row.start, row.duration].filter(Boolean).join(" · ") || null
44
- }
45
- }
46
-
47
- export function PlacementListRowContent({
48
- row,
49
- hiddenColKeys,
50
- boardColumns,
51
- conditionalRules,
52
- onOpen,
53
- }: {
54
- row: Placement
55
- hiddenColKeys: Set<string>
56
- boardColumns: ColumnDef<Placement>[]
57
- conditionalRules: ConditionalRule[] | undefined
58
- onOpen: (id: number) => void
59
- }) {
60
- const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
61
- const showStudent = isBoardFieldActive("student", hiddenColKeys, boardColumns)
62
- const showStatus = isBoardFieldActive("status", hiddenColKeys, boardColumns)
63
- const showSite = isBoardFieldActive("site", hiddenColKeys, boardColumns)
64
- const showSpec = isBoardFieldActive("specialization", hiddenColKeys, boardColumns)
65
- const showInternship = isBoardFieldActive("internship", hiddenColKeys, boardColumns)
66
- const showSchedule = SCHEDULE_KEYS.some(k => isBoardFieldActive(k, hiddenColKeys, boardColumns))
67
- const schedule = showSchedule ? scheduleSummary(row) : null
68
-
69
- const title = showStudent ? row.student : `Placement ${row.id}`
70
-
71
- const leading = showStudent ? (
72
- <ListPageBoardCardAvatar initials={row.initials} className="size-9" />
73
- ) : (
74
- <span className="size-9 shrink-0 rounded-full bg-muted/80" aria-hidden />
75
- )
76
-
77
- const rowEnd = showStatus ? (
78
- <div className="flex shrink-0 items-center gap-2 pt-0.5">
79
- <StatusBadge status={row.status} surface="board" />
80
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden />
81
- </div>
82
- ) : (
83
- <i className="fa-light fa-chevron-right mt-1 shrink-0 text-xs text-muted-foreground" aria-hidden />
84
- )
85
-
86
- return (
87
- <ListPageBoardCard
88
- layout="row"
89
- leading={leading}
90
- rowEnd={rowEnd}
91
- isNew={row.isNew}
92
- style={ruleBg ? { background: ruleBg } : undefined}
93
- onClick={() => onOpen(row.id)}
94
- >
95
- <div className="space-y-1">
96
- <div className="flex flex-wrap items-center gap-2">
97
- <span className="text-sm font-semibold text-foreground">{title}</span>
98
- {row.isNew ? (
99
- <Badge variant="secondary" className="h-5 px-1.5 text-xs font-medium">
100
- New
101
- </Badge>
102
- ) : null}
103
- </div>
104
- {showSite ? (
105
- <p className="text-xs text-foreground/90">
106
- <span className="font-medium">{row.site}</span>
107
- {row.siteAddress ? (
108
- <span className="text-muted-foreground"> · {row.siteAddress}</span>
109
- ) : null}
110
- </p>
111
- ) : null}
112
- {(showSpec || showInternship) ? (
113
- <p className="text-xs text-muted-foreground">
114
- {[showSpec ? row.specialization : null, showInternship ? row.internship : null]
115
- .filter(Boolean)
116
- .join(" · ")}
117
- </p>
118
- ) : null}
119
- {schedule ? <p className="text-xs text-muted-foreground tabular-nums">{schedule}</p> : null}
120
- </div>
121
- </ListPageBoardCard>
122
- )
123
- }
124
-
@@ -1,166 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { Button } from "@/components/ui/button"
5
- import { Kbd, KbdGroup } from "@/components/ui/kbd"
6
- import { PageHeader } from "@/components/page-header"
7
- import {
8
- DropdownMenu,
9
- DropdownMenuContent,
10
- DropdownMenuItem,
11
- DropdownMenuSeparator,
12
- DropdownMenuTrigger,
13
- Shortcut,
14
- } from "@/components/ui/dropdown-menu"
15
- import { Tip } from "@/components/ui/tip"
16
- import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
17
- import { isEditableTarget } from "@/lib/editable-target"
18
-
19
- export interface PlacementsPageHeaderProps {
20
- /** Main heading in the page header */
21
- title?: string
22
- /** Primary button label */
23
- primaryCtaLabel?: string
24
- /** Shown under the page title */
25
- subtitle?: string
26
- onNewPlacement: () => void
27
- onExport: () => void
28
- showMetrics: boolean
29
- onToggleMetrics: () => void
30
- /** When false, title + subtitle are hidden visually (Display options). */
31
- showTitleBlock?: boolean
32
- className?: string
33
- }
34
-
35
- /**
36
- * List hub shell header — title, primary CTA, overflow menu (export, metrics).
37
- * Reusable for any route that needs the same chrome.
38
- */
39
- export function PlacementsPageHeader({
40
- title = "Sample records",
41
- primaryCtaLabel = "New row",
42
- subtitle = "24 demo rows · Last updated now",
43
- onNewPlacement,
44
- onExport,
45
- showMetrics,
46
- onToggleMetrics,
47
- showTitleBlock = true,
48
- className,
49
- }: PlacementsPageHeaderProps) {
50
- const mod = useModKeyLabel()
51
- const alt = useAltKeyLabel()
52
- const [moreOpen, setMoreOpen] = React.useState(false)
53
-
54
- /** ⌘⌥N / Ctrl+Alt+N — avoids ⌘⇧N / Ctrl+Shift+N (private/incognito windows). */
55
- React.useEffect(() => {
56
- function onKey(e: KeyboardEvent) {
57
- if (!e.altKey || (!e.metaKey && !e.ctrlKey)) return
58
- if (e.key.toLowerCase() !== "n") return
59
- if (isEditableTarget(e.target)) return
60
- e.preventDefault()
61
- onNewPlacement()
62
- }
63
- document.addEventListener("keydown", onKey)
64
- return () => document.removeEventListener("keydown", onKey)
65
- }, [onNewPlacement])
66
-
67
- /** ⌘⌥M / Ctrl+Alt+M — avoids ⌘⇧O / Ctrl+Shift+O (bookmark manager in Chromium). */
68
- React.useEffect(() => {
69
- function onKey(e: KeyboardEvent) {
70
- if (!e.altKey || (!e.metaKey && !e.ctrlKey)) return
71
- if (e.key.toLowerCase() !== "m") return
72
- if (isEditableTarget(e.target)) return
73
- e.preventDefault()
74
- setMoreOpen(o => !o)
75
- }
76
- document.addEventListener("keydown", onKey)
77
- return () => document.removeEventListener("keydown", onKey)
78
- }, [])
79
-
80
- return (
81
- <>
82
- <Shortcut keys="⌘⇧E" onInvoke={onExport} />
83
- <Shortcut keys="⌘⌥H" onInvoke={onToggleMetrics} />
84
- <PageHeader
85
- title={title}
86
- subtitle={subtitle}
87
- className={className}
88
- showTitleBlock={showTitleBlock}
89
- actions={
90
- <div className="flex items-center gap-2" role="group" aria-label="Primary list actions">
91
- <Tip
92
- side="bottom"
93
- label={
94
- <>
95
- <span>{primaryCtaLabel}</span>
96
- <KbdGroup>
97
- <Kbd>{mod}</Kbd>
98
- <Kbd>{alt}</Kbd>
99
- <Kbd>N</Kbd>
100
- </KbdGroup>
101
- </>
102
- }
103
- >
104
- <Button size="lg" onClick={onNewPlacement}>
105
- <i className="fa-light fa-plus" aria-hidden="true" />
106
- {primaryCtaLabel}
107
- </Button>
108
- </Tip>
109
- <DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
110
- <Tip
111
- side="bottom"
112
- label={
113
- <>
114
- <span>More actions</span>
115
- <KbdGroup>
116
- <Kbd>{mod}</Kbd>
117
- <Kbd>{alt}</Kbd>
118
- <Kbd>M</Kbd>
119
- </KbdGroup>
120
- </>
121
- }
122
- >
123
- <DropdownMenuTrigger asChild>
124
- <Button
125
- size="lg"
126
- variant="outline"
127
- className="aspect-square px-0"
128
- aria-label="More actions"
129
- >
130
- <i className="fa-light fa-ellipsis text-base" aria-hidden="true" />
131
- </Button>
132
- </DropdownMenuTrigger>
133
- </Tip>
134
- <DropdownMenuContent align="end">
135
- <DropdownMenuItem
136
- shortcut="⌘⇧E"
137
- onSelect={() => {
138
- /* Defer past Radix menu close + focus restore so Export Sheet mounts reliably. */
139
- window.setTimeout(() => onExport(), 0)
140
- }}
141
- >
142
- <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
143
- Export
144
- </DropdownMenuItem>
145
- <DropdownMenuSeparator />
146
- <DropdownMenuItem
147
- shortcut="⌘⌥H"
148
- onSelect={() => {
149
- window.setTimeout(() => onToggleMetrics(), 0)
150
- }}
151
- >
152
- <i
153
- className={`fa-light ${showMetrics ? "fa-eye-slash" : "fa-eye"}`}
154
- aria-hidden="true"
155
- />
156
- {showMetrics ? "Hide metric section" : "Show metric section"}
157
- </DropdownMenuItem>
158
- </DropdownMenuContent>
159
- </DropdownMenu>
160
- </div>
161
- }
162
- />
163
- </>
164
- )
165
- }
166
-
@@ -1,22 +0,0 @@
1
- import { describe, expect, it } from "vitest"
2
- import { render, screen } from "@testing-library/react"
3
-
4
- import { HireBadge, ReadinessBadge, StatusBadge } from "./placements-table-cells"
5
-
6
- describe("placements-table-cells", () => {
7
- it("renders StatusBadge label for confirmed", () => {
8
- render(<StatusBadge status="confirmed" />)
9
- expect(screen.getByText("Confirmed")).toBeInTheDocument()
10
- })
11
-
12
- it("ReadinessBadge uses destructive variant for risk copy", () => {
13
- const { container } = render(<ReadinessBadge value="At risk" />)
14
- expect(container.querySelector("[data-slot='badge']")).toBeTruthy()
15
- expect(screen.getByText("At risk")).toBeInTheDocument()
16
- })
17
-
18
- it("HireBadge shows em dash for empty", () => {
19
- render(<HireBadge value="" />)
20
- expect(screen.getByText("—")).toBeInTheDocument()
21
- })
22
- })
@@ -1,173 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * Placement table cell primitives — extracted from placements-table for reuse and easier testing.
5
- */
6
-
7
- import * as React from "react"
8
- import { Badge } from "@/components/ui/badge"
9
- import { Button } from "@/components/ui/button"
10
- import { Tip } from "@/components/ui/tip"
11
- import {
12
- DropdownMenu,
13
- DropdownMenuContent,
14
- DropdownMenuItem,
15
- DropdownMenuSeparator,
16
- DropdownMenuTrigger,
17
- } from "@/components/ui/dropdown-menu"
18
- import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
19
- import {
20
- PLACEMENT_STATUS_BADGE_CLASS,
21
- PLACEMENT_STATUS_ICON,
22
- PLACEMENT_STATUS_LABEL,
23
- } from "@/lib/list-status-badges"
24
- import { AvatarInitials } from "@/components/ui/avatar"
25
- import type { Placement, Status } from "@/lib/mock/placements"
26
-
27
- // ─────────────────────────────────────────────────────────────────────────────
28
- // Placement status — same maps + shell as other list hubs (`list-status-badges`)
29
- // ─────────────────────────────────────────────────────────────────────────────
30
-
31
- function isPlacementStatus(v: string): v is Status {
32
- return v in PLACEMENT_STATUS_LABEL
33
- }
34
-
35
- export function StatusBadge({
36
- status,
37
- surface = "table",
38
- }: {
39
- status: Status | string
40
- surface?: "table" | "board"
41
- }) {
42
- if (!isPlacementStatus(status)) {
43
- return (
44
- <Badge variant="outline" className="text-xs shrink-0">
45
- {String(status)}
46
- </Badge>
47
- )
48
- }
49
- return (
50
- <ListHubStatusBadge
51
- surface={surface}
52
- label={PLACEMENT_STATUS_LABEL[status]}
53
- tintClassName={PLACEMENT_STATUS_BADGE_CLASS[status]}
54
- icon={PLACEMENT_STATUS_ICON[status]}
55
- />
56
- )
57
- }
58
-
59
- export function AvatarCircle({ initials }: { initials: string }) {
60
- return (
61
- <AvatarInitials
62
- initials={initials}
63
- className="size-7 shrink-0 text-xs"
64
- fallbackClassName="text-xs"
65
- />
66
- )
67
- }
68
-
69
- export function WeeksProgressCell({ row }: { row: Placement }) {
70
- const { progressWeeksDone, progressWeeksTotal } = row
71
- const total = Math.max(1, progressWeeksTotal)
72
- const pct = Math.min(100, Math.round((progressWeeksDone / total) * 100))
73
- return (
74
- <div className="flex min-w-[128px] max-w-[200px] flex-col gap-1.5">
75
- <div className="h-2 overflow-hidden rounded-full bg-muted">
76
- <div
77
- className="h-full rounded-full bg-primary transition-[width]"
78
- style={{ width: `${pct}%` }}
79
- />
80
- </div>
81
- <span className="text-xs tabular-nums text-muted-foreground">
82
- {progressWeeksDone} / {progressWeeksTotal} wks
83
- </span>
84
- </div>
85
- )
86
- }
87
-
88
- export function ReadinessBadge({ value }: { value: string }) {
89
- const lower = value.toLowerCase()
90
- const variant =
91
- lower.includes("risk") || lower.includes("blocked")
92
- ? "destructive"
93
- : lower.includes("review")
94
- ? "secondary"
95
- : "outline"
96
- return (
97
- <Badge variant={variant} className="h-6 px-2 py-1 text-xs font-medium leading-none">
98
- {value}
99
- </Badge>
100
- )
101
- }
102
-
103
- export function HireBadge({ value }: { value: string }) {
104
- if (value === "—" || !value) return <span className="text-sm text-muted-foreground">—</span>
105
- const yes = value.toLowerCase() === "yes"
106
- return (
107
- <Badge
108
- variant={yes ? "default" : "secondary"}
109
- className="h-6 border-0 px-2 py-1 text-xs font-medium leading-none"
110
- >
111
- {value}
112
- </Badge>
113
- )
114
- }
115
-
116
- // ─────────────────────────────────────────────────────────────────────────────
117
- // Row actions
118
- // ─────────────────────────────────────────────────────────────────────────────
119
-
120
- export interface RowActionDef {
121
- label: string
122
- icon: string
123
- onClick: (row: Placement) => void
124
- variant?: "destructive"
125
- }
126
-
127
- export const PLACEMENT_ROW_ACTIONS: RowActionDef[] = [
128
- { label: "Edit", icon: "fa-pen-to-square", onClick: _row => {} },
129
- { label: "Open", icon: "fa-arrow-up-right", onClick: _row => {} },
130
- { label: "Delete", icon: "fa-trash", onClick: _row => {}, variant: "destructive" },
131
- ]
132
-
133
- export function RowActions({ row, actions }: { row: Placement; actions: RowActionDef[] }) {
134
- if (!actions.length) return null
135
-
136
- if (actions.length === 1) {
137
- const a = actions[0]
138
- return (
139
- <Tip label={a.label} side="top">
140
- <Button size="icon-sm" variant="ghost" aria-label={`${a.label} ${row.student}`}
141
- onClick={() => a.onClick(row)}>
142
- <i className={`fa-light ${a.icon} text-sm`} aria-hidden="true" />
143
- </Button>
144
- </Tip>
145
- )
146
- }
147
-
148
- return (
149
- <DropdownMenu>
150
- <Tip label={`More options for ${row.student}`} side="top">
151
- <DropdownMenuTrigger asChild>
152
- <Button size="icon-sm" variant="ghost" aria-label={`More options for ${row.student}`}>
153
- <i className="fa-light fa-ellipsis text-sm" aria-hidden="true" />
154
- </Button>
155
- </DropdownMenuTrigger>
156
- </Tip>
157
- <DropdownMenuContent align="end">
158
- {actions.map((a, i) => (
159
- <React.Fragment key={a.label}>
160
- {a.variant === "destructive" && i > 0 && <DropdownMenuSeparator />}
161
- <DropdownMenuItem
162
- onClick={() => a.onClick(row)}
163
- className={a.variant === "destructive" ? "text-destructive focus:text-destructive" : ""}
164
- >
165
- <i className={`fa-light ${a.icon}`} aria-hidden="true" />
166
- {a.label}
167
- </DropdownMenuItem>
168
- </React.Fragment>
169
- ))}
170
- </DropdownMenuContent>
171
- </DropdownMenu>
172
- )
173
- }
@@ -1,210 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * Placements columns, empty state, and Properties drawer label.
5
- * Owned by the placements feature (`PlacementsClient` / `/data-list`); consumed by `PlacementsTable`.
6
- *
7
- * NOTE: Lifecycle parameterisation (all/upcoming/ongoing/completed) was removed.
8
- * One canonical column set is exposed via `getPlacementColumns()`.
9
- */
10
-
11
- import type { FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
12
- import type { ColumnDef } from "@/components/data-table/types"
13
- import {
14
- AvatarCircle,
15
- PLACEMENT_ROW_ACTIONS,
16
- RowActions,
17
- StatusBadge,
18
- } from "@/components/placements-table-cells"
19
- import { uniquePlacementFieldOptions, type Placement } from "@/lib/mock/placements"
20
-
21
- const COLUMN_SELECT: ColumnDef<Placement> = {
22
- key: "select",
23
- label: "",
24
- width: 40,
25
- minWidth: 40,
26
- defaultPin: "left",
27
- lockPin: true,
28
- }
29
-
30
- const COLUMN_ACTIONS: ColumnDef<Placement> = {
31
- key: "actions",
32
- label: "",
33
- width: 48,
34
- minWidth: 48,
35
- defaultPin: "right",
36
- lockPin: true,
37
- cell: (row) => (
38
- <div className="flex items-center justify-center">
39
- <RowActions row={row} actions={PLACEMENT_ROW_ACTIONS} />
40
- </div>
41
- ),
42
- }
43
-
44
- const CELL_STUDENT: ColumnDef<Placement>["cell"] = (row) => (
45
- <div className="flex items-center gap-2.5 min-w-0">
46
- <AvatarCircle initials={row.initials} />
47
- <div className="flex flex-col min-w-0">
48
- <span className="font-medium text-foreground text-sm leading-tight truncate">
49
- {row.student}
50
- </span>
51
- <span className="text-xs text-muted-foreground leading-tight mt-0.5 truncate">
52
- {row.email}
53
- </span>
54
- </div>
55
- </div>
56
- )
57
-
58
-
59
- function placementColumnToFilterFieldDef(c: ColumnDef<Placement>): FilterFieldDef | null {
60
- if (!c.filter) return null
61
- const f = c.filter
62
- const defaultOps =
63
- f.type === "select" || f.type === "date"
64
- ? (["is", "is_not"] as FilterOperator[])
65
- : (["contains", "not_contains"] as FilterOperator[])
66
- return {
67
- key: c.key,
68
- label: c.label,
69
- icon: f.icon ?? "fa-filter",
70
- type: f.type,
71
- operators: (f.operators ?? defaultOps) as FilterOperator[],
72
- options:
73
- f.type === "date"
74
- ? uniquePlacementFieldOptions(c.key as keyof Placement)
75
- : f.options,
76
- ...(f.textMask ? { textMask: f.textMask } : {}),
77
- }
78
- }
79
-
80
- export function columnsToFilterFields(cols: ColumnDef<Placement>[]): FilterFieldDef[] {
81
- return cols.map(placementColumnToFilterFieldDef).filter((x): x is FilterFieldDef => x !== null)
82
- }
83
-
84
- /** All columns — original placements overview */
85
- const PLACEMENT_COLUMNS_ALL: ColumnDef<Placement>[] = [
86
- COLUMN_SELECT,
87
- {
88
- key: "student",
89
- label: "Student",
90
- width: 210,
91
- minWidth: 180,
92
- sortable: true,
93
- sortKey: "student",
94
- defaultPin: "left",
95
- filter: {
96
- type: "select",
97
- icon: "fa-user",
98
- operators: ["is", "is_not"],
99
- options: uniquePlacementFieldOptions("student"),
100
- },
101
- cell: CELL_STUDENT,
102
- },
103
- {
104
- key: "specialization",
105
- label: "Specialization",
106
- width: 160,
107
- minWidth: 100,
108
- sortable: true,
109
- sortKey: "specialization",
110
- filter: {
111
- type: "select",
112
- icon: "fa-stethoscope",
113
- operators: ["is", "is_not"],
114
- options: uniquePlacementFieldOptions("specialization"),
115
- },
116
- cell: (row) => (
117
- <span className="block truncate text-sm text-foreground/80">{row.specialization}</span>
118
- ),
119
- },
120
- {
121
- key: "site",
122
- label: "Site",
123
- width: 180,
124
- minWidth: 100,
125
- sortable: true,
126
- sortKey: "site",
127
- filter: {
128
- type: "select",
129
- icon: "fa-hospital",
130
- operators: ["is", "is_not"],
131
- options: uniquePlacementFieldOptions("site"),
132
- },
133
- cell: (row) => (
134
- <div className="min-w-0" title={`${row.site} · ${row.siteAddress}`}>
135
- <span className="block truncate text-sm font-medium text-foreground leading-tight">{row.site}</span>
136
- <span className="block truncate text-xs text-muted-foreground mt-0.5 leading-tight">{row.siteAddress}</span>
137
- </div>
138
- ),
139
- },
140
- {
141
- key: "status",
142
- label: "Status",
143
- width: 130,
144
- minWidth: 110,
145
- sortable: true,
146
- sortKey: "status",
147
- filter: {
148
- type: "select",
149
- icon: "fa-circle-dot",
150
- operators: ["is", "is_not"],
151
- options: [
152
- { value: "confirmed", label: "Confirmed" },
153
- { value: "pending", label: "Pending" },
154
- { value: "under-review", label: "Under Review" },
155
- { value: "rejected", label: "Rejected" },
156
- { value: "completed", label: "Completed" },
157
- ],
158
- },
159
- cell: (row) => <StatusBadge status={row.status} />,
160
- },
161
- {
162
- key: "start",
163
- label: "Start Date",
164
- width: 130,
165
- minWidth: 110,
166
- sortable: true,
167
- sortKey: "start",
168
- filter: {
169
- type: "date",
170
- icon: "fa-calendar-days",
171
- operators: ["is", "is_not"],
172
- },
173
- cell: (row) => (
174
- <span className="text-sm text-foreground/80 tabular-nums whitespace-nowrap">{row.start}</span>
175
- ),
176
- },
177
- {
178
- key: "duration",
179
- label: "Duration",
180
- width: 96,
181
- minWidth: 80,
182
- cell: (row) => (
183
- <span className="text-sm text-foreground/80 whitespace-nowrap">{row.duration}</span>
184
- ),
185
- },
186
- {
187
- key: "supervisor",
188
- label: "Supervisor",
189
- width: 152,
190
- minWidth: 100,
191
- filter: {
192
- type: "select",
193
- icon: "fa-user-tie",
194
- operators: ["is", "is_not"],
195
- options: uniquePlacementFieldOptions("supervisor"),
196
- },
197
- cell: (row) => (
198
- <span className="block truncate text-sm text-foreground/80">{row.supervisor}</span>
199
- ),
200
- },
201
- COLUMN_ACTIONS,
202
- ]
203
-
204
- export function getPlacementColumns(): ColumnDef<Placement>[] {
205
- return PLACEMENT_COLUMNS_ALL
206
- }
207
-
208
- export const PLACEMENT_EMPTY_COPY = "No rows match your filters."
209
- export const PLACEMENT_DRAWER_LABEL = "Placements"
210
-