@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.
- package/CHANGELOG.md +701 -6
- package/README.md +138 -0
- package/bin/init.mjs +134 -31
- package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
- package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
- package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
- package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
- package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
- package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
- package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
- package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
- package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
- package/consumer-extras/handbook/HANDBOOK.md +2 -0
- package/consumer-extras/handbook/glossary.md +2 -1
- package/consumer-extras/handbook/reference-implementations.md +31 -4
- package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
- package/consumer-extras/patterns/data-views-pattern.md +18 -16
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
- package/dist/components/data-table/index.js +2 -2
- package/dist/components/data-table/index.js.map +1 -1
- package/dist/components/data-table/pagination.js +3 -3
- package/dist/components/data-table/pagination.js.map +1 -1
- package/dist/components/data-table/use-table-state.d.ts +1 -1
- package/dist/components/data-table/use-table-state.js.map +1 -1
- package/dist/components/data-views/data-row-list.js.map +1 -1
- package/dist/components/data-views/finder-panel-view.d.ts +1 -1
- package/dist/components/data-views/finder-panel-view.js.map +1 -1
- package/dist/components/data-views/hub-table.d.ts +9 -3
- package/dist/components/data-views/hub-table.js +262 -40
- package/dist/components/data-views/hub-table.js.map +1 -1
- package/dist/components/data-views/index.js +262 -40
- package/dist/components/data-views/index.js.map +1 -1
- package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
- package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
- package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
- package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
- package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
- package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
- package/dist/components/data-views/os-folder-glyph.js.map +1 -1
- package/dist/components/ui/avatar.d.ts +1 -1
- package/dist/components/ui/key-metrics.js.map +1 -1
- package/dist/index.js +136 -39
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/components/data-table/index.tsx +2 -2
- package/src/components/data-table/pagination.tsx +5 -1
- package/src/components/data-table/use-table-state.ts +1 -1
- package/src/components/data-views/data-row-list.tsx +1 -1
- package/src/components/data-views/finder-panel-view.tsx +2 -2
- package/src/components/data-views/hub-table.tsx +149 -41
- package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
- package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/src/components/data-views/os-folder-glyph.tsx +1 -1
- package/src/components/ui/key-metrics.tsx +1 -1
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
- package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
- package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
- package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
- package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
- package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
- package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
- package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
- package/template/AGENTS.md +43 -37
- package/template/app/(app)/columns/page.tsx +11 -0
- package/template/app/(app)/library/all/page.tsx +11 -0
- package/template/app/(app)/library/find/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
- package/template/app/(app)/library/list/page.tsx +12 -0
- package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
- package/template/app/(app)/library/page.tsx +11 -0
- package/template/app/(app)/tokens-themes/page.tsx +11 -0
- package/template/components/ask-leo-composer.tsx +2 -2
- package/template/components/columns-client.tsx +158 -0
- package/template/components/columns-showcase.tsx +541 -0
- package/template/components/data-views/index.ts +32 -6
- package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
- package/template/components/data-views/table-cells.tsx +673 -0
- package/template/components/folder-details-shell.tsx +11 -11
- package/template/components/hub-tree-panel-view.tsx +24 -24
- package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
- package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
- package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
- package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
- package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
- package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
- package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
- package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
- package/template/components/library-panel-activator.tsx +8 -0
- package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
- package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
- package/template/components/list-hub-status-badge.tsx +2 -2
- package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
- package/template/components/sidebar/app-sidebar.tsx +61 -5
- package/template/components/sidebar/secondary-panel.tsx +109 -56
- package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
- package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
- package/template/components/table-properties/types.ts +1 -1
- package/template/components/templates/discovery-hub-template.tsx +1 -1
- package/template/components/templates/new-focus-template.tsx +2 -2
- package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
- package/template/components/tokens-secondary-nav.tsx +192 -0
- package/template/components/tokens-themes-client.tsx +476 -0
- package/template/components/tokens-themes-section.tsx +386 -0
- package/template/docs/HANDBOOK.md +187 -0
- package/template/docs/blueprints/README.md +1 -1
- package/template/docs/blueprints/board-card.md +1 -1
- package/template/docs/blueprints/data-table.md +2 -2
- package/template/docs/blueprints/list-page-template.md +3 -3
- package/template/docs/blueprints/page-header.md +4 -4
- package/template/docs/collaboration-access-pattern.md +7 -7
- package/template/docs/component-selection-guide.md +1 -1
- package/template/docs/data-views-pattern.md +18 -16
- package/template/docs/glossary.md +58 -0
- package/template/docs/kpi-flat-band-pattern.md +3 -3
- package/template/docs/kpi-trend-pattern.md +18 -3
- package/template/docs/large-dataset-strategy.md +155 -0
- package/template/docs/library-hub-header-pattern.md +25 -0
- package/template/docs/migrations/_template.md +1 -1
- package/template/docs/reference-implementations.md +151 -0
- package/template/docs/token-taxonomy.md +1 -1
- package/template/docs/voice-and-tone.md +262 -0
- package/template/eslint.config.mjs +9 -39
- package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
- package/template/lib/ask-leo-route-context.ts +6 -18
- package/template/lib/coach-mark-registry.ts +0 -16
- package/template/lib/command-menu-config.ts +5 -12
- package/template/lib/command-menu-search-data.ts +8 -39
- package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
- package/template/lib/library-dedicated-search.ts +19 -0
- package/template/lib/library-hub-search.ts +90 -0
- package/template/lib/library-nav.ts +477 -0
- package/template/lib/library-recent-searches.ts +22 -0
- package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
- package/template/lib/list-status-badges.ts +16 -104
- package/template/lib/mock/dashboard.ts +1 -1
- package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
- package/template/lib/mock/library-header-collaborators.ts +54 -0
- package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
- package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
- package/template/lib/mock/library.ts +249 -0
- package/template/lib/mock/navigation.tsx +32 -26
- package/template/lib/table-state-lifecycle.ts +1 -1
- package/template/next.config.mjs +7 -4
- package/template/package.json +0 -1
- package/tokens/hooks-index.json +2874 -0
- package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
- package/template/app/(app)/examples/page.tsx +0 -41
- package/template/app/(app)/question-bank/find/page.tsx +0 -12
- package/template/app/(app)/question-bank/library/page.tsx +0 -11
- package/template/app/(app)/question-bank/list/page.tsx +0 -12
- package/template/app/(app)/question-bank/page.tsx +0 -11
- package/template/components/compliance-board-view.tsx +0 -142
- package/template/components/compliance-client.tsx +0 -92
- package/template/components/compliance-page-header.tsx +0 -89
- package/template/components/compliance-table.tsx +0 -468
- package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
- package/template/components/data-view-dashboard-charts-team.tsx +0 -971
- package/template/components/data-view-dashboard-charts.tsx +0 -1503
- package/template/components/new-placement-back-btn.tsx +0 -28
- package/template/components/new-placement-form.tsx +0 -942
- package/template/components/placement-board-card.tsx +0 -250
- package/template/components/placement-detail.tsx +0 -438
- package/template/components/placements-board-view.tsx +0 -397
- package/template/components/placements-client.tsx +0 -220
- package/template/components/placements-list-view.tsx +0 -124
- package/template/components/placements-page-header.tsx +0 -166
- package/template/components/placements-table-cells.test.tsx +0 -22
- package/template/components/placements-table-cells.tsx +0 -173
- package/template/components/placements-table-columns.tsx +0 -210
- package/template/components/placements-table.tsx +0 -934
- package/template/components/question-bank-panel-activator.tsx +0 -8
- package/template/components/rotations-empty-state.tsx +0 -50
- package/template/components/rotations-panel-activator.tsx +0 -8
- package/template/components/sites-board-view.tsx +0 -67
- package/template/components/sites-client.tsx +0 -154
- package/template/components/sites-table.tsx +0 -249
- package/template/components/team-board-view.tsx +0 -122
- package/template/components/team-client.tsx +0 -100
- package/template/components/team-page-header.tsx +0 -92
- package/template/components/team-table.tsx +0 -553
- package/template/docs/question-bank-hub-header-pattern.md +0 -25
- package/template/lib/compliance-supported-views.ts +0 -10
- package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
- package/template/lib/mock/compliance-kpi.ts +0 -61
- package/template/lib/mock/compliance.ts +0 -146
- package/template/lib/mock/placements-kpi.ts +0 -134
- package/template/lib/mock/placements.ts +0 -176
- package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
- package/template/lib/mock/question-bank.ts +0 -249
- package/template/lib/mock/sites-directory.ts +0 -16
- package/template/lib/mock/sites-kpi.ts +0 -25
- package/template/lib/mock/team-kpi.ts +0 -60
- package/template/lib/mock/team.ts +0 -118
- package/template/lib/placement-board-card-layout.ts +0 -79
- package/template/lib/question-bank-dedicated-search.ts +0 -19
- package/template/lib/question-bank-hub-search.ts +0 -90
- package/template/lib/question-bank-nav.ts +0 -477
- package/template/lib/question-bank-recent-searches.ts +0 -22
- package/template/lib/question-bank-supported-views.ts +0 -12
- package/template/lib/sites-supported-views.ts +0 -10
- 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'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
|
-
}
|