import React, { useRef, useState } from 'react'; import { FileSearch, FileText, MessageSquare, X } from 'lucide-react'; import type { ScribeDraftSummary, ScribeNoteOutcome } from '../../../services/ScribeService'; import type { OverviewMessageRow } from '../../../utils/overviewMessageFeed'; import type { PatientNameResolution } from '../../../hooks/useAppointmentPatientNames'; import type { ResultAwaitingReview } from '../../../hooks/useResultsAwaitingReview'; import { daysUntilExpiry } from './owedWork'; import { RowPriority } from '../../../components/ui/row-meta'; import type { SendOperation } from '../../../utils/messaging/sendOperation'; import { MAY_HAVE_BEEN_SENT } from '../../../hooks/useHeldSendOperations'; import { useScopedSendRecovery } from '../../../hooks/useScopedSendRecovery'; import EarlierSendCheck from '../../../components/messages/EarlierSendCheck'; /** * The panel an owed row BORROWS -. * * NOT A NEW PAGE AND NOT A NEW ROUTE. Design §4.3: the queue keeps its selection the whole time, and * closing - or clicking any patient - returns exactly where the practitioner was. The header says so out * loud, naming the patient it will go back to, because a panel that replaced a chart with no way back * would read as having lost it. * * THE PANEL IS NOT IDLE SPACE. The page auto-selects the first patient on load and the next candidate * after a finish, so a patient is in it nearly always - this borrows, it does not fill a gap. (An earlier * draft of the design justified itself as "using dead space"; that was wrong and is corrected here.) */ export type OwedPanelKind = 'notes' | 'messages' | 'results'; interface Props { kind: OwedPanelKind; drafts: ScribeDraftSummary[]; messages: OverviewMessageRow[]; /** Results this practitioner ordered that have come back and nobody has acknowledged. */ results: ResultAwaitingReview[]; /** * Opens one result where it can actually be acted on - the patient's own Tests & Imaging area, * which is where the Mark reviewed control lives. * * A ROW THAT CANNOT BE ACTED ON FROM WHERE IT APPEARS is the toast's problem all over again, which * is this card's own stated rule. Acknowledging a result is a clinical decision made against the * chart, so the row carries the clinician to the chart rather than offering a one-click * acknowledgement from a list with no values on it. */ onOpenResult: (result: ResultAwaitingReview) => void; patientNames: PatientNameResolution; nowMs: number; /** The patient the panel will hand back to, when there is one. */ returnToName?: string; onClose: () => void; onOpenDraft: (draft: ScribeDraftSummary) => void; onSendReply: (row: OverviewMessageRow, text: string, send?: SendOperation) => Promise; /** * - the other way out. Optional, because the drafts panel shares this component and has * no conversation to dismiss; a missing handler hides the control rather than rendering a dead one. */ onDismiss?: (row: OverviewMessageRow) => Promise; } const PANEL_TITLE: Record = { notes: 'Notes to finish', messages: 'Messages awaiting your reply', results: 'Results to review', }; /** The queue's word for a draft without its note, from what became of the request; older drafts keep the failure wording. */ const missingNoteSuffix = (outcome: ScribeNoteOutcome | undefined): string => outcome === 'unconfirmed' ? ' · the note could not be confirmed' : outcome === 'not-sent' ? ' · no note was drafted' : ' · the note could not be generated'; const expiryLabel = (draft: ScribeDraftSummary, nowMs: number): string => { const days = daysUntilExpiry(draft, nowMs); if (days <= 0) return 'Expires today'; return days === 1 ? 'Expires in 1 day' : `Expires in ${days} days`; }; /** * The reply box. * * A TEXTAREA, THREE ROWS, RESIZABLE (Spencer). A clinical answer to a patient is a paragraph, and a * one-line input instructs them to answer in a sentence - on the one screen where the realistic * alternative is not answering at all. */ const ReplyBox: React.FC<{ row: OverviewMessageRow; onSend: (text: string, send: SendOperation) => Promise; onDismiss?: () => Promise; }> = ({ row, onSend, onDismiss }) => { const [text, setText] = useState(''); const [sending, setSending] = useState(false); const [failed, setFailed] = useState(); // Its opening ends on a row change, a viewer change or unmount; a late answer then publishes nothing here. const recovery = useScopedSendRecovery('overview-reply', `thread:${row.threadRef.threadId}`); // Bumped on every keystroke: an answer clears only the draft it was sent from. const revision = useRef(0); const [dismissing, setDismissing] = useState(false); const [dismissFailed, setDismissFailed] = useState(false); const send = async () => { if (!text.trim() || sending || recovery.waits) return; const attempt = recovery.begin(); const words = text; const drafted = revision.current; setSending(true); setFailed(undefined); const result = await recovery.run(attempt, `thread:${row.threadRef.threadId}|${words}`, (operation, leaving) => { leaving(); return onSend(words, operation); }); if (result.state === 'retired') return; setSending(false); // Cleared ONLY on success. A silently-lost reply makes a clinician believe the patient was // answered, and the patient waits on a message that was never sent. if (result.state === 'sent') { if (revision.current === drafted) setText(''); return; } setFailed(result.state === 'not-sent' ? result.reason : result.unsure ? 'That reply was not confirmed. ' + MAY_HAVE_BEEN_SENT : 'That reply did not send. Your text is still here. Check it before sending again.'); }; /** * - clear it without saying anything. * * REPORTED ON FAILURE, exactly as a failed reply is. The whole point of this control is that the * count can reach zero; a dismissal that silently did not save would leave the practitioner believing * they had cleared a thread that is still owed - which is the same defect one layer down. */ const dismiss = async () => { if (!onDismiss || dismissing) return; setDismissing(true); setDismissFailed(false); try { await onDismiss(); } catch { setDismissFailed(true); } finally { setDismissing(false); } }; return (