import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Appointment, Patient, Task } from "fhir/r4"; import { ArrowLeft, LoaderCircle, Mail } from "lucide-react"; import { PatientMark } from "./PatientMark.tsx"; import PatientProfileCard, { getPatientSubline, } from "../../../components/PatientProfileCard/PatientProfileCard"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "../../../components/ui/dialog.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../../components/ui/tabs.tsx"; import { formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { useStaffPermissionServiceContext, usePatientServiceContext } from "../../../services"; import WorkspaceAppointments from "./WorkspaceAppointments.tsx"; import WorkspaceAppointmentDetail from "./WorkspaceAppointmentDetail.tsx"; import PatientTasks from "../PatientsPage/PatientPage/PatientTasks/PatientTasks.tsx"; import PatientQuestionnaireResponses from "../PatientsPage/PatientPage/PatientQuestionnaireResponses/PatientQuestionnaireResponses.tsx"; import type { TaskAssignmentKind } from "../PatientsPage/PatientPage/PatientTasks/PatientTasks.tsx"; import { CustomTaskAssignmentModal, PacketAssignmentModal, QuestionnaireTaskAssignmentModal, } from "../../../components"; import WorkspaceTaskDetail from "./WorkspaceTaskDetail.tsx"; import CoverageListPage from "../InsurancePage/CoverageListPage/CoverageListPage.tsx"; import PatientNotes from "../../../components/PatientNotes/PatientNotes.tsx"; import PatientInvoiceList from "../BillingPage/PatientInvoiceList.tsx"; import InvoiceDetail from "../BillingPage/InvoiceDetail.tsx"; import RecordAuthorizationDialog from "../../../components/RecordAuthorizationDialog/RecordAuthorizationDialog.tsx"; import { ORG_AGREEMENT_ORDER } from "../../../components/OrgAgreements/orgAgreements"; import type { StaffPermissionSection } from "../../../utils/staffAccess"; import { RecordGuardianDialog } from "../RelatedPersonsPage/RecordGuardianDialog"; import { isMinorPatient } from "../../../utils/adolescentFeatureGate"; import { useAgreementReread } from "../../../hooks/useAgreementReread"; import { useRecordAccessStanding } from "../../../hooks/useRecordAccessStanding"; import "./ReceptionistPatientWorkspaceDialog.css"; // The seven sections, in order, for the desktop tabs and the phone select alike. Questionnaire Answers is // intake paperwork rather than chart, and comes last so the six before it keep their places. const PATIENT_SECTIONS = [ { value: "profile", label: "Profile" }, { value: "appointments", label: "Appointments" }, { value: "tasks", label: "Tasks" }, { value: "insurance", label: "Insurance" }, { value: "invoices", label: "Invoices" }, { value: "notes", label: "Internal Notes" }, { value: "questionnaire-responses", label: "Questionnaire Answers" }, ] as const; /** The server reads invoices for an Admin with billing view and refuses everyone else, the front desk included. */ export const mayOpenPatientInvoices = ( activeWorkspace: string | undefined, canView: (section: StaffPermissionSection) => boolean, ): boolean => activeWorkspace === "Admin" && canView("billing"); interface ReceptionistPatientWorkspaceDialogProps { open: boolean; patient?: Patient; loading: boolean; error?: string; onOpenChange: (open: boolean) => void; onBack?: () => void; /** What the footer's way out says, such as "Back to patients". No label and no detail open: no footer. */ backLabel?: string; onPatientUpdated?: (patient: Patient) => void; /** The tab it opens on, such as "invoices" from checkout. Profile when absent. */ initialTab?: string; } // The receptionist's all-in-one patient workspace: a sticky identity header (photo + name + age/sex/phone, // kept across every tab) over Profile / Appointments / Tasks / Insurance. The tab bodies read patientState // from CONTEXT - the parent sets it up front before opening. Replaces the heavy /dashboard/patients/:id page // for the receptionist workspace. Built on ui/dialog + ui/tabs (portal-within-root, framework-agnostic). export default function ReceptionistPatientWorkspaceDialog({ open, patient, loading, error, onOpenChange, onBack, backLabel, onPatientUpdated, initialTab, }: ReceptionistPatientWorkspaceDialogProps) { const patientName = patient ? formatFhirHumanName(patient.name, { emptyText: "Patient" }) : "Patient"; const subline = patient ? getPatientSubline(patient) : ""; // The patient may answer from their own portal while this is open; the rows read again while it is seen. const agreementReread = useAgreementReread(open && !!patient?.id); const { canManage, canView, activeWorkspace } = useStaffPermissionServiceContext(); const { fetchPatient } = usePatientServiceContext(); const navigate = useNavigate(); // The chart's rule for a new message, read the same way: once the patient has withdrawn this practice's // authorization the server refuses a new conversation (AUTHORIZATION_WITHDRAWN), so Message is not offered here // as if it could start one, and says why in place. Earlier messages stay readable in Messages. const { standing } = useRecordAccessStanding({ patientId: patient?.id, enabled: open && !!patient?.id }); const messageWithdrawn = standing.kind === "withdrawn"; // The same entry the rest of the product uses to open (or start) a patient's conversation. const messagePatient = () => { if (!patient?.id) return; onOpenChange(false); navigate("/dashboard/staff-messages", { state: { tab: "patients", openWith: patient.id } }); }; // THE SAME SHAPE THE CHART USES, applied rather than redesigned: the write reports back and the caller // refreshes the one fact it holds. Recording an authorization writes Patient.generalPractitioner // server-side, so the copy this dialog was handed is stale the instant the attest returns - and the // Care & identity row below is rendered from that copy. // // forceReload IS LOAD-BEARING. fetchPatient serves from patientList unless forced, so without it this // resolves to the same object and the refresh is inert while reading as correct. // // It reports through the EXISTING onPatientUpdated channel rather than a new prop, so both parents get // the fresh record without either of them changing. const refreshAfterAuthorization = useCallback(async () => { const id = patient?.id; if (!id || !onPatientUpdated) return; try { onPatientUpdated(await fetchPatient(id, false, undefined, true)); } catch (err) { // The write succeeded and only the refresh failed. A stale row is the defect this fixes, but // claiming otherwise would be worse, so it stays quiet and the next open reads the truth. console.error("Failed to refresh the patient after recording authorization:", err); } }, [patient?.id, fetchPatient, onPatientUpdated]); const canManageBilling = canManage("billing"); // A section that can only answer 403 is not offered. const sections = PATIENT_SECTIONS.filter((section) => section.value !== "invoices" || mayOpenPatientInvoices(activeWorkspace, canView)); // THE SECTION SELECT WHENEVER THE TABS WOULD CLIP, not only on a phone: a dialog narrower than its seven // tabs cut them off at any width. The tab list stays laid out while hidden so its own width can still be // measured, and the choice follows the dialog as it resizes. const stripRef = useRef(null); const [sectionsFit, setSectionsFit] = useState(true); useLayoutEffect(() => { const strip = stripRef.current; if (!strip) return undefined; const measure = () => { const list = strip.querySelector('[role="tablist"]'); setSectionsFit(!list || list.scrollWidth <= strip.clientWidth); }; measure(); if (typeof ResizeObserver === "undefined") return undefined; const observer = new ResizeObserver(measure); observer.observe(strip); return () => observer.disconnect(); }, [patient?.id, sections.length]); // Per-tab list<->detail stack (no nested router). Reset to the lists when the patient changes or the modal // (re)opens. const [selectedTask, setSelectedTask] = useState(null); // Assigning is a third state on the Tasks tab, alongside list and detail. It is drilled in rather than // opened as a modal because the assignment forms are still WP-shaped and cannot portal inside this Dialog. const [assignmentKind, setAssignmentKind] = useState( null, ); const [selectedAppointment, setSelectedAppointment] = useState(null); const [selectedInvoiceId, setSelectedInvoiceId] = useState( null, ); const [activeTab, setActiveTab] = useState("profile"); useEffect(() => { setSelectedTask(null); setAssignmentKind(null); setSelectedAppointment(null); setSelectedInvoiceId(null); // Only a section it offers; otherwise the picker and the body would name different sections. setActiveTab(initialTab && sections.some((section) => section.value === initialTab) ? initialTab : "profile"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [patient?.id, open, initialTab]); // The footer Back is context-aware: if a detail is open on the current tab, it pops back to that tab's list; // otherwise it returns to what opened the quick view (search, an appointment) or closes it. One predictable // Back that always goes up exactly one step. const detailOpenOnActiveTab = (activeTab === "appointments" && Boolean(selectedAppointment)) || (activeTab === "tasks" && Boolean(selectedTask || assignmentKind)) || (activeTab === "invoices" && Boolean(selectedInvoiceId)); const handleHeaderBack = () => { if (activeTab === "appointments" && selectedAppointment) { setSelectedAppointment(null); return; } if (activeTab === "tasks" && selectedTask) { setSelectedTask(null); return; } if (activeTab === "tasks" && assignmentKind) { setAssignmentKind(null); return; } if (activeTab === "invoices" && selectedInvoiceId) { setSelectedInvoiceId(null); return; } if (onBack) onBack(); else onOpenChange(false); }; const footerLabel = detailOpenOnActiveTab ? "Back" : backLabel; return ( {/* The section sets the width: the deck draws Profile at 524px and the list sections at 610px. */} {patient && }
{patientName} {/* Each part keeps to one line, so a phone wraps below the age as the deck draws it, never mid-number. */} {subline ? subline.split(" • ").map((part, index) => ( {index > 0 && " • "} {part} )) : "Patient"} {patient?.id && messageWithdrawn && (

New messages need the patient's current authorization.

)}
{patient?.id && ( )}
{loading ? (
Loading patient…
) : error ? (
{error}
) : patient ? (
{sections.map((section) => ( {section.label} ))} {/* The same sections as one labelled select: below 640px, and wherever the tabs would clip. */}
{/* THREE DOCUMENTS, ONE ROW EACH - the same bundle check-in shows, and for a harder reason than consistency. THIS IS THE ONLY CAPTURE A DESK CAN REACH BEFORE AN APPOINTMENT EXISTS. The booking gate refuses a patient with nothing on record and tells the desk to sign the form in the office and check them in - but check-in lives on an appointment, and the booking that would create one has just been refused. The patient's own record is the way through, and it was asking about ONE of the three. ⇒ A desk that followed the instruction recorded the HIPAA Authorization, watched it succeed, and hit the same refusal with nothing left to try. The gate is JOINT; one attestation was never going to clear it. */} {patient.id && (
{ORG_AGREEMENT_ORDER.map((instrument) => ( { void refreshAfterAuthorization(); }} /> ))}
)} {/* The in-person declaration, from the desk's own patient view rather than the clinical chart. */} {patient.id && isMinorPatient(patient) && (
undefined} />
)}
{selectedAppointment ? ( ) : ( )} {selectedTask ? ( ) : assignmentKind === "questionnaire" ? ( setAssignmentKind(null)} /> ) : assignmentKind === "packet" ? ( setAssignmentKind(null)} /> ) : assignmentKind === "custom" ? ( setAssignmentKind(null)} /> ) : ( )} {/* Add and edit open over the list, as they do for the patient. */} {selectedInvoiceId ? ( {}} showInvoiceNumber /> ) : ( )} {/* The same component the record uses, reading the same patient from context - not a second list of the same answers. Its one navigating control, Edit, is practitioner-only, so nothing here can walk a receptionist out of the dialog. */}
) : (
Patient profile is unavailable.
)} {footerLabel && (
)}
); }