mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
- 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>
475 lines
21 KiB
TypeScript
475 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||
import { faBuilding, faPlus, faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
|
||
import { Button } from '@/components/base/buttons/button';
|
||
import { Badge } from '@/components/base/badges/badges';
|
||
import { Table, TableCard } from '@/components/application/table/table';
|
||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||
import { TopBar } from '@/components/layout/top-bar';
|
||
import {
|
||
ClinicForm,
|
||
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. 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
|
||
|
||
// -- Fetched shapes from the platform ----------------------------------------
|
||
|
||
type ClinicNode = {
|
||
id: string;
|
||
clinicName: string | null;
|
||
status: ClinicStatus | null;
|
||
addressCustom: {
|
||
addressStreet1: string | null;
|
||
addressStreet2: string | null;
|
||
addressCity: string | null;
|
||
addressState: string | null;
|
||
addressPostcode: string | null;
|
||
} | null;
|
||
phone: { primaryPhoneNumber: string | null } | null;
|
||
email: { primaryEmail: string | null } | 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;
|
||
// 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 = `{
|
||
clinics(first: 100) {
|
||
edges {
|
||
node {
|
||
id
|
||
clinicName
|
||
status
|
||
addressCustom {
|
||
addressStreet1 addressStreet2 addressCity addressState addressPostcode
|
||
}
|
||
phone { primaryPhoneNumber }
|
||
email { primaryEmail }
|
||
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||
opensAt closesAt
|
||
walkInAllowed onlineBooking
|
||
cancellationWindowHours arriveEarlyMin
|
||
holidays(first: 50) {
|
||
edges { node { id date reasonLabel } }
|
||
}
|
||
clinicRequiredDocuments(first: 50) {
|
||
edges { node { id documentType } }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}`;
|
||
|
||
// -- 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 ?? '',
|
||
addressCity: clinic.addressCustom?.addressCity ?? '',
|
||
addressState: clinic.addressCustom?.addressState ?? '',
|
||
addressPostcode: clinic.addressCustom?.addressPostcode ?? '',
|
||
phone: clinic.phone?.primaryPhoneNumber ?? '',
|
||
email: clinic.email?.primaryEmail ?? '',
|
||
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) : '',
|
||
arriveEarlyMin: clinic.arriveEarlyMin != null ? String(clinic.arriveEarlyMin) : '',
|
||
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> = {
|
||
ACTIVE: 'Active',
|
||
TEMPORARILY_CLOSED: 'Temporarily closed',
|
||
PERMANENTLY_CLOSED: 'Permanently closed',
|
||
};
|
||
|
||
const statusColor: Record<ClinicStatus, 'success' | 'warning' | 'gray'> = {
|
||
ACTIVE: 'success',
|
||
TEMPORARILY_CLOSED: 'warning',
|
||
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<ClinicNode[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [slideoutOpen, setSlideoutOpen] = useState(false);
|
||
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: ClinicNode }[] } }>(
|
||
CLINICS_QUERY,
|
||
);
|
||
setClinics(data.clinics.edges.map((e) => e.node));
|
||
} catch {
|
||
// toast already shown by apiClient
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchClinics();
|
||
}, [fetchClinics]);
|
||
|
||
const handleAdd = () => {
|
||
setEditTarget(null);
|
||
setFormValues(emptyClinicFormValues());
|
||
setSlideoutOpen(true);
|
||
};
|
||
|
||
const handleEdit = (clinic: ClinicNode) => {
|
||
setEditTarget(clinic);
|
||
setFormValues(toFormValues(clinic));
|
||
setSlideoutOpen(true);
|
||
};
|
||
|
||
const handleSave = async (close: () => void) => {
|
||
if (!formValues.clinicName.trim()) {
|
||
notify.error('Clinic name is required');
|
||
return;
|
||
}
|
||
setIsSaving(true);
|
||
try {
|
||
const coreInput = clinicCoreToGraphQLInput(formValues);
|
||
|
||
// 1. Upsert the clinic itself.
|
||
let clinicId: string;
|
||
if (editTarget) {
|
||
await updateClinicMutation(editTarget.id, coreInput);
|
||
clinicId = editTarget.id;
|
||
} else {
|
||
const res = await createClinicMutation(coreInput);
|
||
clinicId = res.createClinic.id;
|
||
notify.success('Clinic added', `${formValues.clinicName} has been added.`);
|
||
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) {
|
||
console.error('[clinics] save failed', err);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const activeCount = useMemo(
|
||
() => clinics.filter((c) => c.status === 'ACTIVE').length,
|
||
[clinics],
|
||
);
|
||
|
||
return (
|
||
<div className="flex flex-1 flex-col overflow-hidden">
|
||
<TopBar title="Clinics" subtitle="Manage hospital branches and visiting hours" />
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<TableCard.Root size="sm">
|
||
<TableCard.Header
|
||
title="Clinic branches"
|
||
badge={clinics.length}
|
||
description={`${activeCount} active`}
|
||
contentTrailing={
|
||
<Button
|
||
size="sm"
|
||
color="primary"
|
||
iconLeading={({ className }: { className?: string }) => (
|
||
<FontAwesomeIcon icon={faPlus} className={className} />
|
||
)}
|
||
onClick={handleAdd}
|
||
>
|
||
Add clinic
|
||
</Button>
|
||
}
|
||
/>
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<p className="text-sm text-tertiary">Loading clinics...</p>
|
||
</div>
|
||
) : clinics.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||
<div className="flex size-12 items-center justify-center rounded-full bg-brand-secondary">
|
||
<FontAwesomeIcon icon={faBuilding} className="size-5 text-fg-brand-primary" />
|
||
</div>
|
||
<p className="text-sm font-semibold text-primary">No clinics yet</p>
|
||
<p className="max-w-xs text-center text-xs text-tertiary">
|
||
Add your first clinic branch to start booking appointments and assigning doctors.
|
||
</p>
|
||
<Button size="sm" color="primary" onClick={handleAdd}>
|
||
Add clinic
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<Table>
|
||
<Table.Header>
|
||
<Table.Head label="NAME" isRowHeader />
|
||
<Table.Head label="ADDRESS" />
|
||
<Table.Head label="CONTACT" />
|
||
<Table.Head label="HOURS" />
|
||
<Table.Head label="STATUS" />
|
||
<Table.Head label="" />
|
||
</Table.Header>
|
||
<Table.Body items={clinics}>
|
||
{(clinic) => {
|
||
const addressLine = [
|
||
clinic.addressCustom?.addressStreet1,
|
||
clinic.addressCustom?.addressCity,
|
||
]
|
||
.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>
|
||
<span className="text-sm font-medium text-primary">
|
||
{clinic.clinicName ?? 'Unnamed clinic'}
|
||
</span>
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<span className="text-sm text-tertiary">{addressLine || '—'}</span>
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<div className="flex flex-col">
|
||
<span className="text-sm text-primary">
|
||
{clinic.phone?.primaryPhoneNumber ?? '—'}
|
||
</span>
|
||
<span className="text-xs text-tertiary">
|
||
{clinic.email?.primaryEmail ?? ''}
|
||
</span>
|
||
</div>
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<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">
|
||
{statusLabel[status]}
|
||
</Badge>
|
||
</Table.Cell>
|
||
<Table.Cell>
|
||
<Button
|
||
size="sm"
|
||
color="secondary"
|
||
iconLeading={({ className }: { className?: string }) => (
|
||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||
)}
|
||
onClick={() => handleEdit(clinic)}
|
||
>
|
||
Edit
|
||
</Button>
|
||
</Table.Cell>
|
||
</Table.Row>
|
||
);
|
||
}}
|
||
</Table.Body>
|
||
</Table>
|
||
)}
|
||
</TableCard.Root>
|
||
</div>
|
||
|
||
<SlideoutMenu isOpen={slideoutOpen} onOpenChange={setSlideoutOpen} isDismissable>
|
||
{({ close }) => (
|
||
<>
|
||
<SlideoutMenu.Header onClose={close}>
|
||
<div className="flex items-center gap-3 pr-8">
|
||
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
|
||
<FontAwesomeIcon icon={faBuilding} className="size-5 text-fg-brand-primary" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-lg font-semibold text-primary">
|
||
{editTarget ? 'Edit clinic' : 'Add clinic'}
|
||
</h2>
|
||
<p className="text-sm text-tertiary">
|
||
{editTarget
|
||
? 'Update branch details, hours, and policy'
|
||
: 'Add a new hospital branch'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</SlideoutMenu.Header>
|
||
|
||
<SlideoutMenu.Content>
|
||
<ClinicForm value={formValues} onChange={setFormValues} />
|
||
</SlideoutMenu.Content>
|
||
|
||
<SlideoutMenu.Footer>
|
||
<div className="flex items-center justify-end gap-3">
|
||
<Button size="md" color="secondary" onClick={close}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
size="md"
|
||
color="primary"
|
||
isLoading={isSaving}
|
||
showTextWhileLoading
|
||
onClick={() => handleSave(close)}
|
||
>
|
||
{isSaving ? 'Saving...' : editTarget ? 'Save changes' : 'Add clinic'}
|
||
</Button>
|
||
</div>
|
||
</SlideoutMenu.Footer>
|
||
</>
|
||
)}
|
||
</SlideoutMenu>
|
||
</div>
|
||
);
|
||
};
|