From a287a97fe4d2ec293fd2798cce5d5943426e29c5 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Tue, 7 Apr 2026 07:54:35 +0530 Subject: [PATCH] feat(onboarding/phase-5): wire real forms into the setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Phase 2 StepPlaceholder with six dedicated wizard step components, each wrapping the corresponding Phase 3/4 form. The parent setup-wizard.tsx is now a thin dispatcher that owns shell state + markSetupStepComplete wiring; each step owns its own data load, form state, validation, and save action. - src/components/setup/wizard-step-types.ts — shared WizardStepComponentProps shape - src/components/setup/wizard-step-identity.tsx — minimal brand form (hospital name + logo URL) hitting /api/config/theme, links out to /branding for full customisation - src/components/setup/wizard-step-clinics.tsx — ClinicForm + createClinic mutation, always presents an empty "add new" form - src/components/setup/wizard-step-doctors.tsx — DoctorForm with clinic dropdown, blocks with an inline warning when no clinics exist yet - src/components/setup/wizard-step-team.tsx — InviteMemberForm with real roles fetched from getRoles, sends invitations via sendInvitations - src/components/setup/wizard-step-telephony.tsx — loads masked config from /api/config/telephony, validates required Ozonetel fields on save - src/components/setup/wizard-step-ai.tsx — loads AI config, clamps temperature 0..2, doesn't auto-advance (last step, admin taps Finish) - src/pages/setup-wizard.tsx — dispatches to the right step component based on activeStep, passes a WizardStepComponentProps bundle Each step calls onComplete(step) after a successful save, which updates the shared SetupState so the left-nav badges reflect the new status immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/setup/wizard-step-ai.tsx | 84 ++++++++++++ src/components/setup/wizard-step-clinics.tsx | 68 ++++++++++ src/components/setup/wizard-step-doctors.tsx | 101 +++++++++++++++ src/components/setup/wizard-step-identity.tsx | 120 ++++++++++++++++++ src/components/setup/wizard-step-team.tsx | 100 +++++++++++++++ .../setup/wizard-step-telephony.tsx | 102 +++++++++++++++ src/components/setup/wizard-step-types.ts | 20 +++ src/pages/setup-wizard.tsx | 105 ++++++++------- 8 files changed, 646 insertions(+), 54 deletions(-) create mode 100644 src/components/setup/wizard-step-ai.tsx create mode 100644 src/components/setup/wizard-step-clinics.tsx create mode 100644 src/components/setup/wizard-step-doctors.tsx create mode 100644 src/components/setup/wizard-step-identity.tsx create mode 100644 src/components/setup/wizard-step-team.tsx create mode 100644 src/components/setup/wizard-step-telephony.tsx create mode 100644 src/components/setup/wizard-step-types.ts diff --git a/src/components/setup/wizard-step-ai.tsx b/src/components/setup/wizard-step-ai.tsx new file mode 100644 index 0000000..e75947d --- /dev/null +++ b/src/components/setup/wizard-step-ai.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react'; +import { WizardStep } from './wizard-step'; +import { AiForm, emptyAiFormValues, type AiFormValues, type AiProvider } from '@/components/forms/ai-form'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import type { WizardStepComponentProps } from './wizard-step-types'; + +type ServerAiConfig = { + provider?: AiProvider; + model?: string; + temperature?: number; + systemPromptAddendum?: string; +}; + +// AI step — loads the current AI config, lets the admin pick provider and +// model, and saves. This is the last step, so on save we fire the finish +// flow instead of advancing. +export const WizardStepAi = (props: WizardStepComponentProps) => { + const [values, setValues] = useState(emptyAiFormValues); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + apiClient + .get('/api/config/ai', { silent: true }) + .then((data) => { + setValues({ + provider: data.provider ?? 'openai', + model: data.model ?? 'gpt-4o-mini', + temperature: data.temperature != null ? String(data.temperature) : '0.7', + systemPromptAddendum: data.systemPromptAddendum ?? '', + }); + }) + .catch(() => { + // non-fatal — defaults will do + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + if (!values.model.trim()) { + notify.error('Model is required'); + return; + } + const temperature = Number(values.temperature); + setSaving(true); + try { + await apiClient.put('/api/config/ai', { + provider: values.provider, + model: values.model.trim(), + temperature: Number.isNaN(temperature) ? 0.7 : Math.min(2, Math.max(0, temperature)), + systemPromptAddendum: values.systemPromptAddendum, + }); + notify.success('AI settings saved', 'Your assistant is ready.'); + await props.onComplete('ai'); + // Don't auto-advance — this is the last step, the WizardStep + // shell already renders a "Finish setup" button the admin taps + // themselves. + } catch (err) { + console.error('[wizard/ai] save failed', err); + } finally { + setSaving(false); + } + }; + + return ( + + {loading ? ( +

Loading AI settings…

+ ) : ( + + )} +
+ ); +}; diff --git a/src/components/setup/wizard-step-clinics.tsx b/src/components/setup/wizard-step-clinics.tsx new file mode 100644 index 0000000..d9fdd8a --- /dev/null +++ b/src/components/setup/wizard-step-clinics.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { WizardStep } from './wizard-step'; +import { + ClinicForm, + clinicFormToGraphQLInput, + emptyClinicFormValues, + type ClinicFormValues, +} from '@/components/forms/clinic-form'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import type { WizardStepComponentProps } from './wizard-step-types'; + +// Clinic step — presents a single-clinic form. On save, creates the clinic, +// marks the step complete, and advances. The admin can come back to +// /settings/clinics later to add more branches or edit existing ones. +// +// We don't pre-load the existing clinic list here because we always want the +// form to represent "add a new clinic"; the list page is the right surface +// for editing. +export const WizardStepClinics = (props: WizardStepComponentProps) => { + const [values, setValues] = useState(emptyClinicFormValues); + const [saving, setSaving] = useState(false); + + const handleSave = async () => { + if (!values.clinicName.trim()) { + notify.error('Clinic name is required'); + return; + } + setSaving(true); + try { + await apiClient.graphql( + `mutation CreateClinic($data: ClinicCreateInput!) { + createClinic(data: $data) { id } + }`, + { data: clinicFormToGraphQLInput(values) }, + ); + notify.success('Clinic added', values.clinicName); + await props.onComplete('clinics'); + setValues(emptyClinicFormValues()); + props.onAdvance(); + } catch (err) { + console.error('[wizard/clinics] save failed', err); + } finally { + setSaving(false); + } + }; + + return ( + + {props.isCompleted && ( +
+ You've already added at least one clinic. Fill the form again to add another, or click{' '} + Next to continue. +
+ )} + +
+ ); +}; diff --git a/src/components/setup/wizard-step-doctors.tsx b/src/components/setup/wizard-step-doctors.tsx new file mode 100644 index 0000000..de43983 --- /dev/null +++ b/src/components/setup/wizard-step-doctors.tsx @@ -0,0 +1,101 @@ +import { useEffect, useMemo, useState } from 'react'; +import { WizardStep } from './wizard-step'; +import { + DoctorForm, + doctorFormToGraphQLInput, + emptyDoctorFormValues, + type DoctorFormValues, +} from '@/components/forms/doctor-form'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import type { WizardStepComponentProps } from './wizard-step-types'; + +// Doctor step — mirrors the clinics step but also fetches the clinic list +// for the DoctorForm's clinic dropdown. If there are no clinics yet we let +// the admin know they need to complete step 2 first (the wizard doesn't +// force ordering, but a doctor without a clinic is useless). + +type ClinicLite = { id: string; clinicName: string | null }; + +export const WizardStepDoctors = (props: WizardStepComponentProps) => { + const [values, setValues] = useState(emptyDoctorFormValues); + const [clinics, setClinics] = useState([]); + const [loadingClinics, setLoadingClinics] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + apiClient + .graphql<{ clinics: { edges: { node: ClinicLite }[] } }>( + `{ clinics(first: 100) { edges { node { id clinicName } } } }`, + undefined, + { silent: true }, + ) + .then((data) => setClinics(data.clinics.edges.map((e) => e.node))) + .catch(() => setClinics([])) + .finally(() => setLoadingClinics(false)); + }, []); + + const clinicOptions = useMemo( + () => clinics.map((c) => ({ id: c.id, label: c.clinicName ?? 'Unnamed clinic' })), + [clinics], + ); + + const handleSave = async () => { + if (!values.firstName.trim() || !values.lastName.trim()) { + notify.error('First and last name are required'); + return; + } + setSaving(true); + try { + await apiClient.graphql( + `mutation CreateDoctor($data: DoctorCreateInput!) { + createDoctor(data: $data) { id } + }`, + { data: doctorFormToGraphQLInput(values) }, + ); + notify.success('Doctor added', `Dr. ${values.firstName} ${values.lastName}`); + await props.onComplete('doctors'); + setValues(emptyDoctorFormValues()); + props.onAdvance(); + } catch (err) { + console.error('[wizard/doctors] save failed', err); + } finally { + setSaving(false); + } + }; + + return ( + + {loadingClinics ? ( +

Loading clinics…

+ ) : clinics.length === 0 ? ( +
+

Add a clinic first

+

+ You need at least one clinic before you can assign doctors. Go back to the{' '} + Clinics step and add a branch first. +

+
+ ) : ( + <> + {props.isCompleted && ( +
+ You've already added at least one doctor. Fill the form again to add another, or + click Next to continue. +
+ )} + + + )} +
+ ); +}; diff --git a/src/components/setup/wizard-step-identity.tsx b/src/components/setup/wizard-step-identity.tsx new file mode 100644 index 0000000..b2b5ae3 --- /dev/null +++ b/src/components/setup/wizard-step-identity.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router'; +import { Input } from '@/components/base/input/input'; +import { WizardStep } from './wizard-step'; +import { notify } from '@/lib/toast'; +import type { WizardStepComponentProps } from './wizard-step-types'; + +// Minimal identity step — just the two most important fields (hospital name +// and logo URL). Full branding (colors, fonts, login copy) is handled on the +// /branding page and linked from here. Keeping the wizard lean means admins +// can clear setup in under ten minutes; the branding page is there whenever +// they want to polish further. + +const THEME_API_URL = + import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + +export const WizardStepIdentity = (props: WizardStepComponentProps) => { + const [hospitalName, setHospitalName] = useState(''); + const [logoUrl, setLogoUrl] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + fetch(`${THEME_API_URL}/api/config/theme`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (data?.brand) { + setHospitalName(data.brand.hospitalName ?? ''); + setLogoUrl(data.brand.logo ?? ''); + } + }) + .catch(() => { + // non-fatal — admin can fill in fresh values + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + if (!hospitalName.trim()) { + notify.error('Hospital name is required'); + return; + } + setSaving(true); + try { + const response = await fetch(`${THEME_API_URL}/api/config/theme`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + brand: { + hospitalName: hospitalName.trim(), + logo: logoUrl.trim() || undefined, + }, + }), + }); + if (!response.ok) throw new Error(`PUT /api/config/theme failed: ${response.status}`); + notify.success('Identity saved', 'Hospital name and logo updated.'); + await props.onComplete('identity'); + props.onAdvance(); + } catch (err) { + notify.error('Save failed', 'Could not update hospital identity. Please try again.'); + console.error('[wizard/identity] save failed', err); + } finally { + setSaving(false); + } + }; + + return ( + + {loading ? ( +

Loading current branding…

+ ) : ( +
+ + + {logoUrl && ( +
+ Preview: + Logo preview ((e.target as HTMLImageElement).style.display = 'none')} + /> +
+ )} +
+

+ Need to pick brand colors, fonts, or customise the login page copy? Open the full{' '} + + branding settings + {' '} + page after completing setup. +

+
+
+ )} +
+ ); +}; diff --git a/src/components/setup/wizard-step-team.tsx b/src/components/setup/wizard-step-team.tsx new file mode 100644 index 0000000..99424ac --- /dev/null +++ b/src/components/setup/wizard-step-team.tsx @@ -0,0 +1,100 @@ +import { useEffect, useState } from 'react'; +import { WizardStep } from './wizard-step'; +import { + InviteMemberForm, + type InviteMemberFormValues, + type RoleOption, +} from '@/components/forms/invite-member-form'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import type { WizardStepComponentProps } from './wizard-step-types'; + +// Team step — fetch roles from the platform and present the invite form. +// The admin types one or more emails and picks a role. sendInvitations +// fires, the backend emails them, and the wizard advances on success. +// +// Role assignment itself happens AFTER the invitee accepts (since we only +// have a workspaceMemberId once they've joined the workspace). For now we +// just send the invitations — the admin can finalise role assignments +// from /settings/team once everyone has accepted. +export const WizardStepTeam = (props: WizardStepComponentProps) => { + const [values, setValues] = useState({ emails: [], roleId: '' }); + const [roles, setRoles] = useState([]); + const [saving, setSaving] = useState(false); + + useEffect(() => { + apiClient + .graphql<{ + getRoles: { + id: string; + label: string; + description: string | null; + canBeAssignedToUsers: boolean; + }[]; + }>(`{ getRoles { id label description canBeAssignedToUsers } }`, undefined, { silent: true }) + .then((data) => + setRoles( + data.getRoles + .filter((r) => r.canBeAssignedToUsers) + .map((r) => ({ + id: r.id, + label: r.label, + supportingText: r.description ?? undefined, + })), + ), + ) + .catch(() => setRoles([])); + }, []); + + const handleSave = async () => { + if (values.emails.length === 0) { + notify.error('Add at least one email'); + return; + } + setSaving(true); + try { + await apiClient.graphql( + `mutation SendInvitations($emails: [String!]!) { + sendInvitations(emails: $emails) { success errors } + }`, + { emails: values.emails }, + ); + notify.success( + 'Invitations sent', + `${values.emails.length} invitation${values.emails.length === 1 ? '' : 's'} sent.`, + ); + await props.onComplete('team'); + setValues({ emails: [], roleId: '' }); + props.onAdvance(); + } catch (err) { + console.error('[wizard/team] invite failed', err); + } finally { + setSaving(false); + } + }; + + return ( + + {props.isCompleted && ( +
+ Invitations already sent. Add more emails below to invite additional members, or click{' '} + Next to continue. +
+ )} + +

+ Invited members receive an email with a link to set their password. Fine-tune role assignments + from the Team page after they join. +

+
+ ); +}; diff --git a/src/components/setup/wizard-step-telephony.tsx b/src/components/setup/wizard-step-telephony.tsx new file mode 100644 index 0000000..4c441b0 --- /dev/null +++ b/src/components/setup/wizard-step-telephony.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from 'react'; +import { WizardStep } from './wizard-step'; +import { + TelephonyForm, + emptyTelephonyFormValues, + type TelephonyFormValues, +} from '@/components/forms/telephony-form'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import type { WizardStepComponentProps } from './wizard-step-types'; + +// Telephony step — loads the existing masked config from the sidecar and +// lets the admin fill in the Ozonetel/SIP/Exotel credentials. On save, PUTs +// the full form (the backend treats '***masked***' as "no change") and +// marks the step complete. +// +// Unlike the entity steps, this is a single-doc config so we always load the +// current state rather than treating the form as "add new". + +export const WizardStepTelephony = (props: WizardStepComponentProps) => { + const [values, setValues] = useState(emptyTelephonyFormValues); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + apiClient + .get('/api/config/telephony', { silent: true }) + .then((data) => { + setValues({ + ozonetel: { + agentId: data.ozonetel?.agentId ?? '', + agentPassword: data.ozonetel?.agentPassword ?? '', + did: data.ozonetel?.did ?? '', + sipId: data.ozonetel?.sipId ?? '', + campaignName: data.ozonetel?.campaignName ?? '', + }, + sip: { + domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com', + wsPort: data.sip?.wsPort ?? '444', + }, + exotel: { + apiKey: data.exotel?.apiKey ?? '', + apiToken: data.exotel?.apiToken ?? '', + accountSid: data.exotel?.accountSid ?? '', + subdomain: data.exotel?.subdomain ?? 'api.exotel.com', + }, + }); + }) + .catch(() => { + // If the endpoint is unreachable, fall back to defaults so the + // admin can at least fill out the form. + }) + .finally(() => setLoading(false)); + }, []); + + const handleSave = async () => { + // Required fields for a working Ozonetel setup. + if ( + !values.ozonetel.agentId.trim() || + !values.ozonetel.did.trim() || + !values.ozonetel.sipId.trim() || + !values.ozonetel.campaignName.trim() + ) { + notify.error('Missing required fields', 'Agent ID, DID, SIP ID, and campaign name are all required.'); + return; + } + setSaving(true); + try { + await apiClient.put('/api/config/telephony', { + ozonetel: values.ozonetel, + sip: values.sip, + exotel: values.exotel, + }); + notify.success('Telephony saved', 'Changes are live — no restart needed.'); + await props.onComplete('telephony'); + props.onAdvance(); + } catch (err) { + console.error('[wizard/telephony] save failed', err); + } finally { + setSaving(false); + } + }; + + return ( + + {loading ? ( +

Loading telephony settings…

+ ) : ( + + )} +
+ ); +}; diff --git a/src/components/setup/wizard-step-types.ts b/src/components/setup/wizard-step-types.ts new file mode 100644 index 0000000..137d9ad --- /dev/null +++ b/src/components/setup/wizard-step-types.ts @@ -0,0 +1,20 @@ +import type { SetupStepName } from '@/lib/setup-state'; + +// Shared prop shape for every wizard step. The parent (setup-wizard.tsx) +// dispatches to the right component based on activeStep; each component +// handles its own data loading, form state, and save action, then calls +// onComplete + onAdvance when the user clicks "Mark complete". +export type WizardStepComponentProps = { + isCompleted: boolean; + isLast: boolean; + onPrev: (() => void) | null; + onNext: (() => void) | null; + // Called by each step after a successful save. Parent handles both the + // markSetupStepComplete API call AND the local state update so the left + // nav reflects the new completion immediately. + onComplete: (step: SetupStepName) => Promise; + // Move to the next step (used after a successful save, or directly via + // the Next button when the step is already complete). + onAdvance: () => void; + onFinish: () => void; +}; diff --git a/src/pages/setup-wizard.tsx b/src/pages/setup-wizard.tsx index 9ccc12f..8eda105 100644 --- a/src/pages/setup-wizard.tsx +++ b/src/pages/setup-wizard.tsx @@ -1,10 +1,15 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { WizardShell } from '@/components/setup/wizard-shell'; -import { WizardStep } from '@/components/setup/wizard-step'; +import { WizardStepIdentity } from '@/components/setup/wizard-step-identity'; +import { WizardStepClinics } from '@/components/setup/wizard-step-clinics'; +import { WizardStepDoctors } from '@/components/setup/wizard-step-doctors'; +import { WizardStepTeam } from '@/components/setup/wizard-step-team'; +import { WizardStepTelephony } from '@/components/setup/wizard-step-telephony'; +import { WizardStepAi } from '@/components/setup/wizard-step-ai'; +import type { WizardStepComponentProps } from '@/components/setup/wizard-step-types'; import { SETUP_STEP_NAMES, - SETUP_STEP_LABELS, type SetupState, type SetupStepName, getSetupState, @@ -15,34 +20,41 @@ import { notify } from '@/lib/toast'; import { useAuth } from '@/providers/auth-provider'; // Top-level onboarding wizard. Auto-shown by login.tsx redirect when the -// workspace has incomplete setup steps. Each step renders a placeholder card -// in Phase 2 — Phase 5 swaps the placeholders for real form components from -// the matching settings pages (clinics, doctors, team, telephony, ai). +// workspace has incomplete setup steps. // -// The wizard is functional even at placeholder level: each step has a -// "Mark complete" button that calls PUT /api/config/setup-state/steps/, -// which lets the operator click through and verify first-run detection + -// dismiss flow without waiting for Phase 5. +// Phase 5: each step is now backed by a real form component. The parent +// owns the shell navigation + per-step completion state, and passes a +// WizardStepComponentProps bundle to the dispatched child so the child +// can trigger save + mark-complete + advance from its own Save action. + +const STEP_COMPONENTS: Record React.ReactElement> = { + identity: WizardStepIdentity, + clinics: WizardStepClinics, + doctors: WizardStepDoctors, + team: WizardStepTeam, + telephony: WizardStepTelephony, + ai: WizardStepAi, +}; + export const SetupWizardPage = () => { const navigate = useNavigate(); const { user } = useAuth(); const [state, setState] = useState(null); const [activeStep, setActiveStep] = useState('identity'); const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); useEffect(() => { let cancelled = false; getSetupState() - .then(s => { + .then((s) => { if (cancelled) return; setState(s); - // Land on the first incomplete step so the operator picks - // up where they left off. - const firstIncomplete = SETUP_STEP_NAMES.find(name => !s.steps[name].completed); + // Land on the first incomplete step so the operator picks up + // where they left off. + const firstIncomplete = SETUP_STEP_NAMES.find((name) => !s.steps[name].completed); if (firstIncomplete) setActiveStep(firstIncomplete); }) - .catch(err => { + .catch((err) => { console.error('Failed to load setup state', err); notify.error('Setup', 'Could not load setup state. Please reload.'); }) @@ -67,20 +79,20 @@ export const SetupWizardPage = () => { const onPrev = activeIndex > 0 ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex - 1]) : null; const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null; - const handleMarkComplete = async () => { - setSaving(true); + const handleComplete = async (step: SetupStepName) => { try { - const updated = await markSetupStepComplete(activeStep, user?.email); + const updated = await markSetupStepComplete(step, user?.email); setState(updated); - notify.success('Step complete', SETUP_STEP_LABELS[activeStep].title); - // Auto-advance to next incomplete step (or stay if this was the last). - if (!isLastStep) { - setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]); - } - } catch { - notify.error('Setup', 'Could not save step status. Please try again.'); - } finally { - setSaving(false); + } catch (err) { + console.error('Failed to mark step complete', err); + // Non-fatal — the step's own save already succeeded. We just + // couldn't persist the wizard-state badge. + } + }; + + const handleAdvance = () => { + if (!isLastStep) { + setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]); } }; @@ -99,6 +111,17 @@ export const SetupWizardPage = () => { } }; + const StepComponent = STEP_COMPONENTS[activeStep]; + const stepProps: WizardStepComponentProps = { + isCompleted: state.steps[activeStep].completed, + isLast: isLastStep, + onPrev, + onNext, + onComplete: handleComplete, + onAdvance: handleAdvance, + onFinish: handleFinish, + }; + return ( { onSelectStep={setActiveStep} onDismiss={handleDismiss} > - - - + ); }; - -// Placeholder body for each step. Phase 5 will replace this dispatcher with -// real form components (clinic-form, doctor-form, invite-member-form, etc). -const StepPlaceholder = ({ step }: { step: SetupStepName }) => { - const meta = SETUP_STEP_LABELS[step]; - return ( -
-

Coming in Phase 5

-

- The {meta.title.toLowerCase()} form will live here. For now, click Mark complete below - to test the wizard flow end-to-end. -

-
- ); -};