@exxatdesignux/ui 0.0.6 → 0.0.7

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,262 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Placement-specific board card — composes shared board primitives with column defs and lifecycle layout rules.
5
+ */
6
+
7
+ import * as React from "react"
8
+ import { cn } from "@/lib/utils"
9
+ import type { Placement } from "@/lib/mock/placements"
10
+ import { StatusBadge } from "@/components/data-list-table-cells"
11
+ import { AvatarInitials } from "@/components/ui/avatar"
12
+ import { Badge } from "@/components/ui/badge"
13
+ import {
14
+ ListPageBoardCard,
15
+ ListPageBoardCardBadgeRow,
16
+ ListPageBoardCardBody,
17
+ ListPageBoardCardHeader,
18
+ ListPageBoardCardSecondary,
19
+ ListPageBoardCardTitleRow,
20
+ } from "@/components/data-views/list-page-board-card"
21
+ import type { BoardLineCount } from "@/lib/data-list-display-options"
22
+ import {
23
+ type BoardCardLifecycleTabId,
24
+ filterColumnsForBoardCard,
25
+ isBoardFieldActive,
26
+ remainingBodyColumns,
27
+ scheduleKeysForTab,
28
+ } from "@/lib/placement-board-card-layout"
29
+ import { getConditionalRowBackground } from "@/lib/conditional-rule-match"
30
+ import type { ConditionalRule } from "@/components/table-properties/types"
31
+ import type { CellContext, ColumnDef } from "@/components/data-table/types"
32
+ import {
33
+ BoardCardIconRow,
34
+ BoardCardTwoLineBlock,
35
+ lineClampClass,
36
+ } from "@/components/data-views/board-card-primitives"
37
+
38
+ const BOARD_CELL_CTX: CellContext<Placement> = {
39
+ rowIndex: 0,
40
+ selected: false,
41
+ onSelect: () => {},
42
+ }
43
+
44
+ function columnIconClass(col: ColumnDef<Placement>): string {
45
+ if (col.filter?.icon) return col.filter.icon
46
+ const fallbacks: Record<string, string> = {
47
+ specialization: "fa-stethoscope",
48
+ site: "fa-hospital",
49
+ internship: "fa-briefcase",
50
+ supervisor: "fa-user-tie",
51
+ start: "fa-calendar-days",
52
+ compliance: "fa-shield-check",
53
+ daysUntilStart: "fa-calendar-days",
54
+ readiness: "fa-flag",
55
+ progressWeeksDone: "fa-chart-line",
56
+ endDate: "fa-calendar-xmark",
57
+ lastCheckin: "fa-calendar-clock",
58
+ completionDate: "fa-calendar-check",
59
+ finalStatus: "fa-circle-check",
60
+ rating: "fa-star",
61
+ suggestedToHire: "fa-user-check",
62
+ duration: "fa-clock",
63
+ program: "fa-graduation-cap",
64
+ student: "fa-user",
65
+ status: "fa-circle-dot",
66
+ }
67
+ return fallbacks[col.key] ?? "fa-tag"
68
+ }
69
+
70
+ function boardCellContent(row: Placement, col: ColumnDef<Placement>): React.ReactNode {
71
+ if (col.key === "status") return <StatusBadge status={row.status} surface="board" />
72
+ if (col.cell) return col.cell(row, BOARD_CELL_CTX)
73
+ return <span className="text-foreground/90">{String(row[col.key as keyof Placement] ?? "")}</span>
74
+ }
75
+
76
+ function renderScheduleSection(
77
+ row: Placement,
78
+ tab: BoardCardLifecycleTabId,
79
+ hiddenColKeys: Set<string>,
80
+ boardColumns: ColumnDef<Placement>[],
81
+ ): React.ReactNode {
82
+ const sk = scheduleKeysForTab(tab)
83
+ const anyActive = sk.some(k => isBoardFieldActive(k, tab, hiddenColKeys, boardColumns))
84
+ if (!anyActive) return null
85
+
86
+ switch (tab) {
87
+ case "all": {
88
+ const aStart = isBoardFieldActive("start", tab, hiddenColKeys, boardColumns)
89
+ const aDur = isBoardFieldActive("duration", tab, hiddenColKeys, boardColumns)
90
+ if (!aStart && !aDur) return null
91
+ return (
92
+ <BoardCardTwoLineBlock
93
+ iconClass="fa-calendar-days"
94
+ line1={aStart ? row.start : "—"}
95
+ line2={aDur ? row.duration : "—"}
96
+ />
97
+ )
98
+ }
99
+ case "upcoming": {
100
+ const aStart = isBoardFieldActive("start", tab, hiddenColKeys, boardColumns)
101
+ const aDays = isBoardFieldActive("daysUntilStart", tab, hiddenColKeys, boardColumns)
102
+ if (!aStart && !aDays) return null
103
+ const line2 =
104
+ aDays && row.daysUntilStart > 0
105
+ ? `Starts in ${row.daysUntilStart} days`
106
+ : aDays && row.daysUntilStart === 0
107
+ ? "Starts today"
108
+ : "—"
109
+ return (
110
+ <BoardCardTwoLineBlock
111
+ iconClass="fa-calendar-days"
112
+ line1={aStart ? row.start : "—"}
113
+ line2={line2}
114
+ />
115
+ )
116
+ }
117
+ case "ongoing": {
118
+ const aP = isBoardFieldActive("progressWeeksDone", tab, hiddenColKeys, boardColumns)
119
+ const aEnd = isBoardFieldActive("endDate", tab, hiddenColKeys, boardColumns)
120
+ if (!aP && !aEnd) return null
121
+ return (
122
+ <BoardCardTwoLineBlock
123
+ iconClass="fa-calendar-days"
124
+ line1={aP ? `${row.progressWeeksDone} / ${row.progressWeeksTotal} wks` : "—"}
125
+ line2={aEnd ? row.endDate : "—"}
126
+ />
127
+ )
128
+ }
129
+ case "completed": {
130
+ const aComp = isBoardFieldActive("completionDate", tab, hiddenColKeys, boardColumns)
131
+ const aFinal = isBoardFieldActive("finalStatus", tab, hiddenColKeys, boardColumns)
132
+ if (!aComp && !aFinal) return null
133
+ const finalCol = boardColumns.find(c => c.key === "finalStatus")
134
+ return (
135
+ <BoardCardTwoLineBlock
136
+ iconClass="fa-calendar-check"
137
+ line1={aComp ? row.completionDate : "—"}
138
+ line2={
139
+ aFinal && finalCol ? (
140
+ <span className="inline-flex min-w-0 max-w-full [&_span]:text-xs">
141
+ {boardCellContent(row, finalCol)}
142
+ </span>
143
+ ) : aFinal ? (
144
+ row.finalStatus
145
+ ) : (
146
+ "—"
147
+ )
148
+ }
149
+ line2ClassName={aFinal && finalCol ? "text-xs" : undefined}
150
+ />
151
+ )
152
+ }
153
+ default:
154
+ return null
155
+ }
156
+ }
157
+
158
+ export function BoardPlacementCard({
159
+ row,
160
+ lifecycleTabId,
161
+ hiddenColKeys,
162
+ lineCount,
163
+ conditionalRules,
164
+ boardColumns,
165
+ onOpen,
166
+ }: {
167
+ row: Placement
168
+ lifecycleTabId: BoardCardLifecycleTabId
169
+ hiddenColKeys: Set<string>
170
+ lineCount: BoardLineCount
171
+ conditionalRules: ConditionalRule[] | undefined
172
+ boardColumns: ColumnDef<Placement>[]
173
+ onOpen: (id: number) => void
174
+ }) {
175
+ const lc = lineClampClass(lineCount)
176
+ const ruleBg = getConditionalRowBackground(row, conditionalRules)
177
+
178
+ const visibleCols = boardColumns.filter(c => !hiddenColKeys.has(c.key))
179
+ const showStudent = visibleCols.some(c => c.key === "student")
180
+ const cardCols = filterColumnsForBoardCard(lifecycleTabId, visibleCols)
181
+ const remainingCols = remainingBodyColumns(lifecycleTabId, cardCols)
182
+
183
+ const showStatus = isBoardFieldActive("status", lifecycleTabId, hiddenColKeys, boardColumns)
184
+ const showSite = isBoardFieldActive("site", lifecycleTabId, hiddenColKeys, boardColumns)
185
+ const siteCol = boardColumns.find(c => c.key === "site")
186
+
187
+ const cardShell = (className: string, children: React.ReactNode) => (
188
+ <ListPageBoardCard
189
+ className={className}
190
+ style={ruleBg ? { background: ruleBg } : undefined}
191
+ isNew={row.isNew}
192
+ onClick={() => onOpen(row.id)}
193
+ >
194
+ {children}
195
+ </ListPageBoardCard>
196
+ )
197
+
198
+ if (visibleCols.length === 0) {
199
+ return cardShell(
200
+ "cursor-pointer",
201
+ <ListPageBoardCardHeader className="gap-1 pb-2">
202
+ <ListPageBoardCardTitleRow title={`Placement #${row.id}`} />
203
+ <ListPageBoardCardSecondary>
204
+ Unhide columns in Properties → Columns to show card fields.
205
+ </ListPageBoardCardSecondary>
206
+ </ListPageBoardCardHeader>,
207
+ )
208
+ }
209
+
210
+ const titlePrimary = showStudent ? row.student : `Placement ${row.id}`
211
+ const headerBadgeRow = showStatus || row.isNew
212
+
213
+ return cardShell(
214
+ "cursor-pointer",
215
+ <ListPageBoardCardHeader>
216
+ <ListPageBoardCardTitleRow
217
+ title={titlePrimary}
218
+ titleClassName={lc}
219
+ trailing={
220
+ showStudent ? (
221
+ <AvatarInitials
222
+ initials={row.initials}
223
+ className="size-7 shrink-0 text-xs"
224
+ fallbackClassName="text-xs"
225
+ />
226
+ ) : undefined
227
+ }
228
+ />
229
+
230
+ {headerBadgeRow ? (
231
+ <ListPageBoardCardBadgeRow>
232
+ {showStatus ? <StatusBadge status={row.status} surface="board" /> : null}
233
+ {row.isNew ? (
234
+ <Badge variant="secondary" className="h-6 px-2 text-xs font-medium">
235
+ New
236
+ </Badge>
237
+ ) : null}
238
+ </ListPageBoardCardBadgeRow>
239
+ ) : null}
240
+
241
+ <ListPageBoardCardBody>
242
+ {showSite && siteCol ? (
243
+ <BoardCardIconRow iconClass="fa-hospital">
244
+ <div className={cn(lc, "[&_.text-sm]:text-xs")}>{boardCellContent(row, siteCol)}</div>
245
+ </BoardCardIconRow>
246
+ ) : null}
247
+
248
+ {renderScheduleSection(row, lifecycleTabId, hiddenColKeys, boardColumns)}
249
+
250
+ {remainingCols.length > 0 ? (
251
+ <div className="flex flex-col gap-2">
252
+ {remainingCols.map(col => (
253
+ <BoardCardIconRow key={col.key} iconClass={columnIconClass(col)}>
254
+ <div className={cn(lc)}>{boardCellContent(row, col)}</div>
255
+ </BoardCardIconRow>
256
+ ))}
257
+ </div>
258
+ ) : null}
259
+ </ListPageBoardCardBody>
260
+ </ListPageBoardCardHeader>,
261
+ )
262
+ }
@@ -0,0 +1,375 @@
1
+ "use client"
2
+
3
+ /**
4
+ * ExportDrawer — floating right-side drawer with export form.
5
+ *
6
+ * Uses the same Sheet pattern as TablePropertiesDrawer:
7
+ * - showCloseButton={false}, showOverlay={false}
8
+ * - Rounded, floating, inset from viewport edges
9
+ * - Button + Tip from our own component library
10
+ *
11
+ * Form fields (shadcn Form + react-hook-form + zod):
12
+ * • File format CSV · Excel · PDF (SelectionTileGrid radio)
13
+ * • Date range From / To (FilterTextValueInput dateMDY — same as Settings / table filters)
14
+ * • Columns All · Visible only (radio)
15
+ * • Apply active filters (checkbox)
16
+ *
17
+ * WCAG 2.1 AA:
18
+ * ✓ All inputs labelled via FormLabel linked to control id (1.3.1)
19
+ * ✓ Error messages linked via aria-describedby (3.3.1)
20
+ * ✓ Focus returns to trigger on close (2.4.3)
21
+ */
22
+
23
+ import * as React from "react"
24
+ import { useForm } from "react-hook-form"
25
+ import { z } from "zod"
26
+ import { zodResolver } from "@hookform/resolvers/zod"
27
+
28
+ import { devLog } from "@/lib/dev-log"
29
+ import { Button } from "@/components/ui/button"
30
+ import { Checkbox } from "@/components/ui/checkbox"
31
+ import { Label } from "@/components/ui/label"
32
+ import { Tip } from "@/components/ui/tip"
33
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
34
+ import { Shortcut } from "@/components/ui/dropdown-menu"
35
+ import { RadioGroup, RadioGroupItem, RadioGroupLabel } from "@/components/ui/radio-group"
36
+ import { SelectionTileGrid } from "@/components/ui/selection-tile-grid"
37
+ import { FilterTextValueInput } from "@/components/data-table/filter-text-value-input"
38
+ import type { SelectionTileOption } from "@/components/ui/selection-tile-grid"
39
+ import {
40
+ Sheet,
41
+ SheetContent,
42
+ SheetTitle,
43
+ } from "@/components/ui/sheet"
44
+ import {
45
+ Form,
46
+ FormControl,
47
+ FormDescription,
48
+ FormField,
49
+ FormItem,
50
+ FormLabel,
51
+ FormMessage,
52
+ } from "@/components/ui/form"
53
+
54
+ /** Parse full MM/DD/YYYY from masked input; partial/invalid → undefined. */
55
+ function parseMdyToDate(raw: string | undefined): Date | undefined {
56
+ if (!raw?.trim()) return undefined
57
+ const m = raw.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
58
+ if (!m) return undefined
59
+ const month = Number(m[1])
60
+ const day = Number(m[2])
61
+ const year = Number(m[3])
62
+ if (month < 1 || month > 12 || day < 1 || day > 31) return undefined
63
+ const d = new Date(year, month - 1, day, 12, 0, 0, 0)
64
+ if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return undefined
65
+ return d
66
+ }
67
+
68
+ // ── Validation schema ─────────────────────────────────────────────────────────
69
+
70
+ const EXPORT_FORMAT_OPTIONS: SelectionTileOption<"csv" | "excel" | "pdf">[] = [
71
+ { value: "csv", label: "CSV", icon: "fa-file-csv" },
72
+ { value: "excel", label: "Excel", icon: "fa-file-excel" },
73
+ { value: "pdf", label: "PDF", icon: "fa-file-pdf" },
74
+ ]
75
+
76
+ const exportSchema = z
77
+ .object({
78
+ format: z.enum(["csv", "excel", "pdf"]),
79
+ columns: z.enum(["all", "visible"]),
80
+ dateFrom: z.string().optional(),
81
+ dateTo: z.string().optional(),
82
+ includeFilters: z.boolean(),
83
+ })
84
+ .superRefine((data, ctx) => {
85
+ const from = parseMdyToDate(data.dateFrom)
86
+ const to = parseMdyToDate(data.dateTo)
87
+ if (data.dateFrom?.trim() && !from) {
88
+ ctx.addIssue({ code: "custom", message: "Enter a valid from date", path: ["dateFrom"] })
89
+ }
90
+ if (data.dateTo?.trim() && !to) {
91
+ ctx.addIssue({ code: "custom", message: "Enter a valid to date", path: ["dateTo"] })
92
+ }
93
+ if (from && to && to < from) {
94
+ ctx.addIssue({ code: "custom", message: "End date must be after start date", path: ["dateTo"] })
95
+ }
96
+ })
97
+
98
+ type ExportForm = z.infer<typeof exportSchema>
99
+
100
+ // ── Component ─────────────────────────────────────────────────────────────────
101
+
102
+ export interface ExportDrawerProps {
103
+ open: boolean
104
+ onOpenChange: (open: boolean) => void
105
+ totalRows?: number
106
+ visibleColumns?: number
107
+ }
108
+
109
+ export function ExportDrawer({
110
+ open,
111
+ onOpenChange,
112
+ totalRows = 0,
113
+ visibleColumns,
114
+ }: ExportDrawerProps) {
115
+ const form = useForm<ExportForm>({
116
+ resolver: zodResolver(exportSchema),
117
+ defaultValues: {
118
+ format: "csv",
119
+ columns: "visible",
120
+ dateFrom: "",
121
+ dateTo: "",
122
+ includeFilters: true,
123
+ },
124
+ })
125
+
126
+ const [isExporting, setIsExporting] = React.useState(false)
127
+
128
+ async function onSubmit(values: ExportForm) {
129
+ setIsExporting(true)
130
+ await new Promise(r => setTimeout(r, 1200))
131
+ devLog("Export:", {
132
+ ...values,
133
+ dateFrom: parseMdyToDate(values.dateFrom)?.toISOString(),
134
+ dateTo: parseMdyToDate(values.dateTo)?.toISOString(),
135
+ })
136
+ setIsExporting(false)
137
+ onOpenChange(false)
138
+ form.reset()
139
+ }
140
+
141
+ return (
142
+ <Sheet open={open} onOpenChange={onOpenChange}>
143
+ <SheetContent
144
+ data-slot="export-drawer"
145
+ side="right"
146
+ showCloseButton={false}
147
+ showOverlay={false}
148
+ className="z-[60] w-80 sm:max-w-80 p-0 gap-0 flex flex-col border border-border shadow-xl rounded-xl overflow-hidden"
149
+ style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
150
+ >
151
+ {/* Header */}
152
+ <div className="flex items-center justify-between gap-3 px-4 pt-5 pb-3">
153
+ <SheetTitle className="text-base font-semibold leading-tight">Export data</SheetTitle>
154
+ <Tip label="Close" side="bottom">
155
+ <Button
156
+ type="button"
157
+ variant="ghost"
158
+ size="icon-sm"
159
+ aria-label="Close"
160
+ onClick={() => onOpenChange(false)}
161
+ >
162
+ <i className="fa-light fa-xmark text-[13px]" aria-hidden="true" />
163
+ </Button>
164
+ </Tip>
165
+ </div>
166
+
167
+ {/* Record count */}
168
+ <p className="px-4 pb-3 text-sm text-muted-foreground -mt-1">
169
+ {totalRows} record{totalRows !== 1 ? "s" : ""} available for export.
170
+ </p>
171
+
172
+ {/* Form body */}
173
+ <Form {...form}>
174
+ <form
175
+ id="export-form"
176
+ onSubmit={form.handleSubmit(onSubmit)}
177
+ className="flex-1 overflow-y-auto"
178
+ >
179
+ <div className="px-4 pb-4 space-y-5">
180
+
181
+ {/* File format */}
182
+ <FormField
183
+ control={form.control}
184
+ name="format"
185
+ render={({ field }) => (
186
+ <FormItem>
187
+ <FormControl>
188
+ <SelectionTileGrid
189
+ sectionLabel="File format"
190
+ options={EXPORT_FORMAT_OPTIONS}
191
+ columns={3}
192
+ value={field.value}
193
+ onValueChange={field.onChange}
194
+ interaction="radio"
195
+ idPrefix="export-fmt"
196
+ itemVariant="outline"
197
+ itemMotion="pop"
198
+ />
199
+ </FormControl>
200
+ <FormMessage />
201
+ </FormItem>
202
+ )}
203
+ />
204
+
205
+ {/* Date range */}
206
+ <fieldset className="space-y-2">
207
+ <legend className="text-sm font-medium leading-none mb-2">
208
+ Date range{" "}
209
+ <span className="text-muted-foreground font-normal">(optional)</span>
210
+ </legend>
211
+ <div className="grid grid-cols-2 gap-3">
212
+ <FormField
213
+ control={form.control}
214
+ name="dateFrom"
215
+ render={({ field }) => (
216
+ <FormItem className="gap-1">
217
+ <FormLabel htmlFor="export-date-from" className="text-xs text-muted-foreground">
218
+ From
219
+ </FormLabel>
220
+ <FormControl>
221
+ <FilterTextValueInput
222
+ id="export-date-from"
223
+ mask="dateMDY"
224
+ aria-label="Export from date (optional)"
225
+ placeholder="MM/DD/YYYY"
226
+ value={field.value ?? ""}
227
+ onValueChange={field.onChange}
228
+ className="h-8 text-sm"
229
+ />
230
+ </FormControl>
231
+ <FormDescription className="text-[11px]">MM/DD/YYYY</FormDescription>
232
+ <FormMessage />
233
+ </FormItem>
234
+ )}
235
+ />
236
+ <FormField
237
+ control={form.control}
238
+ name="dateTo"
239
+ render={({ field }) => (
240
+ <FormItem className="gap-1">
241
+ <FormLabel htmlFor="export-date-to" className="text-xs text-muted-foreground">
242
+ To
243
+ </FormLabel>
244
+ <FormControl>
245
+ <FilterTextValueInput
246
+ id="export-date-to"
247
+ mask="dateMDY"
248
+ aria-label="Export to date (optional)"
249
+ placeholder="MM/DD/YYYY"
250
+ value={field.value ?? ""}
251
+ onValueChange={field.onChange}
252
+ className="h-8 text-sm"
253
+ />
254
+ </FormControl>
255
+ <FormDescription className="text-[11px]">MM/DD/YYYY</FormDescription>
256
+ <FormMessage />
257
+ </FormItem>
258
+ )}
259
+ />
260
+ </div>
261
+ </fieldset>
262
+
263
+ {/* Columns */}
264
+ <FormField
265
+ control={form.control}
266
+ name="columns"
267
+ render={({ field }) => (
268
+ <FormItem>
269
+ <FormLabel className="text-sm font-medium">Columns</FormLabel>
270
+ <FormControl>
271
+ <RadioGroup
272
+ value={field.value}
273
+ onValueChange={field.onChange}
274
+ className="space-y-1.5 mt-1"
275
+ itemVariant="outline"
276
+ itemMotion="pop"
277
+ >
278
+ {([
279
+ { value: "all", label: "All columns", sub: null },
280
+ { value: "visible", label: "Visible columns only", sub: visibleColumns !== undefined ? `${visibleColumns} columns` : null },
281
+ ] as const).map(opt => (
282
+ <div
283
+ key={opt.value}
284
+ className="flex items-center gap-2.5 rounded-lg border border-border px-3 py-2.5 transition-colors hover:bg-interactive-hover has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-brand/10 cursor-pointer"
285
+ >
286
+ <RadioGroupItem value={opt.value} id={`col-${opt.value}`} />
287
+ <RadioGroupLabel
288
+ htmlFor={`col-${opt.value}`}
289
+ className="min-h-0 flex-1 cursor-pointer py-0 text-sm font-normal leading-none"
290
+ >
291
+ {opt.label}
292
+ {opt.sub && (
293
+ <span className="text-muted-foreground ml-1.5 font-normal">({opt.sub})</span>
294
+ )}
295
+ </RadioGroupLabel>
296
+ </div>
297
+ ))}
298
+ </RadioGroup>
299
+ </FormControl>
300
+ <FormMessage />
301
+ </FormItem>
302
+ )}
303
+ />
304
+
305
+ {/* Include filters */}
306
+ <FormField
307
+ control={form.control}
308
+ name="includeFilters"
309
+ render={({ field }) => (
310
+ <FormItem>
311
+ <div className="flex items-start gap-3 rounded-lg border border-border px-3 py-2.5 transition-colors hover:bg-interactive-hover has-[[data-state=checked]]:border-primary">
312
+ <FormControl>
313
+ <Checkbox
314
+ id="include-filters"
315
+ checked={field.value}
316
+ onCheckedChange={field.onChange}
317
+ className="mt-0.5 shrink-0"
318
+ />
319
+ </FormControl>
320
+ <div className="min-w-0">
321
+ <Label htmlFor="include-filters" className="text-sm cursor-pointer font-medium leading-none">
322
+ Apply active filters
323
+ </Label>
324
+ <p className="text-xs text-muted-foreground mt-1">
325
+ Export only rows matching current filters
326
+ </p>
327
+ </div>
328
+ </div>
329
+ <FormMessage />
330
+ </FormItem>
331
+ )}
332
+ />
333
+
334
+ </div>
335
+ </form>
336
+ </Form>
337
+
338
+ {/* Global bindings — only active while the drawer is open (Sheet unmounts content on close) */}
339
+ <Shortcut keys="Enter" disabled={isExporting} onInvoke={() => form.handleSubmit(onSubmit)()} />
340
+
341
+ {/* Footer */}
342
+ <div className="flex items-center gap-2 px-4 py-3 border-t border-border">
343
+ <Button
344
+ type="button"
345
+ variant="outline"
346
+ className="flex-1"
347
+ onClick={() => onOpenChange(false)}
348
+ >
349
+ Cancel
350
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">Esc</Kbd></KbdGroup>
351
+ </Button>
352
+ <Button
353
+ type="submit"
354
+ form="export-form"
355
+ className="flex-1"
356
+ disabled={isExporting}
357
+ >
358
+ {isExporting ? (
359
+ <>
360
+ <i className="fa-light fa-spinner-third fa-spin text-[13px]" aria-hidden="true" />
361
+ Exporting…
362
+ </>
363
+ ) : (
364
+ <>
365
+ <i className="fa-light fa-arrow-down-to-line text-[13px]" aria-hidden="true" />
366
+ Export
367
+ <KbdGroup className="ml-1.5"><Kbd variant="bare">⏎</Kbd></KbdGroup>
368
+ </>
369
+ )}
370
+ </Button>
371
+ </div>
372
+ </SheetContent>
373
+ </Sheet>
374
+ )
375
+ }