import { useEffect, useMemo, useRef, useState } from "react"; import { OverlayActions } from "../../../components/ui/overlay-actions"; import { useDirtyReport } from "../../../hooks/useDirtyReport"; import type { Organization } from "fhir/r4"; import { useAuthServiceContext } from "../../../services/AuthService"; import { useOrganizationServiceContext } from "../../../services/OrganizationService"; import { useFhirHealthcareServices } from "../../../hooks"; import { isNewPatientConsultationType } from "../../../config/newPatientExam"; import { OFFSET_PRESETS, formatOffset, resolveOrgReminderPolicy } from "../../../config/appointmentReminderPolicy"; import { DURATION_QUICK_PICK_MINUTES, formatDurationLabel } from "../../../config/appointmentDuration"; import { bookingRulesProblem, changedParts, readBookingRules, toReminderPolicy, withBookingRules, type BookingRulesDraft, type BookingRulesPart, } from "../../../utils/bookingRulesDraft"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { ToggleControl } from "../../../components/ui/toggle-control"; import { Checkbox } from "../../../components/ui/checkbox"; import { Label } from "../../../components/ui/label"; import { RadioGroup, RadioGroupItem } from "../../../components/ui/radio-group"; import { SelectControl } from "../../../components/ui/select-control"; import { TextControl } from "../../../components/ui/text-control"; import { DismissibleAlert } from "../../../components/ui/dismissible-alert"; import { RECORD_CHANGED_MESSAGE, RELOAD_DISCARDS_MESSAGE } from "../../../utils/recordChanged"; import { useRefreshSessionOrganization } from "../../../hooks/useRefreshSessionOrganization"; import { outcomeUnknown, SaveRetiredError, TIMED_OUT_MAY_HAVE_SAVED, useSaveScope } from "../../../utils/saveOperation"; import { olderThan } from "../../../utils/versionOrder"; // The service's translation of the server's 412 (StaleOrganizationError), known by its name as the session hook // knows a retired save: someone else saved the practice first, so this opening saves nothing more until reloaded. const refusedAsStale = (err: unknown): boolean => (err as { name?: unknown } | null)?.name === "StaleOrganizationError"; import "./BookingRulesPanel.css"; // Someone else changed the booking rules this form was filled from: nothing of them is written, and the form is // reloaded before anything more is saved from it. class BookingChangedElsewhere extends Error { constructor() { super(RECORD_CHANGED_MESSAGE); this.name = "BookingChangedElsewhere"; } } // The deck's times, plus any other preset already in use. const DECK_OFFSETS = [60, 120, 1440, 10080]; const PART_LABEL: Record = { booking: "how online bookings work", policy: "video visits", reminders: "appointment reminders", }; const BOOKING_MODES = [ { value: "direct", title: "Direct", detail: "The patient picks a time and it is booked." }, { value: "review", title: "Review", detail: "The slot is held and the front desk approves it." }, { value: "request-only", title: "Request only", detail: "The patient asks and the front desk confirms. A chosen time is held; preferred days hold nothing.", }, ] as const; type BookingRulesPanelProps = { onDirtyChange?: (dirty: boolean) => void }; // One Save over three stores: the booking extensions (a whole-organization write) first, then the // video and reminder routes. Only changed parts are written. const BookingRulesPanel = ({ onDirtyChange }: BookingRulesPanelProps) => { const { currentOrganization, setWpOrganization } = useAuthServiceContext(); const { updateOrganization, updateAppointmentPolicySettings, updateAppointmentReminderSettings } = useOrganizationServiceContext(); const { healthcareServicesBundle, healthcareServicesList } = useFhirHealthcareServices(); const saved = useMemo(() => readBookingRules(currentOrganization), [currentOrganization]); const [draft, setDraft] = useState(saved); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [savedNotice, setSavedNotice] = useState(false); const [customDuration, setCustomDuration] = useState(!DURATION_QUICK_PICK_MINUTES.includes(saved.defaultDurationMinutes)); // Refused because another admin changed the booking rules: nothing more is saved until Reload record. const [stale, setStale] = useState(false); const [reloading, setReloading] = useState(false); const refreshOrganization = useRefreshSessionOrganization(); // Saves and reloads belong to this sheet, practice and viewer; closing or replacing the sheet retires them. const saveScope = useSaveScope(currentOrganization?.id); // The practice as the session holds it now: a reload never refills the form from anything older. const live = useRef(currentOrganization); live.current = currentOrganization; // Re-seed from a newer organization only while the form is untouched. const seededFrom = useRef(saved); useEffect(() => { setDraft((current) => { if (changedParts(current, seededFrom.current).length > 0) return current; seededFrom.current = saved; return saved; }); }, [saved]); const pending = changedParts(draft, saved); useDirtyReport(pending.length > 0, onDirtyChange); // The rule is satisfied by any active service typed as a new patient consultation. Warn, never block, // once the catalogue has loaded and has none. const noConsultationService = Boolean(healthcareServicesBundle) && !(healthcareServicesList ?? []).some( (service) => service.active !== false && isNewPatientConsultationType(service.type), ); const update = (patch: Partial) => { setDraft((current) => ({ ...current, ...patch })); setSavedNotice(false); }; const save = async () => { // Only the booking extensions are checked, and only when they are being written. const problem = pending.includes("booking") ? bookingRulesProblem(draft) : undefined; if (problem) { setError(problem); return; } const operation = saveScope.start(); setSaving(true); setError(undefined); const done: BookingRulesPart[] = []; let latest: Organization | null = currentOrganization; let failed: BookingRulesPart | undefined; try { for (const part of pending) { failed = part; // Admitted before each part is sent: a retired save sends nothing more. if (!operation.belongs()) throw new SaveRetiredError(); if (part === "booking") { if (!latest) throw new Error("The practice is not loaded."); // Only over the booking rules this form was filled from: if someone else changed them since, nothing is // written over them, and nothing more is saved from this form until it is reloaded. Another practice // setting changing does not stand in the way; the write itself is conditional on the version it is built on. if (changedParts(readBookingRules(latest), seededFrom.current).includes("booking")) { throw new BookingChangedElsewhere(); } const base = latest; latest = await operation.step(() => updateOrganization(withBookingRules(base, draft), operation.transport), true); } else if (part === "policy") { latest = (await operation.step(() => updateAppointmentPolicySettings({ telehealthEnabled: draft.telehealthEnabled }, operation.transport), true)).organization; } else { const policy = toReminderPolicy(draft, resolveOrgReminderPolicy(latest)); latest = (await operation.step(() => updateAppointmentReminderSettings(policy, operation.transport), true)).organization; } // Answered after it was retired: nothing of it is published here. if (!operation.belongs()) throw new SaveRetiredError(); setWpOrganization(latest); done.push(part); } seededFrom.current = readBookingRules(latest); setSavedNotice(true); } catch (err) { if (err instanceof SaveRetiredError || !operation.belongs()) return; // What was confirmed saved stays said; the part that failed decides what comes next. const kept = done.length ? `Saved ${done.map((p) => PART_LABEL[p]).join(" and ")}. ` : ""; if (err instanceof BookingChangedElsewhere || refusedAsStale(err)) { setStale(true); setError(`${kept}Not saved: ${failed ? PART_LABEL[failed] : PART_LABEL.booking}. ${RECORD_CHANGED_MESSAGE}`); return; } if (outcomeUnknown(err)) { // Sent, with its answer lost: it may have saved. Not "not saved", and not Save again - Reload record checks. setStale(true); setError(`${kept}Not confirmed: ${failed ? PART_LABEL[failed] : "your changes"}. ${TIMED_OUT_MAY_HAVE_SAVED}`); return; } const reason = relayServerFailure(err, "It could not be saved."); setError(`${kept}Not saved: ${failed ? PART_LABEL[failed] : "your changes"}. ${reason} Save again to retry what is left.`); } finally { if (operation.belongs()) setSaving(false); operation.retire(); } }; // Reload record: the practice as it is now, read fresh, refills the form (discarding its edits, as it said first) // and becomes what the next save is checked against. // Never backward: an answer older than the practice the session holds refills from that newer one. const reload = async () => { const operation = saveScope.start(); setReloading(true); try { const fresh = await refreshOrganization(operation.transport); if (!operation.belongs()) return; if (!fresh) { setError("The practice could not be read. Try Reload record again."); return; } const known = live.current; const newest = known && known.id === fresh.id && olderThan(fresh, known) ? known : fresh; const rules = readBookingRules(newest); seededFrom.current = rules; setDraft(rules); setStale(false); setError(undefined); } catch (err) { if (err instanceof SaveRetiredError || !operation.belongs()) return; setError("The practice could not be read. Try Reload record again."); } finally { if (operation.belongs()) setReloading(false); operation.retire(); } }; const offsets = [...new Set([...DECK_OFFSETS, ...draft.reminderOffsets.filter((m) => OFFSET_PRESETS.includes(m))])].sort( (a, b) => a - b, ); const toggleOffset = (minutes: number, checked: boolean) => update({ reminderOffsets: checked ? [...draft.reminderOffsets, minutes].sort((a, b) => a - b) : draft.reminderOffsets.filter((m) => m !== minutes), }); return (
These reach every patient. Changing how bookings are taken applies to the next patient who opens your portal; changing reminder times applies to every appointment already in the book. A reminder that has already been sent cannot be recalled.
{error && ( setError(undefined)}> {error} )} update({ telehealthEnabled: checked })} disabled={saving} />
How online bookings work update({ bookingMode: value as BookingRulesDraft["bookingMode"] })} disabled={saving} > {BOOKING_MODES.map((mode) => (
))}
{draft.bookingMode === "request-only" && ( update({ requestStyle: value as BookingRulesDraft["requestStyle"] })} disabled={saving} >
)}
update({ requireNewPatientFirst: checked === true })} />
{draft.requireNewPatientFirst && noConsultationService && (
None of your services is a new patient consultation yet, so new patients cannot book online. Give one of your services the type New patient consultation under Services.
)}
Appointment reminders
Emailed only to patients who have turned reminders on in their own communication preferences.
    {offsets.map((minutes) => (
  • toggleOffset(minutes, checked === true)} />
  • ))}
({ label: String(n), value: String(n) }))} onChange={(value) => update({ maxReminders: Number(value) })} disabled={saving} />
More booking settings
({ label: formatDurationLabel(minutes), value: String(minutes) })), { label: "Custom…", value: "custom" }, ]} onChange={(value) => { setCustomDuration(value === "custom"); if (value !== "custom") update({ defaultDurationMinutes: Number(value) }); }} disabled={saving} /> {customDuration && ( update({ defaultDurationMinutes: parseInt(value, 10) || 0 })} disabled={saving} /> )}
update({ quietHours: checked === true ? { start: "21:00", end: "08:00", behavior: "defer" } : null }) } />
{draft.quietHours && (
draft.quietHours && update({ quietHours: { ...draft.quietHours, start } })} disabled={saving} /> draft.quietHours && update({ quietHours: { ...draft.quietHours, end } })} disabled={saving} />
draft.quietHours && update({ quietHours: { ...draft.quietHours, behavior: behavior as "defer" | "suppress" } }) } disabled={saving} >
)}
{savedNotice && pending.length === 0 && Saved} {stale && ( <> {RELOAD_DISCARDS_MESSAGE} )}
); }; export default BookingRulesPanel;