import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { DismissibleAlert } from "../../../components/ui/dismissible-alert"; import { OverlayActions } from "../../../components/ui/overlay-actions"; import DiscardChangesDialog from "../../../components/DiscardChangesDialog/DiscardChangesDialog"; import { useDirtyReport } from "../../../hooks/useDirtyReport"; import { EMPTY_APPOINTMENT_SETUP, appointmentSetupDraftOf, sameAppointmentSetup, withAppointmentSetupDraft, type AppointmentSetupDraft } from "../../../config/appointmentSetupDraft"; import { CheckboxControl } from "../../../components/ui/checkbox-control"; import { SelectControl } from "../../../components/ui/select-control"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { useFhirHealthcareServices, useFhirLocations } from "../../../hooks"; import { useAuthServiceContext, usePractitionerRoleServiceContext, useScheduleServiceContext } from "../../../services"; import { usePractitionerServiceContext } from "../../../services/PractitionerService"; import { buildScheduleAvailabilityExtensions, getScheduleAvailability } from "../../../services/ScheduleService"; import { todayWhereScheduleRuns } from "../../../utils/scheduleExpiry"; import { onlySites } from "../../../utils/roomLocation"; import { locationTimeZoneOf, practiceTimeZoneOf } from "../../../utils/practiceTimezone"; import { formatScheduleDate, initialScheduleHorizon, todayIn } from "../../../utils/initialScheduleHorizon"; import { toServiceActors, toServiceTypeConcepts } from "../../../utils/scheduleServiceActors"; import { describeWriteFailure } from "../../../utils/fetchFailureMessage"; import { HttpError } from "../../../services/HttpService"; import type { HealthcareService, Schedule } from "fhir/r4"; import AddServiceModal from "./AddServiceModal"; import { useServiceCategories, type ServiceCategory } from "../../../config/serviceCategories"; import { servicePracticeCategoryOf } from "../../../config/servicePracticeCategory"; import { PRACTICE_TYPE_SYSTEM } from "../../../utils/staffRoleCatalog"; import { upsertById } from "../../../utils/upsertById"; type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; const DAYS: [Day, string][] = [["mon", "Mon"], ["tue", "Tue"], ["wed", "Wed"], ["thu", "Thu"], ["fri", "Fri"], ["sat", "Sat"], ["sun", "Sun"]]; const ELLIPSIS = String.fromCharCode(8230); const DOT = String.fromCharCode(183); const DRAFT_LEFT = "The earlier draft could not be removed; it will be cleared the next time appointment setup is opened."; const toggle = (list: T[], value: T, on: boolean): T[] => on ? [...list.filter((item) => item !== value), value] : list.filter((item) => item !== value); const inCategory = (service: HealthcareService, category: ServiceCategory): boolean => servicePracticeCategoryOf(service) === category.code; const scheduleShape = (schedule: Schedule): string => [ `${schedule.planningHorizon?.start?.slice(0, 10)}/${schedule.planningHorizon?.end?.slice(0, 10)}`, (schedule.actor ?? []).map((actor) => actor.reference ?? "").sort().join(","), getScheduleAvailability(schedule).availableTime .map((time) => `${[...(time.daysOfWeek ?? [])].sort().join(",")} ${time.availableStartTime}-${time.availableEndTime}`) .sort().join(";"), ].join("|"); // Whether a schedule was published from this draft: same days, times, site and services. const publishedFrom = (schedule: Schedule, draft: AppointmentSetupDraft): boolean => { const refs = (schedule.actor ?? []).map((actor) => actor.reference ?? ""); const days = [...draft.days].sort().join(","); return getScheduleAvailability(schedule).availableTime.some((time) => [...(time.daysOfWeek ?? [])].sort().join(",") === days && time.availableStartTime?.slice(0, 5) === draft.start && time.availableEndTime?.slice(0, 5) === draft.end) && draft.serviceIds.every((id) => refs.includes(`HealthcareService/${id}`)) && (!draft.locationId || refs.includes(`Location/${draft.locationId}`)); }; /** Step 5 after the seat: the clinician's services, one location and weekly hours, saved as a 12-month schedule. */ export const FirstClinicianAppointments = ({ practitionerId, practitionerRoleId, name, onDone, onBack, showHeading = true, onDirtyChange }: { practitionerId: string; practitionerRoleId: string; name: string; onDone: (warning?: string) => void; onBack: () => void; showHeading?: boolean; onDirtyChange?: (dirty: boolean) => void; }) => { const { wpOrganization } = useAuthServiceContext(); const { healthcareServicesList } = useFhirHealthcareServices(); const { locationsList } = useFhirLocations(); const { createSchedule, fetchSchedules, setSchedulesList, transformToFhirSchedule } = useScheduleServiceContext(); const { fetchPractitioner } = usePractitionerServiceContext(); const { readPractitionerRole, updatePractitionerRole } = usePractitionerRoleServiceContext(); const [saving, setSaving] = useState(false); const [problem, setProblem] = useState(null); // Services persist first; when the schedule write then fails, a retry writes only the schedule. const [servicesSaved, setServicesSaved] = useState(false); // A schedule write that got no answer; its fields stay locked until it is found or re-sent. const [pendingSchedule, setPendingSchedule] = useState(null); const locked = saving || pendingSchedule !== null; const navigate = useNavigate(); // Hours already on file are changed in Schedules; this form only adds a first schedule. const [filed, setFiled] = useState("checking"); const [checkRun, setCheckRun] = useState(0); // The saved draft, or empty; the form is unsaved while it differs from this. const [baseline, setBaseline] = useState(EMPTY_APPOINTMENT_SETUP); const [notice, setNotice] = useState(null); const [confirmBack, setConfirmBack] = useState(false); useEffect(() => { let live = true; setFiled("checking"); Promise.all([ readPractitionerRole(practitionerRoleId), fetchPractitioner(practitionerId).then(({ practitioner }) => fetchSchedules(practitioner)), ]) .then(([role, bundle]) => { if (!live) return; const onRecord = (bundle.entry ?? []).flatMap(({ resource }) => (resource ? [resource] : [])); const today = new Date().toISOString().slice(0, 10); const draft = appointmentSetupDraftOf(role); // A draft outlived by a schedule is stale: it is removed, and never offered again once hours expire. const stale = Boolean(draft) && onRecord.some((schedule) => schedule.active !== false && ((schedule.planningHorizon?.end?.slice(0, 10) ?? today) >= today || (draft !== null && publishedFrom(schedule, draft)))); if (stale) void updatePractitionerRole(withAppointmentSetupDraft(role, null)).catch(() => undefined); const saved = stale ? null : draft; if (saved) { setServiceIds(saved.serviceIds); setLocationId(saved.locationId); setDays(saved.days.filter((day): day is Day => DAYS.some(([code]) => code === day))); setStart(saved.start); setEnd(saved.end); setBaseline(saved); } setFiled(onRecord); }) .catch(() => { if (live) setFiled("unknown"); }); return () => { live = false; }; }, [practitionerId, practitionerRoleId, checkRun, fetchPractitioner, fetchSchedules, readPractitionerRole, updatePractitionerRole]); const practiceZone = practiceTimeZoneOf(wpOrganization?.current) ?? null; // Current means not yet ended where the schedule runs; without a zone the UTC date decides. const onFile = Array.isArray(filed) ? filed.find((schedule) => { const end = schedule.planningHorizon?.end?.slice(0, 10); if (schedule.active === false) return false; if (!end) return true; const today = todayWhereScheduleRuns(schedule, locationsList ?? [], practiceZone); return end >= (today ?? new Date().toISOString().slice(0, 10)); }) : undefined; const existingHours = Array.isArray(filed) ? (onFile ? "found" : "none") : filed; const services = healthcareServicesList ?? []; const { categories } = useServiceCategories(); // Services are grouped under the practice categories; one matching none is still listed, under Other services. const practiceCodes = (wpOrganization?.current?.type ?? []).flatMap((concept) => concept.coding ?? []) .filter((coding) => coding.system === PRACTICE_TYPE_SYSTEM).map((coding) => coding.code); const groups = categories.filter((category) => practiceCodes.includes(category.code)) .map((category) => ({ category, services: services.filter((service) => inCategory(service, category)) })); const others = services.filter((service) => !groups.some((group) => group.services.includes(service))); // The category a service is being added under: "" when the practice has none, null while closed. const [addingTo, setAddingTo] = useState(null); const sites = onlySites(locationsList ?? []); const [serviceIds, setServiceIds] = useState([]); const [locationId, setLocationId] = useState(""); // Hours start empty: untouched defaults must never become bookable time. const [days, setDays] = useState([]); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const site = sites.find((location) => location.id === (locationId || sites[0]?.id)); const timeZone = locationTimeZoneOf(site) ?? practiceTimeZoneOf(wpOrganization?.current); const horizon = timeZone ? initialScheduleHorizon(todayIn(timeZone)) : null; const chosen = services.filter((service) => service.id && serviceIds.includes(service.id)); const ready = Boolean(site && horizon && chosen.length && days.length && start && end > start); const setup: AppointmentSetupDraft = { serviceIds, locationId, days, start, end }; const dirty = !sameAppointmentSetup(setup, baseline); useDirtyReport(dirty, onDirtyChange); const serviceBox = (service: HealthcareService) => service.id && ( setServiceIds((current) => toggle(current, service.id as string, on))} /> ); const addButton = (code: string, label: string) => !servicesSaved && ( ); const saveDraft = async () => { setProblem(null); setNotice(null); setSaving(true); try { // Only the draft is written: no services on the role and no schedule, so nothing becomes bookable. const role = await readPractitionerRole(practitionerRoleId); await updatePractitionerRole(withAppointmentSetupDraft(role, setup)); setBaseline(setup); setNotice("Draft saved. These hours are not bookable until you save appointment setup."); } catch (error) { setProblem(describeWriteFailure(error, "The draft was not saved")); } finally { setSaving(false); } }; // A published schedule supersedes the draft. Removing it is awaited so a failure is said, not hidden. const clearDraft = async (): Promise => { try { const role = await readPractitionerRole(practitionerRoleId); if (appointmentSetupDraftOf(role)) await updatePractitionerRole(withAppointmentSetupDraft(role, null)); return true; } catch { return false; } }; const save = async () => { if (!ready || !site || !horizon) return; setProblem(null); setSaving(true); let servicesDone = servicesSaved; try { let role = await readPractitionerRole(practitionerRoleId); if (!servicesDone) { const held = role.healthcareService ?? []; const added = toServiceActors(chosen).filter((ref) => !held.some((item) => item.reference === ref.reference)); // The draft rides along, so a schedule write that fails can resume from what was entered. role = await updatePractitionerRole(withAppointmentSetupDraft({ ...role, healthcareService: [...held, ...added] }, setup)); setBaseline(setup); servicesDone = true; setServicesSaved(true); } const { practitioner } = await fetchPractitioner(practitionerId); const schedule = transformToFhirSchedule( { planningHorizon: horizon, allowBooking: true, allowPatientSelfBooking: true, allowNewPatientGeneralExam: false }, practitioner, role, ); schedule.actor = [...(schedule.actor ?? []), { reference: `Location/${site.id}`, display: site.name }, ...toServiceActors(chosen)]; const concepts = toServiceTypeConcepts(chosen); if (concepts.length) schedule.serviceType = concepts; schedule.extension = [ ...(schedule.extension ?? []), ...buildScheduleAvailabilityExtensions( [{ daysOfWeek: days, allDay: false, availableStartTime: `${start}:00`, availableEndTime: `${end}:00` }], [], ), ]; if (pendingSchedule) { // The last attempt may have saved without a reply; find that exact write, else send it again. const saved = ((await fetchSchedules(practitioner)).entry ?? []) .map((entry) => entry.resource) .find((item) => item && item.active !== false && scheduleShape(item) === scheduleShape(pendingSchedule)); if (saved) { setSchedulesList((current) => (current.some((item) => item.id === saved.id) ? current : [...current, saved])); onDone((await clearDraft()) ? undefined : DRAFT_LEFT); return; } } const attempt = pendingSchedule ?? schedule; let created: Schedule; try { ({ schedule: created } = await createSchedule(attempt)); } catch (error) { // Only a write with no answer may have landed. setPendingSchedule(error instanceof HttpError ? null : attempt); throw error; } setSchedulesList((current) => upsertById(current, created)); onDone((await clearDraft()) ? undefined : DRAFT_LEFT); } catch (error) { setProblem(servicesDone ? `Their services were saved, but the working hours were not. ${describeWriteFailure(error, "Save again to add the hours.")}` : describeWriteFailure(error, "The appointment setup was not saved")); } finally { setSaving(false); } }; if (existingHours !== "none") { return (
{showHeading &&

Set up clinician appointments

}

{name}

{existingHours === "checking" &&

Checking for working hours already on file{ELLIPSIS}

} {existingHours === "found" &&

Working hours are already on file for this clinician. Change them in Schedules.

} {existingHours === "unknown" &&

We could not check whether this clinician already has working hours.

} {existingHours === "found" && } {existingHours === "unknown" && }
); } return (
{showHeading &&

Set up clinician appointments

}

{name}

{problem && setProblem(null)}>{problem}} {notice && !dirty && setNotice(null)}>{notice}}

{servicesSaved ? "These services are saved. Change them later on the staff record." : "Add the services this clinician provides under your practice's categories."}

{groups.map(({ category, services: listed }) => (

{category.display}

{listed.length === 0 &&

No services under this category yet.

} {listed.map(serviceBox)} {addButton(category.code, "Add a service under this category")}
))} {(others.length > 0 || groups.length === 0) && (
{groups.length > 0 &&

Other services

} {groups.length === 0 && services.length === 0 &&

Your practice has no services yet.

} {others.map(serviceBox)} {groups.length === 0 && addButton("", "Add a service")}
)}
({ label: location.name ?? "Location", value: location.id ?? "" }))} onChange={setLocationId} disabled={locked} /> {timeZone &&

{`Time zone ${DOT} ${timeZone}`}

}
{DAYS.map(([code, label]) => ( ))}
setStart(event.target.value)} disabled={locked} />
setEnd(event.target.value)} disabled={locked} />
{horizon ? (

{`Available from ${formatScheduleDate(horizon.start)} to ${formatScheduleDate(horizon.end)} (12 months). Times use the location's time zone. Change these dates and add more availability in Schedules.`}

) : (

This location has no time zone yet, so hours cannot be set. Set the practice time zone first.

)} {start && end && end <= start &&

The end time must be after the start time.

} {confirmBack && ( setConfirmBack(false)} onDiscard={() => { setConfirmBack(false); onBack(); }} /> )} {addingTo !== null && ( setAddingTo(null)} onSaved={(saved) => { const id = saved.id; if (id) setServiceIds((current) => toggle(current, id, true)); }} /> )}
); }; export default FirstClinicianAppointments;