@exxatdesignux/ui 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/CHANGELOG.md +701 -6
  2. package/README.md +138 -0
  3. package/bin/init.mjs +134 -31
  4. package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
  5. package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
  6. package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
  7. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
  8. package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
  9. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
  10. package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
  11. package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
  12. package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
  13. package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
  14. package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
  15. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
  16. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
  17. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
  18. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
  19. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
  20. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
  21. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
  22. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
  23. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
  24. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
  25. package/consumer-extras/handbook/HANDBOOK.md +2 -0
  26. package/consumer-extras/handbook/glossary.md +2 -1
  27. package/consumer-extras/handbook/reference-implementations.md +31 -4
  28. package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
  29. package/consumer-extras/patterns/data-views-pattern.md +18 -16
  30. package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
  31. package/dist/components/data-table/index.js +2 -2
  32. package/dist/components/data-table/index.js.map +1 -1
  33. package/dist/components/data-table/pagination.js +3 -3
  34. package/dist/components/data-table/pagination.js.map +1 -1
  35. package/dist/components/data-table/use-table-state.d.ts +1 -1
  36. package/dist/components/data-table/use-table-state.js.map +1 -1
  37. package/dist/components/data-views/data-row-list.js.map +1 -1
  38. package/dist/components/data-views/finder-panel-view.d.ts +1 -1
  39. package/dist/components/data-views/finder-panel-view.js.map +1 -1
  40. package/dist/components/data-views/hub-table.d.ts +9 -3
  41. package/dist/components/data-views/hub-table.js +262 -40
  42. package/dist/components/data-views/hub-table.js.map +1 -1
  43. package/dist/components/data-views/index.js +262 -40
  44. package/dist/components/data-views/index.js.map +1 -1
  45. package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
  46. package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
  47. package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
  48. package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
  49. package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
  50. package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
  51. package/dist/components/data-views/os-folder-glyph.js.map +1 -1
  52. package/dist/components/ui/avatar.d.ts +1 -1
  53. package/dist/components/ui/key-metrics.js.map +1 -1
  54. package/dist/index.js +136 -39
  55. package/dist/index.js.map +1 -1
  56. package/package.json +3 -2
  57. package/src/components/data-table/index.tsx +2 -2
  58. package/src/components/data-table/pagination.tsx +5 -1
  59. package/src/components/data-table/use-table-state.ts +1 -1
  60. package/src/components/data-views/data-row-list.tsx +1 -1
  61. package/src/components/data-views/finder-panel-view.tsx +2 -2
  62. package/src/components/data-views/hub-table.tsx +149 -41
  63. package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
  64. package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
  65. package/src/components/data-views/os-folder-glyph.tsx +1 -1
  66. package/src/components/ui/key-metrics.tsx +1 -1
  67. package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
  68. package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
  69. package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
  70. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
  71. package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
  72. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
  73. package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
  74. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
  75. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
  76. package/template/AGENTS.md +43 -37
  77. package/template/app/(app)/columns/page.tsx +11 -0
  78. package/template/app/(app)/library/all/page.tsx +11 -0
  79. package/template/app/(app)/library/find/page.tsx +12 -0
  80. package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
  81. package/template/app/(app)/library/list/page.tsx +12 -0
  82. package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
  83. package/template/app/(app)/library/page.tsx +11 -0
  84. package/template/app/(app)/tokens-themes/page.tsx +11 -0
  85. package/template/components/ask-leo-composer.tsx +2 -2
  86. package/template/components/columns-client.tsx +158 -0
  87. package/template/components/columns-showcase.tsx +541 -0
  88. package/template/components/data-views/index.ts +32 -6
  89. package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
  90. package/template/components/data-views/table-cells.tsx +673 -0
  91. package/template/components/folder-details-shell.tsx +11 -11
  92. package/template/components/hub-tree-panel-view.tsx +24 -24
  93. package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
  94. package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
  95. package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
  96. package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
  97. package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
  98. package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
  99. package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
  100. package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
  101. package/template/components/library-panel-activator.tsx +8 -0
  102. package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
  103. package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
  104. package/template/components/list-hub-status-badge.tsx +2 -2
  105. package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
  106. package/template/components/sidebar/app-sidebar.tsx +61 -5
  107. package/template/components/sidebar/secondary-panel.tsx +109 -56
  108. package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
  109. package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
  110. package/template/components/table-properties/types.ts +1 -1
  111. package/template/components/templates/discovery-hub-template.tsx +1 -1
  112. package/template/components/templates/new-focus-template.tsx +2 -2
  113. package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
  114. package/template/components/tokens-secondary-nav.tsx +192 -0
  115. package/template/components/tokens-themes-client.tsx +476 -0
  116. package/template/components/tokens-themes-section.tsx +386 -0
  117. package/template/docs/HANDBOOK.md +187 -0
  118. package/template/docs/blueprints/README.md +1 -1
  119. package/template/docs/blueprints/board-card.md +1 -1
  120. package/template/docs/blueprints/data-table.md +2 -2
  121. package/template/docs/blueprints/list-page-template.md +3 -3
  122. package/template/docs/blueprints/page-header.md +4 -4
  123. package/template/docs/collaboration-access-pattern.md +7 -7
  124. package/template/docs/component-selection-guide.md +1 -1
  125. package/template/docs/data-views-pattern.md +18 -16
  126. package/template/docs/glossary.md +58 -0
  127. package/template/docs/kpi-flat-band-pattern.md +3 -3
  128. package/template/docs/kpi-trend-pattern.md +18 -3
  129. package/template/docs/large-dataset-strategy.md +155 -0
  130. package/template/docs/library-hub-header-pattern.md +25 -0
  131. package/template/docs/migrations/_template.md +1 -1
  132. package/template/docs/reference-implementations.md +151 -0
  133. package/template/docs/token-taxonomy.md +1 -1
  134. package/template/docs/voice-and-tone.md +262 -0
  135. package/template/eslint.config.mjs +9 -39
  136. package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
  137. package/template/lib/ask-leo-route-context.ts +6 -18
  138. package/template/lib/coach-mark-registry.ts +0 -16
  139. package/template/lib/command-menu-config.ts +5 -12
  140. package/template/lib/command-menu-search-data.ts +8 -39
  141. package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
  142. package/template/lib/library-dedicated-search.ts +19 -0
  143. package/template/lib/library-hub-search.ts +90 -0
  144. package/template/lib/library-nav.ts +477 -0
  145. package/template/lib/library-recent-searches.ts +22 -0
  146. package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
  147. package/template/lib/list-status-badges.ts +16 -104
  148. package/template/lib/mock/dashboard.ts +1 -1
  149. package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
  150. package/template/lib/mock/library-header-collaborators.ts +54 -0
  151. package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
  152. package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
  153. package/template/lib/mock/library.ts +249 -0
  154. package/template/lib/mock/navigation.tsx +32 -26
  155. package/template/lib/table-state-lifecycle.ts +1 -1
  156. package/template/next.config.mjs +7 -4
  157. package/template/package.json +0 -1
  158. package/tokens/hooks-index.json +2874 -0
  159. package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
  160. package/template/app/(app)/examples/page.tsx +0 -41
  161. package/template/app/(app)/question-bank/find/page.tsx +0 -12
  162. package/template/app/(app)/question-bank/library/page.tsx +0 -11
  163. package/template/app/(app)/question-bank/list/page.tsx +0 -12
  164. package/template/app/(app)/question-bank/page.tsx +0 -11
  165. package/template/components/compliance-board-view.tsx +0 -142
  166. package/template/components/compliance-client.tsx +0 -92
  167. package/template/components/compliance-page-header.tsx +0 -89
  168. package/template/components/compliance-table.tsx +0 -468
  169. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  170. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  171. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  172. package/template/components/new-placement-back-btn.tsx +0 -28
  173. package/template/components/new-placement-form.tsx +0 -942
  174. package/template/components/placement-board-card.tsx +0 -250
  175. package/template/components/placement-detail.tsx +0 -438
  176. package/template/components/placements-board-view.tsx +0 -397
  177. package/template/components/placements-client.tsx +0 -220
  178. package/template/components/placements-list-view.tsx +0 -124
  179. package/template/components/placements-page-header.tsx +0 -166
  180. package/template/components/placements-table-cells.test.tsx +0 -22
  181. package/template/components/placements-table-cells.tsx +0 -173
  182. package/template/components/placements-table-columns.tsx +0 -210
  183. package/template/components/placements-table.tsx +0 -934
  184. package/template/components/question-bank-panel-activator.tsx +0 -8
  185. package/template/components/rotations-empty-state.tsx +0 -50
  186. package/template/components/rotations-panel-activator.tsx +0 -8
  187. package/template/components/sites-board-view.tsx +0 -67
  188. package/template/components/sites-client.tsx +0 -154
  189. package/template/components/sites-table.tsx +0 -249
  190. package/template/components/team-board-view.tsx +0 -122
  191. package/template/components/team-client.tsx +0 -100
  192. package/template/components/team-page-header.tsx +0 -92
  193. package/template/components/team-table.tsx +0 -553
  194. package/template/docs/question-bank-hub-header-pattern.md +0 -25
  195. package/template/lib/compliance-supported-views.ts +0 -10
  196. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  197. package/template/lib/mock/compliance-kpi.ts +0 -61
  198. package/template/lib/mock/compliance.ts +0 -146
  199. package/template/lib/mock/placements-kpi.ts +0 -134
  200. package/template/lib/mock/placements.ts +0 -176
  201. package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
  202. package/template/lib/mock/question-bank.ts +0 -249
  203. package/template/lib/mock/sites-directory.ts +0 -16
  204. package/template/lib/mock/sites-kpi.ts +0 -25
  205. package/template/lib/mock/team-kpi.ts +0 -60
  206. package/template/lib/mock/team.ts +0 -118
  207. package/template/lib/placement-board-card-layout.ts +0 -79
  208. package/template/lib/question-bank-dedicated-search.ts +0 -19
  209. package/template/lib/question-bank-hub-search.ts +0 -90
  210. package/template/lib/question-bank-nav.ts +0 -477
  211. package/template/lib/question-bank-recent-searches.ts +0 -22
  212. package/template/lib/question-bank-supported-views.ts +0 -12
  213. package/template/lib/sites-supported-views.ts +0 -10
  214. package/template/lib/team-supported-views.ts +0 -10
@@ -1,942 +0,0 @@
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
- useWatch,
29
- type ControllerRenderProps,
30
- type Resolver,
31
- } from "react-hook-form"
32
- import { zodResolver } from "@hookform/resolvers/zod"
33
- import { z } from "zod"
34
-
35
- import { devLog } from "@/lib/dev-log"
36
- import { formatDateFromDate } 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 {
67
- NewFocusTemplate,
68
- type NewFocusStep,
69
- } from "@/components/templates/new-focus-template"
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
- // Field-validation map keyed by step index (0-based) — passed to `form.trigger` before advance.
228
- const STEP_FIELDS: Record<number, (keyof PlacementFormValues)[]> = {
229
- 0: ["firstName", "lastName", "email", "studentId", "program", "cohort"],
230
- 1: ["site", "department", "siteAddress", "rotation", "creditHours"],
231
- 2: ["startDate", "endDate", "duration", "hoursPerWeek", "shift", "remoteOption"],
232
- 3: ["supervisor", "objectives"],
233
- }
234
-
235
- // ── Section heading ───────────────────────────────────────────────────────────
236
-
237
- function SectionHeading({ icon, title, description }: { icon: string; title: string; description?: string }) {
238
- return (
239
- <div className="flex items-start gap-3 mb-6 pb-4 border-b border-border">
240
- <div className="flex items-center justify-center size-9 rounded-lg bg-secondary text-secondary-foreground shrink-0 mt-0.5" aria-hidden="true">
241
- <i className={`fa-light ${icon} text-base`} />
242
- </div>
243
- <div>
244
- <p className="text-base font-semibold text-foreground">{title}</p>
245
- {description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
246
- </div>
247
- </div>
248
- )
249
- }
250
-
251
- // ── Step 1 — Student ──────────────────────────────────────────────────────────
252
-
253
- function Step1() {
254
- const form = useFormContext<PlacementFormValues>()
255
- return (
256
- <div>
257
- <SectionHeading icon="fa-user-graduate" title="Student Information" description="Enter the student's personal and academic details." />
258
- <div className="space-y-5">
259
-
260
- <div className="grid grid-cols-2 gap-4">
261
- <FormField control={form.control} name="firstName" render={({ field }) => (
262
- <FormItem>
263
- <FormLabel>First name <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
264
- <FormControl><Input placeholder="Jane" {...field} aria-required="true" autoComplete="given-name" /></FormControl>
265
- <FormMessage />
266
- </FormItem>
267
- )} />
268
- <FormField control={form.control} name="lastName" render={({ field }) => (
269
- <FormItem>
270
- <FormLabel>Last name <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
271
- <FormControl><Input placeholder="Smith" {...field} aria-required="true" autoComplete="family-name" /></FormControl>
272
- <FormMessage />
273
- </FormItem>
274
- )} />
275
- </div>
276
-
277
- <FormField control={form.control} name="email" render={({ field }) => (
278
- <FormItem>
279
- <FormLabel>Email address <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
280
- <FormControl>
281
- <Input type="email" placeholder="jane.smith@university.edu" {...field} aria-required="true" autoComplete="email" />
282
- </FormControl>
283
- <FormDescription>Must be the student&apos;s institutional email.</FormDescription>
284
- <FormMessage />
285
- </FormItem>
286
- )} />
287
-
288
- <div className="grid grid-cols-2 gap-4">
289
- <FormField control={form.control} name="studentId" render={({ field }) => (
290
- <FormItem>
291
- <FormLabel>Student ID <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
292
- <FormControl><Input placeholder="STU-2024-0001" {...field} aria-required="true" /></FormControl>
293
- <FormDescription>STU-YYYY-####</FormDescription>
294
- <FormMessage />
295
- </FormItem>
296
- )} />
297
- <FormField control={form.control} name="phone" render={({ field }) => (
298
- <FormItem>
299
- <FormLabel>Phone <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
300
- <FormControl>
301
- <PhoneMaskedInput field={field} />
302
- </FormControl>
303
- <FormDescription>US format (555) 555-0100</FormDescription>
304
- <FormMessage />
305
- </FormItem>
306
- )} />
307
- </div>
308
-
309
- <FormField control={form.control} name="program" render={({ field }) => (
310
- <FormItem>
311
- <FormLabel>Program <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
312
- <Select value={field.value} onValueChange={field.onChange}>
313
- <FormControl>
314
- <SelectTrigger aria-required="true"><SelectValue placeholder="Select a program" /></SelectTrigger>
315
- </FormControl>
316
- <SelectContent>
317
- {PROGRAMS.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}
318
- </SelectContent>
319
- </Select>
320
- <FormMessage />
321
- </FormItem>
322
- )} />
323
-
324
- <div className="grid grid-cols-2 gap-4">
325
- <FormField control={form.control} name="cohort" render={({ field }) => (
326
- <FormItem>
327
- <FormLabel>Cohort / Class year <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
328
- <FormControl><Input placeholder="2025" {...field} aria-required="true" /></FormControl>
329
- <FormMessage />
330
- </FormItem>
331
- )} />
332
- <FormField control={form.control} name="gpa" render={({ field }) => (
333
- <FormItem>
334
- <FormLabel>GPA <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
335
- <FormControl><Input placeholder="3.8" {...field} /></FormControl>
336
- <FormDescription>Out of 4.0</FormDescription>
337
- <FormMessage />
338
- </FormItem>
339
- )} />
340
- </div>
341
-
342
- </div>
343
- </div>
344
- )
345
- }
346
-
347
- // ── Step 2 — Site & Program ───────────────────────────────────────────────────
348
-
349
- function Step2() {
350
- const form = useFormContext<PlacementFormValues>()
351
- return (
352
- <div>
353
- <SectionHeading icon="fa-hospital" title="Site & Program Details" description="Where will the placement take place, and what type of rotation?" />
354
- <div className="space-y-5">
355
-
356
- <FormField control={form.control} name="site" render={({ field }) => (
357
- <FormItem>
358
- <FormLabel>Clinical site <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
359
- <Select value={field.value} onValueChange={field.onChange}>
360
- <FormControl>
361
- <SelectTrigger aria-required="true"><SelectValue placeholder="Select a site" /></SelectTrigger>
362
- </FormControl>
363
- <SelectContent>
364
- {SITES.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
365
- </SelectContent>
366
- </Select>
367
- <FormMessage />
368
- </FormItem>
369
- )} />
370
-
371
- <FormField control={form.control} name="department" render={({ field }) => (
372
- <FormItem>
373
- <FormLabel>Department <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
374
- <Select value={field.value} onValueChange={field.onChange}>
375
- <FormControl>
376
- <SelectTrigger aria-required="true"><SelectValue placeholder="Select a department" /></SelectTrigger>
377
- </FormControl>
378
- <SelectContent>
379
- {DEPARTMENTS.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
380
- </SelectContent>
381
- </Select>
382
- <FormMessage />
383
- </FormItem>
384
- )} />
385
-
386
- <FormField control={form.control} name="siteAddress" render={({ field }) => (
387
- <FormItem>
388
- <FormLabel>Site address <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
389
- <FormControl>
390
- <Input placeholder="1400 N Lake Shore Dr, Chicago, IL 60610" {...field} aria-required="true" />
391
- </FormControl>
392
- <FormMessage />
393
- </FormItem>
394
- )} />
395
-
396
- <div className="grid grid-cols-2 gap-4">
397
- <FormField control={form.control} name="siteContact" render={({ field }) => (
398
- <FormItem>
399
- <FormLabel>Site contact <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
400
- <FormControl><Input placeholder="Coordinator name" {...field} /></FormControl>
401
- <FormMessage />
402
- </FormItem>
403
- )} />
404
- <FormField control={form.control} name="sitePhone" render={({ field }) => (
405
- <FormItem>
406
- <FormLabel>Site phone <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
407
- <FormControl>
408
- <PhoneMaskedInput field={field} />
409
- </FormControl>
410
- <FormMessage />
411
- </FormItem>
412
- )} />
413
- </div>
414
-
415
- <FormField control={form.control} name="rotation" render={({ field }) => (
416
- <FormItem>
417
- <FormLabel>Rotation type <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
418
- <FormControl>
419
- <RadioGroup
420
- value={field.value}
421
- onValueChange={field.onChange}
422
- className="grid grid-cols-2 gap-2 mt-1"
423
- aria-required="true"
424
- itemVariant="outline"
425
- itemMotion="pop"
426
- >
427
- {([
428
- { value: "clinical", label: "Clinical", icon: "fa-stethoscope" },
429
- { value: "field", label: "Field", icon: "fa-map-location" },
430
- { value: "internship", label: "Internship", icon: "fa-briefcase" },
431
- { value: "practicum", label: "Practicum", icon: "fa-graduation-cap" },
432
- ] as const).map(opt => (
433
- <RadioGroupLabel
434
- key={opt.value}
435
- htmlFor={`rot-${opt.value}`}
436
- 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"
437
- >
438
- <RadioGroupItem value={opt.value} id={`rot-${opt.value}`} />
439
- <i className={`fa-light ${opt.icon} text-muted-foreground text-sm`} aria-hidden="true" />
440
- <span className="text-sm">{opt.label}</span>
441
- </RadioGroupLabel>
442
- ))}
443
- </RadioGroup>
444
- </FormControl>
445
- <FormMessage />
446
- </FormItem>
447
- )} />
448
-
449
- <FormField control={form.control} name="creditHours" render={({ field }) => (
450
- <FormItem>
451
- <FormLabel>Credit hours <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
452
- <FormControl>
453
- <Input type="number" min={1} max={20} placeholder="3" className="w-32" {...field} aria-required="true" />
454
- </FormControl>
455
- <FormDescription>Number of academic credit hours for this placement.</FormDescription>
456
- <FormMessage />
457
- </FormItem>
458
- )} />
459
-
460
- </div>
461
- </div>
462
- )
463
- }
464
-
465
- // ── Step 3 — Schedule ─────────────────────────────────────────────────────────
466
-
467
- function Step3() {
468
- const form = useFormContext<PlacementFormValues>()
469
- return (
470
- <div>
471
- <SectionHeading icon="fa-calendar-days" title="Schedule" description="Define the placement timeline, hours, and work arrangement." />
472
- <div className="space-y-5">
473
-
474
- {/* Start + End date — using Calendar+Popover (date-picker guideline) */}
475
- <div className="grid grid-cols-2 gap-4">
476
- <FormField control={form.control} name="startDate" render={({ field }) => (
477
- <FormItem>
478
- <FormLabel>Start Date <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
479
- <FormControl>
480
- <DatePickerField value={field.value} onChange={field.onChange} />
481
- </FormControl>
482
- <FormDescription>MM/DD/YYYY</FormDescription>
483
- <FormMessage />
484
- </FormItem>
485
- )} />
486
- <FormField control={form.control} name="endDate" render={({ field }) => (
487
- <FormItem>
488
- <FormLabel>End Date <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
489
- <FormControl>
490
- <DatePickerField value={field.value} onChange={field.onChange} />
491
- </FormControl>
492
- <FormDescription>MM/DD/YYYY</FormDescription>
493
- <FormMessage />
494
- </FormItem>
495
- )} />
496
- </div>
497
-
498
- <div className="grid grid-cols-2 gap-4">
499
- <FormField control={form.control} name="duration" render={({ field }) => (
500
- <FormItem>
501
- <FormLabel>Duration <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
502
- <Select value={field.value} onValueChange={field.onChange}>
503
- <FormControl>
504
- <SelectTrigger aria-required="true"><SelectValue placeholder="Select duration" /></SelectTrigger>
505
- </FormControl>
506
- <SelectContent>
507
- {DURATIONS.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
508
- </SelectContent>
509
- </Select>
510
- <FormMessage />
511
- </FormItem>
512
- )} />
513
- <FormField control={form.control} name="hoursPerWeek" render={({ field }) => (
514
- <FormItem>
515
- <FormLabel>Hours per week <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
516
- <FormControl>
517
- <Input type="number" min={1} max={80} placeholder="40" {...field} aria-required="true" />
518
- </FormControl>
519
- <FormDescription>hrs/wk</FormDescription>
520
- <FormMessage />
521
- </FormItem>
522
- )} />
523
- </div>
524
-
525
- <FormField control={form.control} name="shift" render={({ field }) => (
526
- <FormItem>
527
- <FormLabel>Shift <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
528
- <Select value={field.value} onValueChange={field.onChange}>
529
- <FormControl>
530
- <SelectTrigger aria-required="true"><SelectValue placeholder="Select a shift" /></SelectTrigger>
531
- </FormControl>
532
- <SelectContent>
533
- {SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
534
- </SelectContent>
535
- </Select>
536
- <FormMessage />
537
- </FormItem>
538
- )} />
539
-
540
- <FormField control={form.control} name="totalHours" render={({ field }) => (
541
- <FormItem>
542
- <FormLabel>Total required hours <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
543
- <FormControl>
544
- <Input type="number" min={1} placeholder="480" className="w-40" {...field} />
545
- </FormControl>
546
- <FormDescription>Minimum hours the student must complete.</FormDescription>
547
- <FormMessage />
548
- </FormItem>
549
- )} />
550
-
551
- <FormField control={form.control} name="remoteOption" render={({ field }) => (
552
- <FormItem>
553
- <FormLabel>Work arrangement <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
554
- <FormControl>
555
- <RadioGroup
556
- value={field.value}
557
- onValueChange={field.onChange}
558
- className="flex gap-3 mt-1"
559
- aria-required="true"
560
- itemVariant="outline"
561
- itemMotion="pop"
562
- >
563
- {([
564
- { value: "on-site", label: "On-site", icon: "fa-building" },
565
- { value: "remote", label: "Remote", icon: "fa-house-laptop" },
566
- { value: "hybrid", label: "Hybrid", icon: "fa-circle-half-stroke" },
567
- ] as const).map(opt => (
568
- <RadioGroupLabel
569
- key={opt.value}
570
- htmlFor={`mode-${opt.value}`}
571
- 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"
572
- >
573
- <RadioGroupItem value={opt.value} id={`mode-${opt.value}`} />
574
- <i className={`fa-light ${opt.icon} text-muted-foreground text-sm`} aria-hidden="true" />
575
- <span className="text-sm whitespace-nowrap">{opt.label}</span>
576
- </RadioGroupLabel>
577
- ))}
578
- </RadioGroup>
579
- </FormControl>
580
- <FormMessage />
581
- </FormItem>
582
- )} />
583
-
584
- <FormField control={form.control} name="onWeekends" render={({ field }) => (
585
- <FormItem>
586
- <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">
587
- <FormControl>
588
- <Checkbox id="on-weekends" checked={field.value} onCheckedChange={field.onChange} />
589
- </FormControl>
590
- <div>
591
- <Label htmlFor="on-weekends" className="text-sm font-medium cursor-pointer leading-none">
592
- Includes weekend hours
593
- </Label>
594
- <p className="text-xs text-muted-foreground mt-1">Student may be scheduled on Saturdays or Sundays.</p>
595
- </div>
596
- </div>
597
- <FormMessage />
598
- </FormItem>
599
- )} />
600
-
601
- </div>
602
- </div>
603
- )
604
- }
605
-
606
- // ── Step 4 — Supervisor & Compliance ─────────────────────────────────────────
607
-
608
- const COMPLIANCE_ITEMS = [
609
- { name: "backgroundCheck" as const, label: "Background check completed", desc: "Student has passed required background screening." },
610
- { name: "immunizationOk" as const, label: "Immunization records verified", desc: "All required vaccinations are on file." },
611
- { name: "hipaaTraining" as const, label: "HIPAA training completed", desc: "Student has completed HIPAA privacy training." },
612
- ]
613
-
614
- function Step4() {
615
- const form = useFormContext<PlacementFormValues>()
616
- return (
617
- <div>
618
- <SectionHeading icon="fa-user-tie" title="Supervisor & Compliance" description="Assign a supervisor and confirm compliance requirements." />
619
- <div className="space-y-5">
620
-
621
- <FormField control={form.control} name="supervisor" render={({ field }) => (
622
- <FormItem>
623
- <FormLabel>Primary supervisor <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
624
- <Select value={field.value} onValueChange={field.onChange}>
625
- <FormControl>
626
- <SelectTrigger aria-required="true"><SelectValue placeholder="Select a supervisor" /></SelectTrigger>
627
- </FormControl>
628
- <SelectContent>
629
- {SUPERVISORS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
630
- </SelectContent>
631
- </Select>
632
- <FormMessage />
633
- </FormItem>
634
- )} />
635
-
636
- <div className="grid grid-cols-2 gap-4">
637
- <FormField control={form.control} name="supervisorEmail" render={({ field }) => (
638
- <FormItem>
639
- <FormLabel>Supervisor email <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
640
- <FormControl>
641
- <Input type="email" placeholder="supervisor@hospital.org" {...field} />
642
- </FormControl>
643
- <FormMessage />
644
- </FormItem>
645
- )} />
646
- <FormField control={form.control} name="supervisorPhone" render={({ field }) => (
647
- <FormItem>
648
- <FormLabel>Supervisor phone <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
649
- <FormControl>
650
- <PhoneMaskedInput field={field} />
651
- </FormControl>
652
- <FormMessage />
653
- </FormItem>
654
- )} />
655
- </div>
656
-
657
- <FormField control={form.control} name="preceptor" render={({ field }) => (
658
- <FormItem>
659
- <FormLabel>Preceptor / secondary supervisor <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
660
- <FormControl><Input placeholder="On-site preceptor name" {...field} /></FormControl>
661
- <FormMessage />
662
- </FormItem>
663
- )} />
664
-
665
- <FormField control={form.control} name="objectives" render={({ field }) => (
666
- <FormItem>
667
- <FormLabel>Learning objectives <span className="text-destructive" aria-hidden="true">*</span></FormLabel>
668
- <FormControl>
669
- <Textarea
670
- placeholder="Describe the primary learning goals for this placement…"
671
- className="min-h-[100px] resize-y"
672
- {...field}
673
- aria-required="true"
674
- />
675
- </FormControl>
676
- <FormDescription>Competencies and skills the student should develop.</FormDescription>
677
- <FormMessage />
678
- </FormItem>
679
- )} />
680
-
681
- <FormField control={form.control} name="specialRequirements" render={({ field }) => (
682
- <FormItem>
683
- <FormLabel>Special requirements <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
684
- <FormControl>
685
- <Textarea
686
- placeholder="Dress code, languages, certifications, etc."
687
- className="min-h-[80px] resize-y"
688
- {...field}
689
- />
690
- </FormControl>
691
- <FormMessage />
692
- </FormItem>
693
- )} />
694
-
695
- {/* Compliance checklist */}
696
- <fieldset className="space-y-2">
697
- <legend className="text-sm font-medium text-foreground">Compliance checklist</legend>
698
- {COMPLIANCE_ITEMS.map(item => (
699
- <FormField key={item.name} control={form.control} name={item.name} render={({ field }) => (
700
- <FormItem>
701
- <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">
702
- <FormControl>
703
- <Checkbox
704
- id={item.name}
705
- checked={field.value as boolean}
706
- onCheckedChange={field.onChange}
707
- className="mt-0.5 shrink-0"
708
- />
709
- </FormControl>
710
- <div className="min-w-0">
711
- <Label htmlFor={item.name} className="text-sm font-medium cursor-pointer leading-none">{item.label}</Label>
712
- <p className="text-xs text-muted-foreground mt-1">{item.desc}</p>
713
- </div>
714
- </div>
715
- <FormMessage />
716
- </FormItem>
717
- )} />
718
- ))}
719
- </fieldset>
720
-
721
- <FormField control={form.control} name="notes" render={({ field }) => (
722
- <FormItem>
723
- <FormLabel>Internal notes <span className="text-muted-foreground font-normal text-xs">(optional)</span></FormLabel>
724
- <FormControl>
725
- <Textarea
726
- placeholder="Any internal notes visible only to coordinators…"
727
- className="min-h-[80px] resize-y"
728
- {...field}
729
- />
730
- </FormControl>
731
- <FormMessage />
732
- </FormItem>
733
- )} />
734
-
735
- </div>
736
- </div>
737
- )
738
- }
739
-
740
- // ── Step 5 — Review ───────────────────────────────────────────────────────────
741
-
742
- function ReviewRow({ label, value }: { label: string; value: React.ReactNode }) {
743
- return (
744
- <div className="flex justify-between gap-4 py-2 border-b border-border last:border-0">
745
- <dt className="text-sm text-muted-foreground shrink-0 w-40">{label}</dt>
746
- <dd className="text-sm text-foreground text-right break-all">
747
- {value || <span className="text-muted-foreground italic">—</span>}
748
- </dd>
749
- </div>
750
- )
751
- }
752
-
753
- function ReviewSection({
754
- title, icon, onEdit, children,
755
- }: { title: string; icon: string; onEdit: () => void; children: React.ReactNode }) {
756
- return (
757
- <Card size="sm">
758
- <CardHeader className="border-b">
759
- <CardTitle className="flex items-center gap-2">
760
- <i className={`fa-light ${icon} text-sm text-muted-foreground`} aria-hidden="true" />
761
- <span>{title}</span>
762
- </CardTitle>
763
- <CardAction>
764
- <Button
765
- type="button"
766
- variant="ghost"
767
- size="sm"
768
- className="text-xs h-7 gap-1.5"
769
- onClick={onEdit}
770
- aria-label={`Edit ${title}`}
771
- >
772
- <i className="fa-light fa-pen text-xs" aria-hidden="true" />
773
- Edit
774
- </Button>
775
- </CardAction>
776
- </CardHeader>
777
- <CardContent>
778
- <dl>{children}</dl>
779
- </CardContent>
780
- </Card>
781
- )
782
- }
783
-
784
- function Step5({
785
- data,
786
- goToStep,
787
- }: {
788
- data: Partial<PlacementFormValues>
789
- goToStep: (n: number) => void
790
- }) {
791
- return (
792
- <div>
793
- <SectionHeading icon="fa-clipboard-check" title="Review & Submit" description="Review all details before creating the placement." />
794
- <div className="space-y-4">
795
-
796
- <ReviewSection title="Student Information" icon="fa-user-graduate" onEdit={() => goToStep(1)}>
797
- <ReviewRow label="Name" value={[data.firstName, data.lastName].filter(Boolean).join(" ")} />
798
- <ReviewRow label="Email" value={data.email} />
799
- <ReviewRow label="Student ID" value={data.studentId} />
800
- <ReviewRow label="Phone" value={data.phone} />
801
- <ReviewRow label="Program" value={data.program} />
802
- <ReviewRow label="Cohort" value={data.cohort} />
803
- <ReviewRow label="GPA" value={data.gpa} />
804
- </ReviewSection>
805
-
806
- <ReviewSection title="Site & Program" icon="fa-hospital" onEdit={() => goToStep(2)}>
807
- <ReviewRow label="Site" value={data.site} />
808
- <ReviewRow label="Department" value={data.department} />
809
- <ReviewRow label="Address" value={data.siteAddress} />
810
- <ReviewRow label="Site Contact" value={data.siteContact} />
811
- <ReviewRow label="Rotation" value={data.rotation ? data.rotation.charAt(0).toUpperCase() + data.rotation.slice(1) : undefined} />
812
- <ReviewRow label="Credit Hours" value={data.creditHours} />
813
- </ReviewSection>
814
-
815
- <ReviewSection title="Schedule" icon="fa-calendar-days" onEdit={() => goToStep(3)}>
816
- <ReviewRow label="Start Date" value={data.startDate ? formatDateFromDate(data.startDate) : undefined} />
817
- <ReviewRow label="End Date" value={data.endDate ? formatDateFromDate(data.endDate) : undefined} />
818
- <ReviewRow label="Duration" value={data.duration} />
819
- <ReviewRow label="Hours / Week" value={data.hoursPerWeek ? `${data.hoursPerWeek} hrs` : undefined} />
820
- <ReviewRow label="Shift" value={data.shift} />
821
- <ReviewRow label="Arrangement" value={data.remoteOption} />
822
- <ReviewRow label="Weekends" value={data.onWeekends ? "Yes" : "No"} />
823
- </ReviewSection>
824
-
825
- <ReviewSection title="Supervisor & Compliance" icon="fa-user-tie" onEdit={() => goToStep(4)}>
826
- <ReviewRow label="Supervisor" value={data.supervisor} />
827
- <ReviewRow label="Supervisor Email" value={data.supervisorEmail} />
828
- <ReviewRow label="Preceptor" value={data.preceptor} />
829
- <ReviewRow label="Background ✓" value={data.backgroundCheck ? "Yes" : "No"} />
830
- <ReviewRow label="Immunization ✓" value={data.immunizationOk ? "Yes" : "No"} />
831
- <ReviewRow label="HIPAA ✓" value={data.hipaaTraining ? "Yes" : "No"} />
832
- </ReviewSection>
833
-
834
- <p className="text-xs text-muted-foreground text-center pt-1">
835
- By submitting, you confirm all details are accurate and the student has been notified.
836
- </p>
837
- </div>
838
- </div>
839
- )
840
- }
841
-
842
- // ── Main component ────────────────────────────────────────────────────────────
843
-
844
- export function NewPlacementForm() {
845
- const router = useRouter()
846
- const [step, setStep] = React.useState(0)
847
- const [submitting, setSubmitting] = React.useState(false)
848
-
849
- const form = useForm<PlacementFormValues>({
850
- resolver: zodResolver(placementSchema) as Resolver<PlacementFormValues>,
851
- mode: "onTouched",
852
- defaultValues: {
853
- firstName: "", lastName: "", email: "", studentId: "", phone: "",
854
- program: "", cohort: "", gpa: "",
855
- site: "", department: "", siteAddress: "", siteContact: "", sitePhone: "",
856
- rotation: undefined, creditHours: "",
857
- startDate: undefined, endDate: undefined,
858
- duration: "", hoursPerWeek: "", shift: "", totalHours: "",
859
- remoteOption: undefined, onWeekends: false,
860
- supervisor: "", supervisorEmail: "", supervisorPhone: "",
861
- preceptor: "", objectives: "", specialRequirements: "",
862
- backgroundCheck: false, immunizationOk: false, hipaaTraining: false, notes: "",
863
- },
864
- })
865
-
866
- const goToStep = React.useCallback((n: number) => setStep(n), [])
867
-
868
- const handleNext = React.useCallback(async (): Promise<boolean> => {
869
- const fields = STEP_FIELDS[step]
870
- if (!fields) return true
871
- return await form.trigger(fields)
872
- }, [form, step])
873
-
874
- const handleSubmit = React.useCallback(async (values: PlacementFormValues) => {
875
- setSubmitting(true)
876
- await new Promise(r => setTimeout(r, 1400))
877
- devLog("New placement:", values)
878
- setSubmitting(false)
879
- router.push("/data-list")
880
- }, [router])
881
-
882
- const handleFormSubmit = React.useCallback(
883
- () => form.handleSubmit(handleSubmit)(),
884
- [form, handleSubmit],
885
- )
886
-
887
- // `useWatch` is memoization-friendly (returns a stable reactive value)
888
- // unlike `form.watch()`, which the React Compiler can't memoize safely.
889
- const formData = useWatch({ control: form.control })
890
-
891
- const steps: NewFocusStep[] = React.useMemo(
892
- () => [
893
- { id: "student", label: "Student", icon: "fa-user-graduate", render: () => <Step1 /> },
894
- { id: "site", label: "Site", icon: "fa-hospital", render: () => <Step2 /> },
895
- { id: "schedule", label: "Schedule", icon: "fa-calendar-days", render: () => <Step3 /> },
896
- { id: "supervisor", label: "Supervisor", icon: "fa-user-tie", render: () => <Step4 /> },
897
- { id: "review", label: "Review", icon: "fa-clipboard-check", render: () => <Step5 data={formData} goToStep={goToStep} /> },
898
- ],
899
- [formData, goToStep],
900
- )
901
- const lastIndex = steps.length - 1
902
-
903
- return (
904
- <Form {...form}>
905
- {/*
906
- Implicit submit only fires on the final step (Review) — Enter on earlier steps
907
- is ignored so the review never auto-closes when users hit Enter inside an input
908
- on steps before the last.
909
- */}
910
- {/*
911
- `<form>` participates in the (app)/layout flex row alongside the sidebar +
912
- secondary panel + Ask Leo rail, so it MUST behave like a normal flex column
913
- host (flex-1 + min-w-0). Without these classes the form shrinks to its
914
- intrinsic content width and the page collapses into a narrow column on the
915
- left with the rest of the viewport empty.
916
- */}
917
- <form
918
- onSubmit={(e) => {
919
- if (step !== lastIndex) { e.preventDefault(); return }
920
- form.handleSubmit(handleSubmit)(e)
921
- }}
922
- noValidate
923
- aria-label="New placement form"
924
- className="flex min-h-0 min-w-0 flex-1 flex-col"
925
- >
926
- <NewFocusTemplate
927
- variant="workflow"
928
- title="New record"
929
- description="Multi-step wizard shell (demo fields) — swap the form for your product flow."
930
- back={{ href: "/data-list", label: "Back", ariaLabel: "Back to list hub" }}
931
- steps={steps}
932
- step={step}
933
- onStepChange={setStep}
934
- onNext={handleNext}
935
- onSubmit={handleFormSubmit}
936
- submitLabel="Create placement"
937
- submitting={submitting}
938
- />
939
- </form>
940
- </Form>
941
- )
942
- }