@exxatdesignux/ui 0.0.6 → 0.0.8

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 (264) hide show
  1. package/bin/init.mjs +29 -0
  2. package/package.json +7 -2
  3. package/template/.nvmrc +1 -0
  4. package/template/.prettierignore +7 -0
  5. package/template/.prettierrc +11 -0
  6. package/template/AGENTS.md +485 -0
  7. package/template/Logo/Exxat_Prism.svg +39 -0
  8. package/template/Logo/Exxat_one.svg +36 -0
  9. package/template/README.md +58 -0
  10. package/template/app/(app)/compliance/page.tsx +10 -0
  11. package/template/app/(app)/dashboard/loading.tsx +18 -0
  12. package/template/app/(app)/dashboard/page.tsx +36 -0
  13. package/template/app/(app)/data-list/[id]/page.tsx +28 -0
  14. package/template/app/(app)/data-list/new/page.tsx +31 -0
  15. package/template/app/(app)/data-list/page.tsx +10 -0
  16. package/template/app/(app)/error.tsx +43 -0
  17. package/template/app/(app)/help/page.tsx +34 -0
  18. package/template/app/(app)/layout.tsx +54 -0
  19. package/template/app/(app)/loading.tsx +18 -0
  20. package/template/app/(app)/question-bank/page.tsx +10 -0
  21. package/template/app/(app)/rotations/page.tsx +15 -0
  22. package/template/app/(app)/settings/page.tsx +17 -0
  23. package/template/app/(app)/sites/all/page.tsx +13 -0
  24. package/template/app/(app)/team/page.tsx +10 -0
  25. package/template/app/favicon.ico +0 -0
  26. package/template/app/globals.css +1811 -0
  27. package/template/app/layout.tsx +95 -0
  28. package/template/app/page.tsx +9 -0
  29. package/template/components/.gitkeep +0 -0
  30. package/template/components/app-sidebar-dynamic.tsx +15 -0
  31. package/template/components/app-sidebar.tsx +901 -0
  32. package/template/components/ask-leo-composer.tsx +216 -0
  33. package/template/components/ask-leo-sidebar.tsx +509 -0
  34. package/template/components/chart-area-interactive.tsx +293 -0
  35. package/template/components/charts-overview.tsx +2321 -0
  36. package/template/components/command-menu-01.tsx +133 -0
  37. package/template/components/command-menu-02.tsx +386 -0
  38. package/template/components/command-menu.tsx +182 -0
  39. package/template/components/compliance-board-view.tsx +134 -0
  40. package/template/components/compliance-client.tsx +92 -0
  41. package/template/components/compliance-list-view.tsx +59 -0
  42. package/template/components/compliance-page-header.tsx +89 -0
  43. package/template/components/compliance-table.tsx +525 -0
  44. package/template/components/dashboard-onboarding-gallery.tsx +13 -0
  45. package/template/components/dashboard-onboarding.tsx +21 -0
  46. package/template/components/dashboard-promo-banner.tsx +67 -0
  47. package/template/components/dashboard-quota-progress-card.tsx +369 -0
  48. package/template/components/dashboard-report-charts.tsx +69 -0
  49. package/template/components/dashboard-section-heading.tsx +68 -0
  50. package/template/components/dashboard-tabs.tsx +598 -0
  51. package/template/components/data-list-client.tsx +239 -0
  52. package/template/components/data-list-table-cells.test.tsx +22 -0
  53. package/template/components/data-list-table-cells.tsx +173 -0
  54. package/template/components/data-list-table.tsx +879 -0
  55. package/template/components/data-table/filter-date-calendar.tsx +38 -0
  56. package/template/components/data-table/filter-text-value-input.tsx +77 -0
  57. package/template/components/data-table/index.tsx +1612 -0
  58. package/template/components/data-table/pagination.tsx +256 -0
  59. package/template/components/data-table/types.ts +91 -0
  60. package/template/components/data-table/use-table-state.ts +566 -0
  61. package/template/components/data-view-dashboard-charts-compliance.tsx +960 -0
  62. package/template/components/data-view-dashboard-charts-team.tsx +968 -0
  63. package/template/components/data-view-dashboard-charts.tsx +1668 -0
  64. package/template/components/data-views/board-card-primitives.tsx +93 -0
  65. package/template/components/data-views/index.ts +41 -0
  66. package/template/components/data-views/list-page-board-card.tsx +192 -0
  67. package/template/components/data-views/list-page-board-template.tsx +122 -0
  68. package/template/components/data-views/placement-board-card.tsx +262 -0
  69. package/template/components/export-drawer.tsx +375 -0
  70. package/template/components/exxat-product-logo.tsx +453 -0
  71. package/template/components/form-layout-01.tsx +131 -0
  72. package/template/components/getting-started.tsx +625 -0
  73. package/template/components/key-metrics.tsx +920 -0
  74. package/template/components/leo-insight-indicator.tsx +364 -0
  75. package/template/components/leo-typing-dots.tsx +121 -0
  76. package/template/components/list-hub-status-badge.tsx +51 -0
  77. package/template/components/list-page-dashboard-charts.tsx +18 -0
  78. package/template/components/nav-documents.tsx +89 -0
  79. package/template/components/nav-main.tsx +58 -0
  80. package/template/components/nav-secondary.tsx +64 -0
  81. package/template/components/nav-user.tsx +190 -0
  82. package/template/components/new-placement-back-btn.tsx +28 -0
  83. package/template/components/new-placement-form.tsx +1066 -0
  84. package/template/components/onboarding/index.ts +4 -0
  85. package/template/components/onboarding/onboarding-01.tsx +7 -0
  86. package/template/components/onboarding/onboarding-02.tsx +7 -0
  87. package/template/components/onboarding/onboarding-03.tsx +7 -0
  88. package/template/components/onboarding/onboarding-04.tsx +7 -0
  89. package/template/components/page-header.tsx +57 -0
  90. package/template/components/placement-detail.tsx +438 -0
  91. package/template/components/placements-board-view.tsx +404 -0
  92. package/template/components/placements-list-view.tsx +285 -0
  93. package/template/components/placements-page-header.tsx +160 -0
  94. package/template/components/placements-table-columns.tsx +639 -0
  95. package/template/components/product-switcher.tsx +116 -0
  96. package/template/components/question-bank-board-view.tsx +205 -0
  97. package/template/components/question-bank-client.tsx +77 -0
  98. package/template/components/question-bank-list-view.tsx +59 -0
  99. package/template/components/question-bank-page-header.tsx +89 -0
  100. package/template/components/question-bank-table.tsx +586 -0
  101. package/template/components/rotations-empty-state.tsx +47 -0
  102. package/template/components/rotations-panel-activator.tsx +8 -0
  103. package/template/components/secondary-nav.tsx +394 -0
  104. package/template/components/secondary-panel.tsx +239 -0
  105. package/template/components/section-cards.tsx +106 -0
  106. package/template/components/settings-appearance-card.tsx +424 -0
  107. package/template/components/settings-client.tsx +537 -0
  108. package/template/components/settings-form-row.tsx +42 -0
  109. package/template/components/sidebar-auto-collapse.tsx +23 -0
  110. package/template/components/sidebar-auto-open.tsx +18 -0
  111. package/template/components/sidebar-shell.tsx +37 -0
  112. package/template/components/site-header.tsx +93 -0
  113. package/template/components/sites-all-client.tsx +154 -0
  114. package/template/components/sites-board-view.tsx +67 -0
  115. package/template/components/sites-list-view.tsx +47 -0
  116. package/template/components/sites-table.tsx +312 -0
  117. package/template/components/system-banner-slot.tsx +66 -0
  118. package/template/components/table-properties/column-row.tsx +90 -0
  119. package/template/components/table-properties/draggable-list.ts +49 -0
  120. package/template/components/table-properties/drawer-button.tsx +231 -0
  121. package/template/components/table-properties/drawer.tsx +1102 -0
  122. package/template/components/table-properties/filter-card.tsx +251 -0
  123. package/template/components/table-properties/index.ts +22 -0
  124. package/template/components/table-properties/sort-card.tsx +59 -0
  125. package/template/components/table-properties/types.ts +124 -0
  126. package/template/components/task-list-panel.tsx +98 -0
  127. package/template/components/task-priority-badge.tsx +28 -0
  128. package/template/components/team-board-view.tsx +114 -0
  129. package/template/components/team-client.tsx +93 -0
  130. package/template/components/team-list-view.tsx +62 -0
  131. package/template/components/team-page-header.tsx +92 -0
  132. package/template/components/team-table.tsx +525 -0
  133. package/template/components/templates/list-page.tsx +576 -0
  134. package/template/components/templates/primary-page-template.tsx +56 -0
  135. package/template/components/theme-color-sync.tsx +32 -0
  136. package/template/components/theme-provider.tsx +71 -0
  137. package/template/components/tinted-icon-disc.tsx +53 -0
  138. package/template/components/ui/ai-thinking-surface.tsx +121 -0
  139. package/template/components/ui/avatar.tsx +1 -0
  140. package/template/components/ui/badge.tsx +1 -0
  141. package/template/components/ui/banner.tsx +1 -0
  142. package/template/components/ui/breadcrumb.tsx +1 -0
  143. package/template/components/ui/button.tsx +1 -0
  144. package/template/components/ui/calendar.tsx +1 -0
  145. package/template/components/ui/card.tsx +1 -0
  146. package/template/components/ui/chart.tsx +1 -0
  147. package/template/components/ui/checkbox.tsx +1 -0
  148. package/template/components/ui/coach-mark.tsx +1 -0
  149. package/template/components/ui/collapsible.tsx +1 -0
  150. package/template/components/ui/command.tsx +1 -0
  151. package/template/components/ui/date-picker-field.tsx +1 -0
  152. package/template/components/ui/dialog.tsx +1 -0
  153. package/template/components/ui/dot-pattern.tsx +159 -0
  154. package/template/components/ui/drag-handle-grip.tsx +1 -0
  155. package/template/components/ui/drawer.tsx +1 -0
  156. package/template/components/ui/dropdown-menu.tsx +1 -0
  157. package/template/components/ui/field.tsx +1 -0
  158. package/template/components/ui/form.tsx +1 -0
  159. package/template/components/ui/input-group.tsx +1 -0
  160. package/template/components/ui/input-mask.tsx +1 -0
  161. package/template/components/ui/input.tsx +1 -0
  162. package/template/components/ui/kbd.tsx +1 -0
  163. package/template/components/ui/label.tsx +1 -0
  164. package/template/components/ui/leo-icon.tsx +726 -0
  165. package/template/components/ui/payment-card-fields.tsx +1 -0
  166. package/template/components/ui/popover.tsx +1 -0
  167. package/template/components/ui/radio-group.tsx +1 -0
  168. package/template/components/ui/select.tsx +1 -0
  169. package/template/components/ui/selection-tile-grid.tsx +1 -0
  170. package/template/components/ui/separator.tsx +1 -0
  171. package/template/components/ui/sheet.tsx +1 -0
  172. package/template/components/ui/sidebar.tsx +1 -0
  173. package/template/components/ui/skeleton.tsx +1 -0
  174. package/template/components/ui/sonner.tsx +1 -0
  175. package/template/components/ui/status-badge.tsx +1 -0
  176. package/template/components/ui/table.tsx +1 -0
  177. package/template/components/ui/tabs.tsx +1 -0
  178. package/template/components/ui/textarea.tsx +1 -0
  179. package/template/components/ui/tip.tsx +1 -0
  180. package/template/components/ui/toggle-group.tsx +1 -0
  181. package/template/components/ui/toggle-switch.tsx +1 -0
  182. package/template/components/ui/toggle.tsx +1 -0
  183. package/template/components/ui/tooltip.tsx +1 -0
  184. package/template/components/ui/view-segmented-control.tsx +1 -0
  185. package/template/components.json +27 -0
  186. package/template/contexts/chart-variant-context.tsx +35 -0
  187. package/template/contexts/command-menu-context.tsx +28 -0
  188. package/template/contexts/dashboard-view-context.tsx +35 -0
  189. package/template/contexts/product-context.tsx +38 -0
  190. package/template/contexts/system-banner-context.tsx +127 -0
  191. package/template/docs/command-menu-pattern.md +45 -0
  192. package/template/docs/data-views-pattern.md +160 -0
  193. package/template/ecosystem.config.cjs +20 -0
  194. package/template/eslint.config.mjs +18 -0
  195. package/template/fontawesome-subset.manifest.json +190 -0
  196. package/template/hooks/.gitkeep +0 -0
  197. package/template/hooks/use-app-theme.ts +1 -0
  198. package/template/hooks/use-coach-mark.ts +1 -0
  199. package/template/hooks/use-mobile.ts +1 -0
  200. package/template/hooks/use-mod-key-label.ts +1 -0
  201. package/template/lib/.gitkeep +0 -0
  202. package/template/lib/ask-leo-route-context.ts +133 -0
  203. package/template/lib/chart-keyboard-selection.test.ts +20 -0
  204. package/template/lib/chart-keyboard-selection.ts +17 -0
  205. package/template/lib/chart-line-dash.ts +16 -0
  206. package/template/lib/coach-mark-registry.ts +68 -0
  207. package/template/lib/command-menu-config.ts +127 -0
  208. package/template/lib/command-menu-search-data.ts +44 -0
  209. package/template/lib/conditional-rule-match.ts +32 -0
  210. package/template/lib/dashboard-customize-coach-mark.ts +18 -0
  211. package/template/lib/dashboard-layout-merge.ts +63 -0
  212. package/template/lib/data-list-display-options.ts +35 -0
  213. package/template/lib/data-list-persistence.ts +280 -0
  214. package/template/lib/data-list-view-surface.ts +58 -0
  215. package/template/lib/data-list-view.ts +29 -0
  216. package/template/lib/data-view-dashboard-storage.ts +101 -0
  217. package/template/lib/date-filter.ts +8 -0
  218. package/template/lib/dev-log.test.ts +28 -0
  219. package/template/lib/dev-log.ts +8 -0
  220. package/template/lib/editable-target.ts +10 -0
  221. package/template/lib/floating-sheet-panel.ts +72 -0
  222. package/template/lib/initials-from-name.ts +7 -0
  223. package/template/lib/list-page-table-properties.ts +52 -0
  224. package/template/lib/list-status-badges.ts +168 -0
  225. package/template/lib/logo-dev.ts +12 -0
  226. package/template/lib/mock/compliance-kpi.ts +61 -0
  227. package/template/lib/mock/compliance.ts +146 -0
  228. package/template/lib/mock/dashboard.ts +105 -0
  229. package/template/lib/mock/navigation.tsx +231 -0
  230. package/template/lib/mock/placements-kpi.ts +134 -0
  231. package/template/lib/mock/placements.ts +183 -0
  232. package/template/lib/mock/question-bank-kpi.ts +61 -0
  233. package/template/lib/mock/question-bank.ts +142 -0
  234. package/template/lib/mock/sites-directory.ts +16 -0
  235. package/template/lib/mock/sites-kpi.ts +25 -0
  236. package/template/lib/mock/team-kpi.ts +60 -0
  237. package/template/lib/mock/team.ts +118 -0
  238. package/template/lib/motion-ui.ts +17 -0
  239. package/template/lib/placement-board-card-layout.ts +79 -0
  240. package/template/lib/placement-lifecycle.ts +5 -0
  241. package/template/lib/row-height.ts +10 -0
  242. package/template/lib/stock-portrait.ts +11 -0
  243. package/template/lib/utils.test.ts +13 -0
  244. package/template/lib/utils.ts +1 -0
  245. package/template/next.config.mjs +15 -0
  246. package/template/package.json +83 -0
  247. package/template/postcss.config.mjs +8 -0
  248. package/template/public/.gitkeep +0 -0
  249. package/template/public/Illustration/Rotation.svg +74 -0
  250. package/template/public/avatars/user.svg +11 -0
  251. package/template/public/favicon/favicon.ico +0 -0
  252. package/template/public/favicon.ico +0 -0
  253. package/template/public/logos/exxat-one.svg +36 -0
  254. package/template/public/logos/exxat-prism.svg +39 -0
  255. package/template/public/mock-schools/emory.svg +4 -0
  256. package/template/public/mock-schools/rush.svg +4 -0
  257. package/template/scripts/fontawesome-subset-audit.mjs +190 -0
  258. package/template/scripts/pm2-startup-macos.sh +13 -0
  259. package/template/skills-lock.json +10 -0
  260. package/template/stores/app-store.ts +33 -0
  261. package/template/tests/setup.ts +1 -0
  262. package/template/tsconfig.json +35 -0
  263. package/template/types/react-payment-inputs.d.ts +19 -0
  264. package/template/vitest.config.ts +18 -0
@@ -0,0 +1,404 @@
1
+ "use client"
2
+
3
+ /**
4
+ * PlacementsBoardView — kanban-style board by lifecycle phase (domain-specific columns).
5
+ * View chrome labels use `dataListViewLabel` from `@/lib/data-list-view` at the page level;
6
+ * this component focuses on placement phase grouping + shared card primitives.
7
+ */
8
+
9
+ import * as React from "react"
10
+ import { useRouter } from "next/navigation"
11
+ import { cn } from "@/lib/utils"
12
+ import type { Placement, PlacementPhase } from "@/lib/mock/placements"
13
+ import { Input } from "@/components/ui/input"
14
+ import { Tip } from "@/components/ui/tip"
15
+ import {
16
+ DropdownMenu,
17
+ DropdownMenuContent,
18
+ DropdownMenuItem,
19
+ DropdownMenuSeparator,
20
+ DropdownMenuSub,
21
+ DropdownMenuSubContent,
22
+ DropdownMenuSubTrigger,
23
+ DropdownMenuTrigger,
24
+ } from "@/components/ui/dropdown-menu"
25
+ import { DEFAULT_DATA_LIST_DISPLAY_OPTIONS, type BoardLineCount } from "@/lib/data-list-display-options"
26
+ import { type BoardCardLifecycleTabId } from "@/lib/placement-board-card-layout"
27
+ import type { ConditionalRule } from "@/components/table-properties/types"
28
+ import type { ColumnDef } from "@/components/data-table/types"
29
+ import { Badge } from "@/components/ui/badge"
30
+ import { BoardPlacementCard } from "@/components/data-views/placement-board-card"
31
+ import { BoardNewCardPlaceholder } from "@/components/data-views/board-card-primitives"
32
+
33
+ const PHASE_COLUMNS: { phase: PlacementPhase; label: string; description: string }[] = [
34
+ { phase: "upcoming", label: "Upcoming", description: "Starting soon" },
35
+ { phase: "ongoing", label: "Ongoing", description: "In progress" },
36
+ { phase: "completed", label: "Completed", description: "Finished" },
37
+ ]
38
+
39
+ /** Substring match across visible card fields (per-phase quick search). */
40
+ function rowMatchesPhaseSearch(row: Placement, q: string): boolean {
41
+ if (!q.trim()) return true
42
+ const lower = q.toLowerCase()
43
+ const hay = [
44
+ row.student,
45
+ row.site,
46
+ row.specialization,
47
+ row.internship,
48
+ row.program,
49
+ row.status,
50
+ row.supervisor,
51
+ row.email,
52
+ row.start,
53
+ ]
54
+ .map(v => String(v ?? "").toLowerCase())
55
+ .join(" ")
56
+ return hay.includes(lower)
57
+ }
58
+
59
+ export interface PlacementsBoardColumnMenu {
60
+ filterableColumns: { key: string; label: string }[]
61
+ sortableColumns: { key: string; label: string }[]
62
+ groupableColumns: { key: string; label: string }[]
63
+ groupBy: string | null
64
+ onAddFilter: (fieldKey: string) => void
65
+ onSortByField: (fieldKey: string, direction: "asc" | "desc") => void
66
+ onToggleGroupBy: (fieldKey: string) => void
67
+ onOpenProperties: () => void
68
+ }
69
+
70
+ export interface BoardDisplaySettings {
71
+ lineCount: BoardLineCount
72
+ showColumnLabels: boolean
73
+ showColumnCounts: boolean
74
+ newCardAbove: boolean
75
+ }
76
+
77
+ export interface PlacementsBoardViewProps {
78
+ placements: Placement[]
79
+ /** Current lifecycle filter tab — drives helper copy above the board. */
80
+ lifecycleTabId: BoardCardLifecycleTabId
81
+ /** When set, each phase column header shows the same actions as a DataTable column header. */
82
+ boardColumnMenu?: PlacementsBoardColumnMenu
83
+ /** Board display options (Properties → view display). */
84
+ boardDisplay?: BoardDisplaySettings
85
+ /** Column visibility from table state — hidden columns omit matching card fields. */
86
+ hiddenColKeys?: Set<string>
87
+ /** Same conditional formatting as the table (row background when a rule matches). */
88
+ conditionalRules?: ConditionalRule[]
89
+ /** Visible data columns (table order) — drives dates and other fields on the card. */
90
+ boardColumns: ColumnDef<Placement>[]
91
+ }
92
+
93
+ function BoardPhaseColumnHeader({
94
+ label,
95
+ rawCount,
96
+ filteredCount,
97
+ searchValue,
98
+ onSearchChange,
99
+ menu,
100
+ showLabels,
101
+ showCounts,
102
+ }: {
103
+ label: string
104
+ rawCount: number
105
+ filteredCount: number
106
+ searchValue: string
107
+ onSearchChange: (value: string) => void
108
+ menu: PlacementsBoardColumnMenu
109
+ showLabels: boolean
110
+ showCounts: boolean
111
+ }) {
112
+ const searchActive = Boolean(searchValue.trim())
113
+ const countLabel =
114
+ searchActive && filteredCount !== rawCount
115
+ ? `${filteredCount} of ${rawCount} records`
116
+ : `${filteredCount} ${filteredCount === 1 ? "record" : "records"}`
117
+
118
+ const showLeft = showLabels || showCounts
119
+
120
+ return (
121
+ <div className="group/board-col border-b border-border px-3 py-2.5">
122
+ <div className="flex items-center justify-between gap-2">
123
+ {showLeft ? (
124
+ <div className="flex min-w-0 flex-1 items-center gap-2">
125
+ {showLabels ? (
126
+ <p className="min-w-0 truncate text-sm font-semibold text-foreground">{label}</p>
127
+ ) : null}
128
+ {showCounts ? (
129
+ <div className="flex shrink-0 items-center gap-1.5">
130
+ <Badge
131
+ variant="outline"
132
+ className="inline-flex h-6 min-w-6 items-center justify-center border-0 bg-muted/70 px-2 text-xs font-semibold tabular-nums text-foreground"
133
+ aria-label={countLabel}
134
+ >
135
+ {filteredCount}
136
+ </Badge>
137
+ {searchActive && filteredCount !== rawCount ? (
138
+ <span className="text-xs font-medium tabular-nums text-muted-foreground" aria-hidden>
139
+ / {rawCount}
140
+ </span>
141
+ ) : null}
142
+ </div>
143
+ ) : null}
144
+ </div>
145
+ ) : (
146
+ <div className="min-w-0 flex-1" aria-hidden />
147
+ )}
148
+ <DropdownMenu>
149
+ <Tip label="Column options" side="top">
150
+ <DropdownMenuTrigger asChild>
151
+ <button
152
+ type="button"
153
+ aria-label={`${label} column options`}
154
+ onClick={e => e.stopPropagation()}
155
+ className={cn(
156
+ "opacity-0 group-hover/board-col:opacity-100 group-focus-within/board-col:opacity-100",
157
+ "inline-flex shrink-0 items-center justify-center size-7 rounded-md",
158
+ "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover-row",
159
+ "transition-opacity focus-visible:opacity-100",
160
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
161
+ )}
162
+ >
163
+ <i className="fa-light fa-ellipsis-vertical text-xs" aria-hidden="true" />
164
+ </button>
165
+ </DropdownMenuTrigger>
166
+ </Tip>
167
+ <DropdownMenuContent align="end" className="min-w-44">
168
+ <div className="px-2 pt-2 pb-1">
169
+ <div className="relative">
170
+ <i
171
+ className="fa-light fa-magnifying-glass pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"
172
+ aria-hidden="true"
173
+ />
174
+ <Input
175
+ placeholder={`Search ${label}…`}
176
+ value={searchValue}
177
+ onChange={e => onSearchChange(e.target.value)}
178
+ onKeyDown={e => e.stopPropagation()}
179
+ className="h-7 pl-6 text-xs"
180
+ />
181
+ {searchValue ? (
182
+ <button
183
+ type="button"
184
+ aria-label="Clear search"
185
+ onClick={() => onSearchChange("")}
186
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground transition-colors hover:text-interactive-hover-foreground"
187
+ >
188
+ <i className="fa-light fa-xmark text-xs" aria-hidden="true" />
189
+ </button>
190
+ ) : null}
191
+ </div>
192
+ </div>
193
+ <DropdownMenuSeparator />
194
+
195
+ {menu.filterableColumns.length > 0 && (
196
+ <>
197
+ <DropdownMenuSub>
198
+ <DropdownMenuSubTrigger>
199
+ <i className="fa-light fa-filter" aria-hidden="true" />
200
+ Filter by field…
201
+ </DropdownMenuSubTrigger>
202
+ <DropdownMenuSubContent className="max-h-[min(280px,60vh)] overflow-y-auto">
203
+ {menu.filterableColumns.map(col => (
204
+ <DropdownMenuItem key={col.key} onClick={() => menu.onAddFilter(col.key)}>
205
+ {col.label}
206
+ </DropdownMenuItem>
207
+ ))}
208
+ </DropdownMenuSubContent>
209
+ </DropdownMenuSub>
210
+ <DropdownMenuSeparator />
211
+ </>
212
+ )}
213
+
214
+ {menu.sortableColumns.length > 0 && (
215
+ <>
216
+ <DropdownMenuSub>
217
+ <DropdownMenuSubTrigger>
218
+ <i className="fa-light fa-arrow-up-arrow-down" aria-hidden="true" />
219
+ Sort by…
220
+ </DropdownMenuSubTrigger>
221
+ <DropdownMenuSubContent className="max-h-[min(320px,60vh)] overflow-y-auto">
222
+ {menu.sortableColumns.map(col => (
223
+ <React.Fragment key={col.key}>
224
+ <DropdownMenuItem onClick={() => menu.onSortByField(col.key, "asc")}>
225
+ <i className="fa-light fa-arrow-up-az" aria-hidden="true" />
226
+ {col.label} — ascending
227
+ </DropdownMenuItem>
228
+ <DropdownMenuItem onClick={() => menu.onSortByField(col.key, "desc")}>
229
+ <i className="fa-light fa-arrow-down-az" aria-hidden="true" />
230
+ {col.label} — descending
231
+ </DropdownMenuItem>
232
+ </React.Fragment>
233
+ ))}
234
+ </DropdownMenuSubContent>
235
+ </DropdownMenuSub>
236
+ <DropdownMenuSeparator />
237
+ </>
238
+ )}
239
+
240
+ {menu.groupableColumns.length > 0 && (
241
+ <>
242
+ <DropdownMenuSub>
243
+ <DropdownMenuSubTrigger>
244
+ <i className="fa-light fa-layer-group" aria-hidden="true" />
245
+ Group by…
246
+ </DropdownMenuSubTrigger>
247
+ <DropdownMenuSubContent className="max-h-[min(280px,60vh)] overflow-y-auto">
248
+ {menu.groupableColumns.map(col => (
249
+ <DropdownMenuItem
250
+ key={col.key}
251
+ onClick={() => menu.onToggleGroupBy(col.key)}
252
+ >
253
+ {menu.groupBy === col.key ? (
254
+ <>
255
+ <i className="fa-light fa-check text-xs" aria-hidden="true" />
256
+ Grouped by {col.label}
257
+ </>
258
+ ) : (
259
+ <>
260
+ <span className="inline-block w-3" aria-hidden />
261
+ Group by {col.label}
262
+ </>
263
+ )}
264
+ </DropdownMenuItem>
265
+ ))}
266
+ </DropdownMenuSubContent>
267
+ </DropdownMenuSub>
268
+ <DropdownMenuSeparator />
269
+ </>
270
+ )}
271
+
272
+ <DropdownMenuItem onClick={menu.onOpenProperties}>
273
+ <i className="fa-light fa-palette" aria-hidden="true" />
274
+ Add conditional rule
275
+ </DropdownMenuItem>
276
+ </DropdownMenuContent>
277
+ </DropdownMenu>
278
+ </div>
279
+ </div>
280
+ )
281
+ }
282
+
283
+ export function PlacementsBoardView({
284
+ placements,
285
+ lifecycleTabId,
286
+ boardColumnMenu,
287
+ boardDisplay: boardDisplayProp,
288
+ hiddenColKeys: hiddenColKeysProp,
289
+ conditionalRules,
290
+ boardColumns,
291
+ }: PlacementsBoardViewProps) {
292
+ const router = useRouter()
293
+
294
+ const bd: BoardDisplaySettings = {
295
+ lineCount: boardDisplayProp?.lineCount ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.boardLineCount,
296
+ showColumnLabels: boardDisplayProp?.showColumnLabels ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.showColumnLabels,
297
+ showColumnCounts: boardDisplayProp?.showColumnCounts ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.showBoardColumnCounts,
298
+ newCardAbove: boardDisplayProp?.newCardAbove ?? DEFAULT_DATA_LIST_DISPLAY_OPTIONS.boardNewCardAbove,
299
+ }
300
+ const hiddenColKeys = hiddenColKeysProp ?? new Set<string>()
301
+
302
+ const [phaseSearch, setPhaseSearch] = React.useState<Record<PlacementPhase, string>>({
303
+ upcoming: "",
304
+ ongoing: "",
305
+ completed: "",
306
+ })
307
+
308
+ const byPhase = React.useMemo(() => {
309
+ const map: Record<PlacementPhase, Placement[]> = {
310
+ upcoming: [],
311
+ ongoing: [],
312
+ completed: [],
313
+ }
314
+ for (const p of placements) {
315
+ map[p.placementPhase].push(p)
316
+ }
317
+ return map
318
+ }, [placements])
319
+
320
+ const cardsByPhase = React.useMemo(() => {
321
+ const out: Record<PlacementPhase, Placement[]> = {
322
+ upcoming: [],
323
+ ongoing: [],
324
+ completed: [],
325
+ }
326
+ for (const phase of PHASE_COLUMNS.map(c => c.phase)) {
327
+ const q = phaseSearch[phase]
328
+ out[phase] = byPhase[phase].filter(row => rowMatchesPhaseSearch(row, q))
329
+ }
330
+ return out
331
+ }, [byPhase, phaseSearch])
332
+
333
+ return (
334
+ <div className="px-4 pb-8 pt-2 lg:px-6">
335
+ <p className="text-xs text-muted-foreground mb-4">
336
+ {lifecycleTabId === "all"
337
+ ? "Rows grouped by phase (same data as Table view and List view)."
338
+ : `Filtered to ${lifecycleTabId} — cards shown in matching columns only.`}
339
+ </p>
340
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3 min-h-[min(480px,calc(100vh-14rem))]">
341
+ {PHASE_COLUMNS.map(col => {
342
+ const rawInPhase = byPhase[col.phase]
343
+ const cards = cardsByPhase[col.phase]
344
+
345
+ return (
346
+ <div
347
+ key={col.phase}
348
+ className="group/board-col flex min-h-0 flex-col rounded-xl border border-border bg-muted/30"
349
+ >
350
+ {boardColumnMenu ? (
351
+ <BoardPhaseColumnHeader
352
+ label={col.label}
353
+ rawCount={rawInPhase.length}
354
+ filteredCount={cards.length}
355
+ searchValue={phaseSearch[col.phase]}
356
+ onSearchChange={v => setPhaseSearch(prev => ({ ...prev, [col.phase]: v }))}
357
+ menu={boardColumnMenu}
358
+ showLabels={bd.showColumnLabels}
359
+ showCounts={bd.showColumnCounts}
360
+ />
361
+ ) : (
362
+ <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2.5">
363
+ {bd.showColumnLabels ? (
364
+ <p className="min-w-0 truncate text-sm font-semibold text-foreground">{col.label}</p>
365
+ ) : (
366
+ <span className="min-w-0 flex-1" aria-hidden />
367
+ )}
368
+ {bd.showColumnCounts ? (
369
+ <Badge
370
+ variant="outline"
371
+ className="inline-flex h-6 min-w-6 shrink-0 items-center justify-center border-0 bg-muted/70 px-2 text-xs font-semibold tabular-nums text-foreground"
372
+ aria-label={`${rawInPhase.length} ${rawInPhase.length === 1 ? "record" : "records"}`}
373
+ >
374
+ {rawInPhase.length}
375
+ </Badge>
376
+ ) : null}
377
+ </div>
378
+ )}
379
+ <div className="flex flex-1 flex-col gap-2 overflow-y-auto p-2">
380
+ {bd.newCardAbove ? <BoardNewCardPlaceholder position="above" /> : null}
381
+ {cards.length === 0 ? (
382
+ <p className="px-2 py-6 text-center text-xs text-muted-foreground">No placements</p>
383
+ ) : (
384
+ cards.map(row => (
385
+ <BoardPlacementCard
386
+ key={row.id}
387
+ row={row}
388
+ lifecycleTabId={lifecycleTabId}
389
+ hiddenColKeys={hiddenColKeys}
390
+ lineCount={bd.lineCount}
391
+ conditionalRules={conditionalRules}
392
+ boardColumns={boardColumns}
393
+ onOpen={id => router.push(`/data-list/${id}`)}
394
+ />
395
+ ))
396
+ )}
397
+ </div>
398
+ </div>
399
+ )
400
+ })}
401
+ </div>
402
+ </div>
403
+ )
404
+ }
@@ -0,0 +1,285 @@
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
+ * Long lists use window scroll virtualization (TanStack Virtual) to limit DOM size.
7
+ */
8
+
9
+ import * as React from "react"
10
+ import { useRouter } from "next/navigation"
11
+ import { useWindowVirtualizer } from "@tanstack/react-virtual"
12
+ import { cn } from "@/lib/utils"
13
+ import type { Placement } from "@/lib/mock/placements"
14
+ import { StatusBadge } from "@/components/data-list-table-cells"
15
+ import { Badge } from "@/components/ui/badge"
16
+ import { ListPageBoardCard, ListPageBoardCardAvatar } from "@/components/data-views/list-page-board-card"
17
+ import {
18
+ type BoardCardLifecycleTabId,
19
+ isBoardFieldActive,
20
+ scheduleKeysForTab,
21
+ } from "@/lib/placement-board-card-layout"
22
+ import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
23
+ import type { ConditionalRule } from "@/components/table-properties/types"
24
+ import type { ColumnDef } from "@/components/data-table/types"
25
+
26
+ /** Above this count, the list is virtualized against the window scroll. */
27
+ const VIRTUAL_ROWS_THRESHOLD = 80
28
+ /** Initial row height guess (px); `measureElement` refines for variable content. */
29
+ const ESTIMATE_ROW_PX = 100
30
+
31
+ function scheduleSummary(row: Placement, tab: BoardCardLifecycleTabId): string | null {
32
+ switch (tab) {
33
+ case "all":
34
+ return [row.start, row.duration].filter(Boolean).join(" · ") || null
35
+ case "upcoming":
36
+ return row.daysUntilStart > 0
37
+ ? `${row.start} · Starts in ${row.daysUntilStart} days`
38
+ : row.start
39
+ case "ongoing":
40
+ return `${row.progressWeeksDone} / ${row.progressWeeksTotal} wks · Ends ${row.endDate}`
41
+ case "completed":
42
+ return [row.completionDate, row.finalStatus].filter(v => v && v !== "—").join(" · ") || null
43
+ default:
44
+ return null
45
+ }
46
+ }
47
+
48
+ function PlacementListRowContent({
49
+ row,
50
+ tab,
51
+ hiddenColKeys,
52
+ boardColumns,
53
+ conditionalRules,
54
+ onOpen,
55
+ }: {
56
+ row: Placement
57
+ tab: BoardCardLifecycleTabId
58
+ hiddenColKeys: Set<string>
59
+ boardColumns: ColumnDef<Placement>[]
60
+ conditionalRules: ConditionalRule[] | undefined
61
+ onOpen: (id: number) => void
62
+ }) {
63
+ const ruleBg = getConditionalRowBackground(row, conditionalRules)
64
+ const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
65
+ const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
66
+ const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
67
+ const showSpec = isBoardFieldActive("specialization", tab, hiddenColKeys, boardColumns)
68
+ const showInternship = isBoardFieldActive("internship", tab, hiddenColKeys, boardColumns)
69
+ const sk = scheduleKeysForTab(tab)
70
+ const showSchedule = sk.some(k => isBoardFieldActive(k, tab, hiddenColKeys, boardColumns))
71
+ const schedule = showSchedule ? scheduleSummary(row, tab) : null
72
+
73
+ const title = showStudent ? row.student : `Placement ${row.id}`
74
+
75
+ const leading = showStudent ? (
76
+ <ListPageBoardCardAvatar initials={row.initials} className="size-9" />
77
+ ) : (
78
+ <span className="size-9 shrink-0 rounded-full bg-muted/80" aria-hidden />
79
+ )
80
+
81
+ const rowEnd = showStatus ? (
82
+ <div className="flex shrink-0 items-center gap-2 pt-0.5">
83
+ <StatusBadge status={row.status} surface="board" />
84
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden />
85
+ </div>
86
+ ) : (
87
+ <i className="fa-light fa-chevron-right mt-1 shrink-0 text-xs text-muted-foreground" aria-hidden />
88
+ )
89
+
90
+ return (
91
+ <ListPageBoardCard
92
+ layout="row"
93
+ leading={leading}
94
+ rowEnd={rowEnd}
95
+ isNew={row.isNew}
96
+ style={ruleBg ? { background: ruleBg } : undefined}
97
+ onClick={() => onOpen(row.id)}
98
+ >
99
+ <div className="space-y-1">
100
+ <div className="flex flex-wrap items-center gap-2">
101
+ <span className="text-sm font-semibold text-foreground">{title}</span>
102
+ {row.isNew ? (
103
+ <Badge variant="secondary" className="h-5 px-1.5 text-xs font-medium">
104
+ New
105
+ </Badge>
106
+ ) : null}
107
+ </div>
108
+ {showSite ? (
109
+ <p className="text-xs text-foreground/90">
110
+ <span className="font-medium">{row.site}</span>
111
+ {row.siteAddress ? (
112
+ <span className="text-muted-foreground"> · {row.siteAddress}</span>
113
+ ) : null}
114
+ </p>
115
+ ) : null}
116
+ {(showSpec || showInternship) ? (
117
+ <p className="text-xs text-muted-foreground">
118
+ {[showSpec ? row.specialization : null, showInternship ? row.internship : null]
119
+ .filter(Boolean)
120
+ .join(" · ")}
121
+ </p>
122
+ ) : null}
123
+ {schedule ? <p className="text-xs text-muted-foreground tabular-nums">{schedule}</p> : null}
124
+ </div>
125
+ </ListPageBoardCard>
126
+ )
127
+ }
128
+
129
+ function PlacementListRow({
130
+ row,
131
+ tab,
132
+ hiddenColKeys,
133
+ boardColumns,
134
+ conditionalRules,
135
+ onOpen,
136
+ }: {
137
+ row: Placement
138
+ tab: BoardCardLifecycleTabId
139
+ hiddenColKeys: Set<string>
140
+ boardColumns: ColumnDef<Placement>[]
141
+ conditionalRules: ConditionalRule[] | undefined
142
+ onOpen: (id: number) => void
143
+ }) {
144
+ return (
145
+ <li>
146
+ <PlacementListRowContent
147
+ row={row}
148
+ tab={tab}
149
+ hiddenColKeys={hiddenColKeys}
150
+ boardColumns={boardColumns}
151
+ conditionalRules={conditionalRules}
152
+ onOpen={onOpen}
153
+ />
154
+ </li>
155
+ )
156
+ }
157
+
158
+ function PlacementsListViewVirtualized({
159
+ rows,
160
+ lifecycleTabId,
161
+ hiddenColKeys,
162
+ boardColumns,
163
+ conditionalRules,
164
+ onOpen,
165
+ }: {
166
+ rows: Placement[]
167
+ lifecycleTabId: BoardCardLifecycleTabId
168
+ hiddenColKeys: Set<string>
169
+ boardColumns: ColumnDef<Placement>[]
170
+ conditionalRules: ConditionalRule[] | undefined
171
+ onOpen: (id: number) => void
172
+ }) {
173
+ const anchorRef = React.useRef<HTMLDivElement>(null)
174
+ const [scrollMargin, setScrollMargin] = React.useState(0)
175
+
176
+ const updateScrollMargin = React.useCallback(() => {
177
+ const el = anchorRef.current
178
+ if (!el) return
179
+ setScrollMargin(el.getBoundingClientRect().top + window.scrollY)
180
+ }, [])
181
+
182
+ React.useLayoutEffect(() => {
183
+ updateScrollMargin()
184
+ window.addEventListener("resize", updateScrollMargin)
185
+ return () => window.removeEventListener("resize", updateScrollMargin)
186
+ }, [updateScrollMargin, rows.length, lifecycleTabId])
187
+
188
+ const virtualizer = useWindowVirtualizer({
189
+ count: rows.length,
190
+ estimateSize: () => ESTIMATE_ROW_PX,
191
+ overscan: 8,
192
+ scrollMargin,
193
+ })
194
+
195
+ return (
196
+ <div ref={anchorRef} className="px-4 pb-8 pt-2 lg:px-6">
197
+ <ul
198
+ role="list"
199
+ className="relative m-0 w-full list-none p-0"
200
+ style={{ height: virtualizer.getTotalSize() }}
201
+ >
202
+ {virtualizer.getVirtualItems().map(vr => {
203
+ const row = rows[vr.index]
204
+ if (!row) return null
205
+ return (
206
+ <li
207
+ key={vr.key}
208
+ data-index={vr.index}
209
+ ref={virtualizer.measureElement}
210
+ className="absolute left-0 top-0 w-full pb-2"
211
+ style={{ transform: `translateY(${vr.start}px)` }}
212
+ >
213
+ <PlacementListRowContent
214
+ row={row}
215
+ tab={lifecycleTabId}
216
+ hiddenColKeys={hiddenColKeys}
217
+ boardColumns={boardColumns}
218
+ conditionalRules={conditionalRules}
219
+ onOpen={onOpen}
220
+ />
221
+ </li>
222
+ )
223
+ })}
224
+ </ul>
225
+ </div>
226
+ )
227
+ }
228
+
229
+ export interface PlacementsListViewProps {
230
+ rows: Placement[]
231
+ lifecycleTabId: BoardCardLifecycleTabId
232
+ hiddenColKeys: Set<string>
233
+ boardColumns: ColumnDef<Placement>[]
234
+ conditionalRules?: ConditionalRule[]
235
+ emptyCopy: string
236
+ }
237
+
238
+ export function PlacementsListView({
239
+ rows,
240
+ lifecycleTabId,
241
+ hiddenColKeys,
242
+ boardColumns,
243
+ conditionalRules,
244
+ emptyCopy,
245
+ }: PlacementsListViewProps) {
246
+ const router = useRouter()
247
+ const onOpen = React.useCallback((id: number) => router.push(`/data-list/${id}`), [router])
248
+
249
+ if (rows.length === 0) {
250
+ return (
251
+ <div className="px-4 py-16 text-center lg:px-6">
252
+ <p className="text-sm text-muted-foreground">{emptyCopy}</p>
253
+ </div>
254
+ )
255
+ }
256
+
257
+ if (rows.length >= VIRTUAL_ROWS_THRESHOLD) {
258
+ return (
259
+ <PlacementsListViewVirtualized
260
+ rows={rows}
261
+ lifecycleTabId={lifecycleTabId}
262
+ hiddenColKeys={hiddenColKeys}
263
+ boardColumns={boardColumns}
264
+ conditionalRules={conditionalRules}
265
+ onOpen={onOpen}
266
+ />
267
+ )
268
+ }
269
+
270
+ return (
271
+ <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
272
+ {rows.map(row => (
273
+ <PlacementListRow
274
+ key={row.id}
275
+ row={row}
276
+ tab={lifecycleTabId}
277
+ hiddenColKeys={hiddenColKeys}
278
+ boardColumns={boardColumns}
279
+ conditionalRules={conditionalRules}
280
+ onOpen={onOpen}
281
+ />
282
+ ))}
283
+ </ul>
284
+ )
285
+ }