mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
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:
@@ -8,21 +8,37 @@ import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-m
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import {
|
||||
ClinicForm,
|
||||
clinicFormToGraphQLInput,
|
||||
clinicCoreToGraphQLInput,
|
||||
holidayInputsFromForm,
|
||||
requiredDocInputsFromForm,
|
||||
emptyClinicFormValues,
|
||||
type ClinicFormValues,
|
||||
type ClinicStatus,
|
||||
type DocumentType,
|
||||
type ClinicHolidayEntry,
|
||||
} from '@/components/forms/clinic-form';
|
||||
import { formatTimeLabel } from '@/components/application/date-picker/time-picker';
|
||||
import { formatDaySelection, type DaySelection } from '@/components/application/day-selector/day-selector';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { markSetupStepComplete } from '@/lib/setup-state';
|
||||
|
||||
// /settings/clinics — list + add/edit slideout for the clinic entity. Uses the
|
||||
// platform GraphQL API directly; there's no wrapping hook because this is the
|
||||
// only consumer of clinics CRUD (the call desk uses a read-only doctors
|
||||
// query and bypasses clinics entirely).
|
||||
// /settings/clinics — list + add/edit slideout. Schema aligns with the
|
||||
// reworked Clinic entity in helix-engage/src/objects/clinic.object.ts:
|
||||
// - openMonday..openSunday (7 BOOLEANs) for the weekly pattern
|
||||
// - opensAt/closesAt (TEXT, HH:MM) for the shared daily time range
|
||||
// - two child entities: Holiday (closures) and ClinicRequiredDocument
|
||||
// (required-doc selection per clinic)
|
||||
//
|
||||
// Save flow:
|
||||
// 1. createClinic / updateClinic (main record)
|
||||
// 2. Fire child mutations in parallel:
|
||||
// - For holidays: delete-all-recreate on edit (simple, idempotent)
|
||||
// - For required docs: diff old vs new, delete removed, create added
|
||||
|
||||
type Clinic = {
|
||||
// -- Fetched shapes from the platform ----------------------------------------
|
||||
|
||||
type ClinicNode = {
|
||||
id: string;
|
||||
clinicName: string | null;
|
||||
status: ClinicStatus | null;
|
||||
@@ -35,14 +51,28 @@ type Clinic = {
|
||||
} | null;
|
||||
phone: { primaryPhoneNumber: string | null } | null;
|
||||
email: { primaryEmail: string | null } | null;
|
||||
weekdayHours: string | null;
|
||||
saturdayHours: string | null;
|
||||
sundayHours: string | null;
|
||||
openMonday: boolean | null;
|
||||
openTuesday: boolean | null;
|
||||
openWednesday: boolean | null;
|
||||
openThursday: boolean | null;
|
||||
openFriday: boolean | null;
|
||||
openSaturday: boolean | null;
|
||||
openSunday: boolean | null;
|
||||
opensAt: string | null;
|
||||
closesAt: string | null;
|
||||
walkInAllowed: boolean | null;
|
||||
onlineBooking: boolean | null;
|
||||
cancellationWindowHours: number | null;
|
||||
arriveEarlyMin: number | null;
|
||||
requiredDocuments: string | null;
|
||||
// Reverse-side collections. Platform exposes them as Relay edges.
|
||||
holidays?: {
|
||||
edges: Array<{
|
||||
node: { id: string; date: string | null; reasonLabel: string | null };
|
||||
}>;
|
||||
};
|
||||
clinicRequiredDocuments?: {
|
||||
edges: Array<{ node: { id: string; documentType: DocumentType | null } }>;
|
||||
};
|
||||
};
|
||||
|
||||
const CLINICS_QUERY = `{
|
||||
@@ -57,16 +87,34 @@ const CLINICS_QUERY = `{
|
||||
}
|
||||
phone { primaryPhoneNumber }
|
||||
email { primaryEmail }
|
||||
weekdayHours saturdayHours sundayHours
|
||||
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||
opensAt closesAt
|
||||
walkInAllowed onlineBooking
|
||||
cancellationWindowHours arriveEarlyMin
|
||||
requiredDocuments
|
||||
holidays(first: 50) {
|
||||
edges { node { id date reasonLabel } }
|
||||
}
|
||||
clinicRequiredDocuments(first: 50) {
|
||||
edges { node { id documentType } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const toFormValues = (clinic: Clinic): ClinicFormValues => ({
|
||||
// -- Helpers -----------------------------------------------------------------
|
||||
|
||||
const toDaySelection = (c: ClinicNode): DaySelection => ({
|
||||
monday: !!c.openMonday,
|
||||
tuesday: !!c.openTuesday,
|
||||
wednesday: !!c.openWednesday,
|
||||
thursday: !!c.openThursday,
|
||||
friday: !!c.openFriday,
|
||||
saturday: !!c.openSaturday,
|
||||
sunday: !!c.openSunday,
|
||||
});
|
||||
|
||||
const toFormValues = (clinic: ClinicNode): ClinicFormValues => ({
|
||||
clinicName: clinic.clinicName ?? '',
|
||||
addressStreet1: clinic.addressCustom?.addressStreet1 ?? '',
|
||||
addressStreet2: clinic.addressCustom?.addressStreet2 ?? '',
|
||||
@@ -75,15 +123,29 @@ const toFormValues = (clinic: Clinic): ClinicFormValues => ({
|
||||
addressPostcode: clinic.addressCustom?.addressPostcode ?? '',
|
||||
phone: clinic.phone?.primaryPhoneNumber ?? '',
|
||||
email: clinic.email?.primaryEmail ?? '',
|
||||
weekdayHours: clinic.weekdayHours ?? '',
|
||||
saturdayHours: clinic.saturdayHours ?? '',
|
||||
sundayHours: clinic.sundayHours ?? '',
|
||||
openDays: toDaySelection(clinic),
|
||||
opensAt: clinic.opensAt ?? null,
|
||||
closesAt: clinic.closesAt ?? null,
|
||||
status: clinic.status ?? 'ACTIVE',
|
||||
walkInAllowed: clinic.walkInAllowed ?? true,
|
||||
onlineBooking: clinic.onlineBooking ?? true,
|
||||
cancellationWindowHours: clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
|
||||
cancellationWindowHours:
|
||||
clinic.cancellationWindowHours != null ? String(clinic.cancellationWindowHours) : '',
|
||||
arriveEarlyMin: clinic.arriveEarlyMin != null ? String(clinic.arriveEarlyMin) : '',
|
||||
requiredDocuments: clinic.requiredDocuments ?? '',
|
||||
requiredDocumentTypes:
|
||||
clinic.clinicRequiredDocuments?.edges
|
||||
.map((e) => e.node.documentType)
|
||||
.filter((t): t is DocumentType => t !== null) ?? [],
|
||||
holidays:
|
||||
clinic.holidays?.edges
|
||||
.filter((e) => e.node.date) // date is required on create but platform may have nulls from earlier
|
||||
.map(
|
||||
(e): ClinicHolidayEntry => ({
|
||||
id: e.node.id,
|
||||
date: e.node.date ?? '',
|
||||
label: e.node.reasonLabel ?? '',
|
||||
}),
|
||||
) ?? [],
|
||||
});
|
||||
|
||||
const statusLabel: Record<ClinicStatus, string> = {
|
||||
@@ -98,17 +160,69 @@ const statusColor: Record<ClinicStatus, 'success' | 'warning' | 'gray'> = {
|
||||
PERMANENTLY_CLOSED: 'gray',
|
||||
};
|
||||
|
||||
// Save-flow helpers — each mutation is a thin wrapper so handleSave
|
||||
// reads linearly.
|
||||
const createClinicMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ createClinic: { id: string } }>(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const updateClinicMutation = (id: string, data: Record<string, unknown>) =>
|
||||
apiClient.graphql<{ updateClinic: { id: string } }>(
|
||||
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
|
||||
updateClinic(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id, data },
|
||||
);
|
||||
|
||||
const createHolidayMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateHoliday($data: HolidayCreateInput!) {
|
||||
createHoliday(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteHolidayMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteHoliday($id: UUID!) { deleteHoliday(id: $id) { id } }`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
const createRequiredDocMutation = (data: Record<string, unknown>) =>
|
||||
apiClient.graphql(
|
||||
`mutation CreateClinicRequiredDocument($data: ClinicRequiredDocumentCreateInput!) {
|
||||
createClinicRequiredDocument(data: $data) { id }
|
||||
}`,
|
||||
{ data },
|
||||
);
|
||||
|
||||
const deleteRequiredDocMutation = (id: string) =>
|
||||
apiClient.graphql(
|
||||
`mutation DeleteClinicRequiredDocument($id: UUID!) {
|
||||
deleteClinicRequiredDocument(id: $id) { id }
|
||||
}`,
|
||||
{ id },
|
||||
);
|
||||
|
||||
// -- Page --------------------------------------------------------------------
|
||||
|
||||
export const ClinicsPage = () => {
|
||||
const [clinics, setClinics] = useState<Clinic[]>([]);
|
||||
const [clinics, setClinics] = useState<ClinicNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [slideoutOpen, setSlideoutOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Clinic | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<ClinicNode | null>(null);
|
||||
const [formValues, setFormValues] = useState<ClinicFormValues>(emptyClinicFormValues);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const fetchClinics = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiClient.graphql<{ clinics: { edges: { node: Clinic }[] } }>(CLINICS_QUERY);
|
||||
const data = await apiClient.graphql<{ clinics: { edges: { node: ClinicNode }[] } }>(
|
||||
CLINICS_QUERY,
|
||||
);
|
||||
setClinics(data.clinics.edges.map((e) => e.node));
|
||||
} catch {
|
||||
// toast already shown by apiClient
|
||||
@@ -127,7 +241,7 @@ export const ClinicsPage = () => {
|
||||
setSlideoutOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (clinic: Clinic) => {
|
||||
const handleEdit = (clinic: ClinicNode) => {
|
||||
setEditTarget(clinic);
|
||||
setFormValues(toFormValues(clinic));
|
||||
setSlideoutOpen(true);
|
||||
@@ -140,27 +254,47 @@ export const ClinicsPage = () => {
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const input = clinicFormToGraphQLInput(formValues);
|
||||
const coreInput = clinicCoreToGraphQLInput(formValues);
|
||||
|
||||
// 1. Upsert the clinic itself.
|
||||
let clinicId: string;
|
||||
if (editTarget) {
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateClinic($id: UUID!, $data: ClinicUpdateInput!) {
|
||||
updateClinic(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{ id: editTarget.id, data: input },
|
||||
);
|
||||
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
|
||||
await updateClinicMutation(editTarget.id, coreInput);
|
||||
clinicId = editTarget.id;
|
||||
} else {
|
||||
await apiClient.graphql(
|
||||
`mutation CreateClinic($data: ClinicCreateInput!) {
|
||||
createClinic(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
const res = await createClinicMutation(coreInput);
|
||||
clinicId = res.createClinic.id;
|
||||
notify.success('Clinic added', `${formValues.clinicName} has been added.`);
|
||||
// First clinic added unblocks the wizard's clinics step. Failures
|
||||
// here are silent — the badge will just be stale until next load.
|
||||
markSetupStepComplete('clinics').catch(() => {});
|
||||
}
|
||||
|
||||
// 2. Holidays — delete-all-recreate. Simple, always correct.
|
||||
if (editTarget?.holidays?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.holidays.edges.map((e) => deleteHolidayMutation(e.node.id)),
|
||||
);
|
||||
}
|
||||
if (formValues.holidays.length > 0) {
|
||||
const holidayInputs = holidayInputsFromForm(formValues, clinicId);
|
||||
await Promise.all(holidayInputs.map((data) => createHolidayMutation(data)));
|
||||
}
|
||||
|
||||
// 3. Required docs — delete-all-recreate for symmetry.
|
||||
if (editTarget?.clinicRequiredDocuments?.edges?.length) {
|
||||
await Promise.all(
|
||||
editTarget.clinicRequiredDocuments.edges.map((e) =>
|
||||
deleteRequiredDocMutation(e.node.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (formValues.requiredDocumentTypes.length > 0) {
|
||||
const docInputs = requiredDocInputsFromForm(formValues, clinicId);
|
||||
await Promise.all(docInputs.map((data) => createRequiredDocMutation(data)));
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
notify.success('Clinic updated', `${formValues.clinicName} has been updated.`);
|
||||
}
|
||||
await fetchClinics();
|
||||
close();
|
||||
} catch (err) {
|
||||
@@ -170,7 +304,10 @@ export const ClinicsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const activeCount = useMemo(() => clinics.filter((c) => c.status === 'ACTIVE').length, [clinics]);
|
||||
const activeCount = useMemo(
|
||||
() => clinics.filter((c) => c.status === 'ACTIVE').length,
|
||||
[clinics],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -231,6 +368,11 @@ export const ClinicsPage = () => {
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const status = clinic.status ?? 'ACTIVE';
|
||||
const dayLabel = formatDaySelection(toDaySelection(clinic));
|
||||
const hoursLabel =
|
||||
clinic.opensAt && clinic.closesAt
|
||||
? `${formatTimeLabel(clinic.opensAt)}–${formatTimeLabel(clinic.closesAt)}`
|
||||
: 'Not set';
|
||||
return (
|
||||
<Table.Row id={clinic.id}>
|
||||
<Table.Cell>
|
||||
@@ -252,9 +394,10 @@ export const ClinicsPage = () => {
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">
|
||||
{clinic.weekdayHours ?? 'Not set'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-primary">{dayLabel}</span>
|
||||
<span className="text-xs text-tertiary">{hoursLabel}</span>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor[status]} type="pill-color">
|
||||
|
||||
Reference in New Issue
Block a user