import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import type { Appointment, Patient } from 'fhir/r4'; import { toast } from 'sonner'; import { SelectControl } from '../ui/select-control'; import { TextControl } from '../ui/text-control'; import { Spinner } from '../ui/spinner'; import { DismissibleAlert } from '../ui/dismissible-alert'; import { Flex, FlexItem } from '../ui/flex'; import { Modal } from '../ui/wp-modal-shim'; import { TaskPrioritySelect, type TaskPriority } from '../TaskPrioritySelect/TaskPrioritySelect'; import { formatCustomTaskAppointmentDisplay } from '../CustomTaskAssignmentModal/CustomTaskAssignmentModal'; import { useAppointmentServiceContext } from '../../services/AppointmentService.tsx'; import { useFhirServiceContext } from '../../services/FhirService.tsx'; import { describePacketRefusal, usePacketPractice, useQuestionnairePacketService, type PacketAssignmentAnswer, type PacketAssignmentRequest, type QuestionnairePacket, } from '../../services/QuestionnairePacketService'; import { isSaveRetired, outcomeUnknown, useSaveScope, type SaveOperation } from '../../utils/saveOperation'; import { writeThroughViewer } from '../../utils/writeThroughHold'; import { admitPacketAssignmentSend, heldPacketAssignment, onPacketAssignmentRecoveryChange, packetAssignmentOwner, refusalSettles, releasePacketAssignment, reservePacketAssignment, successSettles, type HeldPacketAssignment, type PacketAssignmentOwner, } from './packetAssignmentRecovery'; interface PacketAssignmentModalProps { isOpen: boolean; patient: Patient; onClose: () => void; onAssigned?: (answer: PacketAssignmentAnswer) => void; presentation?: 'modal' | 'inline'; formTitle?: string; formDescription?: string; } type DueMode = 'none' | 'date' | 'appointment'; const UPCOMING = new Set(['proposed', 'pending', 'booked', 'arrived', 'checked-in', 'waitlist']); const formsCount = (packet: QuestionnairePacket) => `${packet.members.length} ${packet.members.length === 1 ? 'form' : 'forms'}`; const NOT_KNOWN = 'Whether the packet was assigned is not known. Try again to check: it will not be assigned twice.'; const NOT_KEPT = 'This assignment could not be kept in this browser, so nothing was sent. Try again.'; const UNREADABLE = 'An earlier assignment from this browser could not be read back, so no other can be started here. Ask support to check it.'; const SETTLED_ELSEWHERE = 'This assignment was settled in another window. Choose a packet to assign another.'; export const PacketAssignmentModal = ({ isOpen, patient, onClose, onAssigned, presentation = 'modal', formTitle, formDescription, }: PacketAssignmentModalProps) => { const { listPackets, assignPacket } = useQuestionnairePacketService(); const { fetchAppointments } = useAppointmentServiceContext(); const { unpackBundle } = useFhirServiceContext(); const practice = usePacketPractice(); const key = `${practice ?? ''}|${patient.id ?? ''}`; const lives = useSaveScope(key); const scopeOf = (life: SaveOperation) => ({ admit: life.admit, signal: life.transport.signal, organizationId: practice }); const listRef = useRef(listPackets); listRef.current = listPackets; const [read, setRead] = useState<'loading' | 'ready' | 'failed'>('loading'); const [attempt, setAttempt] = useState(0); const [packets, setPackets] = useState([]); const [complete, setComplete] = useState(true); const [packetId, setPacketId] = useState(''); const [priority, setPriority] = useState('routine'); const [dueMode, setDueMode] = useState('none'); const [dueDate, setDueDate] = useState(''); const [appointmentId, setAppointmentId] = useState(''); const [appointments, setAppointments] = useState<{ read: 'loading' | 'ready' | 'failed'; list: Appointment[] }>({ read: 'loading', list: [] }); const [appointmentsAttempt, setAppointmentsAttempt] = useState(0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(); const [unknown, setUnknown] = useState(false); // Who this opening is for, and the assignment it still holds, if any. Recovery survives a reload in this browser. const viewer = writeThroughViewer(); const [owner, setOwner] = useState(); const [held, setHeld] = useState(); const heldRef = useRef(undefined); heldRef.current = held; // Each opening of a held assignment is a generation; a reset retires the attempts of the one before it. const generation = useRef(0); const active = useRef(undefined); // The reservation the current attempt is making, by opening and route, and its operation once the lock created it. // Only that exact operation is adopted as its own; a predecessor clears only the marker it set. const reservation = useRef<{ opening: number; route: string; operationId?: string } | undefined>(undefined); // Operations this opening's current attempt is releasing itself: their removal is its own result, not a settlement elsewhere. const releasing = useRef(new Set()); const showHeld = (entry: HeldPacketAssignment | undefined, message?: string) => { setHeld(entry); setPacketId(entry?.request.packetId ?? ''); setPriority(entry?.request.priority ?? 'routine'); setDueMode(entry?.request.appointmentId ? 'appointment' : entry?.request.dueDate ? 'date' : 'none'); setDueDate(entry?.request.dueDate ?? ''); setAppointmentId(entry?.request.appointmentId ?? ''); setUnknown(Boolean(entry)); setError(entry ? message ?? NOT_KNOWN : message); }; // Each opening finds the assignment this viewer, practice and patient still hold, and follows it in other windows. useEffect(() => { if (!isOpen) return undefined; let live = true; let stop = () => {}; active.current?.retire(); generation.current += 1; setSubmitting(false); setOwner(undefined); showHeld(undefined); packetAssignmentOwner(viewer, practice, patient.id).then((found) => { if (!live) return; const follow = () => { let now: HeldPacketAssignment | undefined; try { now = heldPacketAssignment(found); } catch { setOwner('unreadable'); setError(UNREADABLE); return; } const before = heldRef.current; if (!before && !now) return; if (before && now?.operationId === before.operationId) return; if (before && !now && releasing.current.has(before.operationId)) return; // This opening's own reservation, seen before it returns: adopted, not a change from elsewhere. const own = reservation.current; if (!before && now && own && own.opening === generation.current && own.route === now.route && own.operationId === now.operationId) { heldRef.current = now; setHeld(now); return; } if (before || now) { // What the old opening was asking about is no longer this one's: its attempts are retired first. active.current?.retire(); generation.current += 1; setSubmitting(false); } showHeld(now, before && !now ? SETTLED_ELSEWHERE : undefined); }; setOwner(found); follow(); stop = onPacketAssignmentRecoveryChange(found, follow); }, () => { if (live) setOwner('missing'); }); return () => { live = false; stop(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- one owner per opening, viewer, practice and patient }, [isOpen, key, viewer]); // Each opening reads the practice's packets afresh. useEffect(() => { if (!isOpen) return undefined; const life = lives.start(); setRead('loading'); life.step(() => listRef.current(scopeOf(life))).then((answer) => { setPackets(answer.packets.filter((packet) => packet.status === 'active') .sort((left, right) => left.title.localeCompare(right.title))); setComplete(answer.complete); setRead('ready'); }, (failure) => { if (isSaveRetired(failure)) return; setRead('failed'); }); return () => life.retire(); // eslint-disable-next-line react-hooks/exhaustive-deps -- a new read per opening, practice and patient, and per Try again }, [isOpen, key, attempt]); // The visits a packet can be due at are read when that is the choice, not before. useEffect(() => { if (!isOpen || dueMode !== 'appointment') return undefined; const life = lives.start(); setAppointments({ read: 'loading', list: [] }); const from = new Date(Date.now() - 60_000).toISOString(); life.step(() => fetchAppointments(patient, from, undefined, practice, { admit: life.admit, signal: life.transport.signal })).then((bundle) => { const now = Date.now(); const list = (unpackBundle(bundle) as Appointment[]) .filter((appointment) => appointment.id && appointment.start && Date.parse(appointment.start) >= now && UPCOMING.has(appointment.status)) .sort((left, right) => Date.parse(left.start as string) - Date.parse(right.start as string)); setAppointments({ read: 'ready', list }); }, (failure) => { if (isSaveRetired(failure)) return; setAppointments({ read: 'failed', list: [] }); }); return () => life.retire(); // eslint-disable-next-line react-hooks/exhaustive-deps -- read once per choice, and per Try again }, [isOpen, dueMode, appointmentsAttempt, key]); useEffect(() => { if (isOpen) return; setSubmitting(false); }, [isOpen]); const selected = useMemo(() => packets.find((packet) => packet.id === packetId), [packets, packetId]); if (!isOpen) return null; const request = (): PacketAssignmentRequest | string => { if (!selected) return 'Select a packet to assign.'; if (dueMode === 'date' && !dueDate) return 'Choose the date the packet is due.'; if (dueMode === 'appointment' && !appointmentId) return 'Choose the appointment the packet is due at.'; return { packetId: selected.id, priority, ...(dueMode === 'date' ? { dueDate } : {}), ...(dueMode === 'appointment' ? { appointmentId } : {}), }; }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (!patient.id || submitting) return; if (!owner || owner === 'missing' || owner === 'unreadable') { setError(owner === 'unreadable' ? UNREADABLE : NOT_KEPT); return; } const mine = owner; const opening = generation.current; const life = lives.start(); active.current = life; // Only this attempt of this opening may change what the dialog shows; a retired one changes nothing. const current = () => life.belongs() && generation.current === opening; // Released only while this attempt is still current once the lock is held; its own change event is marked as its own. const releaseOwn = async (operationId: string) => { releasing.current.add(operationId); try { await releasePacketAssignment(mine, operationId, () => { if (!current()) throw new Error('This attempt was retired.'); }); } catch { // Kept: a retired attempt changes nothing, and a failed write is answered by the next check. } finally { releasing.current.delete(operationId); } }; setSubmitting(true); setError(undefined); let sending = heldRef.current; try { if (!sending) { const asked = request(); if (typeof asked === 'string') { setError(asked); return; } let reserved: { entry: HeldPacketAssignment; fresh: boolean }; const marker: { opening: number; route: string; operationId?: string } = { opening, route: mine.route }; reservation.current = marker; try { reserved = await reservePacketAssignment(mine, asked, life.admit, (entry) => { marker.operationId = entry.operationId; }); } catch (failure) { if (isSaveRetired(failure) || !current()) return; setError(NOT_KEPT); return; } finally { if (reservation.current === marker) reservation.current = undefined; } if (!current()) return; setHeld(reserved.entry); heldRef.current = reserved.entry; if (!reserved.fresh) { showHeld(reserved.entry); return; } sending = reserved.entry; } const operation = sending; const admitted = await admitPacketAssignmentSend(mine, operation.operationId, operation.request); if (!current()) return; if (!admitted) { showHeld(undefined, SETTLED_ELSEWHERE); return; } const answer = await life.step(() => assignPacket(patient.id as string, operation.operationId, operation.request, scopeOf(life)), true); if (!current()) return; const made = await successSettles(mine.practice, operation.operationId, answer); if (!current()) return; if (!made) { setUnknown(true); setError(NOT_KNOWN); return; } // A release that fails leaves the entry; the next check of this operation answers it as made and releases it. await releaseOwn(operation.operationId); if (!current()) return; showHeld(undefined); const name = answer.assignment.title || selected?.title || 'The packet'; toast.success(answer.notification === 'queued' ? `${name} was assigned.` : `${name} was assigned. The email telling the patient has not been confirmed yet.`); onAssigned?.(answer); onClose(); } catch (failure) { if (isSaveRetired(failure) || !current()) return; const status = (failure as { status?: unknown })?.status; const operation = sending ?? heldRef.current; const ended = Boolean(operation) && !outcomeUnknown(failure) && await refusalSettles(mine.practice, (operation as HeldPacketAssignment).operationId, failure); if (!current()) return; if (ended && operation) { await releaseOwn(operation.operationId); if (!current()) return; // Ended for good: what was chosen stays on the form, and the next press is a new assignment. setHeld(undefined); setUnknown(false); setError(describePacketRefusal(failure, 'Failed to assign the packet.')); return; } setUnknown(Boolean(heldRef.current)); setError(outcomeUnknown(failure) || (typeof status === 'number' && status >= 500) ? NOT_KNOWN : describePacketRefusal(failure, 'Failed to assign the packet.')); } finally { if (current()) setSubmitting(false); } }; const handleClose = () => { if (submitting) return; onClose(); }; const locked = submitting || unknown; const title = formTitle || 'Assign packet'; const appointmentLabel = (appointment: Appointment) => formatCustomTaskAppointmentDisplay(appointment) || 'Appointment'; const form = (
{error && {error}} {read === 'loading' ? (
Loading packets…
) : read === 'failed' ? (

The practice’s packets could not be read, so none can be offered yet.

) : packets.length === 0 && !unknown ? ( {complete ? 'No packet is active in this practice. A Platform Manager sets a packet to Active under Forms when it is ready.' : 'No active packet could be found, but not every packet could be read. Try again before concluding there is none.'} ) : ( <> {!complete && (

Not every packet could be read, so one may be missing from this list.

)} ({ label: `${packet.title} (${formsCount(packet)})`, value: packet.id })), ...(unknown && packetId && !selected ? [{ label: 'The packet asked for before', value: packetId }] : []), ]} onChange={(value) => { setPacketId(value); const packet = packets.find((each) => each.id === value); if (packet) setPriority(packet.priority); setError(undefined); }} disabled={locked} /> {selected && (
    {selected.members.map((member, index) => (
  1. {member.title || 'Untitled questionnaire'}
  2. ))}
)} { setDueMode(value as DueMode); setError(undefined); }} disabled={locked} /> {dueMode === 'date' && ( )} {dueMode === 'appointment' && ( appointments.read === 'loading' ? (
Checking upcoming appointments…
) : appointments.read === 'failed' ? (

The patient’s upcoming appointments could not be read.

) : appointments.list.length === 0 && !unknown ? (

This patient has no upcoming appointment. Choose a date instead, or book one first.

) : ( ({ label: appointmentLabel(appointment), value: appointment.id as string })), ]} onChange={(value) => { setAppointmentId(value); setError(undefined); }} disabled={locked} /> ) )} )}
); if (presentation === 'inline') { return (

{title}

{formDescription &&

{formDescription}

}
{form}
); } return ( {form} ); };