import { useEffect, useRef, useState } from 'react'; import type { Questionnaire } from 'fhir/r4'; import { Archive, MoreHorizontal } from 'lucide-react'; import { Item, ItemGroup } from '../../../../components/ui/item-group'; import { Spinner } from '../../../../components/ui/spinner'; import { Spacer } from '../../../../components/ui/spacer'; import { Text } from '../../../../components/ui/text'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { DropdownMenu, DropdownMenuItem } from '../../../../components/ui/dropdown-menu'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../../../components/ui/dialog'; import { PacketChangedError, describePacketRefusal, usePacketPractice, useQuestionnairePacketService, type PacketStatus, type QuestionnairePacket, } from '../../../../services/QuestionnairePacketService'; import { isSaveRetired, outcomeUnknown, useSaveScope, type SaveOperation } from '../../../../utils/saveOperation'; import { draftOfPacket, triggerSummary } from '../../../../utils/questionnairePacketDraft'; import PacketEditorDialog from './PacketEditorDialog'; import { packetName } from './packetWrites'; interface QuestionnairePacketsProps { /** The practice's clinical questionnaires; public forms are never offered. */ questionnaires: Questionnaire[]; onPreview: (questionnaire: Questionnaire) => void; } const STATUS_LABELS: Record = { draft: 'Draft', active: 'Active', retired: 'Retired' }; // Clinical questionnaires -> Packets: a practice's named, ordered sets of its own clinical questionnaires. const QuestionnairePackets = ({ questionnaires, onPreview }: QuestionnairePacketsProps) => { const { listPackets, updatePacket } = useQuestionnairePacketService(); // Read through a ref: a new service identity on a re-render is not a reason to read again. const listRef = useRef(listPackets); listRef.current = listPackets; const practice = usePacketPractice(); // Reads and retirements belong to the practice they were started in; moving to another closes what was open. const lives = useSaveScope(practice, () => { setEditing(null); setToRetire(null); }); const scopeOf = (life: SaveOperation) => ({ admit: life.admit, signal: life.transport.signal, organizationId: practice }); // The list is unknown until its read answers; a failed or pending read is never "no packets". const [read, setRead] = useState<'loading' | 'ready' | 'failed'>('loading'); const [attempt, setAttempt] = useState(0); const [packets, setPackets] = useState([]); const [complete, setComplete] = useState(true); const [editing, setEditing] = useState<{ packet?: QuestionnairePacket } | null>(null); const [toRetire, setToRetire] = useState(null); const [retiring, setRetiring] = useState>({}); const [listError, setListError] = useState(null); useEffect(() => { const life = lives.start(); setRead('loading'); setListError(null); life.step(() => listRef.current(scopeOf(life))).then((answer) => { setPackets(answer.packets); setComplete(answer.complete); setRead('ready'); }, (error) => { if (isSaveRetired(error)) return; setRead('failed'); }); return () => life.retire(); // eslint-disable-next-line react-hooks/exhaustive-deps -- a new read per practice and per Try again }, [attempt, practice]); const reload = () => setAttempt((n) => n + 1); const keep = (saved: QuestionnairePacket) => setPackets((list) => ( list.some((each) => each.id === saved.id) ? list.map((each) => (each.id === saved.id ? saved : each)) : [saved, ...list] )); const retire = async () => { const packet = toRetire; setToRetire(null); if (!packet?.version) return; setRetiring((busy) => ({ ...busy, [packet.id]: true })); setListError(null); const life = lives.start(); try { const version = packet.version; keep(await life.step(() => updatePacket(packet.id, version, { ...draftOfPacket(packet), status: 'retired' }, scopeOf(life)), true)); } catch (error) { if (isSaveRetired(error)) return; if (error instanceof PacketChangedError) { if (error.current) keep(error.current); setListError(`${packet.title} changed since this list was loaded, so it was not retired. Check it and try again.`); } else if (outcomeUnknown(error)) { setListError(`We could not confirm ${packet.title} was retired. The list has been refreshed.`); reload(); } else { setListError(describePacketRefusal(error, `${packet.title} was not retired`)); } } finally { setRetiring((busy) => ({ ...busy, [packet.id]: false })); } }; const memberTitle = (questionnaireId: string, saved?: string) => questionnaires.find((questionnaire) => questionnaire.id === questionnaireId)?.title || saved || 'Untitled questionnaire'; // A dialog opened from the row menu keeps focus; the closing menu must not pull it back to its trigger. const dialogFromMenu = useRef(false); const keepDialogFocus = (event: Event) => { if (!dialogFromMenu.current) return; dialogFromMenu.current = false; event.preventDefault(); }; const renderRow = (packet: QuestionnairePacket) => { const editable = packet.status !== 'retired'; const count = packet.members.length; return (
{editable ? ( ) : ( {packet.title} )} {STATUS_LABELS[packet.status]}

{count} questionnaire{count === 1 ? '' : 's'}: {packet.members.map((member) => memberTitle(member.questionnaireId, member.title)).join(', ')}

{packet.status === 'retired' ? 'Retired, so it cannot be assigned' : triggerSummary(packet.trigger)}

{editable && (
{retiring[packet.id] ? : ( )}
)}
); }; return (

Packets

Send several clinical questionnaires to a patient as one item, in the order you set.

{listError && ( <> setListError(null)}>{listError} )} {read === 'loading' ? ( Loading packets… ) : read === 'failed' ? (
We could not load your packets. Your packets have not changed.
) : ( <> {!complete && ( <> Some packets could not be read, so this list may be missing some.{' '} )} {packets.length > 0 ? ( {packets.map(renderRow)} ) : complete ? ( No packets yet. ) : null} )} {editing && ( each.id)} onUnconfirmed={reload} onClose={() => setEditing(null)}/> )} {toRetire && ( { if (!isOpen) setToRetire(null); }}> Retire {packetName(toRetire)}?

It can no longer be assigned, by hand or automatically. Packets already sent to patients, and their answers, are not changed.

)}
); }; export default QuestionnairePackets;