import { useChatSendRecovery } from '../../../../hooks/useChatSendRecovery'; import { scannedPhotoSrc } from '../../../../utils/fhirPhoto'; import React, { useState, useEffect, useContext } from 'react'; import { Attachment, Communication, CommunicationRequest, Patient, Practitioner, Reference, } from 'fhir/r4'; import { useAuthServiceContext, useCommunicationServiceContext, useFhirServiceContext, usePatientServiceContext, usePractitionerServiceContext, } from '../../../../services'; import { Chat, FhirCommunicationRequestDisplay, Paginator, } from '../../../../components'; import PresenceDot from '../../../../components/PresenceDot/PresenceDot'; import { formatFhirHumanName } from '../../../../utils/fhirHumanName.tsx'; import { useFhirCommunicationRequestTargetedBroadcast, useCommunicationManager, useFhirPractitioners, useNeedsReply, useInboxThreads, } from '../../../../hooks'; import { isStaffSide } from '../../ReceptionistHomePage/frontDeskRail'; import { CommunicationRequestModal } from '../../../../modals'; import { Card, CardContent, CardTitle } from '../../../../components/ui/card'; import { Button } from '../../../../components/ui/button'; import { Skeleton } from '../../../../components/ui/skeleton'; import { Spinner } from '../../../../components/ui/spinner'; import { InputGroup, InputGroupAddon, InputGroupInput, } from '../../../../components/ui/input-group'; import { Loader2, MessageSquareText, SearchIcon, TriangleAlert, X } from 'lucide-react'; import { EmptyState } from '../../../../components/ui/empty-state'; import FrontDeskClaimStrip from '../../../../components/messages/FrontDeskClaimStrip'; import { DemoVisitRoleContext } from '../../../Demo/DemoRoleSwitch'; import './PatientsPanel.css'; import ReferralArrivalItem from '../../../../components/messages/ReferralArrivalItem'; import { useReferralQueue } from '../../../../hooks/useReferralQueue'; import { referralMatchStateFor } from '../../../../utils/messaging/referralMatchState'; import MatchReferralToPatientDialog from '../../ReferralsPage/MatchReferralToPatientDialog'; import { isSaveRetired, useSaveScope, type SaveOperation } from '../../../../utils/saveOperation'; import type { ReferralRequestScope } from '../../../../utils/referralRequestScope'; import { MessagesLoadFailure } from './MessagesLoadFailure'; import { awaitsDeskReply, claimAfterReply, type ThreadClaimState } from '../../../../utils/messaging/threadClaim'; import { getReferralSubject } from '../../../../utils/messaging/referralThread'; import { isPoolThreadRequest, counterpartyReference, getThreadPoolKey, FRONT_DESK_POOL_CHANNEL, FRONT_DESK_ASIDE_POOL_KEY, readFrontDeskPoolNotice, } from '../../../../utils/messaging/poolThread'; import { isEncounterRoomThreadRequest, ROOM_THREAD_LABEL, } from '../../../../utils/messaging/roomThread'; import { useWebSocketServiceContext } from '../../../../services/WebSocketService'; import { CONVERSATION_COMPOSER_ID } from '../../../../components/Chat/Chat'; import { useClaimKeepAlive } from '../../../../hooks/useClaimKeepAlive'; import { getThreadOrganizationId } from '../../../../utils/messaging/counterparty'; import clsx from 'clsx'; import { toast } from 'sonner'; import { MESSAGES_SPLIT_GRID, messagesInboxPane, messagesThreadPane } from "../../../../utils/messagesLayout"; import { threadLastActivity, threadPreview } from "../../../../utils/threadSummary"; import { formatRelativeTime } from "../../../../utils/formatRelativeTime"; import { describeBlockedAction, isRefusedRead } from "../../../../utils/fetchFailureMessage"; import { InboxAvatar } from "../../MessagesPage/InboxRow"; import { SearchControl } from "../../../../components/ui/search-control"; import { DeskDirectoryRow, DeskInboxRow } from "./DeskInboxRow"; import { useColleagueInboxRows } from "./useColleagueInboxRows"; import { patientDirectoryLine } from "./patientDirectoryLine"; import { BackToConversationsButton, NewConversationButton } from "./ConversationListSwitch"; import { counterpartyRowName, rowNameText, unnamedAuthorOf, type NameRead, type RowName } from "../../../../utils/messaging/counterpartyRowName"; import { CounterpartyAvatar, CounterpartyName } from "./CounterpartyName"; import { ColleaguesPanel } from "./ColleaguesPanel"; import { namedSend, type SendOperation } from "../../../../utils/messaging/sendOperation"; import { rankThreadListing, rowsNoticeFor } from "../../../../utils/messaging/threadListingNotice"; import { ThreadListingNotice } from "../../../../components/messages/ThreadListingNotice"; import type { SaveTransport } from '../../../../utils/saveOperation'; interface PatientsPanelProps { initialOpenRequestId?: string; initialOpenWith?: string; // When true, wraps the list-column renderContent() output in the original standalone // Card/CardTitle("Messages") chrome - for standalone mounts (the per-chart // communication-requests route via the PatientMessagesPage wrapper) that have no shell // providing their own chrome. Defaults falsy for the StaffMessagesPage shell mount, which // already supplies its own Card/CardTitle and would otherwise get a doubled title/border. showChrome?: boolean; /** The desk's flat tabs choose the list; absent keeps this panel's own Inbox | Patients row. */ view?: "inbox" | "directory"; /** A colleague conversation for the desk inbox to open. */ initialOpenColleagueRequestId?: string; } const photoUrlOf = (photo?: Attachment): string | undefined => scannedPhotoSrc(photo); // Ported from PatientMessagesPage (Slice 2a Task 3). Role-gating (Practitioner/Receptionist) now // happens at the StaffMessagesPage shell, which only renders this panel for eligible roles/tabs. const requirePatient = (patient: Patient | undefined): Patient => { if (!patient) throw new Error("Patient not found"); return patient; }; export const PatientsPanel: React.FC = ({ initialOpenRequestId, initialOpenWith, showChrome = false, view, initialOpenColleagueRequestId }) => { // Chat holds each named send for a full reload and settles what a reload left. const sendRecovery = useChatSendRecovery(); const desk = view !== undefined; const { currentUser, USER_ROLES, userRole, currentOrganization } = useAuthServiceContext(); const { fetchPatients, fetchPatient, patientState, setTempPatientState } = usePatientServiceContext(); const { saveCommunicationBundle, readCommunications, createCommunication, // The write's own result goes on screen - see the note at the send. addCommunicationToState, openDirectThread, // - the office's conversation, and who is answering it. openFrontDeskThread, // - the same queue, opened by referral rather than by patient. openFrontDeskReferralItem, claimFrontDeskThread, releaseFrontDeskThread, createCommunicationForThread, threadListingState, } = useCommunicationServiceContext(); const { extractReferenceId, createReferenceObject } = useFhirServiceContext(); // - the roster the claim strip resolves a colleague's NAME from. const { practitionersList, fetchPractitioner } = usePractitionerServiceContext(); // THIS PANEL READ THE STAFF ROSTER WITHOUT EVER ASKING FOR IT. // // `practitionersList` is context state that somebody has to populate; the Colleagues panel next door // calls this same hook, and nothing on this side did. So the list was whatever a previously-visited // screen happened to leave behind - EMPTY on a fresh load of Messages. // // Caught in a browser: the patient-messaging toggle did not render for a physician who should have it, // then appeared after visiting Colleagues and coming back. A control that is absent until you wander // through another tab is a control nobody finds, which is the exact failure its placement on this page // was chosen to avoid. The claim strip beside it reads the same list to name the colleague who answered, // and was quietly getting nothing on that same first load. useFhirPractitioners(); const { patientCommunicationRequests, loading: communicationLoading, error: communicationError, errorRetryable: communicationErrorRetryable, notPermitted: messagingNotPermitted, getMessagesForRequest, getUnreadCountForRequest, updateCommunicationRequest, setActiveThread, clearError: clearCommunicationError, // The inbox refresh the front-desk pool ping triggers - see the subscription below. loadCommunicationData, } = useCommunicationManager({ rereadOnOpen: true }); const { subscribe, unsubscribe } = useWebSocketServiceContext(); // Staff have no helper grant to refuse - see `rankThreadListing` - so only the scope's own // listing state decides this. Retried the same way everywhere: a fresh read for this scope. const listingRank = rankThreadListing({ state: threadListingState }); const retryListing = () => void loadCommunicationData(true); const currentPractitionerRef = currentUser?.fhir?.id ? `Practitioner/${currentUser.fhir.id}` : ''; const isReceptionist = userRole === USER_ROLES.RECEPTIONIST; const { needsReply, dismiss, isDismissing, getLastCounterpartyMessageId } = useNeedsReply({ requests: patientCommunicationRequests, getMessagesForRequest, getUnreadCountForRequest, currentUserRef: currentPractitionerRef, // ON A PATIENT THREAD THE COUNTERPARTY IS THE PATIENT, and everyone on staff is one side of it. // The front desk is a shared inbox, so the receptionist who answers is usually not the one looking // at the count - and an answered thread must leave the count for all of them, not only for whoever // typed it. // // isStaffSide is the receptionist overview rail's own predicate, reused rather than respelled. The // rail already drew this distinction correctly and this page did not, which is how the same thread // could be owed on one screen and answered on the other. isOurSide: isStaffSide, enabled: isReceptionist, }); const [error, setError] = useState(); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedPatient, setSelectedPatient] = useState( undefined, ); const [selectedCommunicationRequest, setSelectedCommunicationRequest] = useState(); /** * The claim on the open POOL conversation, or undefined when the open one is not a pool thread. * * SEEDED FROM THE THREAD THE SERVER RETURNED, never derived here. A claim expires by the clock, and a * second implementation of "is it still claimed" would drift from the server about who is answering a * patient - so `claimed` arrives already decided and this only holds it. */ const [poolClaim, setPoolClaim] = useState(undefined); // A demo visit cannot take a conversation: the server refuses claims there, so none is asked for. const inDemoVisit = useContext(DemoVisitRoleContext) !== undefined; // The open pool conversation's claim could not be read: ownership is unknown, not unclaimed. const [poolClaimUnavailable, setPoolClaimUnavailable] = useState(false); const [poolClaimRetrying, setPoolClaimRetrying] = useState(false); // Working in the conversation keeps MY claim on it alive; the server lets an unrenewed claim lapse. // A renewal lands only on the conversation it was for. const keepClaimAlive = useClaimKeepAlive({ claim: poolClaim, viewerAccountId: currentUser?.fhir?.id, renew: claimFrontDeskThread, onChanged: React.useCallback((claim: ThreadClaimState) => { setPoolClaim((prev) => (prev?.threadId === claim.threadId ? claim : prev)); }, []), }); /** * The colleague's NAME for the claim strip. * * Resolved from the practitioner roster that is already loaded, and degraded to undefined rather than * to the account id - `claimNotice` turns that into "Someone is replying", which is honest. An id on * screen tells a receptionist nothing and reads as a fault. */ const nameForStaffAccount = React.useCallback( (accountId: string): string | undefined => { const match = practitionersList.find((entry) => entry.practitioner.id === accountId); return match ? formatFhirHumanName(match.practitioner.name) : undefined; }, [practitionersList], ); const [selectedChat, setSelectedChat] = useState< CommunicationRequest | null >(null); // The desk inbox also lists colleague conversations; one of those can be the open one instead. const [colleagueThreadId, setColleagueThreadId] = useState(initialOpenColleagueRequestId); const colleagueRows = useColleagueInboxRows(desk); // Each open takes a ticket; an answer for an older ticket belongs to a conversation no longer chosen. const openTicket = React.useRef(0); const [chatLoading, setChatLoading] = useState(false); /** * - the arriving referral the desk currently has open, or `undefined` for a conversation. * * DERIVED FROM THE OPEN SHELL RATHER THAN HELD AS ITS OWN STATE, deliberately: a second piece of * state saying "this one is a referral" is one that can be left standing after a DIFFERENT thread is * opened - which is exactly the defect recorded for `poolClaim` (a pool thread's claim strip * left over on a doctor's private conversation). */ const selectedReferral = React.useMemo( () => getReferralSubject(selectedChat ?? undefined), [selectedChat], ); /** * - whether the patient match is open for the referral above. * * A BOOLEAN, NOT A COPY OF THE REFERRAL. `selectedReferral` is derived from the open thread, so a * second copy held here could outlive the thread it came from - the same defect recorded for * a claim strip left standing on a conversation it did not belong to. */ const [matching, setMatching] = React.useState(false); // The match's own lifetime: a different practice, thread or viewer, or closing it, retires everything it started. const MATCH_OPENING_MS = 8 * 60 * 60 * 1000; const [matchOpening, setMatchOpening] = React.useState<{ id: number; life: SaveOperation; scope: ReferralRequestScope }>(); const matchLives = useSaveScope([currentOrganization?.id, selectedReferral?.referralId].join('|'), () => { setMatching(false); setMatchOpening(undefined); }); const openMatch = () => { matchOpening?.life.retire(); const life = matchLives.start(MATCH_OPENING_MS); setMatchOpening({ id: Date.now(), life, scope: { admit: life.admit, signal: life.transport.signal, organizationId: currentOrganization?.id } }); setMatching(true); }; const closeMatch = (next: boolean) => { if (next) return; matchOpening?.life.retire(); setMatchOpening(undefined); setMatching(false); }; /** * Whether this arriving referral has ALREADY been matched by somebody else. * * The pool thread is composed once, on arrival, and no later write touches it - so the thread cannot * answer this and the item went on offering the match forever. The coordinator's queue can: a referral * only enters it when a patient is linked, at the same id. It carries state and the sending practice * and no patient identity, which is what makes it readable from the front desk at all. */ const referralQueue = useReferralQueue(currentOrganization?.id); const referralMatchState = React.useMemo( () => referralMatchStateFor(selectedReferral?.referralId, { rows: referralQueue.rows, loading: referralQueue.loading, error: referralQueue.error, }), [selectedReferral?.referralId, referralQueue.rows, referralQueue.loading, referralQueue.error], ); const [ selectedCommunicationRequestForDisplay, setSelectedCommunicationRequestForDisplay, ] = useState(null); const [showDisplayModal, setShowDisplayModal] = useState(false); const [activeTab, setActiveTab] = useState<"inbox" | "patients">("inbox"); const [searchTerm, setSearchTerm] = useState(""); const [showConversationMobile, setShowConversationMobile] = useState(false); const [patientPhotoCache, setPatientPhotoCache] = useState>({}); // How each counterparty's own record read went, so a row can say loading, refused or failed rather than print an id. const [nameReads, setNameReads] = useState>({}); const fetchedPatientIds = React.useRef>(new Set()); // Deep link from the shell (e.g. the receptionist overview): the CommunicationRequest id or // patient id to open, captured once on mount from props (the shell owns clearing router state). const [pendingOpenRequestId, setPendingOpenRequestId] = useState(initialOpenRequestId); const [pendingOpenPatientId, setPendingOpenPatientId] = useState(initialOpenWith); // Set from the first render so a deep link never shows the pick-someone state while it opens. const [openingConversation, setOpeningConversation] = useState(Boolean(initialOpenWith)); useFhirCommunicationRequestTargetedBroadcast({ communicationRequest: selectedCommunicationRequestForDisplay || undefined, isOpen: showDisplayModal, onUpdate: (updatedCommunicationRequest) => { setSelectedCommunicationRequestForDisplay(updatedCommunicationRequest); }, onDelete: () => { setShowDisplayModal(false); setSelectedCommunicationRequestForDisplay(null); }, }); useEffect(() => { return () => { setActiveThread(null); }; }, [setActiveThread]); /** * THE FRONT-DESK POOL PING - the server has emitted it since S6 and NOTHING LISTENED. * * `utils/frontDeskFanout.ts` broadcasts on a patient's message arriving and on every claim/release, and * `FrontDeskPool` appeared nowhere in this client. The socket dispatches strictly by channel name, so * the event was dropped on the floor - leaving the exact defect that file's header says it was written * to close: *"no screen anywhere changed until somebody reloaded."* * Two things were silent, not one: a patient's message never moved any inbox, AND a colleague taking * a conversation never reached the receptionist already looking at it. */ useEffect(() => { const onPoolChanged = (frame?: unknown) => { const notice = readFrontDeskPoolNotice(frame); // EVERY staff socket at the practice receives this, clinicians included - deliberately. Working // out who holds the receptionist role here would be a second, drifting copy of the server's // `canAccessFrontDeskPool`, and the refetch is what actually decides: a clinician simply gets no // pool threads back. The only thing worth filtering is another practice's ping. if ( notice?.organizationId && currentOrganization?.id && notice.organizationId !== currentOrganization.id ) { return; } // The inbox itself is re-read by the communication service, which hears this ping on every page; // re-reading it here too read the whole inbox twice per ping and made the list jump. // The payload carries NO thread, patient or message id - a pool thread's id contains the // patient's - so the claim can only be RE-READ, never patched out of the event. const openRequest = selectedChat ?? undefined; const patientId = selectedPatient?.id; const organizationId = getThreadOrganizationId(openRequest) || currentOrganization?.id; if (!isPoolThreadRequest(openRequest) || !patientId || !organizationId) return; // Idempotent - the same pool id comes back, and the claim on it is the SERVER's answer, expiry // included. Never re-derived here. const ticket = openTicket.current; void openFrontDeskThread(patientId, organizationId, getThreadPoolKey(openRequest)) .then((thread) => { if (ticket === openTicket.current) setPoolClaim(thread.claim); }) .catch(() => { // A failed refresh LEAVES THE LAST KNOWN CLAIM STANDING rather than blanking the strip. // Asserting "nobody is replying" because the network hiccupped is the duplicate reply this // whole feature exists to prevent. }); }; subscribe(FRONT_DESK_POOL_CHANNEL, onPoolChanged); return () => unsubscribe(FRONT_DESK_POOL_CHANNEL, onPoolChanged); }, [ subscribe, unsubscribe, openFrontDeskThread, selectedChat, selectedPatient, currentOrganization, ]); // - ALL THREE OF THESE RESOLVED THE COUNTERPARTY WITH THE SAME INLINE SWAP, and on a POOL // thread that swap lands on `undefined`: the staff side of a pool shell is not populated at all // (`staffAccountId` is null by design), so the row rendered "Unknown" beside a blank silhouette while // the patient sat right there in `recipient`, already display-enriched. // Now they all go through the SHARED rule in `utils/messaging/poolThread`, because this is the third // client surface to be taught the same fact - after the inbox filter and the conversation pane's render // gate - and each time it was taught by copying, one copy was later fixed alone. const getCounterpartyId = React.useCallback( (request: CommunicationRequest): string | undefined => extractReferenceId(counterpartyReference(request, currentUser?.fhir?.id)) as | string | undefined, [currentUser?.fhir?.id, extractReferenceId], ); const getCounterpartyReference = React.useCallback( (request: CommunicationRequest): string | undefined => counterpartyReference(request, currentUser?.fhir?.id)?.reference, [currentUser?.fhir?.id], ); const getCounterpartyName = React.useCallback( (request: CommunicationRequest): RowName => { // A referral has no counterparty; it is named for the practice that sent it. const referral = getReferralSubject(request); if (referral) return { kind: "name", text: referral.label }; const counterparty = counterpartyReference(request, currentUser?.fhir?.id); const id = extractReferenceId(counterparty) as string | undefined; const resourceType = counterparty?.reference?.split("/")[0]; const selectedName = selectedChat?.id === request.id && selectedPatient ? formatFhirHumanName(selectedPatient.name) : ""; if (selectedName) return { kind: "name", text: selectedName }; // A colleague on a patient thread is named from the roster the claim strip already reads. const rosterName = resourceType === "Practitioner" && id ? nameForStaffAccount(id) : undefined; if (rosterName) return { kind: "name", text: rosterName }; // Never the id: a display that is only an id falls to how the record read went. return counterpartyRowName({ display: counterparty?.display, id, resourceType, read: id ? nameReads[id] : undefined }); }, [currentUser?.fhir?.id, extractReferenceId, selectedChat?.id, selectedPatient, nameForStaffAccount, nameReads], ); const { dedupedThreads, inboxThreads } = useInboxThreads( patientCommunicationRequests, getCounterpartyId, { getMessagesForRequest, getUnreadCountForRequest }, ); const filteredInboxRequests = React.useMemo(() => { if (!searchTerm.trim()) return inboxThreads; return inboxThreads.filter((request) => rowNameText(getCounterpartyName(request)).toLowerCase().includes(searchTerm.toLowerCase()), ); }, [inboxThreads, searchTerm, getCounterpartyName]); useEffect(() => { const missing = new Map(); dedupedThreads.forEach((request) => { const id = getCounterpartyId(request); if (id && !fetchedPatientIds.current.has(id)) missing.set(id, getCounterpartyReference(request)?.split("/")[0]); }); if (missing.size === 0) return; missing.forEach((_, id) => fetchedPatientIds.current.add(id)); void Promise.all( [...missing].map(async ([id, resourceType]): Promise<{ id: string; read: NameRead; patient?: Patient }> => { try { // A colleague is read as a Practitioner; asking for a Patient by their id is refused. if (resourceType === "Practitioner") { const found = await fetchPractitioner(id) as { practitioner?: Practitioner } | Practitioner | undefined; const practitioner = found && "practitioner" in found ? found.practitioner : found as Practitioner | undefined; if (!practitioner) return { id, read: { status: "failed" } }; return { id, read: { status: "loaded", name: formatFhirHumanName(practitioner.name) } }; } const patient = await fetchPatient(id); if (!patient) return { id, read: { status: "failed" } }; return { id, patient, read: { status: "loaded", name: formatFhirHumanName(patient.name) } }; } catch (error) { return { id, read: { status: isRefusedRead(error) ? "refused" : "failed" } }; } }), ).then((results) => { const newEntries: Record = {}; const reads: Record = {}; results.forEach(({ id, patient, read }) => { if (patient) newEntries[id] = patient; reads[id] = read; }); if (Object.keys(newEntries).length > 0) { setPatientPhotoCache((prev) => ({ ...prev, ...newEntries })); } setNameReads((prev) => ({ ...prev, ...reads })); }); }, [dedupedThreads, getCounterpartyId, getCounterpartyReference, fetchPatient, fetchPractitioner]); const getCounterpartyPhoto = React.useCallback( (request: CommunicationRequest) => { const id = getCounterpartyId(request); if (!id) return undefined; return patientPhotoCache[id]?.photo?.[0]; }, [getCounterpartyId, patientPhotoCache], ); const handleCommunicationRequestCreated = async ( communicationRequest: CommunicationRequest, ) => { setIsModalOpen(false); await chat(null, communicationRequest); toast.success("Communication request created successfully.", { position: "top-right", }); }; const handleCommunicationRequestUpdated = async ( communicationRequest: CommunicationRequest, ) => { updateCommunicationRequest(communicationRequest); setIsModalOpen(false); toast.success("Communication request updated successfully.", { position: "top-right", }); }; // The patient may still be loading; it is read alongside the thread rather than before it. const openPatientConversation = async ( patientId: string, patientSource: Patient | Promise, ) => { const ticket = ++openTicket.current; setColleagueThreadId(undefined); setChatLoading(true); setError(undefined); try { const organizationId = currentOrganization?.id; if (!organizationId) { throw new Error("Organization context is missing"); } // - A RECEPTIONIST OPENS THE OFFICE'S CONVERSATION, NOT A PRIVATE ONE WITH THEMSELVES. // // This used to open a person-keyed direct thread for every staff role, which meant the receptionist // was answering in a DIFFERENT conversation from the one the patient wrote into: the patient's // message sits in the front-desk pool, and the reply went to a thread keyed on whichever receptionist // happened to click. Spencer: *"it doesn't matter which receptionist handles it... we've never built // that general routing in yet."* // // A PRACTITIONER STILL GETS A DIRECT THREAD, and that is the opposite requirement rather than an // omission: clinical continuity means a patient's conversation with their doctor belongs to that // doctor. Pooling it would be the same defect in the other direction. // // Existing person-keyed patientreceptionist threads are left alone (plan §7.3) - they keep // working and still appear in the list; only NEW office conversations pool. const [thread, patient] = await Promise.all([ isReceptionist ? openFrontDeskThread(patientId, organizationId) : openDirectThread( patientId, currentUser?.fhir?.id as string, "practitioner", organizationId, ), Promise.resolve(patientSource).then(requirePatient), ]); if (ticket !== openTicket.current) return; setSelectedPatient(patient); // The claim comes off the thread the server just returned, and `openFrontDeskThread` is the only // opener that sets it. So a practitioner's direct thread clears it rather than leaving it // standing: a stale strip on a doctor's private conversation would name a receptionist as // "replying" to a thread they cannot even see. setPoolClaim(thread.claim); setPoolClaimUnavailable(false); if (!patientState) { setTempPatientState(patient); } if (thread.threadId) { setActiveThread(thread.threadId); } setSelectedChat(thread.request); if (!desk) setActiveTab("inbox"); setShowConversationMobile(true); } catch (err: unknown) { if (ticket !== openTicket.current) return; // Retired with the practice or viewer it was opened for: nothing to say, show or select here. if (isSaveRetired(err)) return; console.error("Error opening thread:", err); // A REFUSAL IS NOT A FAILURE. The patient portal met this first - a caregiver refused by the // authority tier was told to try again - and the same string was here, on the same component, // for a reader who can meet the same refusals from the other side of the thread. const openMessage = describeBlockedAction(err, "Failed to open chat"); setError(openMessage); toast.error(openMessage, { position: "top-right", }); } finally { if (ticket === openTicket.current) setChatLoading(false); } }; const handlePatientClick = (patient: Patient) => openPatientConversation(patient.id as string, patient); // Ref mirror so the deep-link effect below can call the latest opener without needing it in its // dependency array (it isn't memoized) - same idiom as ColleaguesPanel's handleStaffClickRef. const openPatientConversationRef = React.useRef(openPatientConversation); openPatientConversationRef.current = openPatientConversation; const clearError = () => { clearCommunicationError(); setError(undefined); }; const handleSendMessage = async ( message: string, attachment?: Attachment, references?: Reference[], send?: SendOperation, ) => { if ( (!message.trim() && !attachment && (!references || references.length === 0)) || !selectedChat ) { return; } try { const recipientReferences: Reference[] = selectedPatient && getCounterpartyId(selectedChat) === selectedPatient.id ? [createReferenceObject('Patient', selectedPatient.id as string, formatFhirHumanName(selectedPatient.name))] : (selectedChat.recipient || []); const transformedCommunication: Communication = createCommunicationForThread( message, selectedChat.id as string, currentUser?.fhir as Practitioner, recipientReferences, attachment, references, ); // See the note in `MessagesPage`: the write's own result goes on screen, because the // search that would fetch it back is eventually consistent and loses the race. This is // the panel the defect was measured on. addCommunicationToState(await createCommunication(namedSend(transformedCommunication, send), send)); // Whoever answers an unclaimed desk conversation is handling it; the server says whether they are. const handled = inDemoVisit ? undefined : await claimAfterReply(poolClaimUnavailable ? undefined : poolClaim, claimFrontDeskThread); if (handled) setPoolClaim((prev) => (prev?.threadId === handled.threadId ? handled : prev)); } catch (sendError: unknown) { console.error("Error sending message:", sendError); // The conversation marks the message failed and keeps the words only if it hears about it. throw sendError; } }; const handleUpdateMessages = async (updatedMessages: Communication[], transport?: SaveTransport): Promise => { try { return await saveCommunicationBundle(updatedMessages, transport); } catch (updateError: unknown) { console.error("Failed to update message receipt status:", updateError); return []; } }; const handleCloseChat = () => { openTicket.current += 1; setActiveThread(null); setColleagueThreadId(undefined); setPoolClaimUnavailable(false); setSelectedChat(null); setSelectedPatient(undefined); setTempPatientState(undefined); setShowConversationMobile(false); }; const chat = async ( event: React.MouseEvent | null, communicationRequest: CommunicationRequest, ) => { if (event) { event.preventDefault(); event.stopPropagation(); } const ticket = ++openTicket.current; setColleagueThreadId(undefined); setChatLoading(true); try { /* * - AN ARRIVING REFERRAL OPENS ON A DIFFERENT ROUTE, and it has to: the line below * resolves the patient out of the shell's `recipient` and fetches them. A referral item has no * patient by design, so `ids[0]` is `undefined` and the fetch throws into the catch - the row * would look inert, with the failure only in the console. * And no patient is invented on the way past: the receiving practice has no record for that * person, which is the premise of the whole feature. */ const referral = getReferralSubject(communicationRequest); if (referral) { const referralOrganizationId = getThreadOrganizationId(communicationRequest) || currentOrganization?.id; const item = await openFrontDeskReferralItem(referral.referralId, referralOrganizationId); if (ticket !== openTicket.current) return; if (communicationRequest.id) setActiveThread(communicationRequest.id); setSelectedPatient(undefined); setPoolClaim(item.claim); setPoolClaimUnavailable(false); setSelectedChat(communicationRequest); setShowConversationMobile(true); return; } // The party who is not the viewer: on a thread the patient started, the first recipient is the clinician. const patient: Patient = await fetchPatient(getCounterpartyId(communicationRequest) as string); if (ticket !== openTicket.current) return; // - the claim has to be settled on THIS route too, in both directions. // // Until now `poolClaim` was only ever set by opening a patient from the Patients tab, because the // office conversation was never listed in the inbox at all and this path could not reach one. Now // that it is listed, two things go wrong if this is left alone: opening the office from the inbox // shows a pool conversation with NO claim controls (so "Nobody is replying yet" - the unclaimed // state - is unreachable), and opening a DIRECT thread afterwards leaves the previous pool thread's // strip standing, naming a receptionist as replying to a doctor's private conversation. // // Re-opening through `openFrontDeskThread` rather than deriving a claim here: it is idempotent // (the same pool id comes back) and the claim is the SERVER's answer, expiry included. A second // client-side notion of "is it still claimed" is exactly what refused to build. const organizationId = getThreadOrganizationId(communicationRequest) || currentOrganization?.id; let claim: ThreadClaimState | undefined; let claimUnavailable = false; if (isPoolThreadRequest(communicationRequest) && organizationId) { try { claim = (await openFrontDeskThread(patient.id as string, organizationId, getThreadPoolKey(communicationRequest))).claim; } catch (claimError: unknown) { if (isSaveRetired(claimError)) return; // The conversation still opens; nobody is offered the claim on a state nobody could read. console.error("Error reading reply ownership:", claimError); claimUnavailable = true; } if (ticket !== openTicket.current) return; } // Patient, claim and conversation change together, so no frame pairs one thread with another's patient. if (communicationRequest.id) setActiveThread(communicationRequest.id); setSelectedPatient(patient); if (!patientState) { setTempPatientState(patient); } setPoolClaim(claim); setPoolClaimUnavailable(claimUnavailable); setSelectedChat(communicationRequest); setShowConversationMobile(true); } catch (chatError: unknown) { if (ticket !== openTicket.current) return; if (isSaveRetired(chatError)) return; console.error("Error opening chat:", chatError); setError(describeBlockedAction(chatError, "Failed to open chat")); } finally { if (ticket === openTicket.current) setChatLoading(false); } }; // Ref mirror of `chat` for the same reason as openPatientConversationRef above. const retryPoolClaim = async () => { const patientId = selectedPatient?.id; const organizationId = getThreadOrganizationId(selectedChat ?? undefined) || currentOrganization?.id; if (!patientId || !organizationId) return; const ticket = openTicket.current; setPoolClaimRetrying(true); try { const poolThread = await openFrontDeskThread(patientId, organizationId, getThreadPoolKey(selectedChat ?? undefined)); if (ticket !== openTicket.current) return; setPoolClaim(poolThread.claim); setPoolClaimUnavailable(false); } catch (claimError: unknown) { if (!isSaveRetired(claimError)) console.error("Error reading reply ownership:", claimError); } finally { setPoolClaimRetrying(false); } }; // A colleague conversation from the desk inbox opens in this pane through the Colleagues panel. const openColleagueThread = (requestId: string) => { openTicket.current += 1; setSelectedChat(null); setSelectedPatient(undefined); setPoolClaim(undefined); setPoolClaimUnavailable(false); setChatLoading(false); setColleagueThreadId(requestId); setShowConversationMobile(true); }; const chatRef = React.useRef(chat); chatRef.current = chat; // Deep-link effect 1: open an existing CommunicationRequest by id (mirrors ColleaguesPanel). useEffect(() => { if (!pendingOpenRequestId || communicationLoading) return; const target = patientCommunicationRequests.find( (request) => request.id === pendingOpenRequestId, ); if (!target) return; const counterpartyId = getCounterpartyId(target); const threadRequest = dedupedThreads.find( (request) => getCounterpartyId(request) === counterpartyId, ) || target; setPendingOpenRequestId(undefined); void chatRef.current(null, threadRequest); }, [pendingOpenRequestId, communicationLoading, patientCommunicationRequests, getCounterpartyId, dedupedThreads]); // Deep-link effect 2: open (or start) a thread with a patient by id. Unlike ColleaguesPanel's // practitionersList lookup, there's no preloaded patient list here, so resolve via fetchPatient. useEffect(() => { if (!pendingOpenPatientId) return; const patientId = pendingOpenPatientId; setPendingOpenPatientId(undefined); setOpeningConversation(true); setShowConversationMobile(true); void openPatientConversationRef.current(patientId, fetchPatient(patientId)) .finally(() => setOpeningConversation(false)); }, [pendingOpenPatientId, fetchPatient]); const currentChatMessages = selectedChat ? getMessagesForRequest(selectedChat) : []; const currentPractitioner = currentUser?.fhir as Practitioner; const renderInboxList = () => { // A LISTING THAT FAILED IS NOT AN EMPTY INBOX, AND IT MUST NOT BORROW THAT SENTENCE. // // The thread listing is non-fatal on purpose, so a failure lands here as zero rows and used to be // reported as "No conversations yet" - a positive claim about somebody's inbox, made at the one // moment the screen has no idea what is in it. This says what is actually known instead, and offers // the only thing that can help. Only a KNOWN-COMPLETE empty list may use the search or "no // conversations" wording below - every other rank says what is actually known instead. if (filteredInboxRequests.length === 0 && listingRank === 'failed') { return (
Try again} data-testid="patients-inbox-unavailable" />
); } if (filteredInboxRequests.length === 0 && listingRank === 'partial') { return (
Try again} data-testid="patients-inbox-partial" />
); } if (filteredInboxRequests.length === 0 && listingRank === 'pending') { return (
); } if (filteredInboxRequests.length === 0 && listingRank === 'unknown') { return (
); } if (filteredInboxRequests.length === 0) { return ( // (B26) - the old copy read "Switch to Patients to start a chat" while the reader was // sitting on a tab called Patients. It was not naming the wrong control: this panel's INNER tabs // are Inbox | Patients, and the OUTER tabs are Patients | Colleagues, so two nested controls share // one name and "switch to Patients" cannot be resolved by the person reading it. // The fix is not to name the other tab - it is to name the ACTION, which survives a rename and // needs no knowledge of which tab level is meant. // This is a correction to the card's own framing, which concluded the tab name was // "role-dependent". It is not: both panels already named their own sibling correctly, and // Colleagues only looked right because "Directory" happens not to collide.
); } const rowsNotice = rowsNoticeFor(listingRank); return (
{rowsNotice && ( )} {filteredInboxRequests.map((communicationRequest: CommunicationRequest) => { const unreadCount = getUnreadCountForRequest(communicationRequest.id as string); const rowName = getCounterpartyName(communicationRequest); const counterpartyRef = getCounterpartyReference(communicationRequest); // WHAT THIS CONVERSATION SAYS - the row already answers "which conversation"; this is what // answers "which one needs me FIRST". Read from the shared derivation rather than spelled out // here: two versions of "the last thing said" drift a sentence at a time. /* * - A REFERRAL ITEM HAS NO MESSAGES, so both of the derivations above answer * "nothing here": an empty preview, and a time of `0` that renders as `--`. * * Neither is right for a work item, and `--` is the worse of the two - the referral DID * arrive at a moment, and a queue whose rows will not say when they landed cannot be worked * oldest-first. The arrival time is the shell's `authoredOn`, which the server stamped from * the item's `createdAt`. * And the empty-preview rule is KEPT for conversations: a patient thread with no messages * genuinely has nothing to preview, which is the whole point of `threadLastActivity` returning * 0 rather than the epoch. */ const rowReferral = getReferralSubject(communicationRequest); // The preview never names whom the row withholds: the same reference, not a matching name. const preview = rowReferral ? rowReferral.summary : threadPreview(getMessagesForRequest(communicationRequest), undefined, { unnamedAuthor: unnamedAuthorOf(rowName, counterpartyRef), }); const lastActivity = rowReferral ? new Date(communicationRequest.authoredOn ?? 0).getTime() : threadLastActivity(getMessagesForRequest(communicationRequest)); // One reading per render pass, so every row is measured against the same instant. const now = Date.now(); return (
void chat(null, communicationRequest)} onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); void chat(null, communicationRequest); }} className={clsx( "ahr-mm-row focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2", selectedChat?.id === communicationRequest.id && "ahr-mm-row--on", )} > {/* `min-w-0` on both: a flex child defaults to `min-width: auto`, so without it the preview below refuses to truncate and pushes the row's time and unread badge off. */}

{counterpartyRef && }

{/* THE ONLY THING THAT TELLS THESE TWO ROWS APART. Since card 356 stopped the pool and direct conversations collapsing into one, a patient who has written to the front desk AND to a named staffer shows up as two rows with the SAME name and nothing else to distinguish them — so which one a receptionist opens is a coin flip. Same words as the conversation header, deliberately: the label the row promises is the label the pane confirms. */} {/* Card 608 — a referral says what it IS, not which inbox it is in. Every row here is in the shared inbox, so that chip distinguishes a referral from nothing; what a receptionist needs to know at a glance is that this one is not a conversation and nobody is waiting on a reply. */} {rowReferral ? (

Referral to triage

) : isPoolThreadRequest(communicationRequest) ? (

{getThreadPoolKey(communicationRequest) === FRONT_DESK_ASIDE_POOL_KEY ? "Staff only" : "Shared inbox"}

) : isEncounterRoomThreadRequest(communicationRequest) && ( /* The patient's name stays the title, because on this side the counterparty IS the patient - what the clinician needs is which of their conversations this is. The patient's own inbox names the row for the visit instead, for the mirror reason: there, the other party is the room. */

{ROOM_THREAD_LABEL}

)} {preview &&

{preview}

} {needsReply(communicationRequest.id as string) && ( Awaiting reply )}
{/* Same defect as the Colleagues list, fixed the same way: the timestamp came from the CONVERSATION's `authoredOn` rather than the last message, and rendered as a bare hour and minute — so an old thread showed a time of day that could sit in the future against the viewer's clock. See `threadLastActivity`. */}

{formatRelativeTime(lastActivity, now) || '--'}

{/* THE ONE COUNT BADGE. This was a `Badge` carrying `h-6 w-6 rounded px-1 font-mono`, so it drew as a monospace SQUARE while the same unread number was a circle in the patient inbox and in the header - one idea, three drawings, and a staff member who also uses the portal met both. `ahr-count-badge` is the named rule for it. */} {unreadCount > 0 && ( {unreadCount} )}
); })}
); }; const renderPatientList = () => { return ( {(patients: Patient[]) => { const normalizedSearch = searchTerm.trim().toLowerCase(); const filtered = !normalizedSearch ? patients : patients.filter((patient) => { return formatFhirHumanName(patient.name, { emptyText: '' }) .toLowerCase() .includes(normalizedSearch); }); if (filtered.length === 0) { return (

{searchTerm ? "No matching patients" : "No patients found"}

); } return (
{filtered.map((patient: Patient) => { const name = formatFhirHumanName(patient.name) || "Name not recorded"; const photo = patient.photo?.[0]; return ( } name={name} sub={patientDirectoryLine(patient)} onOpen={() => handlePatientClick(patient)} flat /> ); })}
); }}
); }; // The desk inbox: patient and colleague conversations in one list, unread first, then most recent. const renderDeskInbox = () => { const now = Date.now(); const term = searchTerm.trim().toLowerCase(); const entries = [ ...filteredInboxRequests.map((request) => { const referral = getReferralSubject(request); const messages = getMessagesForRequest(request); const photo = getCounterpartyPhoto(request); const label = getCounterpartyName(request); return { key: `patient-${request.id}`, label, imageUrl: scannedPhotoSrc(photo), chip: referral ? "Referral to triage" : isPoolThreadRequest(request) ? getThreadPoolKey(request) === FRONT_DESK_ASIDE_POOL_KEY ? "Staff only" : "Shared inbox" : isEncounterRoomThreadRequest(request) ? ROOM_THREAD_LABEL : "Patient", preview: referral ? referral.summary : threadPreview(messages, undefined, { unnamedAuthor: unnamedAuthorOf(label, getCounterpartyReference(request)) }), activity: referral ? new Date(request.authoredOn ?? 0).getTime() : threadLastActivity(messages), unread: getUnreadCountForRequest(request.id as string), selected: !colleagueThreadId && selectedChat?.id === request.id, open: () => void chat(null, request), }; }), ...colleagueRows .filter((row) => !term || row.name.toLowerCase().includes(term)) .map((row) => ({ key: `colleague-${row.request.id}`, label: { kind: "name", text: row.name } as RowName, imageUrl: row.photoUrl, chip: "Colleague", preview: row.preview, activity: row.lastActivity, unread: row.unread, selected: colleagueThreadId === row.request.id, open: () => openColleagueThread(row.request.id as string), })), ].sort((a, b) => (Number(b.unread > 0) - Number(a.unread > 0)) || b.activity - a.activity); if (entries.length === 0 && listingRank === 'failed') { return ( Try again} className="ahr-desk-empty" data-testid="patients-inbox-unavailable" /> ); } if (entries.length === 0 && listingRank === 'partial') { return ( Try again} className="ahr-desk-empty" data-testid="patients-inbox-partial" /> ); } if (entries.length === 0 && listingRank === 'pending') { return ( ); } if (entries.length === 0 && listingRank === 'unknown') { return ( ); } if (entries.length === 0) { return ( ); } const rowsNotice = rowsNoticeFor(listingRank); return (
{rowsNotice && ( )} {entries.map((entry) => ( } name={} chip={entry.chip} preview={entry.preview} time={formatRelativeTime(entry.activity, now) || "--"} unread={entry.unread} selected={entry.selected} onOpen={entry.open} /> ))}
); }; const renderDeskDirectory = () => ( {(patients: Patient[]) => { const term = searchTerm.trim().toLowerCase(); const shown = !term ? patients : patients.filter((patient) => formatFhirHumanName(patient.name, { emptyText: "" }).toLowerCase().includes(term)); if (shown.length === 0) { return ( ); } return (
{shown.map((patient) => { const name = formatFhirHumanName(patient.name) || "Name not recorded"; const photo = patient.photo?.[0]; return ( } name={name} onOpen={() => handlePatientClick(patient)} /> ); })}
); }}
); const renderDeskList = () => { if (messagingNotPermitted || communicationError) { return messagingNotPermitted ? ( {}} /> ) : ( { clearError(); window.location.reload(); }} /> ); } if (communicationLoading) { return (
{Array.from({ length: 6 }).map((_, index) => (
))}
); } if (view === "directory") return renderDeskDirectory(); return ( <> {renderDeskInbox()} ); }; const renderContent = () => { // - the same three states as the Colleagues panel, through the same component. The // identical hard-coded "Unable to load messages / Please try again later / Retry" block lived in both // files: one defect with two faces, which is the shape that gets half fixed and reads as done. if (messagingNotPermitted || communicationError) { const errorBody = messagingNotPermitted ? ( {}} /> ) : ( { clearError(); window.location.reload(); }} /> ); return showChrome ? ( {errorBody} ) : errorBody; } if (communicationLoading) { const loadingBody = (
{Array.from({ length: 8 }).map((_, i) => (
))}
); return showChrome ? ( Messages {loadingBody} ) : loadingBody; } const successBody = ( <>
{/* The deck has no second tab row: conversations first, New message swaps in the patients. The directory searches through its own paginated field; a second box here filtered one page. */} {activeTab === "inbox" ? (
setSearchTerm(e.target.value)} /> { setActiveTab("patients"); setSearchTerm(""); }} />
) : ( { setActiveTab("inbox"); setSearchTerm(""); }} /> )} {activeTab === "inbox" ? renderInboxList() : renderPatientList()}
); return showChrome ? ( Messages {successBody} ) : successBody; }; // A claim answer lands only on the conversation it was asked about. const claimTicket = openTicket.current; const applyClaim = (claim: ThreadClaimState) => { if (claimTicket === openTicket.current) setPoolClaim(claim); }; const ownershipUnavailable = selectedChat && !poolClaim && poolClaimUnavailable ? (
) : null; const claimStrip = selectedChat && poolClaim && inDemoVisit ? (

Taking a conversation is unavailable in this demo.

) : selectedChat && poolClaim ? ( claimFrontDeskThread(poolClaim.threadId)} onRelease={() => releaseFrontDeskThread(poolClaim.threadId)} onChanged={applyClaim} awaitingReply={awaitsDeskReply(currentChatMessages)} // - the override calls NOTHING on the server. It used to attempt a claim, lose // the 409 it deserved, and re-render identically, so the button looked broken. A colleague // may always reply; they just do not take the claim. onReplyAnyway={() => document.getElementById(CONVERSATION_COMPOSER_ID)?.focus()} /> ) : null; return ( <>
{error && (

{error}

)} {desk ? renderDeskList() : renderContent()}
{desk && colleagueThreadId ? ( setShowConversationMobile(false)} onClose={handleCloseChat} /> ) : ( <> {/* CARD 608 — AN ARRIVING REFERRAL IS A WORK ITEM AND RENDERS AS ONE, not as a conversation with no messages in it. Spencer: *"No, no reply."* The affordance is ABSENT rather than disabled, and the only way to make it absent by construction is to render a component that has no composer — a `readOnly` flag on `Chat` would leave it one forgotten prop away from coming back, on a component three other surfaces share. */} {selectedChat && selectedReferral ? ( claimFrontDeskThread(poolClaim?.threadId ?? (selectedChat.id as string))} onRelease={() => releaseFrontDeskThread(poolClaim?.threadId ?? (selectedChat.id as string))} onChanged={applyClaim} onClose={handleCloseChat} onMatchPatient={openMatch} matchState={referralMatchState} /> ) : ( <> {/* Card 257 — only a POOL conversation has a claim. A patient's thread with their own doctor has an owner by construction, so a claim strip on it would be meaningless furniture. */} {!desk && selectedChat && !poolClaim && poolClaimUnavailable && (

Unable to check reply ownership. It is unavailable, not unclaimed: check who is handling this before replying.

)} {!desk && claimStrip} {/* NOTHING SELECTED IS A STATE, NOT A BLANK. Measured in the running app: this pane was a 604x800 region containing no elements and no text, under two stacked rows of tabs — so the screen read as broken rather than as waiting. `Chat` carries its own "Select a conversation" state, but it is unreachable from here: the component is not mounted until something IS selected, which is the one case it cannot speak for. The patient portal's own messages screen has said this for a long time; this is the staff side catching up. */} {!selectedChat && (openingConversation ? (
) : desk ? ( ) : (
))} {selectedChat && ( handleSendMessage(message, attachment, undefined, send)} sendRecovery={sendRecovery} onComposerActivity={keepClaimAlive} onUpdateMessages={handleUpdateMessages} onReadMessages={readCommunications} onClose={handleCloseChat} // Back on a phone returns to the list with this conversation still chosen. onBack={() => setShowConversationMobile(false)} loading={chatLoading} // - THE STAFF SIDE NEVER TOLD `Chat` IT WAS LOOKING AT A POOL THREAD, so the // office's conversation rendered as an ordinary one-to-one: header badge "Direct thread" // (while the inbox row beside it now says "Shared inbox"), a person icon, and a composer // placeholder naming a person. The patient side has always passed this; only the staff // side had not, which is why header fix looked ineffective here. poolThreadKey={getThreadPoolKey(selectedChat ?? undefined)} isRoomThread={isEncounterRoomThreadRequest(selectedChat ?? undefined)} // WHICH PRACTICE THIS CONVERSATION BELONGS TO, and the staff side never said. `Chat` // falls back to the literal word "Organization" when nobody tells it, so a clinician // opening a patient thread read `Organization` where the patient reads the practice's // name in the very same header. // // The same omission, one prop along, as the pool-thread one above: the patient side has // always passed this and only the staff side had not. Two panels rendering one component // is a place to check every prop the other one sets, not just the one being fixed. // // Named only when the thread belongs to the practice this person is currently in. A // thread carrying a different organization gets NO name rather than this one's - a // wrong practice on a conversation is worse than an unnamed one. conversationOrganizationName={ selectedChat && (getThreadOrganizationId(selectedChat) ?? currentOrganization?.id) === currentOrganization?.id ? currentOrganization?.name : undefined } conversationRoleLabel="Patient" participantCount={2} needsReply={selectedChat ? needsReply(selectedChat.id as string) : false} onDismissReply={ selectedChat ? () => { const lastMsgId = getLastCounterpartyMessageId(selectedChat.id as string); if (lastMsgId) dismiss(selectedChat.id as string, lastMsgId); } : undefined } layout={desk ? "desk" : undefined} threadNotice={desk ? <>{ownershipUnavailable}{claimStrip} : undefined} /> )} )} )}
{/* Card 686 — the step that turns a queued piece of paper into a referral on a patient's record. Seeded from the SENDER and never preselecting anybody: matching is global, so an auto-selected lookalike is a duplicate human. */} row.loggedReferralId === selectedReferral.referralId)?.fax : undefined} // Reload rather than patch: the server created the referral and the queue is the only honest // account of what the desk is now looking at. onMatched={() => { void loadCommunicationData(); }} /> {selectedCommunicationRequestForDisplay && ( { setShowDisplayModal(false); setSelectedCommunicationRequestForDisplay(null); }} /> )} {isModalOpen && ( { setSelectedPatient(undefined); setTempPatientState(undefined); setSelectedCommunicationRequest(undefined); setIsModalOpen(false); }} /> )} ); };