mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +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 { 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user