feat(onboarding/phase-6): setup wizard polish, seed script alignment, doctor visit slots

- Setup wizard: 3-pane layout with right-side live previews, resume
  banner, edit/copy icons on team step, AI prompt configuration
- Forms: employee-create replaces invite-member (no email invites),
  clinic form with address/hours/payment, doctor form with visit slots
- Seed script: aligned to current SDK schema — doctors created as
  workspace members (HelixEngage Manager role), visitingHours replaced
  by doctorVisitSlot entity, clinics seeded, portalUserId linked
  dynamically, SUB/ORIGIN/GQL configurable via env vars
- Pages: clinics + doctors CRUD updated for new schema, team settings
  with temp password + role assignment
- New components: time-picker, day-selector, wizard-right-panes,
  wizard-layout-context, resume-setup-banner
- Removed: invite-member-form (replaced by employee-create-form per
  no-email-invites rule)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 08:37:34 +05:30
parent efe67dc28b
commit f57fbc1f24
25 changed files with 3461 additions and 706 deletions

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { WizardStep } from './wizard-step';
import { ClinicsRightPane, type ClinicSummary } from './wizard-right-panes';
import {
ClinicForm,
clinicFormToGraphQLInput,
clinicCoreToGraphQLInput,
holidayInputsFromForm,
requiredDocInputsFromForm,
emptyClinicFormValues,
type ClinicFormValues,
} from '@/components/forms/clinic-form';
@@ -10,16 +13,67 @@ 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.
// Clinic step — presents a single-clinic form. On save the wizard runs
// a three-stage create chain:
// 1. createClinic (main record → get id)
// 2. createHoliday × N (one per holiday entry)
// 3. createClinicRequiredDocument × N (one per required doc type)
//
// 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.
// This mirrors what the /settings/clinics list page does, minus the
// delete-old-first step (wizard is always creating, never updating).
// Failures inside the chain throw up through onComplete so the user
// sees the error loud, and the wizard stays on the current step.
export const WizardStepClinics = (props: WizardStepComponentProps) => {
const [values, setValues] = useState<ClinicFormValues>(emptyClinicFormValues);
const [saving, setSaving] = useState(false);
const [clinics, setClinics] = useState<ClinicSummary[]>([]);
const fetchClinics = useCallback(async () => {
try {
// Field names match what the platform actually exposes:
// - the SDK ADDRESS field is named "address" but the
// platform mounts it as `addressCustom` (composite type
// with addressCity / addressStreet / etc.)
// - the SDK SELECT field labelled "Status" lands as plain
// `status: ClinicStatusEnum`, NOT `clinicStatus`
// Verified via __type introspection — keep this query
// pinned to the actual schema to avoid silent empty fetches.
type ClinicNode = {
id: string;
clinicName: string | null;
addressCustom: { addressCity: string | null } | null;
status: string | null;
};
const data = await apiClient.graphql<{
clinics: { edges: { node: ClinicNode }[] };
}>(
`{ clinics(first: 100, orderBy: { createdAt: DescNullsLast }) {
edges { node {
id clinicName
addressCustom { addressCity }
status
} }
} }`,
undefined,
{ silent: true },
);
// Flatten into the shape ClinicsRightPane expects.
setClinics(
data.clinics.edges.map((e) => ({
id: e.node.id,
clinicName: e.node.clinicName,
addressCity: e.node.addressCustom?.addressCity ?? null,
clinicStatus: e.node.status,
})),
);
} catch (err) {
console.error('[wizard/clinics] fetch failed', err);
}
}, []);
useEffect(() => {
fetchClinics();
}, [fetchClinics]);
const handleSave = async () => {
if (!values.clinicName.trim()) {
@@ -28,16 +82,54 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
}
setSaving(true);
try {
await apiClient.graphql(
// 1. Core clinic record
const res = await apiClient.graphql<{ createClinic: { id: string } }>(
`mutation CreateClinic($data: ClinicCreateInput!) {
createClinic(data: $data) { id }
}`,
{ data: clinicFormToGraphQLInput(values) },
{ data: clinicCoreToGraphQLInput(values) },
);
const clinicId = res.createClinic.id;
// 2. Holidays
if (values.holidays.length > 0) {
const holidayInputs = holidayInputsFromForm(values, clinicId);
await Promise.all(
holidayInputs.map((data) =>
apiClient.graphql(
`mutation CreateHoliday($data: HolidayCreateInput!) {
createHoliday(data: $data) { id }
}`,
{ data },
),
),
);
}
// 3. Required documents
if (values.requiredDocumentTypes.length > 0) {
const docInputs = requiredDocInputsFromForm(values, clinicId);
await Promise.all(
docInputs.map((data) =>
apiClient.graphql(
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
createClinicRequiredDocument(data: $data) { id }
}`,
{ data },
),
),
);
}
notify.success('Clinic added', values.clinicName);
await props.onComplete('clinics');
await fetchClinics();
// Mark complete on first successful create. Don't auto-advance —
// admins typically add multiple clinics in one sitting; the
// Continue button on the wizard nav handles forward motion.
if (!props.isCompleted) {
await props.onComplete('clinics');
}
setValues(emptyClinicFormValues());
props.onAdvance();
} catch (err) {
console.error('[wizard/clinics] save failed', err);
} finally {
@@ -45,24 +137,35 @@ export const WizardStepClinics = (props: WizardStepComponentProps) => {
}
};
// Same trick as the Team step: once at least one clinic exists,
// flip isCompleted=true so the WizardStep renders the "Continue"
// button as the primary action — the form stays open below for
// adding more clinics.
const pretendCompleted = props.isCompleted || clinics.length > 0;
return (
<WizardStep
step="clinics"
isCompleted={props.isCompleted}
isCompleted={pretendCompleted}
isLast={props.isLast}
onPrev={props.onPrev}
onNext={props.onNext}
onMarkComplete={handleSave}
onFinish={props.onFinish}
saving={saving}
rightPane={<ClinicsRightPane clinics={clinics} />}
>
{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} />
<div className="mt-6 flex justify-end">
<button
type="button"
disabled={saving}
onClick={handleSave}
className="inline-flex items-center gap-2 rounded-lg bg-brand-solid px-4 py-2 text-sm font-semibold text-primary_on-brand shadow-xs transition hover:bg-brand-solid_hover disabled:opacity-60"
>
{saving ? 'Adding…' : 'Add clinic'}
</button>
</div>
</WizardStep>
);
};