feat(onboarding/phase-5): wire real forms into the setup wizard

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 07:54:35 +05:30
parent a7b2fd7fbe
commit a287a97fe4
8 changed files with 646 additions and 54 deletions

View File

@@ -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<AiFormValues>(emptyAiFormValues);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
apiClient
.get<ServerAiConfig>('/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 (
<WizardStep
step="ai"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
>
{loading ? (
<p className="text-sm text-tertiary">Loading AI settings</p>
) : (
<AiForm value={values} onChange={setValues} />
)}
</WizardStep>
);
};

View File

@@ -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<ClinicFormValues>(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 (
<WizardStep
step="clinics"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
>
{props.isCompleted && (
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
You've already added at least one clinic. Fill the form again to add another, or click{' '}
<b>Next</b> to continue.
</div>
)}
<ClinicForm value={values} onChange={setValues} />
</WizardStep>
);
};

View File

@@ -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<DoctorFormValues>(emptyDoctorFormValues);
const [clinics, setClinics] = useState<ClinicLite[]>([]);
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 (
<WizardStep
step="doctors"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
>
{loadingClinics ? (
<p className="text-sm text-tertiary">Loading clinics</p>
) : clinics.length === 0 ? (
<div className="rounded-lg border border-dashed border-warning bg-warning-primary p-4">
<p className="text-sm font-semibold text-warning-primary">Add a clinic first</p>
<p className="mt-1 text-xs text-tertiary">
You need at least one clinic before you can assign doctors. Go back to the{' '}
<b>Clinics</b> step and add a branch first.
</p>
</div>
) : (
<>
{props.isCompleted && (
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
You've already added at least one doctor. Fill the form again to add another, or
click <b>Next</b> to continue.
</div>
)}
<DoctorForm value={values} onChange={setValues} clinics={clinicOptions} />
</>
)}
</WizardStep>
);
};

View File

@@ -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 (
<WizardStep
step="identity"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
>
{loading ? (
<p className="text-sm text-tertiary">Loading current branding</p>
) : (
<div className="flex flex-col gap-5">
<Input
label="Hospital name"
isRequired
placeholder="e.g. Ramaiah Memorial Hospital"
value={hospitalName}
onChange={setHospitalName}
/>
<Input
label="Logo URL"
placeholder="https://yourhospital.com/logo.png"
hint="Paste a URL to your hospital logo. Square images work best."
value={logoUrl}
onChange={setLogoUrl}
/>
{logoUrl && (
<div className="flex items-center gap-3 rounded-lg border border-secondary bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary">Preview:</span>
<img
src={logoUrl}
alt="Logo preview"
className="size-10 rounded-lg border border-secondary bg-primary object-contain p-1"
onError={(e) => ((e.target as HTMLImageElement).style.display = 'none')}
/>
</div>
)}
<div className="rounded-lg border border-dashed border-secondary bg-secondary p-4">
<p className="text-xs text-tertiary">
Need to pick brand colors, fonts, or customise the login page copy? Open the full{' '}
<Link to="/branding" className="font-semibold text-brand-primary underline">
branding settings
</Link>{' '}
page after completing setup.
</p>
</div>
</div>
)}
</WizardStep>
);
};

View File

@@ -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<InviteMemberFormValues>({ emails: [], roleId: '' });
const [roles, setRoles] = useState<RoleOption[]>([]);
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 (
<WizardStep
step="team"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
>
{props.isCompleted && (
<div className="mb-5 rounded-lg border border-secondary bg-secondary p-4 text-xs text-tertiary">
Invitations already sent. Add more emails below to invite additional members, or click{' '}
<b>Next</b> to continue.
</div>
)}
<InviteMemberForm value={values} onChange={setValues} roles={roles} />
<p className="mt-4 text-xs text-tertiary">
Invited members receive an email with a link to set their password. Fine-tune role assignments
from the Team page after they join.
</p>
</WizardStep>
);
};

View File

@@ -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<TelephonyFormValues>(emptyTelephonyFormValues);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
apiClient
.get<TelephonyFormValues>('/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 (
<WizardStep
step="telephony"
isCompleted={props.isCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
>
{loading ? (
<p className="text-sm text-tertiary">Loading telephony settings</p>
) : (
<TelephonyForm value={values} onChange={setValues} />
)}
</WizardStep>
);
};

View File

@@ -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<void>;
// 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;
};

View File

@@ -1,10 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { WizardShell } from '@/components/setup/wizard-shell'; 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 { import {
SETUP_STEP_NAMES, SETUP_STEP_NAMES,
SETUP_STEP_LABELS,
type SetupState, type SetupState,
type SetupStepName, type SetupStepName,
getSetupState, getSetupState,
@@ -15,34 +20,41 @@ import { notify } from '@/lib/toast';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
// Top-level onboarding wizard. Auto-shown by login.tsx redirect when the // Top-level onboarding wizard. Auto-shown by login.tsx redirect when the
// workspace has incomplete setup steps. Each step renders a placeholder card // workspace has incomplete setup steps.
// in Phase 2 — Phase 5 swaps the placeholders for real form components from
// the matching settings pages (clinics, doctors, team, telephony, ai).
// //
// The wizard is functional even at placeholder level: each step has a // Phase 5: each step is now backed by a real form component. The parent
// "Mark complete" button that calls PUT /api/config/setup-state/steps/<name>, // owns the shell navigation + per-step completion state, and passes a
// which lets the operator click through and verify first-run detection + // WizardStepComponentProps bundle to the dispatched child so the child
// dismiss flow without waiting for Phase 5. // can trigger save + mark-complete + advance from its own Save action.
const STEP_COMPONENTS: Record<SetupStepName, (p: WizardStepComponentProps) => React.ReactElement> = {
identity: WizardStepIdentity,
clinics: WizardStepClinics,
doctors: WizardStepDoctors,
team: WizardStepTeam,
telephony: WizardStepTelephony,
ai: WizardStepAi,
};
export const SetupWizardPage = () => { export const SetupWizardPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const [state, setState] = useState<SetupState | null>(null); const [state, setState] = useState<SetupState | null>(null);
const [activeStep, setActiveStep] = useState<SetupStepName>('identity'); const [activeStep, setActiveStep] = useState<SetupStepName>('identity');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
getSetupState() getSetupState()
.then(s => { .then((s) => {
if (cancelled) return; if (cancelled) return;
setState(s); setState(s);
// Land on the first incomplete step so the operator picks // Land on the first incomplete step so the operator picks up
// up where they left off. // where they left off.
const firstIncomplete = SETUP_STEP_NAMES.find(name => !s.steps[name].completed); const firstIncomplete = SETUP_STEP_NAMES.find((name) => !s.steps[name].completed);
if (firstIncomplete) setActiveStep(firstIncomplete); if (firstIncomplete) setActiveStep(firstIncomplete);
}) })
.catch(err => { .catch((err) => {
console.error('Failed to load setup state', err); console.error('Failed to load setup state', err);
notify.error('Setup', 'Could not load setup state. Please reload.'); notify.error('Setup', 'Could not load setup state. Please reload.');
}) })
@@ -67,21 +79,21 @@ export const SetupWizardPage = () => {
const onPrev = activeIndex > 0 ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex - 1]) : null; const onPrev = activeIndex > 0 ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex - 1]) : null;
const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null; const onNext = !isLastStep ? () => setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]) : null;
const handleMarkComplete = async () => { const handleComplete = async (step: SetupStepName) => {
setSaving(true);
try { try {
const updated = await markSetupStepComplete(activeStep, user?.email); const updated = await markSetupStepComplete(step, user?.email);
setState(updated); setState(updated);
notify.success('Step complete', SETUP_STEP_LABELS[activeStep].title); } catch (err) {
// Auto-advance to next incomplete step (or stay if this was the last). 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) { if (!isLastStep) {
setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]); setActiveStep(SETUP_STEP_NAMES[activeIndex + 1]);
} }
} catch {
notify.error('Setup', 'Could not save step status. Please try again.');
} finally {
setSaving(false);
}
}; };
const handleFinish = () => { const handleFinish = () => {
@@ -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 ( return (
<WizardShell <WizardShell
state={state} state={state}
@@ -106,33 +129,7 @@ export const SetupWizardPage = () => {
onSelectStep={setActiveStep} onSelectStep={setActiveStep}
onDismiss={handleDismiss} onDismiss={handleDismiss}
> >
<WizardStep <StepComponent {...stepProps} />
step={activeStep}
isCompleted={state.steps[activeStep].completed}
isLast={isLastStep}
onPrev={onPrev}
onNext={onNext}
onMarkComplete={handleMarkComplete}
onFinish={handleFinish}
saving={saving}
>
<StepPlaceholder step={activeStep} />
</WizardStep>
</WizardShell> </WizardShell>
); );
}; };
// 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 (
<div className="rounded-lg border border-dashed border-secondary bg-secondary px-6 py-12 text-center">
<p className="text-sm font-semibold text-secondary">Coming in Phase 5</p>
<p className="mt-2 text-xs text-tertiary">
The {meta.title.toLowerCase()} form will live here. For now, click <b>Mark complete</b> below
to test the wizard flow end-to-end.
</p>
</div>
);
};