mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
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:
84
src/components/setup/wizard-step-ai.tsx
Normal file
84
src/components/setup/wizard-step-ai.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
68
src/components/setup/wizard-step-clinics.tsx
Normal file
68
src/components/setup/wizard-step-clinics.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
101
src/components/setup/wizard-step-doctors.tsx
Normal file
101
src/components/setup/wizard-step-doctors.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
120
src/components/setup/wizard-step-identity.tsx
Normal file
120
src/components/setup/wizard-step-identity.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
src/components/setup/wizard-step-team.tsx
Normal file
100
src/components/setup/wizard-step-team.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
src/components/setup/wizard-step-telephony.tsx
Normal file
102
src/components/setup/wizard-step-telephony.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
src/components/setup/wizard-step-types.ts
Normal file
20
src/components/setup/wizard-step-types.ts
Normal 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;
|
||||
};
|
||||
@@ -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/<name>,
|
||||
// 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<SetupStepName, (p: WizardStepComponentProps) => 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<SetupState | null>(null);
|
||||
const [activeStep, setActiveStep] = useState<SetupStepName>('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 (
|
||||
<WizardShell
|
||||
state={state}
|
||||
@@ -106,33 +129,7 @@ export const SetupWizardPage = () => {
|
||||
onSelectStep={setActiveStep}
|
||||
onDismiss={handleDismiss}
|
||||
>
|
||||
<WizardStep
|
||||
step={activeStep}
|
||||
isCompleted={state.steps[activeStep].completed}
|
||||
isLast={isLastStep}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
onMarkComplete={handleMarkComplete}
|
||||
onFinish={handleFinish}
|
||||
saving={saving}
|
||||
>
|
||||
<StepPlaceholder step={activeStep} />
|
||||
</WizardStep>
|
||||
<StepComponent {...stepProps} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user