@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,1066 @@
1
+ "use client"
2
+
3
+ /**
4
+ * NewPlacementForm — 5-step multi-step placement creation form
5
+ *
6
+ * Steps:
7
+ * 1. Student Information — personal & academic details
8
+ * 2. Site & Program — where and what rotation
9
+ * 3. Schedule — dates (via picker), hours, shift
10
+ * 4. Supervisor & Compliance — supervisor info + compliance checklist + notes
11
+ * 5. Review & Submit — read-only summary before saving
12
+ *
13
+ * WCAG 2.1 AA:
14
+ * ✓ All fields have visible <label> via FormLabel (WCAG 1.3.1, 2.4.6)
15
+ * ✓ Required fields marked aria-required (WCAG 3.3.2)
16
+ * ✓ Errors tied to inputs via aria-describedby (WCAG 3.3.1)
17
+ * ✓ Step indicator conveys state via aria-current="step" (WCAG 1.3.1)
18
+ * ✓ Focus moves to step heading on advance for screen readers (WCAG 2.4.3)
19
+ * ✓ Date inputs use Calendar + Popover — never plain text input (date-picker guideline)
20
+ * ✓ Review section uses <dl> with visible term/value pairs (WCAG 1.3.1)
21
+ */
22
+
23
+ import * as React from "react"
24
+ import { useRouter } from "next/navigation"
25
+ import {
26
+ useForm,
27
+ useFormContext,
28
+ type ControllerRenderProps,
29
+ type Resolver,
30
+ } from "react-hook-form"
31
+ import { zodResolver } from "@hookform/resolvers/zod"
32
+ import { z } from "zod"
33
+
34
+ import { cn } from "@/lib/utils"
35
+ import { devLog } from "@/lib/dev-log"
36
+ import { formatDateUS } from "@/lib/date-filter"
37
+
38
+ import {
39
+ Form,
40
+ FormControl,
41
+ FormDescription,
42
+ FormField,
43
+ FormItem,
44
+ FormLabel,
45
+ FormMessage,
46
+ } from "@/components/ui/form"
47
+ import { Input } from "@/components/ui/input"
48
+ import {
49
+ exxatMaskPatterns,
50
+ useExxatMaskedFieldRef,
51
+ } from "@/components/ui/input-mask"
52
+ import { Button } from "@/components/ui/button"
53
+ import { Textarea } from "@/components/ui/textarea"
54
+ import { Checkbox } from "@/components/ui/checkbox"
55
+ import { Label } from "@/components/ui/label"
56
+ import { DatePickerField } from "@/components/ui/date-picker-field"
57
+ import {
58
+ Select,
59
+ SelectContent,
60
+ SelectItem,
61
+ SelectTrigger,
62
+ SelectValue,
63
+ } from "@/components/ui/select"
64
+ import { RadioGroup, RadioGroupItem, RadioGroupLabel } from "@/components/ui/radio-group"
65
+ import { Card, CardHeader, CardTitle, CardAction, CardContent } from "@/components/ui/card"
66
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
67
+ import { Tip } from "@/components/ui/tip"
68
+ import { Shortcut } from "@/components/ui/dropdown-menu"
69
+ import { useModKeyLabel, useAltKeyLabel } from "@/hooks/use-mod-key-label"
70
+
71
+ // ── Constants ─────────────────────────────────────────────────────────────────
72
+
73
+ const PROGRAMS = [
74
+ "Nursing",
75
+ "Physical Therapy",
76
+ "Occupational Therapy",
77
+ "Social Work",
78
+ "Pharmacy",
79
+ "Respiratory Therapy",
80
+ "Medical Laboratory Science",
81
+ "Radiologic Technology",
82
+ "Dental Hygiene",
83
+ "Dietetics",
84
+ ]
85
+
86
+ const SITES = [
87
+ "City Medical Center",
88
+ "Metro Rehab",
89
+ "Bay Area Health",
90
+ "Sunrise Hospital",
91
+ "Community Care",
92
+ "Summit Sports Med",
93
+ "Harbor Medical",
94
+ "Valley Health",
95
+ "Westside Clinic",
96
+ "Hope Community Ctr",
97
+ "Lakeside Hospital",
98
+ ]
99
+
100
+ const DEPARTMENTS = [
101
+ "Emergency Medicine",
102
+ "Cardiology",
103
+ "Orthopedics",
104
+ "Pediatrics",
105
+ "Oncology",
106
+ "Neurology",
107
+ "General Surgery",
108
+ "Internal Medicine",
109
+ "Rehabilitation",
110
+ "Outpatient Clinic",
111
+ ]
112
+
113
+ const SUPERVISORS = [
114
+ "Dr. Patel",
115
+ "Dr. Kim",
116
+ "Dr. Torres",
117
+ "Dr. Lee",
118
+ "Dr. Wong",
119
+ "Dr. Santos",
120
+ "Ms. Torres",
121
+ "Dr. Martinez",
122
+ "Dr. Chen",
123
+ ]
124
+
125
+ const DURATIONS = [
126
+ "4 weeks",
127
+ "6 weeks",
128
+ "8 weeks",
129
+ "10 weeks",
130
+ "12 weeks",
131
+ "16 weeks",
132
+ "24 weeks",
133
+ ]
134
+
135
+ const SHIFTS = [
136
+ "Day shift (7 AM – 3 PM)",
137
+ "Evening shift (3 PM – 11 PM)",
138
+ "Night shift (11 PM – 7 AM)",
139
+ "Flexible / varies",
140
+ ]
141
+
142
+ // ── Zod schema ────────────────────────────────────────────────────────────────
143
+
144
+ const placementSchema = z
145
+ .object({
146
+ // Step 1 — Student
147
+ firstName: z.string().min(1, "First name is required"),
148
+ lastName: z.string().min(1, "Last name is required"),
149
+ email: z.string().min(1, "Email is required").email("Enter a valid email address"),
150
+ studentId: z.string().min(1, "Student ID is required"),
151
+ phone: z.string().optional(),
152
+ program: z.string().min(1, "Program is required"),
153
+ cohort: z.string().min(1, "Cohort / class year is required"),
154
+ gpa: z.string().optional(),
155
+
156
+ // Step 2 — Site
157
+ site: z.string().min(1, "Clinical site is required"),
158
+ department: z.string().min(1, "Department is required"),
159
+ siteAddress: z.string().min(1, "Site address is required"),
160
+ siteContact: z.string().optional(),
161
+ sitePhone: z.string().optional(),
162
+ rotation: z.enum(["clinical", "field", "internship", "practicum"]).refine(
163
+ v => v !== undefined,
164
+ { message: "Rotation type is required" }
165
+ ),
166
+ creditHours: z.string().min(1, "Credit hours are required"),
167
+
168
+ // Step 3 — Schedule (dates stored as Date objects — Calendar picker)
169
+ startDate: z.date({ error: "Start date is required" }),
170
+ endDate: z.date({ error: "End date is required" }),
171
+ duration: z.string().min(1, "Duration is required"),
172
+ hoursPerWeek: z.string().min(1, "Hours per week is required"),
173
+ shift: z.string().min(1, "Shift is required"),
174
+ totalHours: z.string().optional(),
175
+ onWeekends: z.boolean().default(false),
176
+ remoteOption: z.enum(["on-site", "remote", "hybrid"]).refine(
177
+ v => v !== undefined,
178
+ { message: "Work arrangement is required" }
179
+ ),
180
+
181
+ // Step 4 — Supervisor & compliance
182
+ supervisor: z.string().min(1, "Supervisor is required"),
183
+ supervisorEmail: z.string().email("Enter a valid email").or(z.literal("")).optional(),
184
+ supervisorPhone: z.string().optional(),
185
+ preceptor: z.string().optional(),
186
+ objectives: z.string().min(10, "Enter at least a brief learning objective"),
187
+ specialRequirements: z.string().optional(),
188
+ backgroundCheck: z.boolean().default(false),
189
+ immunizationOk: z.boolean().default(false),
190
+ hipaaTraining: z.boolean().default(false),
191
+ notes: z.string().optional(),
192
+ })
193
+ .refine(d => d.endDate >= d.startDate, {
194
+ message: "End date must be after start date",
195
+ path: ["endDate"],
196
+ })
197
+
198
+ type PlacementFormValues = z.infer<typeof placementSchema>
199
+
200
+ type PhoneFieldName = "phone" | "sitePhone" | "supervisorPhone"
201
+
202
+ /** US phone mask — [Shadcn Studio input-mask](https://shadcnstudio.com/docs/components/input-mask) + RHF `field.ref`. */
203
+ function PhoneMaskedInput({
204
+ field,
205
+ placeholder = "(555) 555-0100",
206
+ }: {
207
+ field: ControllerRenderProps<PlacementFormValues, PhoneFieldName>
208
+ placeholder?: string
209
+ }) {
210
+ const ref = useExxatMaskedFieldRef(field.ref, exxatMaskPatterns.phoneUS)
211
+ return (
212
+ <Input
213
+ type="tel"
214
+ autoComplete="tel"
215
+ placeholder={placeholder}
216
+ name={field.name}
217
+ value={field.value ?? ""}
218
+ onBlur={field.onBlur}
219
+ onChange={field.onChange}
220
+ ref={ref}
221
+ />
222
+ )
223
+ }
224
+
225
+ // ── Step metadata ─────────────────────────────────────────────────────────────
226
+
227
+ const STEPS = [
228
+ { id: 1, label: "Student", icon: "fa-user-graduate" },
229
+ { id: 2, label: "Site", icon: "fa-hospital" },
230
+ { id: 3, label: "Schedule", icon: "fa-calendar-days" },
231
+ { id: 4, label: "Supervisor", icon: "fa-user-tie" },
232
+ { id: 5, label: "Review", icon: "fa-clipboard-check" },
233
+ ] as const
234
+
235
+ // Fields validated at each step before advancing
236
+ const STEP_FIELDS: Record<number, (keyof PlacementFormValues)[]> = {
237
+ 1: ["firstName", "lastName", "email", "studentId", "program", "cohort"],
238
+ 2: ["site", "department", "siteAddress", "rotation", "creditHours"],
239
+ 3: ["startDate", "endDate", "duration", "hoursPerWeek", "shift", "remoteOption"],
240
+ 4: ["supervisor", "objectives"],
241
+ }
242
+
243
+ // ── Step indicator ────────────────────────────────────────────────────────────
244
+
245
+ function StepIndicator({ current }: { current: number }) {
246
+ return (
247
+ <nav aria-label="Form steps" className="mb-8">
248
+ <ol className="flex items-center">
249
+ {STEPS.map((step, idx) => {
250
+ const isCompleted = step.id < current
251
+ const isActive = step.id === current
252
+ const isLast = idx === STEPS.length - 1
253
+
254
+ return (
255
+ <React.Fragment key={step.id}>
256
+ <li className="flex flex-col items-center gap-1.5 shrink-0">
257
+ <div
258
+ aria-current={isActive ? "step" : undefined}
259
+ className={cn(
260
+ "flex size-9 items-center justify-center rounded-full border-2 text-xs font-semibold transition-all",
261
+ isCompleted && "border-emerald-600 bg-emerald-600 text-white",
262
+ isActive && "border-brand bg-brand/10 text-brand",
263
+ !isCompleted && !isActive && "border-border bg-muted/40 text-muted-foreground",
264
+ )}
265
+ >
266
+ {isCompleted
267
+ ? <i className="fa-light fa-check text-xs" aria-hidden="true" />
268
+ : <span>{step.id}</span>
269
+ }
270
+ <span className="sr-only">
271
+ Step {step.id}: {step.label}
272
+ {isCompleted ? " (completed)" : isActive ? " (current)" : ""}
273
+ </span>
274
+ </div>
275
+ <span
276
+ className={cn(
277
+ "hidden sm:block text-xs whitespace-nowrap",
278
+ isActive && "text-foreground font-medium",
279
+ isCompleted && "text-emerald-600 font-medium",
280
+ !isCompleted && !isActive && "text-muted-foreground",
281
+ )}
282
+ aria-hidden="true"
283
+ >
284
+ {step.label}
285
+ </span>
286
+ </li>
287
+
288
+ {!isLast && (
289
+ <div
290
+ aria-hidden="true"
291
+ className={cn(
292
+ "flex-1 h-0.5 mx-2 mb-5 rounded-full transition-colors",
293
+ step.id < current ? "bg-emerald-600" : "bg-border"
294
+ )}
295
+ />
296
+ )}
297
+ </React.Fragment>
298
+ )
299
+ })}
300
+ </ol>
301
+ </nav>
302
+ )
303
+ }
304
+
305
+ // ── Section heading ───────────────────────────────────────────────────────────
306
+
307
+ function SectionHeading({ icon, title, description }: { icon: string; title: string; description?: string }) {
308
+ return (
309
+ <div className="flex items-start gap-3 mb-6 pb-4 border-b border-border">
310
+ <div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground shrink-0 mt-0.5" aria-hidden="true">
311
+ <i className={`fa-light ${icon} text-base`} />
312
+ </div>
313
+ <div>
314
+ <p className="text-base font-semibold text-foreground">{title}</p>
315
+ {description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
316
+ </div>
317
+ </div>
318
+ )
319
+ }
320
+
321
+ // ── Step 1 — Student ──────────────────────────────────────────────────────────
322
+
323
+ function Step1() {
324
+ const form = useFormContext<PlacementFormValues>()
325
+ return (
326
+ <div>
327
+ <SectionHeading icon="fa-user-graduate" title="Student Information" description="Enter the student's personal and academic details." />
328
+ <div className="space-y-5">
329
+
330
+ <div className="grid grid-cols-2 gap-4">
331
+ <FormField control={form.control} name="firstName" render={({ field }) => (
332
+ <FormItem>
333
+ <FormLabel>First name <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
334
+ <FormControl><Input placeholder="Jane" {...field} aria-required="true" autoComplete="given-name" /></FormControl>
335
+ <FormMessage />
336
+ </FormItem>
337
+ )} />
338
+ <FormField control={form.control} name="lastName" render={({ field }) => (
339
+ <FormItem>
340
+ <FormLabel>Last name <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
341
+ <FormControl><Input placeholder="Smith" {...field} aria-required="true" autoComplete="family-name" /></FormControl>
342
+ <FormMessage />
343
+ </FormItem>
344
+ )} />
345
+ </div>
346
+
347
+ <FormField control={form.control} name="email" render={({ field }) => (
348
+ <FormItem>
349
+ <FormLabel>Email address <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
350
+ <FormControl>
351
+ <Input type="email" placeholder="jane.smith@university.edu" {...field} aria-required="true" autoComplete="email" />
352
+ </FormControl>
353
+ <FormDescription>Must be the student&apos;s institutional email.</FormDescription>
354
+ <FormMessage />
355
+ </FormItem>
356
+ )} />
357
+
358
+ <div className="grid grid-cols-2 gap-4">
359
+ <FormField control={form.control} name="studentId" render={({ field }) => (
360
+ <FormItem>
361
+ <FormLabel>Student ID <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
362
+ <FormControl><Input placeholder="STU-2024-0001" {...field} aria-required="true" /></FormControl>
363
+ <FormDescription>STU-YYYY-####</FormDescription>
364
+ <FormMessage />
365
+ </FormItem>
366
+ )} />
367
+ <FormField control={form.control} name="phone" render={({ field }) => (
368
+ <FormItem>
369
+ <FormLabel>Phone <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
370
+ <FormControl>
371
+ <PhoneMaskedInput field={field} />
372
+ </FormControl>
373
+ <FormDescription>US format (555) 555-0100</FormDescription>
374
+ <FormMessage />
375
+ </FormItem>
376
+ )} />
377
+ </div>
378
+
379
+ <FormField control={form.control} name="program" render={({ field }) => (
380
+ <FormItem>
381
+ <FormLabel>Program <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
382
+ <Select value={field.value} onValueChange={field.onChange}>
383
+ <FormControl>
384
+ <SelectTrigger aria-required="true"><SelectValue placeholder="Select a program" /></SelectTrigger>
385
+ </FormControl>
386
+ <SelectContent>
387
+ {PROGRAMS.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}
388
+ </SelectContent>
389
+ </Select>
390
+ <FormMessage />
391
+ </FormItem>
392
+ )} />
393
+
394
+ <div className="grid grid-cols-2 gap-4">
395
+ <FormField control={form.control} name="cohort" render={({ field }) => (
396
+ <FormItem>
397
+ <FormLabel>Cohort / Class year <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
398
+ <FormControl><Input placeholder="2025" {...field} aria-required="true" /></FormControl>
399
+ <FormMessage />
400
+ </FormItem>
401
+ )} />
402
+ <FormField control={form.control} name="gpa" render={({ field }) => (
403
+ <FormItem>
404
+ <FormLabel>GPA <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
405
+ <FormControl><Input placeholder="3.8" {...field} /></FormControl>
406
+ <FormDescription>Out of 4.0</FormDescription>
407
+ <FormMessage />
408
+ </FormItem>
409
+ )} />
410
+ </div>
411
+
412
+ </div>
413
+ </div>
414
+ )
415
+ }
416
+
417
+ // ── Step 2 — Site & Program ───────────────────────────────────────────────────
418
+
419
+ function Step2() {
420
+ const form = useFormContext<PlacementFormValues>()
421
+ return (
422
+ <div>
423
+ <SectionHeading icon="fa-hospital" title="Site & Program Details" description="Where will the placement take place, and what type of rotation?" />
424
+ <div className="space-y-5">
425
+
426
+ <FormField control={form.control} name="site" render={({ field }) => (
427
+ <FormItem>
428
+ <FormLabel>Clinical site <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
429
+ <Select value={field.value} onValueChange={field.onChange}>
430
+ <FormControl>
431
+ <SelectTrigger aria-required="true"><SelectValue placeholder="Select a site" /></SelectTrigger>
432
+ </FormControl>
433
+ <SelectContent>
434
+ {SITES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
435
+ </SelectContent>
436
+ </Select>
437
+ <FormMessage />
438
+ </FormItem>
439
+ )} />
440
+
441
+ <FormField control={form.control} name="department" render={({ field }) => (
442
+ <FormItem>
443
+ <FormLabel>Department <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
444
+ <Select value={field.value} onValueChange={field.onChange}>
445
+ <FormControl>
446
+ <SelectTrigger aria-required="true"><SelectValue placeholder="Select a department" /></SelectTrigger>
447
+ </FormControl>
448
+ <SelectContent>
449
+ {DEPARTMENTS.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
450
+ </SelectContent>
451
+ </Select>
452
+ <FormMessage />
453
+ </FormItem>
454
+ )} />
455
+
456
+ <FormField control={form.control} name="siteAddress" render={({ field }) => (
457
+ <FormItem>
458
+ <FormLabel>Site address <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
459
+ <FormControl>
460
+ <Input placeholder="1400 N Lake Shore Dr, Chicago, IL 60610" {...field} aria-required="true" />
461
+ </FormControl>
462
+ <FormMessage />
463
+ </FormItem>
464
+ )} />
465
+
466
+ <div className="grid grid-cols-2 gap-4">
467
+ <FormField control={form.control} name="siteContact" render={({ field }) => (
468
+ <FormItem>
469
+ <FormLabel>Site contact <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
470
+ <FormControl><Input placeholder="Coordinator name" {...field} /></FormControl>
471
+ <FormMessage />
472
+ </FormItem>
473
+ )} />
474
+ <FormField control={form.control} name="sitePhone" render={({ field }) => (
475
+ <FormItem>
476
+ <FormLabel>Site phone <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
477
+ <FormControl>
478
+ <PhoneMaskedInput field={field} />
479
+ </FormControl>
480
+ <FormMessage />
481
+ </FormItem>
482
+ )} />
483
+ </div>
484
+
485
+ <FormField control={form.control} name="rotation" render={({ field }) => (
486
+ <FormItem>
487
+ <FormLabel>Rotation type <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
488
+ <FormControl>
489
+ <RadioGroup
490
+ value={field.value}
491
+ onValueChange={field.onChange}
492
+ className="grid grid-cols-2 gap-2 mt-1"
493
+ aria-required="true"
494
+ itemVariant="outline"
495
+ itemMotion="pop"
496
+ >
497
+ {([
498
+ { value: "clinical", label: "Clinical", icon: "fa-stethoscope" },
499
+ { value: "field", label: "Field", icon: "fa-map-location" },
500
+ { value: "internship", label: "Internship", icon: "fa-briefcase" },
501
+ { value: "practicum", label: "Practicum", icon: "fa-graduation-cap" },
502
+ ] as const).map(opt => (
503
+ <RadioGroupLabel
504
+ key={opt.value}
505
+ htmlFor={`rot-${opt.value}`}
506
+ className="flex min-h-0 items-center gap-2.5 rounded-lg border border-border px-3 py-2.5 font-normal transition-colors hover:bg-interactive-hover has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-brand/10"
507
+ >
508
+ <RadioGroupItem value={opt.value} id={`rot-${opt.value}`} />
509
+ <i className={`fa-light ${opt.icon} text-muted-foreground text-sm`} aria-hidden="true" />
510
+ <span className="text-sm">{opt.label}</span>
511
+ </RadioGroupLabel>
512
+ ))}
513
+ </RadioGroup>
514
+ </FormControl>
515
+ <FormMessage />
516
+ </FormItem>
517
+ )} />
518
+
519
+ <FormField control={form.control} name="creditHours" render={({ field }) => (
520
+ <FormItem>
521
+ <FormLabel>Credit hours <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
522
+ <FormControl>
523
+ <Input type="number" min={1} max={20} placeholder="3" className="w-32" {...field} aria-required="true" />
524
+ </FormControl>
525
+ <FormDescription>Number of academic credit hours for this placement.</FormDescription>
526
+ <FormMessage />
527
+ </FormItem>
528
+ )} />
529
+
530
+ </div>
531
+ </div>
532
+ )
533
+ }
534
+
535
+ // ── Step 3 — Schedule ─────────────────────────────────────────────────────────
536
+
537
+ function Step3() {
538
+ const form = useFormContext<PlacementFormValues>()
539
+ return (
540
+ <div>
541
+ <SectionHeading icon="fa-calendar-days" title="Schedule" description="Define the placement timeline, hours, and work arrangement." />
542
+ <div className="space-y-5">
543
+
544
+ {/* Start + End date — using Calendar+Popover (date-picker guideline) */}
545
+ <div className="grid grid-cols-2 gap-4">
546
+ <FormField control={form.control} name="startDate" render={({ field }) => (
547
+ <FormItem>
548
+ <FormLabel>Start Date <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
549
+ <FormControl>
550
+ <DatePickerField value={field.value} onChange={field.onChange} />
551
+ </FormControl>
552
+ <FormDescription>MM/DD/YYYY</FormDescription>
553
+ <FormMessage />
554
+ </FormItem>
555
+ )} />
556
+ <FormField control={form.control} name="endDate" render={({ field }) => (
557
+ <FormItem>
558
+ <FormLabel>End Date <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
559
+ <FormControl>
560
+ <DatePickerField value={field.value} onChange={field.onChange} />
561
+ </FormControl>
562
+ <FormDescription>MM/DD/YYYY</FormDescription>
563
+ <FormMessage />
564
+ </FormItem>
565
+ )} />
566
+ </div>
567
+
568
+ <div className="grid grid-cols-2 gap-4">
569
+ <FormField control={form.control} name="duration" render={({ field }) => (
570
+ <FormItem>
571
+ <FormLabel>Duration <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
572
+ <Select value={field.value} onValueChange={field.onChange}>
573
+ <FormControl>
574
+ <SelectTrigger aria-required="true"><SelectValue placeholder="Select duration" /></SelectTrigger>
575
+ </FormControl>
576
+ <SelectContent>
577
+ {DURATIONS.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
578
+ </SelectContent>
579
+ </Select>
580
+ <FormMessage />
581
+ </FormItem>
582
+ )} />
583
+ <FormField control={form.control} name="hoursPerWeek" render={({ field }) => (
584
+ <FormItem>
585
+ <FormLabel>Hours per week <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
586
+ <FormControl>
587
+ <Input type="number" min={1} max={80} placeholder="40" {...field} aria-required="true" />
588
+ </FormControl>
589
+ <FormDescription>hrs/wk</FormDescription>
590
+ <FormMessage />
591
+ </FormItem>
592
+ )} />
593
+ </div>
594
+
595
+ <FormField control={form.control} name="shift" render={({ field }) => (
596
+ <FormItem>
597
+ <FormLabel>Shift <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
598
+ <Select value={field.value} onValueChange={field.onChange}>
599
+ <FormControl>
600
+ <SelectTrigger aria-required="true"><SelectValue placeholder="Select a shift" /></SelectTrigger>
601
+ </FormControl>
602
+ <SelectContent>
603
+ {SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
604
+ </SelectContent>
605
+ </Select>
606
+ <FormMessage />
607
+ </FormItem>
608
+ )} />
609
+
610
+ <FormField control={form.control} name="totalHours" render={({ field }) => (
611
+ <FormItem>
612
+ <FormLabel>Total required hours <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
613
+ <FormControl>
614
+ <Input type="number" min={1} placeholder="480" className="w-40" {...field} />
615
+ </FormControl>
616
+ <FormDescription>Minimum hours the student must complete.</FormDescription>
617
+ <FormMessage />
618
+ </FormItem>
619
+ )} />
620
+
621
+ <FormField control={form.control} name="remoteOption" render={({ field }) => (
622
+ <FormItem>
623
+ <FormLabel>Work arrangement <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
624
+ <FormControl>
625
+ <RadioGroup
626
+ value={field.value}
627
+ onValueChange={field.onChange}
628
+ className="flex gap-3 mt-1"
629
+ aria-required="true"
630
+ itemVariant="outline"
631
+ itemMotion="pop"
632
+ >
633
+ {([
634
+ { value: "on-site", label: "On-site", icon: "fa-building" },
635
+ { value: "remote", label: "Remote", icon: "fa-house-laptop" },
636
+ { value: "hybrid", label: "Hybrid", icon: "fa-circle-half-stroke" },
637
+ ] as const).map(opt => (
638
+ <RadioGroupLabel
639
+ key={opt.value}
640
+ htmlFor={`mode-${opt.value}`}
641
+ className="flex min-h-0 flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border border-border px-3 py-2.5 font-normal transition-colors hover:bg-interactive-hover has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-brand/10"
642
+ >
643
+ <RadioGroupItem value={opt.value} id={`mode-${opt.value}`} />
644
+ <i className={`fa-light ${opt.icon} text-muted-foreground text-sm`} aria-hidden="true" />
645
+ <span className="text-sm whitespace-nowrap">{opt.label}</span>
646
+ </RadioGroupLabel>
647
+ ))}
648
+ </RadioGroup>
649
+ </FormControl>
650
+ <FormMessage />
651
+ </FormItem>
652
+ )} />
653
+
654
+ <FormField control={form.control} name="onWeekends" render={({ field }) => (
655
+ <FormItem>
656
+ <div className="flex items-start gap-3 rounded-lg border border-border px-3 py-2.5 hover:bg-interactive-hover transition-colors has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-brand/10">
657
+ <FormControl>
658
+ <Checkbox id="on-weekends" checked={field.value} onCheckedChange={field.onChange} />
659
+ </FormControl>
660
+ <div>
661
+ <Label htmlFor="on-weekends" className="text-sm font-medium cursor-pointer leading-none">
662
+ Includes weekend hours
663
+ </Label>
664
+ <p className="text-xs text-muted-foreground mt-1">Student may be scheduled on Saturdays or Sundays.</p>
665
+ </div>
666
+ </div>
667
+ <FormMessage />
668
+ </FormItem>
669
+ )} />
670
+
671
+ </div>
672
+ </div>
673
+ )
674
+ }
675
+
676
+ // ── Step 4 — Supervisor & Compliance ─────────────────────────────────────────
677
+
678
+ const COMPLIANCE_ITEMS = [
679
+ { name: "backgroundCheck" as const, label: "Background check completed", desc: "Student has passed required background screening." },
680
+ { name: "immunizationOk" as const, label: "Immunization records verified", desc: "All required vaccinations are on file." },
681
+ { name: "hipaaTraining" as const, label: "HIPAA training completed", desc: "Student has completed HIPAA privacy training." },
682
+ ]
683
+
684
+ function Step4() {
685
+ const form = useFormContext<PlacementFormValues>()
686
+ return (
687
+ <div>
688
+ <SectionHeading icon="fa-user-tie" title="Supervisor & Compliance" description="Assign a supervisor and confirm compliance requirements." />
689
+ <div className="space-y-5">
690
+
691
+ <FormField control={form.control} name="supervisor" render={({ field }) => (
692
+ <FormItem>
693
+ <FormLabel>Primary supervisor <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
694
+ <Select value={field.value} onValueChange={field.onChange}>
695
+ <FormControl>
696
+ <SelectTrigger aria-required="true"><SelectValue placeholder="Select a supervisor" /></SelectTrigger>
697
+ </FormControl>
698
+ <SelectContent>
699
+ {SUPERVISORS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
700
+ </SelectContent>
701
+ </Select>
702
+ <FormMessage />
703
+ </FormItem>
704
+ )} />
705
+
706
+ <div className="grid grid-cols-2 gap-4">
707
+ <FormField control={form.control} name="supervisorEmail" render={({ field }) => (
708
+ <FormItem>
709
+ <FormLabel>Supervisor email <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
710
+ <FormControl>
711
+ <Input type="email" placeholder="supervisor@hospital.org" {...field} />
712
+ </FormControl>
713
+ <FormMessage />
714
+ </FormItem>
715
+ )} />
716
+ <FormField control={form.control} name="supervisorPhone" render={({ field }) => (
717
+ <FormItem>
718
+ <FormLabel>Supervisor phone <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
719
+ <FormControl>
720
+ <PhoneMaskedInput field={field} />
721
+ </FormControl>
722
+ <FormMessage />
723
+ </FormItem>
724
+ )} />
725
+ </div>
726
+
727
+ <FormField control={form.control} name="preceptor" render={({ field }) => (
728
+ <FormItem>
729
+ <FormLabel>Preceptor / secondary supervisor <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
730
+ <FormControl><Input placeholder="On-site preceptor name" {...field} /></FormControl>
731
+ <FormMessage />
732
+ </FormItem>
733
+ )} />
734
+
735
+ <FormField control={form.control} name="objectives" render={({ field }) => (
736
+ <FormItem>
737
+ <FormLabel>Learning objectives <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
738
+ <FormControl>
739
+ <Textarea
740
+ placeholder="Describe the primary learning goals for this placement…"
741
+ className="min-h-[100px] resize-y"
742
+ {...field}
743
+ aria-required="true"
744
+ />
745
+ </FormControl>
746
+ <FormDescription>Competencies and skills the student should develop.</FormDescription>
747
+ <FormMessage />
748
+ </FormItem>
749
+ )} />
750
+
751
+ <FormField control={form.control} name="specialRequirements" render={({ field }) => (
752
+ <FormItem>
753
+ <FormLabel>Special requirements <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
754
+ <FormControl>
755
+ <Textarea
756
+ placeholder="Dress code, languages, certifications, etc."
757
+ className="min-h-[80px] resize-y"
758
+ {...field}
759
+ />
760
+ </FormControl>
761
+ <FormMessage />
762
+ </FormItem>
763
+ )} />
764
+
765
+ {/* Compliance checklist */}
766
+ <fieldset className="space-y-2">
767
+ <legend className="text-sm font-medium text-foreground">Compliance checklist</legend>
768
+ {COMPLIANCE_ITEMS.map(item => (
769
+ <FormField key={item.name} control={form.control} name={item.name} render={({ field }) => (
770
+ <FormItem>
771
+ <div className="flex items-start gap-3 rounded-lg border border-border px-3 py-2.5 hover:bg-interactive-hover transition-colors has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-brand/10">
772
+ <FormControl>
773
+ <Checkbox
774
+ id={item.name}
775
+ checked={field.value as boolean}
776
+ onCheckedChange={field.onChange}
777
+ className="mt-0.5 shrink-0"
778
+ />
779
+ </FormControl>
780
+ <div className="min-w-0">
781
+ <Label htmlFor={item.name} className="text-sm font-medium cursor-pointer leading-none">{item.label}</Label>
782
+ <p className="text-xs text-muted-foreground mt-1">{item.desc}</p>
783
+ </div>
784
+ </div>
785
+ <FormMessage />
786
+ </FormItem>
787
+ )} />
788
+ ))}
789
+ </fieldset>
790
+
791
+ <FormField control={form.control} name="notes" render={({ field }) => (
792
+ <FormItem>
793
+ <FormLabel>Internal notes <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
794
+ <FormControl>
795
+ <Textarea
796
+ placeholder="Any internal notes visible only to coordinators…"
797
+ className="min-h-[80px] resize-y"
798
+ {...field}
799
+ />
800
+ </FormControl>
801
+ <FormMessage />
802
+ </FormItem>
803
+ )} />
804
+
805
+ </div>
806
+ </div>
807
+ )
808
+ }
809
+
810
+ // ── Step 5 — Review ───────────────────────────────────────────────────────────
811
+
812
+ function ReviewRow({ label, value }: { label: string; value: React.ReactNode }) {
813
+ return (
814
+ <div className="flex justify-between gap-4 py-2 border-b border-border last:border-0">
815
+ <dt className="text-sm text-muted-foreground shrink-0 w-40">{label}</dt>
816
+ <dd className="text-sm text-foreground text-right break-all">
817
+ {value || <span className="text-muted-foreground italic">—</span>}
818
+ </dd>
819
+ </div>
820
+ )
821
+ }
822
+
823
+ function ReviewSection({
824
+ title, icon, onEdit, children,
825
+ }: { title: string; icon: string; onEdit: () => void; children: React.ReactNode }) {
826
+ return (
827
+ <Card size="sm">
828
+ <CardHeader className="border-b">
829
+ <CardTitle className="flex items-center gap-2">
830
+ <i className={`fa-light ${icon} text-sm text-muted-foreground`} aria-hidden="true" />
831
+ <span>{title}</span>
832
+ </CardTitle>
833
+ <CardAction>
834
+ <Button
835
+ type="button"
836
+ variant="ghost"
837
+ size="sm"
838
+ className="text-xs h-7 gap-1.5"
839
+ onClick={onEdit}
840
+ aria-label={`Edit ${title}`}
841
+ >
842
+ <i className="fa-light fa-pen text-xs" aria-hidden="true" />
843
+ Edit
844
+ </Button>
845
+ </CardAction>
846
+ </CardHeader>
847
+ <CardContent>
848
+ <dl>{children}</dl>
849
+ </CardContent>
850
+ </Card>
851
+ )
852
+ }
853
+
854
+ function Step5({
855
+ data,
856
+ goToStep,
857
+ }: {
858
+ data: Partial<PlacementFormValues>
859
+ goToStep: (n: number) => void
860
+ }) {
861
+ return (
862
+ <div>
863
+ <SectionHeading icon="fa-clipboard-check" title="Review & Submit" description="Review all details before creating the placement." />
864
+ <div className="space-y-4">
865
+
866
+ <ReviewSection title="Student Information" icon="fa-user-graduate" onEdit={() => goToStep(1)}>
867
+ <ReviewRow label="Name" value={[data.firstName, data.lastName].filter(Boolean).join(" ")} />
868
+ <ReviewRow label="Email" value={data.email} />
869
+ <ReviewRow label="Student ID" value={data.studentId} />
870
+ <ReviewRow label="Phone" value={data.phone} />
871
+ <ReviewRow label="Program" value={data.program} />
872
+ <ReviewRow label="Cohort" value={data.cohort} />
873
+ <ReviewRow label="GPA" value={data.gpa} />
874
+ </ReviewSection>
875
+
876
+ <ReviewSection title="Site & Program" icon="fa-hospital" onEdit={() => goToStep(2)}>
877
+ <ReviewRow label="Site" value={data.site} />
878
+ <ReviewRow label="Department" value={data.department} />
879
+ <ReviewRow label="Address" value={data.siteAddress} />
880
+ <ReviewRow label="Site Contact" value={data.siteContact} />
881
+ <ReviewRow label="Rotation" value={data.rotation ? data.rotation.charAt(0).toUpperCase() + data.rotation.slice(1) : undefined} />
882
+ <ReviewRow label="Credit Hours" value={data.creditHours} />
883
+ </ReviewSection>
884
+
885
+ <ReviewSection title="Schedule" icon="fa-calendar-days" onEdit={() => goToStep(3)}>
886
+ <ReviewRow label="Start Date" value={data.startDate ? formatDateUS(data.startDate.toISOString()) : undefined} />
887
+ <ReviewRow label="End Date" value={data.endDate ? formatDateUS(data.endDate.toISOString()) : undefined} />
888
+ <ReviewRow label="Duration" value={data.duration} />
889
+ <ReviewRow label="Hours / Week" value={data.hoursPerWeek ? `${data.hoursPerWeek} hrs` : undefined} />
890
+ <ReviewRow label="Shift" value={data.shift} />
891
+ <ReviewRow label="Arrangement" value={data.remoteOption} />
892
+ <ReviewRow label="Weekends" value={data.onWeekends ? "Yes" : "No"} />
893
+ </ReviewSection>
894
+
895
+ <ReviewSection title="Supervisor & Compliance" icon="fa-user-tie" onEdit={() => goToStep(4)}>
896
+ <ReviewRow label="Supervisor" value={data.supervisor} />
897
+ <ReviewRow label="Supervisor Email" value={data.supervisorEmail} />
898
+ <ReviewRow label="Preceptor" value={data.preceptor} />
899
+ <ReviewRow label="Background ✓" value={data.backgroundCheck ? "Yes" : "No"} />
900
+ <ReviewRow label="Immunization ✓" value={data.immunizationOk ? "Yes" : "No"} />
901
+ <ReviewRow label="HIPAA ✓" value={data.hipaaTraining ? "Yes" : "No"} />
902
+ </ReviewSection>
903
+
904
+ <p className="text-xs text-muted-foreground text-center pt-1">
905
+ By submitting, you confirm all details are accurate and the student has been notified.
906
+ </p>
907
+ </div>
908
+ </div>
909
+ )
910
+ }
911
+
912
+ // ── Main component ────────────────────────────────────────────────────────────
913
+
914
+ export function NewPlacementForm() {
915
+ const router = useRouter()
916
+ const [step, setStep] = React.useState(1)
917
+ const [submitting, setSubmitting] = React.useState(false)
918
+ const stepHeadingRef = React.useRef<HTMLDivElement>(null)
919
+
920
+ const form = useForm<PlacementFormValues>({
921
+ resolver: zodResolver(placementSchema) as Resolver<PlacementFormValues>,
922
+ mode: "onTouched",
923
+ defaultValues: {
924
+ firstName: "", lastName: "", email: "", studentId: "", phone: "",
925
+ program: "", cohort: "", gpa: "",
926
+ site: "", department: "", siteAddress: "", siteContact: "", sitePhone: "",
927
+ rotation: undefined, creditHours: "",
928
+ startDate: undefined, endDate: undefined,
929
+ duration: "", hoursPerWeek: "", shift: "", totalHours: "",
930
+ remoteOption: undefined, onWeekends: false,
931
+ supervisor: "", supervisorEmail: "", supervisorPhone: "",
932
+ preceptor: "", objectives: "", specialRequirements: "",
933
+ backgroundCheck: false, immunizationOk: false, hipaaTraining: false, notes: "",
934
+ },
935
+ })
936
+
937
+ // Move focus to the top section heading when step changes (WCAG 2.4.3)
938
+ React.useEffect(() => {
939
+ stepHeadingRef.current?.focus()
940
+ }, [step])
941
+
942
+ async function handleNext() {
943
+ const fields = STEP_FIELDS[step]
944
+ if (fields) {
945
+ const ok = await form.trigger(fields)
946
+ if (!ok) return
947
+ }
948
+ setStep(s => Math.min(s + 1, 5))
949
+ window.scrollTo({ top: 0, behavior: "smooth" })
950
+ }
951
+
952
+ function handleBack() {
953
+ setStep(s => Math.max(s - 1, 1))
954
+ window.scrollTo({ top: 0, behavior: "smooth" })
955
+ }
956
+
957
+ function goToStep(n: number) {
958
+ setStep(n)
959
+ window.scrollTo({ top: 0, behavior: "smooth" })
960
+ }
961
+
962
+ async function handleSubmit(values: PlacementFormValues) {
963
+ setSubmitting(true)
964
+ await new Promise(r => setTimeout(r, 1400))
965
+ devLog("New placement:", values)
966
+ setSubmitting(false)
967
+ router.push("/data-list")
968
+ }
969
+
970
+ const formData = form.watch()
971
+ const mod = useModKeyLabel()
972
+ const alt = useAltKeyLabel()
973
+
974
+ return (
975
+ <Form {...form}>
976
+ {/* Implicit submit only fires on step 5 (Review) — Enter on earlier steps is ignored so the review never auto-closes when users hit Enter inside an input on steps 1–4. */}
977
+ <form
978
+ onSubmit={(e) => {
979
+ if (step !== 5) { e.preventDefault(); return }
980
+ form.handleSubmit(handleSubmit)(e)
981
+ }}
982
+ noValidate
983
+ aria-label="New placement form"
984
+ >
985
+ {/* Steps 1–4: ⌘Enter advances. Step 5: ⌘Enter submits — plain Enter is intentionally NOT bound:
986
+ a global Enter shortcut would fire when focus is on the step container (we focus it for
987
+ a11y after each step), submitting the form without an explicit submit action — felt like
988
+ "Review auto-closes". Native Enter on the primary submit button still works. */}
989
+ {step < 5 && <Shortcut keys="⌘Enter" disabled={submitting} onInvoke={handleNext} />}
990
+ {step === 5 && <Shortcut keys="⌘Enter" disabled={submitting} onInvoke={() => form.handleSubmit(handleSubmit)()} />}
991
+ {step > 1 && <Shortcut keys="⌘⌥←" disabled={submitting} onInvoke={handleBack} /> }
992
+ <StepIndicator current={step} />
993
+
994
+ {/* Step content — ref captures focus when step changes */}
995
+ <div ref={stepHeadingRef} tabIndex={-1} className="outline-none">
996
+ {step === 1 && <Step1 />}
997
+ {step === 2 && <Step2 />}
998
+ {step === 3 && <Step3 />}
999
+ {step === 4 && <Step4 />}
1000
+ {step === 5 && <Step5 data={formData} goToStep={goToStep} />}
1001
+ </div>
1002
+
1003
+ {/* Navigation — sticky at bottom of scroll container */}
1004
+ <div className="sticky bottom-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t border-border flex items-center justify-between mt-8 py-4">
1005
+ {/* Progress dots */}
1006
+ <div className="flex items-center gap-1.5" aria-hidden="true">
1007
+ {STEPS.map(s => (
1008
+ <div
1009
+ key={s.id}
1010
+ className={cn(
1011
+ "rounded-full transition-all h-1.5",
1012
+ s.id === step ? "w-6 bg-brand" :
1013
+ s.id < step ? "w-3 bg-brand/40" :
1014
+ "w-3 bg-border"
1015
+ )}
1016
+ />
1017
+ ))}
1018
+ </div>
1019
+
1020
+ {/* Back + Next grouped together */}
1021
+ <div className="flex items-center gap-2">
1022
+ <Button
1023
+ type="button"
1024
+ variant="outline"
1025
+ onClick={handleBack}
1026
+ disabled={step === 1 || submitting}
1027
+ >
1028
+ <i className="fa-light fa-arrow-left text-[13px]" aria-hidden="true" />
1029
+ Back
1030
+ <KbdGroup className="ml-1.5">
1031
+ <Kbd variant="bare">{mod}{alt}←</Kbd>
1032
+ </KbdGroup>
1033
+ </Button>
1034
+
1035
+ {step < 5 ? (
1036
+ <Button type="button" onClick={handleNext}>
1037
+ Next
1038
+ <i className="fa-light fa-arrow-right text-[13px]" aria-hidden="true" />
1039
+ <KbdGroup className="ml-1.5">
1040
+ <Kbd variant="bare">{mod}⏎</Kbd>
1041
+ </KbdGroup>
1042
+ </Button>
1043
+ ) : (
1044
+ <Button type="submit" disabled={submitting} aria-busy={submitting}>
1045
+ {submitting ? (
1046
+ <>
1047
+ <i className="fa-light fa-spinner-third fa-spin text-[13px]" aria-hidden="true" />
1048
+ Creating…
1049
+ </>
1050
+ ) : (
1051
+ <>
1052
+ <i className="fa-light fa-check text-[13px]" aria-hidden="true" />
1053
+ Create placement
1054
+ <KbdGroup className="ml-1.5">
1055
+ <Kbd variant="bare">{mod}⏎</Kbd>
1056
+ </KbdGroup>
1057
+ </>
1058
+ )}
1059
+ </Button>
1060
+ )}
1061
+ </div>
1062
+ </div>
1063
+ </form>
1064
+ </Form>
1065
+ )
1066
+ }