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