Files
helix-engage/src/pages/clinics.tsx
saridsa2 f57fbc1f24 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>
2026-04-10 08:37:34 +05:30

475 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};