@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,171 +0,0 @@
1
- "use client"
2
-
3
- /**
4
- * PlacementsListView — full-width row layout for the data list (vs table grid / board columns).
5
- * Shares column visibility + lifecycle rules with Table Properties via the same board column model.
6
- *
7
- * Shell (empty state + virtualization above 80 rows) comes from the generic
8
- * `DataRowList` primitive in `components/data-views/`. This file owns only
9
- * the placement-specific row body (column-driven field visibility).
10
- */
11
-
12
- import * as React from "react"
13
- import { useRouter } from "next/navigation"
14
- import type { Placement } from "@/lib/mock/placements"
15
- import { StatusBadge } from "@/components/placements-table-cells"
16
- import { Badge } from "@/components/ui/badge"
17
- import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
18
- import { DataRowList } from "@/components/data-views/data-row-list"
19
- import {
20
- type BoardCardLifecycleTabId,
21
- isBoardFieldActive,
22
- scheduleKeysForTab,
23
- } from "@/lib/placement-board-card-layout"
24
- import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
25
- import type { ConditionalRule } from "@/components/table-properties/types"
26
- import type { ColumnDef } from "@/components/data-table/types"
27
-
28
- /** Above this count, `DataRowList` virtualizes against the window scroll. */
29
- const VIRTUAL_ROWS_THRESHOLD = 80
30
- /** Initial row height guess (px); `measureElement` refines for variable content. */
31
- const ESTIMATE_ROW_PX = 100
32
-
33
- function scheduleSummary(row: Placement, tab: BoardCardLifecycleTabId): string | null {
34
- switch (tab) {
35
- case "all":
36
- return [row.start, row.duration].filter(Boolean).join(" · ") || null
37
- case "upcoming":
38
- return row.daysUntilStart > 0
39
- ? `${row.start} · Starts in ${row.daysUntilStart} days`
40
- : row.start
41
- case "ongoing":
42
- return `${row.progressWeeksDone} / ${row.progressWeeksTotal} wks · Ends ${row.endDate}`
43
- case "completed":
44
- return [row.completionDate, row.finalStatus].filter(v => v && v !== "—").join(" · ") || null
45
- default:
46
- return null
47
- }
48
- }
49
-
50
- function PlacementListRowContent({
51
- row,
52
- tab,
53
- hiddenColKeys,
54
- boardColumns,
55
- conditionalRules,
56
- onOpen,
57
- }: {
58
- row: Placement
59
- tab: BoardCardLifecycleTabId
60
- hiddenColKeys: Set<string>
61
- boardColumns: ColumnDef<Placement>[]
62
- conditionalRules: ConditionalRule[] | undefined
63
- onOpen: (id: number) => void
64
- }) {
65
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
66
- const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
67
- const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
68
- const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
69
- const showSpec = isBoardFieldActive("specialization", tab, hiddenColKeys, boardColumns)
70
- const showInternship = isBoardFieldActive("internship", tab, hiddenColKeys, boardColumns)
71
- const sk = scheduleKeysForTab(tab)
72
- const showSchedule = sk.some(k => isBoardFieldActive(k, tab, hiddenColKeys, boardColumns))
73
- const schedule = showSchedule ? scheduleSummary(row, tab) : null
74
-
75
- const title = showStudent ? row.student : `Placement ${row.id}`
76
-
77
- const leading = showStudent ? (
78
- <ListPageBoardCardAvatar initials={row.initials} className="size-9" />
79
- ) : (
80
- <span className="size-9 shrink-0 rounded-full bg-muted/80" aria-hidden />
81
- )
82
-
83
- const rowEnd = showStatus ? (
84
- <div className="flex shrink-0 items-center gap-2 pt-0.5">
85
- <StatusBadge status={row.status} surface="board" />
86
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden />
87
- </div>
88
- ) : (
89
- <i className="fa-light fa-chevron-right mt-1 shrink-0 text-xs text-muted-foreground" aria-hidden />
90
- )
91
-
92
- return (
93
- <ListPageBoardCard
94
- layout="row"
95
- leading={leading}
96
- rowEnd={rowEnd}
97
- isNew={row.isNew}
98
- style={ruleBg ? { background: ruleBg } : undefined}
99
- onClick={() => onOpen(row.id)}
100
- >
101
- <div className="space-y-1">
102
- <div className="flex flex-wrap items-center gap-2">
103
- <span className="text-sm font-semibold text-foreground">{title}</span>
104
- {row.isNew ? (
105
- <Badge variant="secondary" className="h-5 px-1.5 text-xs font-medium">
106
- New
107
- </Badge>
108
- ) : null}
109
- </div>
110
- {showSite ? (
111
- <p className="text-xs text-foreground/90">
112
- <span className="font-medium">{row.site}</span>
113
- {row.siteAddress ? (
114
- <span className="text-muted-foreground"> · {row.siteAddress}</span>
115
- ) : null}
116
- </p>
117
- ) : null}
118
- {(showSpec || showInternship) ? (
119
- <p className="text-xs text-muted-foreground">
120
- {[showSpec ? row.specialization : null, showInternship ? row.internship : null]
121
- .filter(Boolean)
122
- .join(" · ")}
123
- </p>
124
- ) : null}
125
- {schedule ? <p className="text-xs text-muted-foreground tabular-nums">{schedule}</p> : null}
126
- </div>
127
- </ListPageBoardCard>
128
- )
129
- }
130
-
131
- export interface PlacementsListViewProps {
132
- rows: Placement[]
133
- lifecycleTabId: BoardCardLifecycleTabId
134
- hiddenColKeys: Set<string>
135
- boardColumns: ColumnDef<Placement>[]
136
- conditionalRules?: ConditionalRule[]
137
- emptyCopy: string
138
- }
139
-
140
- export function PlacementsListView({
141
- rows,
142
- lifecycleTabId,
143
- hiddenColKeys,
144
- boardColumns,
145
- conditionalRules,
146
- emptyCopy,
147
- }: PlacementsListViewProps) {
148
- const router = useRouter()
149
- const onOpen = React.useCallback((id: number) => router.push(`/data-list/${id}`), [router])
150
-
151
- return (
152
- <DataRowList<Placement>
153
- rows={rows}
154
- getRowId={row => row.id}
155
- emptyState={emptyCopy}
156
- ariaLabel="Placements"
157
- virtualizeThreshold={VIRTUAL_ROWS_THRESHOLD}
158
- estimatedRowHeight={ESTIMATE_ROW_PX}
159
- renderRow={row => (
160
- <PlacementListRowContent
161
- row={row}
162
- tab={lifecycleTabId}
163
- hiddenColKeys={hiddenColKeys}
164
- boardColumns={boardColumns}
165
- conditionalRules={conditionalRules}
166
- onOpen={onOpen}
167
- />
168
- )}
169
- />
170
- )
171
- }
@@ -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
- }