import { createPortal } from "react-dom"; import { assetUrl } from '../../utils/assetUrl'; import { Spinner } from "../../components/ui/spinner"; import { CustomSelectControl } from "../../components/ui/custom-select-control"; import type { CustomSelectOption } from "../../components/ui/custom-select-control"; import { useNavigate, useLocation, Link } from "react-router-dom"; import { MyVisitInvitations } from "../../components/VisitInvitation/MyVisitInvitations.tsx"; import DuplicateMatchPrompt from "../../components/dashboard/DuplicateMatchPrompt.tsx"; import PracticeInvitationNotice from "../../components/PracticeInvitationNotice/PracticeInvitationNotice.tsx"; import ClinicianInvitationNotice from "../../components/ClinicianInvitationNotice/ClinicianInvitationNotice.tsx"; import { LogOutConfirmation } from "../../components/LogOutConfirmation/LogOutConfirmation.tsx"; import { MY_PROFILE_PATH, PatientNavProvider, type SidebarMenuItem } from "./patientNav.tsx"; import { FAMILY_HISTORY_PATH, PATIENT_RECORD_GROUP_IDS, patientRecordBreadcrumbs, patientRecordGroupForPath, } from "./patientRecordNav.tsx"; import { PLATFORM_MANAGER_DESTINATIONS, PLATFORM_MANAGER_DESTINATION_PERMISSIONS, } from "../../utils/platformManagerWorkspace.ts"; import { getDefaultDashboardPath } from "../../utils/staffLanding.ts"; import { useFounderLanding } from "../../utils/founderLanding"; import { navCount } from "../../utils/navCount"; import { readOperatingScope } from "../../utils/operatingScope"; import { isOrglessPatientsHost } from "../../utils/hostedMount"; import { diagnosticsQueueLabel, workspaceWorksTheBench, workspaceWorksTheDiagnosticQueue, } from "../../utils/diagnosticOrderQueue.ts"; import { workspaceReadsStudies } from "../../utils/diagnosticReading"; import { offersDiagnosticsSetup, readDiagnosticsDeclaration } from "../../utils/organizationDiagnostics"; import { Card, CardHeader, CardMedia, CardBody } from '../../components/ui/wp-card'; import { DismissibleAlert } from '../../components/ui/dismissible-alert'; import { Heading } from '../../components/ui/heading'; import { Flex, FlexBlock } from '../../components/ui/flex'; import { Text } from '../../components/ui/text'; import { Spacer } from '../../components/ui/spacer'; import { Modal } from '../../components/ui/wp-modal-shim'; import { useAuthServiceContext, usePatientServiceContext, usePractitionerServiceContext, useStaffPermissionServiceContext, } from "../../services"; import "./DashboardPage.css"; import useWebSocketServiceContext from "../../services/WebSocketService.tsx"; import RoleSwitcher from "../../components/RoleSwitcher/RoleSwitcher.tsx"; import { useFirstSignInGateStatus } from "../../components/FirstSignInGate/firstSignInGateStatus.tsx"; import AgreementGatePane from "./AgreementGatePane.tsx"; import { usePracticeNeedsOwner } from "../../hooks/usePracticeNeedsOwner"; import ProofingBanner from "../../components/ProofingBanner/ProofingBanner.tsx"; import CaregiverProofingBanner from "../../components/ProofingBanner/CaregiverProofingBanner.tsx"; import VerifiedIdentityBadge from "../../components/VerifiedIdentityBadge/VerifiedIdentityBadge.tsx"; import ActingAsBanner from "../../components/ActAs/ActingAsBanner.tsx"; import OutstandingAgreements from "../../components/OrgAgreements/OutstandingAgreements.tsx"; import { useHelpsSomeoneOnOrglessHost } from "../../hooks/useHelpsSomeoneOnOrglessHost"; import { familyOpensOnOrglessPortal, ownBillsOpenOnOrglessPortal, shownOnOrglessPortal } from "../../utils/orglessPortalNav"; import RelatedPersonInvitationNotice from "../../components/RelatedPersonInvitationNotice/RelatedPersonInvitationNotice.tsx"; import CollaborationTeamInvitationNotice from "../../components/CollaborationTeamInvitationNotice/CollaborationTeamInvitationNotice.tsx"; import { useCommunicationManager, useFhirCommunicationBroadcast, useFhirFormSubmissionBroadcast, useFhirRecordBroadcasts, } from "../../hooks"; import { useOverviewMessageFeed } from "../../hooks/useOverviewMessageFeed"; import { useStaffMessagesBadge } from "../../hooks/useStaffMessagesBadge"; import { usePatientInboxUnread } from "../../components/PatientInbox/patientInboxContext"; import { useFormSubmissionNotificationContext } from "../../services/FormSubmissionNotificationService.tsx"; import { useCapabilityServiceContext } from "../../services/CapabilityService.tsx"; import { formatFhirHumanName } from "../../utils/fhirHumanName.tsx"; import { CalendarCheck2, MessageSquareText, Calendar, CalendarDays, MessagesSquare, Building2, IdCardLanyard, Files, Activity, Mails, Info, LogOut, Settings, Users, UserRound, FileText, CreditCard, Menu, ChevronRight, ShieldCheck, Receipt, Printer, FlaskConical, Share2, FileStack, LayoutDashboard, Tags, Download, ChevronsLeft, ChevronsRight, Pencil, LayoutTemplate, ServerCog, Globe, BellRing, UsersRound, Megaphone, ScrollText, LifeBuoy, Inbox, ScanLine, ClipboardPlus, ListTodo, PillBottle, Goal, Hospital, Eye, CircleArrowOutDownLeft, Link2, } from "lucide-react"; import { useClosureGateDetail } from "../../services/PracticeClosureService"; import { PRACTICE_CLOSURE_ACCOUNT_PATH, PRACTICE_CLOSURE_PATH } from "./OrganizationPage/PracticeClosure/closureText"; import ClosedPracticeGate from "./OrganizationPage/PracticeClosure/ClosedPracticeGate"; import MsaOwnerGate from "./OrganizationPage/BaaPage/MsaOwnerGate"; import { useMsaOwnerEntry } from "../../hooks/useMsaOwnerEntry"; import { Button } from "../../components/ui/button.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, } from "../../components/ui/select.tsx"; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, } from "../../components/ui/breadcrumb.tsx"; import { getFhirPhotoUrl } from "../../utils/fhirPhoto.ts"; import { type StaffWorkspace, getWorkspaceDisplay, } from "../../utils/staffRoleCatalog.ts"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Navigate } from "react-router-dom"; import { isPatientExperience, isStaffExperience } from "../../utils/mountContext.ts"; import { staffChartCrumbTrail } from "./PatientsPage/PatientPage/staffChartSections.ts"; import { teamPageCrumbTrail, useOpenTeam } from "./CareTeamsPage/openTeamCrumb.ts"; import { isAdolescentSelf } from "../../utils/adolescentFeatureGate.ts"; import { SCOPED_SWITCH_IN, scopedSwitchInStep } from "../../utils/scopedSwitchIn"; import { ScopedSwitchInHeld } from "../../components/ActAs/ScopedSwitchInHeld"; import { useActAs } from "../../hooks/useActAs"; import { Avatar, AvatarFallback, AvatarImage } from "../../components/ui/avatar"; import { initialsOf } from "../../components/ui/org-badge"; // Nav ids gated to 18+ ( adolescent confidentiality, Spencer 2026-07-31). // // "collaboration-teams" is OUR cross-practice surface (/dashboard/collaboration-teams). It is deliberately // NOT the clinical FHIR CareTeam display at /dashboard/patient/care-provision/care-teams - a different // concept that stays visible, since a minor's care team is ordinary clinical information. // Both used to be called `care-teams`, and this set is exactly why that mattered: it hides items BY // ID, so the collision meant one string standing for two things in a security-relevant filter. // Spencer, 2026-08-13: the name `care-teams` belongs to a true FHIR CareTeam and nothing else. const ADOLESCENT_18_PLUS_NAV_IDS = new Set(["messages", "collaboration-teams"]); // A stable identity, so the staff-side provider value does not change on every render. const EMPTY_PATIENT_NAV: SidebarMenuItem[] = []; /** * The order the approved navigation deck puts the patient's drawer in. * * WHAT THIS USED TO SAY WAS AN INSTRUCTION TO BUILD THE WRONG THING. It claimed the deck also lists * Allergies and My health log, absent only until their own designs land. The deck lists neither, and * it names Allergies once to say the opposite: "Allergies is a STAFF record section and has never * been here." So the note would have had somebody add a staff record section to the patient's drawer * the moment a deck for one appeared. * * Insurance is absent for a different and equally deliberate reason the deck states: the app reaches * it from the home page's own list of destinations, so a nav row would invent a door. */ const PATIENT_NAV_ORDER = [ "overview", "tasks", "appointments", "messages", "my-records", "medications", "care-plans", "goals", "collaboration-teams", "related-persons", "providers", "invoices", // Named here although the deck does not name it, which is the opposite of the rule above and for a // mechanical reason: an unranked row sorts after every ranked one, and Sign Out is ranked last, so // leaving it out would put Help & support below the control that ends the session. "my-support", "profile", "logout", ]; /** * Anything the deck does not name keeps its place after the rows it does. * * Typed on `{ id }` rather than SidebarMenuItem: the array literal below infers a union of its own * object shapes, and a comparator narrowed to the named type does not accept it. */ const byPatientNavOrder = (a: { id: string }, b: { id: string }) => { const rank = (item: { id: string }) => { const index = PATIENT_NAV_ORDER.indexOf(item.id); return index === -1 ? PATIENT_NAV_ORDER.length : index; }; return rank(a) - rank(b); }; const BREAKPOINT = 1150; type BreadcrumbConfigItem = { label: string; parent: string | null; // A nav group with no page of its own links to the page it opens on. to?: string; }; // / D6 - the shape moved to `patientNav.tsx` so the patient home can render the SAME nav // array this file builds. Nothing about it changed; see that file for why a second hand-written // list would be both drift-prone and an adolescent-filtering hole. // The patient's words for a crumb staff read differently; the deck's crumbs say "My Profile". const PATIENT_CRUMB_LABELS: Record = { "/dashboard/profile": "My Profile", }; const getBreadcrumbConfig = (): Record => { return { "/dashboard/home": { label: "Home", parent: "/dashboard" }, "/dashboard/home/task/:id": { label: "Task Details", parent: "/dashboard/home" }, "/dashboard": { label: "Dashboard", parent: null }, "/dashboard/patient": { label: "My Visits & Results", parent: "/dashboard" }, "/dashboard/patient/profile": { label: "My details", parent: "/dashboard/patient" }, "/dashboard/patient/related-persons": { label: "Family & caregivers", parent: "/dashboard/patient" }, // Spencer, 2026-08-25: Tasks is its OWN destination, so the crumb hangs off the dashboard rather // than off the record. It read `Dashboard › My visits & results › Tasks`, which told a patient they // were inside the record when they were not - he read exactly that and believed it. "/dashboard/patient/tasks": { label: "My Tasks", parent: "/dashboard" }, "/dashboard/patient/tasks/:taskId": { label: "Task Details", parent: "/dashboard/patient/tasks" }, // Two destinations of their own in the approved nav, so they are named here rather than left to // the fallback, which title-cases the path segment and produced "Care Provision" - a phrase that // appears nowhere a patient can see. "/dashboard/patient/medications": { label: "Medications", parent: "/dashboard" }, "/dashboard/patient/care-provision": { label: "Care plans", parent: "/dashboard" }, // Goals is NOT named here. It is a record destination, so `patientRecordBreadcrumbs()` below // already supplies it from the same table the drawer renders - which is the point: a destination // named twice can be called one thing in the navigation and another in the trail. // // The line that used to sit here asked for `parent: "/dashboard"` and never got it: the record // spread comes AFTER this point in the same object literal, so the generator was already winning // and Goals already hung off the record. Removing it changes no trail; it removes a second name // and a comment that described an intent the code did not carry. // Every destination inside the record, generated from the SAME table the drawer renders, so a // leaf cannot be named one thing in the navigation and another in the breadcrumb. Each hangs // directly off the record - see patientRecordNav.tsx for why the intermediate crumb had to go. ...patientRecordBreadcrumbs(), // AFTER the record table on purpose - it OVERRIDES the crumb that table generates. // Spencer, 2026-08-29: *"the insurance page in the mockups are correct, point everything at that // insurance page."* It is its own destination, so it hangs off the dashboard, exactly as Tasks, // Medications and Care plans & goals do above. Left to the record table it read // `Dashboard › My Visits & Results › Insurance` - the same sentence Spencer read on Tasks and // believed, which is why that ruling exists. "/dashboard/patient/insurance": { label: "Insurance", parent: "/dashboard" }, // Adding and editing open as an overlay over the list, so their addresses crumb as the list. "/dashboard/patient/insurance/new": { label: "Insurance", parent: "/dashboard" }, "/dashboard/patient/insurance/:coverageId": { label: "Insurance", parent: "/dashboard" }, "/dashboard/my-invoices": { label: "Bills & payments", parent: "/dashboard" }, // The desk's board is its dashboard: the receptionist frames read "Dashboard" alone, as billing's do. "/dashboard/receptionist": { label: "Dashboard", parent: null }, "/dashboard/appointments": { label: "Appointments", parent: "/dashboard" }, "/dashboard/patients": { label: "Patients", parent: "/dashboard" }, "/dashboard/patients/import": { label: "Import patients", parent: "/dashboard/patients" }, "/dashboard/patients/:id": { label: "Patient Details", parent: "/dashboard/patients" }, "/dashboard/form-submissions": { label: "Form Submissions", parent: "/dashboard" }, // - "Questionnaires" named only one of the two kinds the section now holds. "/dashboard/questionnaires": { label: "Forms", parent: "/dashboard" }, // The builder is a child of Forms; the humanised fallback hung "New" straight off Dashboard. "/dashboard/questionnaires/new": { label: "New", parent: "/dashboard/questionnaires" }, "/dashboard/automated-tasks": { label: "Automated Tasks", parent: "/dashboard" }, "/dashboard/portal-pages": { label: "Portal Pages", parent: "/dashboard" }, "/dashboard/diagnostics-setup": { label: "Lab and imaging setup", parent: "/dashboard" }, "/dashboard/form-submission-notifications": { label: "Akashic Public Form Settings", parent: "/dashboard" }, // The billing frames read "Dashboard > ", with the overview as the dashboard itself. The // payments crumb used to name the overview as its parent, which had no entry, so the trail stopped. "/dashboard/billing/overview": { label: "Dashboard", parent: null }, "/dashboard/billing/invoices": { label: "Invoices", parent: "/dashboard" }, "/dashboard/billing/pricing": { label: "Pricing options", parent: "/dashboard" }, "/dashboard/billing/payments": { label: "Payments setup", parent: "/dashboard" }, "/dashboard/billing/export": { label: "Export", parent: "/dashboard" }, "/dashboard/bench": { label: "Bench", parent: "/dashboard" }, // Named at render by the same lens as the rail row, so the trail and the heading agree. "/dashboard/diagnostic-orders": { label: diagnosticsQueueLabel().heading, parent: "/dashboard" }, "/dashboard/diagnostic-orders/:orderId/schedule": { label: "Schedule", parent: "/dashboard/diagnostic-orders" }, "/dashboard/inbound-requisitions": { label: "Requisitions received", parent: "/dashboard" }, "/dashboard/held-lab-results": { label: "Lab results to match", parent: "/dashboard" }, "/dashboard/reading": { label: "Reading", parent: "/dashboard" }, "/dashboard/referrals": { label: "Referrals", parent: "/dashboard" }, "/dashboard/records": { label: "Records requests", parent: "/dashboard" }, "/dashboard/records/connections": { label: "Connections", parent: "/dashboard/records" }, "/dashboard/records/connections/return": { label: "Connecting", parent: "/dashboard/records/connections" }, "/dashboard/fax-settings": { label: "Faxing", parent: "/dashboard" }, "/dashboard/support": { label: "Support", parent: "/dashboard" }, "/dashboard/support/:ticketId": { label: "Ticket Details", parent: "/dashboard/support" }, // The patient's own, on its own pair of routes. Named "Support" rather than "Help & support" so // the trail matches the heading on the page it leads to. "/dashboard/my-support": { label: "Support", parent: "/dashboard" }, "/dashboard/my-support/:ticketId": { label: "Ticket Details", parent: "/dashboard/my-support" }, "/dashboard/staff-messages": { label: "Messages", parent: "/dashboard" }, "/dashboard/staff-collaboration-teams": { label: "Collaboration Teams", parent: "/dashboard" }, "/dashboard/schedules": { label: "Schedules", parent: "/dashboard" }, "/dashboard/campaigns": { label: "Campaigns", parent: "/dashboard" }, "/dashboard/campaigns/new": { label: "New campaign", parent: "/dashboard/campaigns" }, "/dashboard/campaigns/:id": { label: "Campaign", parent: "/dashboard/campaigns" }, "/dashboard/schedules/:scheduleId": { label: "Schedule Details", parent: "/dashboard/schedules" }, "/dashboard/staff": { label: "Staff", parent: "/dashboard" }, "/dashboard/staff/new": { label: "Add staff", parent: "/dashboard/staff" }, "/dashboard/staff/permissions": { label: "Role permissions", parent: "/dashboard/staff" }, "/dashboard/staff/:practitionerId": { label: "Staff Details", parent: "/dashboard/staff" }, "/dashboard/staff/:practitionerId/profile": { label: "Profile", parent: "/dashboard/staff/:practitionerId" }, "/dashboard/staff/:practitionerId/workspaces": { label: "Workspaces", parent: "/dashboard/staff/:practitionerId" }, "/dashboard/staff/:practitionerId/roles": { label: "Roles & Specialties", parent: "/dashboard/staff/:practitionerId" }, "/dashboard/staff/:practitionerId/services": { label: "Services Offered", parent: "/dashboard/staff/:practitionerId" }, "/dashboard/staff/:practitionerId/patient-booking": { label: "Patient Booking", parent: "/dashboard/staff/:practitionerId" }, "/dashboard/medications": { label: "Medications", parent: "/dashboard" }, "/dashboard/consents": { label: "Consents", parent: "/dashboard" }, // The deck crumbs it "Dashboard › My Profile › Settings". "/dashboard/settings": { label: "Settings", parent: "/dashboard/profile" }, "/dashboard/reports": { label: "Reports", parent: "/dashboard" }, "/dashboard/reports/scheduling": { label: "Scheduling & access", parent: "/dashboard/reports" }, "/dashboard/reports/referrals": { label: "Referrals", parent: "/dashboard/reports" }, "/dashboard/reports/orders": { label: "Orders & results", parent: "/dashboard/reports" }, "/dashboard/reports/tasks": { label: "Tasks & work queues", parent: "/dashboard/reports" }, "/dashboard/reports/communication": { label: "Patient communication", parent: "/dashboard/reports" }, "/dashboard/reports/billing": { label: "Billing", parent: "/dashboard/reports" }, "/dashboard/reports/staff-time": { label: "Staff time", parent: "/dashboard/reports" }, "/dashboard/reports/compliance": { label: "Access & compliance", parent: "/dashboard/reports" }, "/dashboard/documents": { label: "Documents", parent: "/dashboard" }, // Was "Audit Events" while the destination that now reaches it is called "Practice Access Log". // This file has already been bitten by a crumb and a menu naming one page two ways - see the note // on the record's own history crumb below - so they are changed together. "/dashboard/audit-events": { label: "Practice Access Log", parent: "/dashboard" }, "/dashboard/subscription": { label: "Subscription", parent: "/dashboard" }, // The admin crumb starts at Dashboard, which for an admin is this page; there is no Practice crumb above it. "/dashboard/organization": { label: "Dashboard", parent: null }, "/dashboard/organization/booking-reminders": { label: "Booking & reminders", parent: "/dashboard/organization" }, "/dashboard/organization/agreements": { label: "Agreements", parent: "/dashboard/organization" }, "/dashboard/organization/referrals": { label: "Referrals", parent: "/dashboard/organization" }, "/dashboard/organization/clinician-readiness": { label: "Clinician appointment readiness", parent: "/dashboard/organization" }, "/dashboard/organization/account": { label: "Practice account", parent: "/dashboard/organization" }, "/dashboard/practice-closure": { label: "Close this practice", parent: "/dashboard/organization/account" }, "/dashboard/organization/locations": { label: "Locations", parent: "/dashboard/organization" }, "/dashboard/organization/healthcare-services": { label: "Healthcare Services", parent: "/dashboard/organization" }, "/dashboard/organization/baa": { label: "Business Associate Agreement", parent: "/dashboard/organization" }, // "Lab and imaging setup" rather than "Diagnostics": that name already belongs to the bench, // the order queue and the chart's diagnostics data, and two destinations must not share one. "/dashboard/organization/diagnostics": { label: "Lab and imaging setup", parent: "/dashboard/organization" }, "/dashboard/profile": { label: "Profile", parent: "/dashboard" }, "/dashboard/profile/edit": { label: "Edit Profile", parent: "/dashboard/profile" }, "/dashboard/my-appointments": { label: "Appointments", parent: "/dashboard" }, "/dashboard/my-records/connections": { label: "Records from other health systems", parent: "/dashboard" }, "/dashboard/my-records/connections/return": { label: "Connecting", parent: "/dashboard/my-records/connections" }, // No "/dashboard/book-appointment" entry: the booking PAGE is retired and the route only // redirects now, so a breadcrumb for it would name a crumb nobody can ever be standing on. "/dashboard/messages": { label: "Messages", parent: "/dashboard" }, "/dashboard/related-persons": { label: "Family & caregivers", parent: "/dashboard" }, // "/dashboard/my-history" IS NOT LISTED HERE, and its absence is the fix rather than an omission. // It is a record destination, so `patientRecordBreadcrumbs()` above already supplies it, hanging // off My Profile through its `crumbParent`. A literal key here sits LATER in the same object and // therefore silently overrode that spread: the drawer said "Who has seen my record" while the crumb // said "My History" and claimed the page lived under the dashboard rather than inside the record. // Seen in a browser 2026-08-27 - the exact two-vocabularies defect this card exists to remove, // surviving in the one place a later object key could reintroduce it. "/dashboard/providers": { label: "My practices", parent: "/dashboard" }, "/dashboard/collaboration-teams": { label: "Collaboration teams", parent: "/dashboard" }, // A team opens over the list, so its crumb is the list's. "/dashboard/collaboration-teams/:teamId": { label: "Collaboration teams", parent: "/dashboard" }, }; }; const isRouteMatch = ( pathname: string, targetPath: string, matchType: "exact" | "prefix" = "prefix", ) => { if (matchType === "exact") { return pathname === targetPath; } return pathname === targetPath || pathname.startsWith(`${targetPath}/`); }; // The person in the sidebar: their photo when they have one, otherwise their initials on the shared avatar. const NavMeAvatar = ({ photo, name }: { photo?: string; name: string }) => ( {photo && } {initialsOf(name)} ); const DashboardPage = () => { const { exit: exitActingAs } = useActAs(); const { actingAs, currentUser, userRole, USER_ROLES, availableWorkspaces, selectedWorkspace, activateWorkspace, isLoading, relatedPatientLoading, currentOrganization, } = useAuthServiceContext(); // A practice that only receives referrals has no schedules or subscription to run until it chooses to operate. const receivingOnly = readOperatingScope(currentOrganization) === "receiving"; const { activeWorkspace, canView: canViewStaffSection, canManage: canManageStaffSection, } = useStaffPermissionServiceContext(); const { connect, disconnect } = useWebSocketServiceContext(); const { patientState } = usePatientServiceContext(); const { practitionerState } = usePractitionerServiceContext(); const [isMobile, setIsMobile] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [showWorkspaceSelector, setShowWorkspaceSelector] = useState(false); // The sidebar's rows are built from data with an onClick, so there is no JSX for a dialog trigger // to wrap. The row asks; the dialog is rendered once beside the nav and driven from here. const [logOutConfirmOpen, setLogOutConfirmOpen] = useState(false); const [imageUrl, setImageUrl] = useState(""); // Without a photo of their own the person gets their initials, as the deck draws them, not a stock picture. const hasOwnPhoto = Boolean(currentUser?.fhir?.photo?.[0]); const [expandedNavIds, setExpandedNavIds] = useState([]); const [isWorkspaceHelpOpen, setIsWorkspaceHelpOpen] = useState(false); const [isNavCollapsed, setIsNavCollapsed] = useState(() => { try { return localStorage.getItem("akashic_nav_collapsed") === "true"; } catch { return false; } }); const setNavCollapsedPersisted = (value: boolean) => { setIsNavCollapsed(value); try { localStorage.setItem("akashic_nav_collapsed", String(value)); } catch { /* localStorage unavailable — collapse still works for this session */ } }; /** * IS THE NAV A DRAWER, OR A PERSISTENT RAIL? ( B11 + the patient-home R1 ruling.) * * Two independent reasons, and they are OR'd rather than merged because they answer different * questions: * - `isMobile` - there is no room for a rail. True on either portal. * - the PATIENT portal - a product decision, at EVERY width: the patient navigates from the header * band and the "Everything else" door list, never from a standing rail. Spencer's 08-13 mock has * no sidebar at 1280px, and `AccountSummary` exists precisely because "removing the left nav * without building this is what made the home feel like it had lost something". * * Keyed on `isStaffExperience()` - the SAME discriminator that picks the menu ITEMS two hundred * lines below. Using a different test here would let the nav's shape and the nav's contents disagree. */ const navIsDrawer = isMobile || !isStaffExperience(); // Every drawer is the dark one: the patient's at every width, and staff below the breakpoint // (receptionist deck #s-navigation, "the same panel the patient portal opens"). const patientNavSkin = navIsDrawer; const agreementGateStatus = useFirstSignInGateStatus(); // Icons-only rail is a PERSISTENT-RAIL affordance. A drawer is already hidden by default, so // collapsing it to icons is a state with nothing to offer - and on the patient portal it would be a // second, competing way to hide navigation the patient never asked to see. const navCollapsed = isNavCollapsed && !navIsDrawer; const navigate = useNavigate(); const location = useLocation(); // The header's and the Inbox tab's number, team messages included. const patientUnreadMessages = usePatientInboxUnread(); // A signed-in 12-17 patient has no messaging (18+) and the server refuses the read, so it is not // loaded for them, and a refusal the product decided on is never shown as a system error. const adolescentSelf = isAdolescentSelf(currentUser); const { error: communicationError, clearError: clearCommunicationError, } = useCommunicationManager({ autoLoad: !adolescentSelf }); /** * WHAT AWAITS A REPLY: the overview's "Waiting on you" number, and the practitioner's badge. * * Spencer ruled the unit in two halves. STAFF count what awaits a reply: a staff member may read a * message and be too busy, or need a colleague's answer, before they can reply, and counting * unread buries exactly that message. PATIENTS count unread: a patient wants to know something * arrived and need not reply. So the patient badge below still reads `patientUnreadMessages` and * must not be converted. * * THE DEFECT THIS CLOSES, measured on the overview: "Waiting on you - 2 - Messages awaiting your * reply" beside a navigation badge reading 1. Two numbers four inches apart, disagreeing, with * nothing saying they answer different questions. Reachable in one click - reply from the quick * reply without opening the thread and the owed count falls while the badge does not. * * READ FROM THE FEED RATHER THAN RE-DERIVED, deliberately, and it costs a fetch on this shell. A * cheaper count built from the threads this component already holds would miss two things the * overview counts - a thread dismissed as needing no reply, and a collaboration-team thread - so it * would agree with the overview most of the time and disagree exactly when somebody acted. That is * the same defect in a form that is harder to notice. */ const { awaitingReplyCount: staffAwaitingReply, teamReadsIncomplete: staffAwaitingIncomplete } = useOverviewMessageFeed(); useFhirCommunicationBroadcast(); const { newFormSubmissionsCount, newFormSubmissionsStatus, refresh: refreshFormSubmissions, } = useFormSubmissionNotificationContext(); const { mayCall } = useCapabilityServiceContext(); useFhirFormSubmissionBroadcast(); // THE PATIENT'S RECORD, KEPT CURRENT WHEREVER THEY ARE IN THE PORTAL. // // Mounted here rather than on each facet page for the same reason the two above are: a subscription // that has to survive navigation belongs at the shell. On the facet pages a clinician's write reached // the patient only while they happened to be looking at that exact screen, and nothing re-asked // afterwards - so a diagnosis recorded mid-session was invisible until a full page reload. // // Ruled by Spencer 2026-09-06, for every record facet rather than the one that was found. The list of // facets lives in the hook, with a test that derives it from the hooks directory so the next one // cannot be missed quietly. useFhirRecordBroadcasts(); // (design-pass findings B7 / B30) - DO NOT ASK A QUESTION THIS SESSION CANNOT ASK. // // This effect was unconditional, so EVERY dashboard mount fetched the form-submissions count - // including the PATIENT portal, where `GET /R4/form-submissions` is a staff endpoint the patient's own // token has no grant for. Result: a 403 and an explicit-deny body in the console on every single // patient dashboard load (B7). // // `DashboardPage` is shared by both portals; `isStaffExperience()` is the mount-level discriminator // that already distinguishes them (it is what picks `adminMenuItems` vs `patientMenuItems` below), so // this needs no new signal and changes nothing for any staff role. // // Scoped to the AUDIENCE, deliberately, not to a role list. Measured 2026-08-12: admin AND // receptionist both get **200** here - so a role-based gate would have been both wrong and fragile. // The thing that is structurally true is that the PATIENT portal has no business asking at all. // // AND THE AUDIENCE GATE IS NOT ENOUGH, which is the half exposed. HR and marketing ARE the // staff experience and are still refused: `GET /R4/form-submissions` lists Admins, Practitioners and // Receptionists - no `Staff` and no `requiredPermission` - so a delegated Staff-group role is denied // and no org setting can grant it. The audience test cannot see that, because it is a fact about the // ROLE rather than about which portal is mounted. Both gates, and they answer different questions. // `=== false` only: `mayCall` returns `undefined` for NOT KNOWN, and treating that as "no" would // silence the badge for admins and receptionists the moment the capability endpoint hiccuped. useEffect(() => { if (!isStaffExperience()) return; if (mayCall('GET', '/form-submissions') === false) return; // Shell-level, so outside the withheld pane: it waits for the agreement gate on its own. if (agreementGateStatus === 'outstanding' || agreementGateStatus === 'unknown') return; void refreshFormSubmissions(); // `mayCall` is a dependency because capabilities resolve asynchronously: on a cold load this runs // while the answer is unknown (the fallback, correct) and must run AGAIN once the real answer lands. }, [refreshFormSubmissions, mayCall, agreementGateStatus]); const openTeam = useOpenTeam(); const isOnMessagesPage = isRouteMatch(location.pathname, "/dashboard/messages", "prefix"); // The staff Messages count stays on its own page, as the decks draw it: it is what still awaits a // reply, and opening the inbox answers none of it. const isOnFormSubmissionsPage = isRouteMatch(location.pathname, "/dashboard/form-submissions", "prefix"); useEffect(() => { const newImageUrl = getFhirPhotoUrl(currentUser?.fhir?.photo?.[0]); setImageUrl(newImageUrl); }, [currentUser?.fhir?.photo?.[0]]); const generateBreadcrumbItems = () => { const breadcrumbConfig = getBreadcrumbConfig(); const currentPath = location.pathname; const items: Array<{ path: string; label: string; isCurrent: boolean }> = []; // A team a phone opened as its own page ends the trail on its name; see openTeamCrumb. const teamTrail = teamPageCrumbTrail(currentPath, openTeam); if (teamTrail) return teamTrail.map((crumb, index) => ({ ...crumb, isCurrent: index === teamTrail.length - 1 })); const chartTrail = isStaffExperience() ? staffChartCrumbTrail(currentPath, patientState && { id: patientState.id, name: formatFhirHumanName(patientState.name, { emptyText: "" }) }) : undefined; if (chartTrail) return chartTrail.map((crumb, index) => ({ ...crumb, isCurrent: index === chartTrail.length - 1 })); const findMatchingRoute = (path: string) => { if (breadcrumbConfig[path]) return path; for (const route in breadcrumbConfig) { const routeSegments = route.split("/"); const pathSegments = path.split("/"); if (routeSegments.length === pathSegments.length) { let isMatch = true; for (let i = 0; i < routeSegments.length; i++) { if (routeSegments[i].startsWith(":")) continue; if (routeSegments[i] !== pathSegments[i]) { isMatch = false; break; } } if (isMatch) return route; } } return null; }; // On a staff-detail route, show the loaded practitioner's name in place of the // static "Staff Details" label. Guard on id-match so a stale/other practitioner // (or an unloaded one on a deep link) falls back to the static label cleanly. const STAFF_DETAIL_ROUTE = "/dashboard/staff/:practitionerId"; const urlStaffId = currentPath.match(/^\/dashboard\/staff\/([^/]+)/)?.[1]; const loadedStaffName = urlStaffId && practitionerState?.practitioner?.id === urlStaffId ? formatFhirHumanName(practitionerState.practitioner.name, { emptyText: "" }) : ""; let currentRoute = findMatchingRoute(currentPath); while (currentRoute) { const config = breadcrumbConfig[currentRoute]; const isStaffDetail = currentRoute === STAFF_DETAIL_ROUTE; items.unshift({ path: isStaffDetail && urlStaffId ? `/dashboard/staff/${urlStaffId}` : config.to ?? currentRoute, label: isStaffDetail && loadedStaffName ? loadedStaffName : currentRoute === "/dashboard/diagnostic-orders" ? diagnosticsQueueLabel(activeUserRole).heading : (!isStaffExperience() && PATIENT_CRUMB_LABELS[currentRoute]) || config.label, isCurrent: items.length === 0, }); currentRoute = config.parent ? findMatchingRoute(config.parent) : null; } if (items.length === 0) { const pathSegments = currentPath.split("/").filter(Boolean); items.push({ path: "/dashboard", label: "Dashboard", isCurrent: pathSegments.length <= 2, }); if (pathSegments.length > 2) { const lastSegment = pathSegments[pathSegments.length - 1]; // (B35) - THIS HUMANISER ASSUMES THE SEGMENT IS A SLUG, AND A RESOURCE ID IS NOT ONE. // Applied to an encounter UUID it produced, verbatim on screen: // "F7f8875b 1e04 4c09 9825 Ffa7559c329c" // - the id split on its hyphens and title-cased, presented to a clinician as the page's title. // A segment that is an opaque identifier has no human label to derive; inventing one is worse // than admitting we do not have it, so fall back to the parent crumb instead of fabricating. // // The RICHER answer the finding asks for - the visit's date and type, with the id as // metadata - needs the Encounter resource, which this builder does not have and should not // fetch. That belongs to the encounter surface itself; this stops the lie in the meantime. const isOpaqueId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(lastSegment) || /^[0-9a-f]{16,}$/i.test(lastSegment) || /^\d+$/.test(lastSegment); if (!isOpaqueId) { const formattedLabel = lastSegment .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); items.push({ path: currentPath, label: formattedLabel, isCurrent: true }); } else { // THE MAP IS ASKED FIRST, AND IT WAS NOT BEING ASKED AT ALL. // // Falling back to the parent is right; humanising the parent SEGMENT to get its label is not, // because the segment is a url and the map above already holds the word the product uses. // `/dashboard/providers/{id}` produced "Providers" from the segment while the very same // config said "My practices" two hundred lines up - the build disagreeing with itself on one // screen, and the patient reading two names for one destination on adjacent pages. // // It is the defect the note above this branch describes, one step further out: there, an // invented label for a segment that has no word; here, an invented label for a segment that // has one. `/dashboard/related-persons/{id}` had it too, saying "Related Persons" where the // product says "Family & caregivers". const parentPath = `/${pathSegments.slice(0, -1).join("/")}`; const mappedParent = findMatchingRoute(parentPath); const parentSegment = pathSegments[pathSegments.length - 2]; const parentLabel = mappedParent ? breadcrumbConfig[mappedParent].label : parentSegment ? parentSegment .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ") : ""; if (parentLabel) { items.push({ path: currentPath, label: parentLabel, isCurrent: true }); } } } } return items; }; useEffect(() => { const handleResize = () => { const mobile = window.innerWidth <= BREAKPOINT; setIsMobile(mobile); // Close only when the nav STOPS being a drawer - i.e. when a persistent rail is about to take // over and an open drawer would be a second copy of the same menu. Not `if (!mobile)`: on the // patient portal the nav is a drawer at every width, so that test slammed it shut on every // resize tick while someone was dragging a window. if (!mobile && isStaffExperience()) setIsExpanded(false); }; handleResize(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); /** * The two things that make a panel a DRAWER rather than a panel that happens to be on top *: the page behind it must not scroll, and Escape must close it. * * The scroll lock restores the PREVIOUS value rather than clearing to `''`. This app is a mount * inside someone else's WordPress page - a theme or another plugin may own `body.overflow`, and * blanking it would be us silently winning an argument we never entered. */ useEffect(() => { if (!navIsDrawer || !isExpanded) return undefined; const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setIsExpanded(false); }; document.addEventListener("keydown", onKeyDown); return () => { document.body.style.overflow = previousOverflow; document.removeEventListener("keydown", onKeyDown); }; }, [navIsDrawer, isExpanded]); useEffect(() => { if (availableWorkspaces.length > 1 && !selectedWorkspace) { setShowWorkspaceSelector(true); } }, [availableWorkspaces, selectedWorkspace]); // - KEYED ON WHO, NOT ON WHICH OBJECT. This depended on `currentUser`, so any render that // produced a new object for the SAME person - the record settling after load is the ordinary case - // tore the socket down and opened another, minting a second handshake ticket. Measured at two // `POST /ws-ticket` calls per page load. The ids are strings and do not churn. // Do not "simplify" this back to `[currentUser,...]`; that is the defect, and it reads as tidier. const cognitoSub = currentUser?.cognito?.sub; const fhirId = currentUser?.fhir?.id; useEffect(() => { if (!cognitoSub || !fhirId) return; connect(); return () => disconnect(); }, [cognitoSub, fhirId, connect, disconnect]); useEffect(() => { const idsToExpand: string[] = []; if (location.pathname.startsWith("/dashboard/patient")) { idsToExpand.push("my-records"); // Which section holds the page you are on, asked of the sections themselves. It cannot be a // list of path prefixes any more: the destinations under `/dashboard/patient/clinical` alone // are filed across four different sections. const openSection = patientRecordGroupForPath(location.pathname); if (openSection) idsToExpand.push(openSection); } // The closure is a Practice page too: admin-v2 draws a closed practice's rail with this group open. if (location.pathname.startsWith("/dashboard/organization") || location.pathname.startsWith(PRACTICE_CLOSURE_PATH)) { idsToExpand.push("organization"); } if (location.pathname.startsWith("/dashboard/staff")) { idsToExpand.push("staff"); } if (idsToExpand.length === 0) { return; } setExpandedNavIds((prev) => { const merged = new Set([...prev, ...idsToExpand]); return Array.from(merged); }); }, [location.pathname]); const handleNavigateToMessages = (path: string) => navigate(path); const toggleNavGroup = (id: string) => { setExpandedNavIds((prev) => { const isTopLevelGroup = [ "my-records", "providers", "organization", "staff", ].includes(id); const isMyRecordsChildGroup = PATIENT_RECORD_GROUP_IDS.includes(id); if (prev.includes(id)) { return prev.filter((itemId) => itemId !== id); } if (isTopLevelGroup) { return [ ...prev.filter((itemId) => !["my-records", "providers", "organization", "staff"].includes(itemId)), id, ]; } if (isMyRecordsChildGroup) { return [...prev.filter((itemId) => !PATIENT_RECORD_GROUP_IDS.includes(itemId)), id]; } return [...prev, id]; }); }; const workspaceOptions = useMemo( () => availableWorkspaces.map((workspace) => ({ key: workspace, name: getWorkspaceDisplay(workspace), value: workspace, })), [availableWorkspaces], ); const selectedWorkspaceOption = useMemo(() => { if (!selectedWorkspace) return workspaceOptions[0]; return workspaceOptions.find( (option) => option.value === selectedWorkspace, ) ?? workspaceOptions[0]; }, [selectedWorkspace, workspaceOptions]); // LIFTED TO `utils/staffLanding.ts`. There was a SECOND copy of this in `App.tsx`, reached by // anybody who asks for `/dashboard` - which is what the dashboard's own index route does - and it // was three workspaces short and disagreed about a fourth. See that file for what it cost. const activeUserRole = activeWorkspace ?? selectedWorkspace ?? userRole; // The Messages badge is the Inbox tab's unread; the practitioner page has no Inbox tab. Only the staff portal // shows it, so only there does it read colleague conversations: the patient's home never asks for staff. const staffMessagesUnread = useStaffMessagesBadge( activeUserRole === USER_ROLES.RECEPTIONIST ? "desk" : activeUserRole === USER_ROLES.PRACTITIONER ? "practitioner" : "colleagues", staffAwaitingReply, isStaffExperience(), ); /** * THE DESTINATION IS REMEMBERED AND NAVIGATED TO ONCE THE WORKSPACE HAS ACTUALLY CHANGED. * * It used to navigate in the same breath as `activateWorkspace`. That sets React state, so the new * route rendered while the permission service still answered for the OLD workspace - and the route * guards read it. `/dashboard/staff` sits behind `RequireStaffPermission section="staff"`, which * redirects to `/dashboard/profile` when `canView('staff')` is false. * * So switching INTO HR landed on the personal profile from any workspace that does not itself grant * staff:view, and worked from the ones that do. Measured in a browser 2026-08-29: * * Platform Manager -> HR /dashboard/profile Receptionist -> HR /dashboard/profile * Admin -> HR /dashboard/staff Billing -> HR /dashboard/staff * Referrals -> HR /dashboard/staff * * The card had only seen the Platform Manager arm; it is every workspace without the destination's own * permission, which is why fixing it per-pair would have missed half of them. * * The `/dashboard/profile` fallback is deliberately untouched - it is the right answer for a genuine * no-grant case. The defect was that a LEGITIMATE destination reached it, and waiting one render is * what stops that. */ const [pendingWorkspaceLanding, setPendingWorkspaceLanding] = useState(null); const selectWorkingWorkspace = (workspace: StaffWorkspace) => { activateWorkspace(workspace); setExpandedNavIds([]); setShowWorkspaceSelector(false); setPendingWorkspaceLanding(workspace); }; useEffect(() => { if (!pendingWorkspaceLanding) return; // Until the permission service reports the new workspace, its guards would still refuse the // destination. Nothing is navigated in the meantime, so the current screen simply stays put. if (activeWorkspace !== pendingWorkspaceLanding) return; navigate(getDefaultDashboardPath(pendingWorkspaceLanding), { replace: true }); setPendingWorkspaceLanding(null); }, [pendingWorkspaceLanding, activeWorkspace, navigate]); /** * AND THE SAME LANDING, ONCE, FOR SOMEBODY WHO HAS ONLY ONE WORKSPACE TO BE IN. * * `getDefaultDashboardPath` was reached from exactly one place - `selectWorkingWorkspace`, which fires * when a person picks a DIFFERENT workspace from the switcher. Somebody with a single workspace never * opens that switcher, so the resolver never ran for them and they landed on Profile every time. * The feature worked for precisely the users who least needed it. Measured on a lab technician, whose * whole job is the queue and who arrived at a form for editing their own name. * * SCOPED TO ONE WORKSPACE ON PURPOSE, which is what makes it safe. A person with several has a * REMEMBERED workspace that `activateWorkspace` persists deliberately, and may be returning to a screen * deep inside it; re-landing them on a default would throw that away. Nobody with a choice is touched. * * ONLY FROM THE ROUTE THEY DID NOT ASK FOR. If they arrived at a real destination - a link in an email, * a bookmarked page - that is a request, and moving them off it would be the same defect pointed the * other way. Profile is the route the app puts them on when they asked for nothing. * * IT WAITS FOR THE PERMISSION SERVICE exactly as the switcher path above does, and for the same reason * recorded there: navigate before `activeWorkspace` agrees and the route guard answers for the OLD * workspace, sending them straight back to Profile. That is why this reuses `pendingWorkspaceLanding` * rather than calling `navigate` itself. * * The guard still has the last word, and that is correct. A practice that never granted the section * behind a workspace's landing leaves that person on Profile - which is the honest outcome, and the * same one they get today. */ const hasLandedOnSignIn = useRef(false); useEffect(() => { if (hasLandedOnSignIn.current) return; if (!isStaffExperience()) return; if (availableWorkspaces.length !== 1) return; const onlyWorkspace = availableWorkspaces[0]; if (activeWorkspace !== onlyWorkspace) return; /* * THE ROUTE HAS TO HAVE SETTLED BEFORE THIS DECIDES ANYTHING, and getting that wrong is what made * the first version of this inert. It burned the once-only ref as soon as the workspace resolved - * but at sign-in the workspace resolves while the router is still on `/login`, mid-redirect. The * path check then failed, the ref was already spent, and the effect never ran again. * Measured in a browser: the lab technician still landed on Profile with the fix in the bundle. * * Anything that is not yet a dashboard route is TRANSIENT. Return without spending the ref and * wait for the next render. * * AND `/dashboard/home` IS TRANSIENT TOO, which is the whole of why this stayed broken through * three fixes. It starts with `/dashboard`, so the test above passed and the ref was spent - and * then the did-not-ask test below rejected it, because the set named only `/dashboard` and * `/dashboard/profile`. One render, ref gone, nothing done, and the effect never runs again. * * MEASURED ON TWO ACCOUNTS ON THE SAME BUNDLE (`fa26d072394cdc1c8f8f`), an imaging technician * and a lab technician, walked separately by two sessions: * * fresh sign-in through the form -> #/dashboard/profile * asking for #/dashboard by hand -> #/dashboard/diagnostic-orders * * Same account, same bundle, minutes apart. The redirect works; the SIGN-IN does not, and the * only difference between the two is that a sign-in transits `/dashboard/home` and a typed * `/dashboard` does not. * * `/dashboard/home` IS a did-not-ask route - it is the redirect hub the router puts somebody on * when they asked for nothing, exactly like `/dashboard` - so it belongs in the set rather than * outside it. THE SET IS NAMED ONCE and both tests read it, because the defect was two tests * disagreeing about which routes are the app's own choice. * * A CAUTION FOR WHOEVER READS THIS NEXT: a warm RELOAD cannot test any of it. The stored route * is restored on a reload, so an account last seen on its queue reloads to its queue whatever * this effect does. Only a fresh sign-in through the form measures this, and a reload standing * in for one is how the previous round was read as working. */ const asked = location.pathname.replace(/\/+$/, ''); if (!asked.startsWith('/dashboard')) return; const DID_NOT_ASK = ['/dashboard', '/dashboard/home', '/dashboard/profile']; hasLandedOnSignIn.current = true; // Anything other than the did-not-ask routes is a destination they chose; leave them on it. if (!DID_NOT_ASK.includes(asked)) return; if (getDefaultDashboardPath(onlyWorkspace) === location.pathname) return; setPendingWorkspaceLanding(onlyWorkspace); }, [availableWorkspaces, activeWorkspace, location.pathname]); // A founder's first sign-in lands on the practice dashboard, carried through sign-up and sign-in. useFounderLanding(currentOrganization, () => { hasLandedOnSignIn.current = true; setPendingWorkspaceLanding(null); }); // - SWITCHING WORKSPACE MUST CLOSE THE DRAWER, exactly as tapping a nav item does. // // Measured in a browser at 886px (drawer mode): open the drawer, switch workspace. The app navigates // to the new workspace's landing route and the whole nav changes underneath you - but `isExpanded` was // never cleared, so the drawer AND its scrim stay up over the new page. // `document.elementFromPoint` at the centre of the viewport then returns // `DIV.fixed inset-0 z-40 bg-black/50`, and the NEXT click anywhere lands on the scrim instead of the // control under it. The scrim dismisses itself on that click, so the second click works - which is why // it reads as "I clicked the button and nothing happened" rather than as a stuck overlay. // The scrim is NOT the defect and must not be removed: it is the drawer's dismiss target, and without // it a drawer over Save and Delete would let clicks straight through. The defect is the drawer outliving // the navigation that made it irrelevant. const closeDrawerAfterNavigating = () => { if (navIsDrawer) setIsExpanded(false); }; const handleWorkspaceViewChange = (newValue: { selectedItem: CustomSelectOption }) => { const nextWorkspace = newValue.selectedItem.value as StaffWorkspace | undefined; if (!nextWorkspace || nextWorkspace === selectedWorkspace) { return; } closeDrawerAfterNavigating(); selectWorkingWorkspace(nextWorkspace); }; // Collapsed-rail workspace switch (ui/select icon popover) - raw value, not the WP select shape. // The close is harmless here rather than redundant: the collapsed rail only exists when the nav is // NOT a drawer, so the guard inside makes it a no-op - and keeping both paths identical is what stops // the next person fixing one and not the other. const handleCollapsedWorkspaceChange = (workspace: string) => { if (!workspace || workspace === selectedWorkspace) { return; } closeDrawerAfterNavigating(); selectWorkingWorkspace(workspace as StaffWorkspace); }; const onSubmit = (data: { workspace: StaffWorkspace }) => { selectWorkingWorkspace(data.workspace); }; const breadcrumbItems = generateBreadcrumbItems(); /** * THE HAMBURGER BELONGS IN THE HEADER, WHICH IS WHERE EVERY DECK DRAWS IT - top right, beside * the avatar. The build had it on the breadcrumb row, and that only became visible when the * assistant moved onto that row and the two ended up sharing a band neither was drawn in. * * / B12 - the accessible name is required, not decoration: this control once * carried no `aria-label`, no text and no `title`, so a screen reader announced "button". * It still keys on `navIsDrawer` and nothing else. A hamburger with no drawer, or a drawer with * no hamburger, are the same bug seen from either end. */ const [headerActionsSlot, setHeaderActionsSlot] = useState(null); useEffect(() => { setHeaderActionsSlot(document.querySelector("[data-akashic-header-actions]")); }, [location.pathname]); const navMenuButton = ( ); // adolescent confidentiality (Spencer, 2026-07-31): messaging and collaboration teams are // 18+. The SERVER is the boundary and already denies these routes; hiding the nav is UX, so that a // 12-17 patient never meets a surface that simply errors. Spencer's decision is ABSENT, not disabled // and not an empty state - so the items are filtered out of the array rather than rendered inert. // // Keyed on `currentUser` - the LOGGED-IN identity - never the act-as subject. A parent acting as // their child keeps `currentUser.fhir` as the PARENT, so they keep messaging on the child's behalf, // which is the entire point of the model. Keying this on the viewed patient would hide messaging from // the parent: the inversion the server-side work flagged as worse than the gap it closes. const hide18PlusFeatures = isAdolescentSelf(currentUser); const helpsSomeone = useHelpsSomeoneOnOrglessHost(); // The drawer's order, from the approved navigation deck. Kept as a list of ids rather than by // moving blocks around the array below: the order is a design decision and belongs somewhere a // reader can check against the deck in one glance. Anything not named here keeps its place after // the named rows, so a new item appears rather than silently jumping to the front. const patientMenuItems = useMemo( () => ([ { id: "overview", label: "Overview", icon: , to: "/dashboard/home", activeMatch: "exact", onClick: () => navigate("/dashboard/home"), }, { id: "appointments", label: "Appointments", icon: , to: "/dashboard/my-appointments", activeMatch: "prefix", onClick: () => navigate("/dashboard/my-appointments"), }, { id: "messages", label: "Messages", icon: , to: "/dashboard/messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/messages"), unreadCount: patientUnreadMessages, showUnread: patientUnreadMessages > 0 && !isOnMessagesPage, }, { id: "related-persons", label: "Family & caregivers", icon: , to: "/dashboard/related-persons", activeMatch: "prefix", onClick: () => navigate("/dashboard/related-persons"), }, { id: "my-records", label: "My Visits & Results", icon: , activeMatch: "prefix", /** * THE PARENT OPENS THE RECORD. Without a `to` it was a pure disclosure tapping it expanded * the group and changed no route so **from the navigation drawer the record itself was * unreachable.** A patient who wanted the stream had to know to go back to the home page and use * the door list instead. Seen in a browser 2026-08-26. * * EXPANDING STILL WORKS: the nav opens a group whose parent also navigates, which is what the * staff charts own sections already do. The children are the second way in, not the only one. */ to: "/dashboard/patient", onClick: () => navigate("/dashboard/patient"), activePaths: ["/dashboard/patient"], // Family health history lives under My Profile, per the approved deck, as does My record activity. exceptPaths: [FAMILY_HISTORY_PATH], /* THE RECORD IS ONE ROW. The approved navigation deck, 2026-08-28: *"The record is ONE ROW: the stream, its facets and the Now lens."* Its 33 destinations were children here; they are now reached from inside the record itself, which already renders them as "Browse your record" beneath the stream, alongside the Now and History lenses. NOTHING IS UNREACHABLE - AND THAT SENTENCE WAS FALSE FOR AS LONG AS IT STOOD HERE. It claimed the facet list is "generated from the same PATIENT_RECORD_SECTIONS table the children were". It was generated from the CHILDREN. Removing them left the resolver with an empty map, every section resolved to nothing, and the browse list returned `null` - so all 33 destinations lost their only door, quietly, while this comment said they had not. It is true now: the list falls back to the authored destinations when the nav offers no children, so the drawer and the record page read one source and cannot disagree. Removing these children is once again a display decision rather than a deletion. */ }, /* Spencer, 2026-08-25: *"Tasks should NOT be under My visits & results, they should be under their own page."* A TOP-LEVEL destination, not a leaf of the record. It was a child of `my-records`, which is what put "My visits & results" in its breadcrumb and made a patient believe they were on the record when they were on their tasks. */ { id: "tasks", label: "My Tasks", icon: , to: "/dashboard/patient/tasks", activeMatch: "prefix", activePaths: ["/dashboard/patient/tasks"], onClick: () => navigate("/dashboard/patient/tasks"), }, /* Two more destinations of their own, from the approved navigation deck. Their pages exist; the deck's other two new rows - Allergies and My health log - are not here because neither has an approved design yet, and a row that dead-ends is worse than a row not offered. */ { id: "medications", label: "Medications", icon: , to: "/dashboard/patient/medications", activeMatch: "prefix", activePaths: ["/dashboard/patient/medications"], onClick: () => navigate("/dashboard/patient/medications"), }, /* TWO ROWS, NOT ONE, AND THE APPROVED DRAWING SAYS WHY: "a goal can belong to a plan and not every goal does, so one page could not carry both the permission rule and the lifecycle." A plan is practitioner-authored and a goal may be written by either, so one page cannot carry one permission rule; a plan is revised in bursts while a goal moves continuously against readings. The old single row was `Care plans & goals` - two nouns joined by an ampersand because nobody had decided whether it was one thing. It is not. */ { id: "care-plans", label: "Care plans", icon: , to: "/dashboard/patient/care-provision", activeMatch: "prefix", // The plans page owns the bare route; the goals leaf below sits under it, so this must not // claim the goals path as its own or both rows light at once. activePaths: ["/dashboard/patient/care-provision"], onClick: () => navigate("/dashboard/patient/care-provision"), }, { // The goals page has existed, worked and been drawn for days with NOTHING REACHING IT - the // word "Goals" appeared nowhere a patient could click. A finished destination with no door // is indistinguishable from an unbuilt one. id: "goals", label: "Goals", icon: , to: "/dashboard/patient/care-provision/goals", activeMatch: "prefix", activePaths: ["/dashboard/patient/care-provision/goals"], onClick: () => navigate("/dashboard/patient/care-provision/goals"), }, { id: "providers", label: "My practices", icon: , to: "/dashboard/providers", activeMatch: "prefix", onClick: () => navigate("/dashboard/providers"), }, { // Spencer, 2026-08-13: *"care-teams should be collaboration-teams unless you're looking at // a true FHIR care team as opposed to our collaboration teams."* This id used to be // `care-teams`, which the CLINICAL CareTeam leaf under care-provision also uses - two // different destinations wearing one id, in a nav where ids select behaviour. // The id is load-bearing, not cosmetic: `ADOLESCENT_18_PLUS_NAV_IDS` hides this item from a // minor's portal BY ID. Renaming here without renaming there would have silently unhidden it. id: "collaboration-teams", label: "Collaboration teams", icon: , to: "/dashboard/collaboration-teams", activeMatch: "prefix", onClick: () => navigate("/dashboard/collaboration-teams"), }, { id: "invoices", label: "Bills & payments", icon: , to: "/dashboard/my-invoices", activeMatch: "prefix", activePaths: ["/dashboard/my-invoices"], onClick: () => navigate("/dashboard/my-invoices"), }, /* The widget raises a report; this is where it is followed. Without a row it is reachable only by typing the URL, which is how the practice's own support screen shipped - and a patient with no way back to their report telephones the practice instead. STILL GATED, and on the two roles the four patient-support routes admit rather than on the mount: a proxy uses the same portal and meets the same broken screen, and their ticket is keyed on their own account. A row offered to anybody the routes refuse opens onto a 403, which reads as the product being broken - the one thing this screen exists to let somebody report. */ ...(userRole === USER_ROLES.PATIENT || userRole === USER_ROLES.RELATED_PERSON ? ([ { id: "my-support", label: "Help & support", icon: , to: "/dashboard/my-support", activeMatch: "prefix", activePaths: ["/dashboard/my-support"], onClick: () => navigate("/dashboard/my-support"), }, ] as SidebarMenuItem[]) : []), /** * MY PROFILE - Spencer, 2026-08-24: *"Now that we're not using the left nav menu in the patient * portal we should probably add a 'My Profile' menu option just in case it's not obvious they can * click their image to get there."* * * THE AVATAR WAS THE ONLY DOOR, and it is an unlabelled one. `/dashboard/profile` has never had * a menu item: you reach it by clicking your own picture in the sidebar. That is a convention this * product cannot assume - it is the same reasoning as D6 on the hamburger, from the same direct * observation: *"especially for older users they don't always understand the hamburger menu * concept."* An unlabelled picture is a weaker affordance than a hamburger, not a stronger one. * And the settled header removes the sidebar for a patient, so the one door that existed is * about to stop being on screen at all. * * NOT PATIENT-ONLY, unlike Settings below. `ProfilePage` renders a RelatedPerson their OWN * record - a parent managing a child's care has a profile of their own, with its own identity * proofing state, and it is reached exactly the same way. Putting this inside the patient-only * block would give the person most likely to need it no way in. */ /* The one group in the patient's drawer, from the approved navigation deck: *"One group, My Profile, led by a child that carries the profile page, because a parent row is a toggle and never navigates."* So the parent carries NO `to` - "Edit my profile" is the child that opens the page the parent used to. Settings moved in here from its own top-level row. Settings and the two record leaves are patient-only; this array is shared with RelatedPerson, which is why the children are built conditionally rather than inline. */ { id: "profile", label: "My Profile", icon: , children: [ { id: "profile-edit", label: "Edit my profile", icon: , to: MY_PROFILE_PATH, activeMatch: "prefix", activePaths: [MY_PROFILE_PATH], onClick: () => navigate(MY_PROFILE_PATH), }, ...(userRole === USER_ROLES.PATIENT ? ([ { id: "family-history", label: "Family health history", icon: , to: FAMILY_HISTORY_PATH, activeMatch: "prefix", onClick: () => navigate(FAMILY_HISTORY_PATH), }, { id: "viewed-records", label: "My record activity", icon: , to: "/dashboard/my-history", activeMatch: "prefix", onClick: () => navigate("/dashboard/my-history"), }, { id: "records-connections", label: "Records from other health systems", icon: , to: "/dashboard/my-records/connections", activeMatch: "prefix", onClick: () => navigate("/dashboard/my-records/connections"), }, { id: "settings", label: "Settings", icon: , to: "/dashboard/settings", activeMatch: "prefix", onClick: () => navigate("/dashboard/settings"), }, ] as SidebarMenuItem[]) : []), ], }, { id: "logout", label: "Sign Out", icon: , to: "#", activeMatch: "exact", onClick: () => setLogOutConfirmOpen(true), isLogout: true, }, ] satisfies SidebarMenuItem[]) .filter((item) => !hide18PlusFeatures || !ADOLESCENT_18_PLUS_NAV_IDS.has(item.id)) .filter((item) => !isOrglessPatientsHost() || shownOnOrglessPortal(item.id, familyOpensOnOrglessPortal(userRole === USER_ROLES.PATIENT, helpsSomeone), ownBillsOpenOnOrglessPortal(userRole === USER_ROLES.PATIENT, !!actingAs))) .filter((item) => !actingAs?.scope || SCOPED_SWITCH_IN[actingAs.scope].navIds.has(item.id)) .sort(byPatientNavOrder), [navigate, patientUnreadMessages, isOnMessagesPage, userRole, USER_ROLES, hide18PlusFeatures, actingAs, helpsSomeone], ); const adminMenuItems = useMemo(() => { const items: SidebarMenuItem[] = []; if ([USER_ROLES.PRACTITIONER, USER_ROLES.RECEPTIONIST].includes(activeUserRole as string)) { // Shared staff nav order (receptionist adds the Overview entry first). if (activeUserRole === USER_ROLES.RECEPTIONIST) { items.push({ id: "receptionist-overview", label: "Overview", icon: , to: "/dashboard/receptionist", activeMatch: "exact", onClick: () => navigate("/dashboard/receptionist"), }); } if (activeUserRole === USER_ROLES.PRACTITIONER) { items.push({ id: "practitioner-overview", label: "Overview", icon: , to: "/dashboard/overview", activeMatch: "exact", onClick: () => navigate("/dashboard/overview"), }); // Practitioner-only: their own collaboration-team memberships (C1a Task 10). Not shown for // Receptionist - membership invites are keyed to a practitioner's clinical role, and this // page only ever renders the caller's own membership row (no fuller roster to browse). items.push({ id: "staff-collaboration-teams", label: "Collaboration Teams", icon: , to: "/dashboard/staff-collaboration-teams", activeMatch: "prefix", onClick: () => navigate("/dashboard/staff-collaboration-teams"), }); } items.push( { id: "messages", label: "Messages", icon: , to: "/dashboard/staff-messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/staff-messages"), unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, }, ); // Form Submissions is a front-desk surface, and as of it is ONLY that - the Admin copy // that used to sit below is deleted. Answering an anonymous submission is daily operational work; // Admin held it with no workflow to do it in, and this hat is where somebody actually replies. // A demo visit has no public forms, so it is offered no door to them. if (activeUserRole === USER_ROLES.RECEPTIONIST && newFormSubmissionsStatus !== 'unavailable') { items.push({ id: "form-submissions", label: "Form Submissions", icon: , to: "/dashboard/form-submissions", activeMatch: "prefix", onClick: () => navigate("/dashboard/form-submissions"), unreadCount: newFormSubmissionsCount, showUnread: newFormSubmissionsCount > 0 && !isOnFormSubmissionsPage, }); } items.push( { id: "appointments", label: "Appointments", icon: , to: "/dashboard/appointments", activeMatch: "prefix", onClick: () => navigate("/dashboard/appointments"), }, { id: "patients", label: "Patients", icon: , to: "/dashboard/patients", activeMatch: "prefix", onClick: () => navigate("/dashboard/patients"), }, ); // Schedules is OFFERED by this nav and REFUSED by its route, and the two were reading // different rules. `App.tsx` guards the route with `RequireStaffPermission section="schedules"`, // which redirects to /dashboard/profile when the grant is missing; this list pushed the item // unconditionally alongside Appointments and Patients, which are not gated at all. // // Observed signed in as a practitioner: the item renders, it navigates, and the practitioner // lands on their own Name/Address/NPI page. No error, no toast, and the item stays offered - so // the only information they get is that the app ignored them. // // It is worse than a dead link because the product SENDS THEM THERE: the appointments page // reads "Working hours are set by an administrator on the practice's schedules", and a clinician // whose every bookable day says "not working" follows exactly that sentence into nothing. // // The fix is the pattern already used by the three items below it. It does NOT decide whether // a practitioner SHOULD hold `schedules` - that is a permission-model question and Spencer's; // this only stops the nav promising what the route will refuse. if (!receivingOnly && canViewStaffSection("schedules")) { items.push({ id: "schedules", label: "Schedules", icon: , to: "/dashboard/schedules", activeMatch: "prefix", onClick: () => navigate("/dashboard/schedules"), }); } // Organization and Campaigns are not rows of these two workspaces: each deck's "Every row this // workspace can hold" frame ends at Staff, and an admin's grants on them leaked in here. They // live in the Admin and Marketing workspaces (workspaces do workflow, RULINGS 2026-08-14). if (canViewStaffSection("staff")) { items.push({ id: "staff", label: "Staff", icon: , to: "/dashboard/staff", activeMatch: "prefix", onClick: () => navigate("/dashboard/staff"), }); } } // THE BENCH QUEUE, offered to the two hats whose job it is and gated on the permission that // gates the route. BOTH CONDITIONS, because they answer different questions: the section decides // whether this person may reach the queue at all, the workspace decides whose rail it belongs in. // // THE SECTION ALONE WAS NOT ENOUGH, and gating on it alone was a spine violation rather than a // layout slip. An Administrator carries a built-in `manage` on every section, so holding // `diagnostics` -- which they hold because somebody has to GRANT it -- opened the Admin workspace // with a bench worklist as its first row, above Organization, on the hat whose job is the // practice as a business. Permissions do access; workspaces do workflow, and a permission was // deciding what is shown on the surface where a hat is most visible. // // AND THE WORKSPACE CONDITION MUST NOT BECOME THE OLD ONE AGAIN. It used to read // `[PRACTITIONER, RECEPTIONIST].includes(activeUserRole)` -- a list that excluded the two hats // the queue was written FOR, so a Lab Tech signed in to a sidebar containing one row, Log Out. // Everything else about them checked out on staging: role active, at the right organization, // `LabTech` in both workspace extensions, `laboratory-professional -> diagnostics: manage` // granted, the workspace resolved and persisted. Four things right and the person still could // not reach their own work, because the branch that draws it was unreachable from their hat. // // SO THE CONDITION NAMES NO WORKSPACE HERE. It reads `workspaceWorksTheDiagnosticQueue`, the one // expression the landing resolver reads too, derived from the same `lensFor` that filters the // queue's rows. A rail that offers what the landing does not reach is the drift this queue has // already had once. // // NOTHING ABOUT THE PERMISSION CHANGES. An administrator is still admitted by the route and by // the API; a practice whose admin also runs the bench gives that person the Lab Technician hat // and they switch to it, which is what the switcher is for. A workspace growing somebody else's // queue is not the answer to somebody wearing two hats. // // The label still follows the hat, because that is presentation rather than access. // THE BENCH SITS ABOVE THE QUEUE, because it is the narrower list and the one a laboratory // technologist works from: what is in their hand, rather than what the practice has outstanding. // Same two axes as the row below - the section decides access, the workspace decides workflow - // and the same predicate the landing resolver reads, so the rail cannot offer a destination the // landing never reaches. if (canViewStaffSection("diagnostics") && workspaceWorksTheBench(activeUserRole)) { items.push({ id: "bench", label: "Bench", icon: , to: "/dashboard/bench", activeMatch: "prefix", onClick: () => navigate("/dashboard/bench"), }); } // READING IS A SECTION OF THE PRACTITIONER WORKSPACE, NOT A HAT, and it is shown when the // `diagnostics` permission is held (roles/diagnostics/lab-and-imaging.md section 11). The workspace // decides whose rail it sits in; the section decides whether this person may reach it. The API // refuses reporting, release and addenda to a practitioner without that permission, so offering the // page without it offers work that can only fail. if (canViewStaffSection("diagnostics") && workspaceReadsStudies(activeUserRole)) { items.push({ id: "reading", label: "Reading", icon: , to: "/dashboard/reading", activeMatch: "prefix", onClick: () => navigate("/dashboard/reading"), }); } // WHAT ARRIVED ON PAPER, offered on the same two axes as the queue below: the section decides // whether this person may reach it, the workspace decides whose rail it belongs in. Accessioning // paper is bench work, so it follows the queue's own predicate rather than a second list. if (canViewStaffSection("diagnostics") && workspaceWorksTheDiagnosticQueue(activeUserRole)) { items.push({ id: "inbound-requisitions", label: "Requisitions received", icon: , to: "/dashboard/inbound-requisitions", activeMatch: "prefix", onClick: () => navigate("/dashboard/inbound-requisitions"), }); // Laboratory results that came back electronically and could not be matched on their own: the same arrivals // work, on the same predicate. items.push({ id: "held-lab-results", label: "Lab results to match", icon: , to: "/dashboard/held-lab-results", activeMatch: "prefix", onClick: () => navigate("/dashboard/held-lab-results"), }); } if (canViewStaffSection("diagnostics") && workspaceWorksTheDiagnosticQueue(activeUserRole)) { items.push({ id: "diagnostic-orders", // ONE ANSWER TO ONE QUESTION. This ternary was the only place the queue's name was decided, // and the page it opens decided again and said something else. The resolver reads the same // lens that filters the queue's rows, so the row, the heading and the contents agree. label: diagnosticsQueueLabel(activeUserRole).heading, icon: , to: "/dashboard/diagnostic-orders", activeMatch: "prefix", onClick: () => navigate("/dashboard/diagnostic-orders"), }); } // THE TWO DIAGNOSTIC HATS, for the one case the section-gated item above cannot cover. Both // land on the queue, so a practice that assigned the hat and never granted `diagnostics` // builds a sidebar with nothing in it -- the empty-nav failure this file's guard exists for, // and the state a misconfigured assignment actually produces. // // THE QUEUE LINK IS NOT OFFERED HERE, deliberately. Its route is section-gated, so a link // shown without the section redirects on click -- the UI offering what the API refuses, which // is the failure the section gate above was just fixed to avoid. What is offered instead is // the page they are already redirected TO, which is true whatever the practice has granted. if ( (activeUserRole === USER_ROLES.LAB_TECH || activeUserRole === USER_ROLES.IMAGING_TECH) && !canViewStaffSection("diagnostics") ) { items.push({ id: "diagnostics-profile", label: "My profile", icon: , to: "/dashboard/profile", activeMatch: "prefix", onClick: () => navigate("/dashboard/profile"), }); } // Colleague messaging belongs to every staff role, and the bench workspaces had no way in to it. if (activeUserRole === USER_ROLES.LAB_TECH || activeUserRole === USER_ROLES.IMAGING_TECH) { items.push({ id: "staff-communication", label: "Messages", icon: , to: "/dashboard/staff-messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/staff-messages"), unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, }); } if (activeUserRole === USER_ROLES.GENERIC || activeUserRole === USER_ROLES.MARKETING) { // A marketing person is NOT a receptionist with extra buttons (staffAccess.ts:144) - they // hold campaigns:manage and nothing else, so this is the menu they actually land in. // // (B52) - the comment above was the INTENT and the code did not honour it. This branch // was gated on GENERIC alone, but a Marketing hire's `activeUserRole` is 'Marketing' (it comes from // the workspace), so they never entered the menu written for them and their sidebar held one item: // Log Out. Every entry below is permission-driven, which is exactly right for both roles - Marketing // gets Campaigns and Organization because that is what `marketing-communications` grants, and // nothing else appears just because the branch now admits them. if (canViewStaffSection("campaigns")) { items.push({ id: "campaigns", label: "Campaigns", icon: , to: "/dashboard/campaigns", activeMatch: "prefix", onClick: () => navigate("/dashboard/campaigns"), }); } if (canViewStaffSection("organization")) { items.push({ id: "organization", label: "Organization", icon: , to: "/dashboard/organization", activeMatch: "prefix", onClick: () => navigate("/dashboard/organization"), }); } if (canViewStaffSection("staff")) { items.push({ id: "staff", label: "Staff", icon: , to: "/dashboard/staff", activeMatch: "prefix", onClick: () => navigate("/dashboard/staff"), }); } if (!receivingOnly && canViewStaffSection("schedules")) { items.push({ id: "schedules", label: "Schedules", icon: , to: "/dashboard/schedules", activeMatch: "prefix", onClick: () => navigate("/dashboard/schedules"), }); } /* * - Spencer, 2026-08-16: *"ALL staff roles should have access to the messaging * system."* This was the LAST workspace without the door: HR, Billing, the Platform Manager and * Admin all had one; Generic and Marketing did not. * * NOT gated on `canViewStaffSection("messages")`, and the distinction is the ruling. That * section governs the shared FRONT-DESK POOL - reading and answering somebody else's * conversation. Messaging a colleague is not a permission a practice grants, so gating this on * it would mean a practice that never wanted marketing watching the front desk had also, * silently, forbidden them from messaging anyone. Every other staff block pushes it * unconditionally for the same reason. * * The item is safe to show before the server is deployed: the page asks the capability * endpoint first, so until the route admits `Staff` these roles land on an honest * *"Messages are not available to you"* rather than a retryable error - and the day it does, the * surface lights up with no client change. */ items.push({ id: "staff-communication", label: "Messages", icon: , to: "/dashboard/staff-messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/staff-messages"), unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, }); } if (activeUserRole === USER_ROLES.HR) { // ORGANIZATION IS NOT OFFERED HERE, and the reason is the same one that took the bench queue // out of the Admin rail. It used to be pushed on `canViewStaffSection("organization")` alone, // and the HR hat carries a built-in `organization: view` while an Administrator carries // `manage` on every section -- so the practice's business settings opened in a workspace whose // job is a hiring-shaped staff directory with per-person setup readiness. A permission was // deciding what is SHOWN, which is the one thing the workspace model exists to prevent. // // Nothing is taken away: the route is unchanged, and whoever needs the practice's identity, // locations, catalogue, agreements or subscription switches to Administrator, which is what // the switcher is for. if (canViewStaffSection("staff")) { items.push({ id: "staff", label: "Staff", icon: , to: "/dashboard/staff", activeMatch: "prefix", onClick: () => navigate("/dashboard/staff"), }); } if (!receivingOnly && canViewStaffSection("schedules")) { items.push({ id: "schedules", label: "Schedules", icon: , to: "/dashboard/schedules", activeMatch: "prefix", onClick: () => navigate("/dashboard/schedules"), }); } items.push({ id: "staff-communication", label: "Messages", icon: , to: "/dashboard/staff-messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/staff-messages"), unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, }); } // -- THE REFERRAL COORDINATOR'S WORKSPACE. One destination today: the two-column queue. // A workspace with no branch here builds a sidebar containing only Log Out, with permissions and // routes both intact --. `everyWorkspaceBuildsANav` fails if this is ever removed. if (activeUserRole === USER_ROLES.REFERRALS) { items.push({ id: "referrals-queue", label: "Referrals", icon: , to: "/dashboard/referrals", activeMatch: "prefix", onClick: () => navigate("/dashboard/referrals"), }); // - the practice's own fax account, in the workspace that needs it. // // HERE RATHER THAN UNDER BILLING, and the payments-setup entry below is the precedent for the // reasoning rather than the placement: that one sits beside the bills because *"the practice's // Stripe connection decides whether a patient can pay a bill."* By the same rule a fax account // decides whether a referral can leave the building - so it belongs beside the referrals, where // the coordinator who just read *"this practice has not set up faxing"* will look for it. items.push({ id: "referrals-fax-settings", label: "Faxing", icon: , to: "/dashboard/fax-settings", activeMatch: "prefix", onClick: () => navigate("/dashboard/fax-settings"), }); } // The page itself says when the practice has not granted records; the hat alone grants nothing. if (activeUserRole === USER_ROLES.RECORDS) { items.push({ id: "records-queue", label: "Records requests", icon: , to: "/dashboard/records", activeMatch: "prefix", onClick: () => navigate("/dashboard/records"), }); } if (activeUserRole === USER_ROLES.BILLING) { items.push({ id: "billing-overview", label: "Overview", icon: , to: "/dashboard/billing/overview", activeMatch: "prefix", onClick: () => navigate("/dashboard/billing/overview"), }); items.push({ id: "billing-invoices", label: "Invoices", icon: , to: "/dashboard/billing/invoices", activeMatch: "prefix", onClick: () => navigate("/dashboard/billing/invoices"), }); items.push({ id: "billing-pricing", label: "Pricing options", icon: , to: "/dashboard/billing/pricing", activeMatch: "prefix", onClick: () => navigate("/dashboard/billing/pricing"), }); // scope item 3 - PAYMENTS SETUP LIVES IN BILLING, moved from Platform Manager. // `roles/billing/billing-workspace-design.md`. The practice's Stripe connection is the thing // that decides whether a patient can pay a bill, so it belongs beside the bills - a biller who // cannot find why payments are off is a biller who cannot do their job. // It is also an ONBOARDING step, and its old home is why exists: the setup checklist // reached into Platform Manager, a workspace the founding admin did not have, so they could not // connect payments and could not finish setup. items.push({ id: "billing-payments-setup", label: "Payments setup", icon: , to: "/dashboard/billing/payments", activeMatch: "prefix", onClick: () => navigate("/dashboard/billing/payments"), }); items.push({ id: "billing-export", label: "Export", icon: , to: "/dashboard/billing/export", activeMatch: "prefix", onClick: () => navigate("/dashboard/billing/export"), }); items.push({ id: "staff-communication", label: "Messages", icon: , to: "/dashboard/staff-messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/staff-messages"), unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, }); } if (activeUserRole === USER_ROLES.PLATFORM_MANAGER) { // - THE DESTINATIONS AND THEIR ORDER NOW COME FROM `utils/platformManagerWorkspace.ts`, // and this loop only supplies the icons and the one item with live state. // // The reason is not tidiness. This workspace's product claim is a claim about a SET - "a login // that can set the portal up and cannot see a single patient record" - and a claim about a set has // to be checkable against something a test can read. is the proof it was not: it deleted // the payment-settings entry from this very block and left the server grant standing, so an // outside agency kept the practice's Stripe credentials by URL, and nothing on either side failed. // The list is the WORKFLOW, never the fence - the fences are the token's scopes and each route's // allowedRoles, pinned in akashic-infrastructure. A URL still reaches anything. const platformManagerIcons: Record = { "patient-portal-page": , questionnaires: , "automated-tasks": , "form-submission-notifications": , "staff-communication": , "diagnostics-setup": , }; // A destination that needs a permission is offered only to a viewer who holds it - the pairing // lives in the workspace module so the whole SET is still readable in one place, and this is the // half that decides who is offered which. // Never a fence: a URL reaches the route regardless, the PAGE re-checks, and the real refusal is // the server's. This only stops offering a door somebody cannot walk through. const offeredDestinations = PLATFORM_MANAGER_DESTINATIONS.filter((destination) => { const required = PLATFORM_MANAGER_DESTINATION_PERMISSIONS[destination.id]; return !required || canManageStaffSection(required); }); offeredDestinations.forEach((destination) => { // Messages is the only one carrying live state, and it routes through the messages handler // rather than a bare navigate so an unread count is cleared the same way it is everywhere else. const isMessages = destination.id === "staff-communication"; items.push({ id: destination.id, label: destination.label, icon: platformManagerIcons[destination.id], to: destination.to, activeMatch: "prefix", onClick: () => isMessages ? handleNavigateToMessages(destination.to) : navigate(destination.to), ...(isMessages ? { unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, } : {}), }); }); } if ([USER_ROLES.ADMIN].includes(activeUserRole as string)) { items.push( { id: "organization", label: "Practice", icon: , activeMatch: "prefix", children: [ { id: "organization-dashboard", label: "Practice Dashboard", icon: , to: "/dashboard/organization", activeMatch: "exact", onClick: () => navigate("/dashboard/organization"), }, { id: "organization-account", label: "Practice account", icon: , to: "/dashboard/organization/account", activeMatch: "prefix", onClick: () => navigate("/dashboard/organization/account"), }, // Where a practice adds its labs and the practices that read its studies; the page's own guard. ...(canManageStaffSection("organization") && offersDiagnosticsSetup(readDiagnosticsDeclaration(currentOrganization)) ? [{ id: "organization-diagnostics", label: "Lab and imaging setup", icon: , to: "/dashboard/organization/diagnostics", activeMatch: "prefix" as const, onClick: () => navigate("/dashboard/organization/diagnostics"), }] : []), ], }, { id: "staff", label: "Staff", icon: , activeMatch: "prefix", children: [ { id: "staff-directory", label: "Staff directory", icon: , to: "/dashboard/staff", activeMatch: "prefix", // A staff member's own page is the directory; the permissions matrix is its sibling. exceptPaths: ["/dashboard/staff/permissions"], onClick: () => navigate("/dashboard/staff"), }, { id: "role-permissions", label: "Role permissions", icon: , to: "/dashboard/staff/permissions", activeMatch: "prefix", onClick: () => navigate("/dashboard/staff/permissions"), }, ], }, { id: "staff-communication", label: "Messages", icon: , to: "/dashboard/staff-messages", activeMatch: "prefix", onClick: () => handleNavigateToMessages("/dashboard/staff-messages"), unreadCount: staffMessagesUnread, showUnread: staffMessagesUnread > 0 || staffAwaitingIncomplete, unreadIncomplete: staffAwaitingIncomplete, }, // - "Form Submissions" IS GONE FROM ADMIN, and it moved by MOVING NOTHING: the // receptionist already had it (see the front-desk block above), so the whole change is this // deletion. Reading an anonymous submission is daily operational work - somebody answers it - // and Admin held it with no workflow to do that in. The pair is now SETTINGS in the Platform // Manager workspace (where an agency routes them) and SUBMISSIONS at the front desk (where they // are answered) roles/platform-manager/platform-manager-workspace-design.md §3d and §4. // The ROUTE is untouched on purpose. An admin who holds the receptionist workspace still // reaches it there, and this is a workflow change, not a permission one - workspaces are hats, // and permissions do access project-workspace-design-principles. // Gated for the same reason the front desk's copy of this item is, and it is NOT dead code // because an admin usually holds the grant: the permission model is configurable per org, so a // practice that moves `schedules` elsewhere would give its admin the same silent bounce to // /dashboard/profile that a practitioner already gets. A no-op in the common case is the point. ...(!receivingOnly && canViewStaffSection("schedules") ? [{ id: "schedules", label: "Schedules", icon: , to: "/dashboard/schedules", activeMatch: "prefix" as const, onClick: () => navigate("/dashboard/schedules"), }] : []), // Admin-only because the report routes are; the server refuses everyone else. { id: "reports", label: "Reports", icon: , to: "/dashboard/reports", activeMatch: "prefix", onClick: () => navigate("/dashboard/reports"), }, { id: "documents", label: "Documents", icon: , to: "/dashboard/documents", activeMatch: "prefix", onClick: () => navigate("/dashboard/documents"), }, ...(!receivingOnly ? [{ id: "subscription", label: "Subscription", icon: , to: "/dashboard/subscription", activeMatch: "prefix" as const, onClick: () => navigate("/dashboard/subscription"), }] : []), /* * THE PRACTICE'S OWN ACCESS LOG, WHICH HAD NO DOOR AT ALL. * * `/dashboard/audit-events` is a real route with a real page, and `audit-events` appeared in * exactly two other places in the whole client: the route itself, and the BREADCRUMB map - which * names a page once you are already on it rather than getting you there. So every audit entry the * product stamps landed somewhere reachable only by typing the path. * * ADMIN-ONLY BECAUSE THE SERVER SAYS SO, not because it reads as an admin sort of screen. The * route table declares `GET /R4/AuditEvent` with allowedRoles [ADMINS] and * AUDIT_EVENT_READ - so this branch is the client half of a gate that already exists and * already refuses. It is placed on the role rather than on a section deliberately: sections are * configurable per organization, and an access log naming who read whose record is not something * a practice should be able to hand to a workspace by reconfiguring a section. * * AND THE LOG IS THE ORGANIZATION'S, NOT THE PLATFORM'S - the same route declares * `requireOrganizationId`, so the same page mounted without an org scope cannot become every * practice's entries at once. */ { id: "audit-events", // "PRACTICE Access Log", not "Access Log" - the patient chart already has an Access Log tab // and it means ONE patient's. This is the organization's. Two destinations wearing one name // in a product where both are about who read what is worse than a longer label. label: "Practice Access Log", icon: , to: "/dashboard/audit-events", activeMatch: "prefix", onClick: () => navigate("/dashboard/audit-events"), }, ); } items.push( // Every staff role reaches this, deliberately ungated - the same reasoning the route itself // carries in App.tsx: raising and following a ticket about the product is not a permissioned // capability, and the server admits every staff role on the same footing (Support/create.ts // and Support/list.ts gate on an organization id, not a section). Placed in this trailing block // rather than inside any one role's branch above so it is not read as belonging to one workspace. { id: "support", label: "Support", icon: , to: "/dashboard/support", activeMatch: "prefix", onClick: () => navigate("/dashboard/support"), }, { id: "logout", label: "Log Out", icon: , to: "#", activeMatch: "exact", onClick: () => setLogOutConfirmOpen(true), isLogout: true, }, ); return items; }, [ navigate, patientUnreadMessages, staffMessagesUnread, newFormSubmissionsCount, isOnFormSubmissionsPage, activeUserRole, USER_ROLES.PRACTITIONER, USER_ROLES.RECEPTIONIST, USER_ROLES.PLATFORM_MANAGER, USER_ROLES.ADMIN, USER_ROLES.GENERIC, USER_ROLES.BILLING, USER_ROLES.HR, canViewStaffSection, canManageStaffSection, currentOrganization, ]); const { gate: closureGate, isOwner: closedOwner, retry: retryClosure } = useClosureGateDetail(isStaffExperience() ? currentOrganization : undefined); const closedToStaff = closureGate !== "open"; const { entry: msaOwnerEntry, retry: retryMsaOwnerEntry } = useMsaOwnerEntry(isStaffExperience() && closureGate === "open"); // A practice with no owner yet has its staff reads refused by the owner gate, which is expected, not a system error. // Asked only while the communication error is showing. const practiceNeedsOwner = usePracticeNeedsOwner(isStaffExperience() ? currentOrganization?.id : undefined, Boolean(communicationError)); // A closed practice refuses its staff everything but its closure and Support, so the nav offers only those, // and the Owner its account's recovery view. const closedMenuItems = useMemo(() => [ { id: "organization", label: "Practice", icon: , activeMatch: "prefix", children: [ { id: "practice-closure", label: "Practice Dashboard", icon: , to: PRACTICE_CLOSURE_PATH, activeMatch: "exact", onClick: () => navigate(PRACTICE_CLOSURE_PATH), }, ...(closedOwner ? [{ id: "practice-closure-account", label: "Practice account", icon: , to: PRACTICE_CLOSURE_ACCOUNT_PATH, activeMatch: "exact" as const, onClick: () => navigate(PRACTICE_CLOSURE_ACCOUNT_PATH), }] : []), ], }, ...adminMenuItems.filter((item) => item.id === "support" || item.id === "logout"), ], [adminMenuItems, closedOwner, navigate]); const menuItems = isStaffExperience() ? (closedToStaff ? closedMenuItems : adminMenuItems) : patientMenuItems; // Closed, the closure is the practice's dashboard, and admin-v2 ("Close and reopen a practice") names it so: // "Practice › Practice Dashboard". Both crumbs point at the closure, the only Practice page a closed // practice serves its staff. const shownCrumbs = closedToStaff && location.pathname === PRACTICE_CLOSURE_PATH ? [ { path: PRACTICE_CLOSURE_PATH, label: "Practice", isCurrent: false }, { path: PRACTICE_CLOSURE_PATH, label: "Practice Dashboard", isCurrent: true }, ] : closedToStaff && location.pathname === PRACTICE_CLOSURE_ACCOUNT_PATH ? [{ path: PRACTICE_CLOSURE_ACCOUNT_PATH, label: "Practice account", isCurrent: true }] : breadcrumbItems; // / D6 - published to the patient home so its "Everything else" list renders the SAME // destinations as this sidebar (including the adolescent filter applied to `patientMenuItems`). // Empty on the staff side: no staff surface consumes it, and handing the patient nav to a staff // workspace would be a route list nobody there can use. const patientNavItems = isStaffExperience() ? EMPTY_PATIENT_NAV : patientMenuItems; const getUserDisplayName = () => { if (currentUser?.fhir?.name?.[0]) return formatFhirHumanName(currentUser.fhir.name[0]); return currentUser?.cognito?.username || "User"; }; const hasActiveDescendant = (item: SidebarMenuItem): boolean => { if (!item.children?.length) return false; return item.children.some((child) => { const candidates = child.activePaths?.length ? child.activePaths : child.to ? [child.to] : []; const childActive = candidates.some((path) => isRouteMatch(location.pathname, path, child.activeMatch ?? "prefix"), ); return childActive || hasActiveDescendant(child); }); }; const renderNavItem = (item: SidebarMenuItem, depth = 0): React.ReactNode => { const { id, label, icon, onClick, to, activeMatch = "prefix", activePaths, exceptPaths, unreadCount, showUnread, unreadIncomplete, isLogout, children, } = item; const count = showUnread ? navCount(unreadCount ?? 0, Boolean(unreadIncomplete)) : null; const hasChildren = !!children?.length; const isExpandedGroup = expandedNavIds.includes(id); const routeCandidates = activePaths?.length ? activePaths : to ? [to] : []; const isExcepted = (exceptPaths ?? []).some((path) => isRouteMatch(location.pathname, path, "prefix")); const isActive = !isLogout && !isExcepted ? routeCandidates.some((path) => isRouteMatch(location.pathname, path, activeMatch)) : false; // Only the row you are on is marked. The group stands in for it only while its rows are hidden. const wrapperIsActive = isActive || ((navCollapsed || !isExpandedGroup) && hasActiveDescendant(item)); // The drawer's child rows carry their own 44px indent (the s5 .kid row), so only the staff rail nests. const childContainerClass = patientNavSkin ? "" : "dashboard-nav-children"; return (
{/* The one count treatment. This drew its own disc twice over - a `bg-red-500` class and an inline `#ff4d4f` - which put white on 3.27:1, under the floor, and spent the colour reserved for destroying things on a pill that means somebody wrote to you. Only the positioning is local now. */} {navCollapsed && count && ( {count.text} )} {/* The id `aria-controls` above points at. It must include the DEPTH, matching the React key: the same nav id can legitimately appear at two depths, and a duplicate id would make `aria-controls` ambiguous — which is indistinguishable from having none. */} {hasChildren && isExpandedGroup && !navCollapsed && (
{children.map((child) => renderNavItem(child, depth + 1))}
)}
); }; if (isLoading || relatedPatientLoading) { return (

Loading your account…

); } if (!currentUser) { return ; } const scopedStep = actingAs?.scope ? scopedSwitchInStep(actingAs.scope, location.pathname, currentUser) : undefined; if (scopedStep?.kind === "held") { return void exitActingAs()} />; } if (scopedStep?.kind === "go") { return ; } if (showWorkspaceSelector) { return ( {/* Card 364 — always OUR mark here, so it names us. */} Akashic Health Records Select Your Workspace Choose the workspace you'd like to use. You can switch between your assigned workspaces at any time. ); } return ( <> {/* - `min-h-screen` here asked for a FULL viewport while this shell sits under the portal header, so every signed-in page was 46px taller than the window. `flex-1` fills whatever the header left instead, and `min-h-0` stops a flex item's default `min-height: auto` from silently re-growing it back past its container. The arithmetic now lives in the layout, not in a number. */}
{/* THE SCRIM — card 238 B11 measured "scrim: none · body overflow while open: visible", i.e. the old panel dimmed nothing and blocked nothing, and the page kept scrolling behind it. It is also the drawer's dismiss target: tapping outside a menu to close it is the gesture people already have. The scrim and the drawer sit ABOVE the acting-as banner, which is sticky at z-50 and later in the document, so its top items were unclickable while acting as somebody. */} {navIsDrawer && isExpanded && (
setIsExpanded(false)} aria-hidden="true" /> )} {/* Outside the aside deliberately: the drawer carries aria-hidden while closed, and a confirmation the user is being asked to answer must not inherit that. */} {isWorkspaceHelpOpen && ( setIsWorkspaceHelpOpen(false)} className="akashic-workspace-help-modal" >

A workspace is a focused view of the app for one of your roles at this organization. If you hold more than one role — say Administrator and Receptionist — each has its own workspace with its own menu and tools.

Switching your current workspace changes what you see and work with. The Admin workspace surfaces organization, staff, and configuration, while the The Receptionist workspace is for front desk work.

It doesn't change your permissions. What you're allowed to do is set by the roles assigned to you — the workspace switcher just changes your view.

)} {/* CARD 644 — THIS MUST NOT BECOME A `
`. It was one for about an hour on 2026-08-23 and the browser reading is what took it back out. WHAT THE PAGE ACTUALLY CONTAINS, measured on `#/dashboard/organization` and `#/dashboard/schedules`: the WordPress theme already renders `
`, our whole app mounts INSIDE it, and that element **is** the target of the theme's "Skip to content" link. SO THE PREMISE WAS WRONG TWICE OVER. The card said no staff screen had a `
` landmark and nothing for a skip link to target — measured on the rendered page, **both already existed**. What was true is narrower: our own markup contributed no landmark, which is not the same claim. AND ADDING ONE MADE IT WORSE: `
` may not nest, so the page carried TWO main landmarks with ours inside the theme's — invalid HTML, and assistive technology offering a choice between two "main" regions that should not both exist. A landmark here would have to be a labelled `region`, not a second `main`, and whether the app wants one is a design-pass question rather than something to invent mid-sweep. Guarded by `src/__tests__/theDashboardHasOneMainLandmark.test.ts`, which now asserts the ABSENCE. */} {/* `min-w-0` IS LOAD-BEARING, AND IT IS THE REASON A PHONE-WIDTH FIX ONE LEVEL DOWN DID NOTHING. A flex item defaults to `min-width: auto`, so with `flex: 1 1 0%` this pane could not shrink below its own min-content width. Measured at a 390 viewport on the patient chart: the pane laid out at **512px inside a 375px client**, and `documentElement.scrollWidth` read 511 against a clientWidth of 375 — the whole page scrolled sideways. THE PART WORTH KEEPING: card 757 had already made the chart stack correctly below tablet, and it changed nothing on screen, because **the chart was never handed a phone width to stack inside.** A responsive fix is only as good as the narrowest box its container will accept. Proven by reversion rather than reasoned about: forcing `min-width: 0` here took the document from 511 to 375 with nothing overflowing, and removing it put 511 straight back. Do not "simplify" this away — it reads like a no-op and it governs every screen the dashboard renders. */}
{/* `OnboardingReturnBanner` IS RETIRED HERE — card 323. It rendered " Back to setup" anywhere in the dashboard, but only AFTER a step had already thrown the admin out, and only in one direction. The product had conceded that onboarding is cross-workspace; it had just conceded it weakly. The widget below is that idea done properly, and two cross-workspace onboarding affordances is one too many. */} {/* CARD 683 — THE WIDGET MOVED UP TO THE FRAME, beside `PortalFrameHeader` in App.tsx, because Spencer's support tab hangs off the header's bottom edge and `top-full` needs the header as its positioning parent. Do not re-add it here: two mounts would render two tabs, and the one in this tree would be 64px down the page attached to nothing. */} {/* ⬆️ ABOVE THE BREADCRUMB, beside the other account notice. Both are true of the PERSON rather than of the page they landed on, and the crumb is the page's. Moved here 2026-09-08; it used to render 140 lines lower, with the breadcrumb sitting between two notices that answer the same kind of question. ⛔ AND NOT IN THE PATIENT PORTAL. Spencer, 2026-09-08: "it shouldn't be in the patient portal workspace, I know that much." An offer to run a practice is staff business, and the patient portal is the one surface in this product that is only ever about the person's own care - a card offering to make somebody the administrator of a company reads as a phishing attempt, on exactly the audience least equipped to judge it. 🗣️ THE RULE THIS FOLLOWS, in his words: "We don't mix patient-side and staff-side things in the different portals. Patient things show in patient portal, staff things show in staff portal. They're different for a reason." So the question is never "where will this be seen" but "whose business is it" - and a practice's administration is the staff portal's, whoever happens to hold the account. 📌 One consequence, recorded as a fact and not as an objection: an invitee holding no staff role anywhere answers this in the staff portal or not at all. The component's own note covers why the inviting practice's site cannot host it either. */} {!isPatientExperience() && } {/* Every experience: somebody invited as a clinician may so far hold only a patient account. */} {activeUserRole === USER_ROLES.RELATED_PERSON && patientState && ( <> You are currently logged in as a Related Person to {formatFhirHumanName(patientState.name)}. This role provides limited access to patient information. )} {/* * The act-as-era mount for identity proofing. The RelatedPerson branch above only fires for * the LEGACY "Sign In As -> Related Person" ceremony; an act-as caregiver signs in as * themselves, so activeUserRole is PATIENT and the verify surface was unreachable for them. * CaregiverProofingBanner gates on actually holding proxy grants, so ordinary patients never * see it — see its header for why the tier itself can't be the gate yet. */} {activeUserRole === USER_ROLES.PATIENT && } {/* CARD 1002 — THE THREE AGREEMENTS, IN THE BANNER SLOT, ON EVERY SCREEN. Spencer: *"We should detect that this patient has an appointment scheduled but has not consented/authorized with that org yet. In that case we should display an alert at the top of the screen on all views/pages."* THIS REPLACED `OrgAuthorizationPrompt`, which asked about ONE document, opened itself as a dialog on arrival, and dismissed to a card on one page. Consent had become a task — something in a list, on a page nobody has a reason to open — while an appointment the practice cannot lawfully honour sat in the diary. Do not re-add that prompt beside this one: two surfaces asking the same patient about overlapping documents means answering one and being asked again by the other. THE SLOT HAS A SETTLED ORDER — Spencer, 2026-09-01: *"Acting-as wins, then agreements, then invitation."* That is DOM order here and nothing else enforces it: `ActingAsBanner` is above, `PracticeInvitationNotice` is below the breadcrumb. Moving this mount reorders the slot. One case deliberately STACKS rather than choosing: a guardian acting as a child who owes agreements. The banner is about the account being VIEWED and the guardian is the one who can answer it, which is why the related-person branch is included here. */} {(activeUserRole === USER_ROLES.PATIENT || (activeUserRole === USER_ROLES.RELATED_PERSON && patientState)) && ( )} {/* CARD 995 — SOMEBODY HAS ASKED FOR ACCESS TO YOUR RECORD, AND IT IS SITTING STILL. Spencer, 2026-09-01: *"When someone invites you to be a related person it should display everywhere on all screens at the top… That can't be buried within an inner page since it needs acted on now."* THIS REPLACED the Family-page list, which rendered on a page somebody has to go looking for — and a person who has just been invited has no reason to go there, because they were not expecting anything. The only people who ever saw it already knew. THIRD IN THE SETTLED ORDER — acting-as, agreements, invitation — and it sits below `OutstandingAgreements` for that reason. An agreement blocking imminent care outranks a request that costs nothing to answer a week late. It self-gates on `actingAs` internally rather than here: the invitation is addressed to your OWN account and cannot be answered at all while you are acting as somebody else, so it returns before its fetch rather than hiding a list read as the wrong person. */} {activeUserRole === USER_ROLES.PATIENT && } {/* An invitation onto somebody else's collaboration team, on every screen, after the related-person one. */} {activeUserRole === USER_ROLES.PATIENT && } {/* The staff Messages page says it in its own list pane, so the banner would repeat it there. */} {communicationError && practiceNeedsOwner !== true && !closedToStaff && !adolescentSelf && !location.pathname.startsWith('/dashboard/staff-messages') && ( <> Communication system error: {communicationError} )}
{/* THE ONLY WAY INTO THE NAV whenever the nav is a drawer — so it is rendered from the same `navIsDrawer` that decides the drawer, and the two cannot disagree. A hamburger with no drawer, or a drawer with no hamburger, are the same bug seen from either end. Card 240 / B12 — the accessible name is required, not decoration: this control used to carry no `aria-label`, no text and no `title`, so a screen reader announced "button". */} {/* IT IS NO LONGER RENDERED HERE WHEN THE HEADER CAN TAKE IT. Every deck draws the hamburger at the top right of the HEADER, beside the avatar; the build had it sitting on the breadcrumb row, which is where the assistant now lives. Spencer, 2026-08-25, looking at the running app: *"the hamburger menu loads in the header, what are you talking about?"* — he was describing the design, and the build had drifted from it. Same portal mechanism as the assistant, and the same fallback: with no header slot it renders here exactly as before, so a surface without the frame keeps its only way into the nav. A hamburger with no drawer, or a drawer with no hamburger, are the same bug seen from either end — which is why this still keys on `navIsDrawer` and nothing else. */} {navIsDrawer && !headerActionsSlot && navMenuButton} {shownCrumbs.map((item, index) => ( {index > 0 && ( )} {item.isCurrent ? ( {item.label} ) : ( {item.label} )} ))} {/* THE ASSISTANT'S OWN BAND. Spencer, 2026-08-25: the breadcrumb line is wasted screen space, it frees room in the header, and *"since the Ai assistant is a unique thing it makes sense to have it in its own unique location."* THE BAND IS THE SAME ON EVERY VIEW, which is the property that makes it work: a breadcrumb never grows to fill the row, so the right-hand end is empty identically everywhere, in every workspace, at every width. THE ATTRIBUTE IS THE CONTRACT, exactly as `data-akashic-header-actions` is — rename it and the assistant silently returns to hanging off the header with nothing failing. `relative` is load-bearing: the panel opens `top-full` of THIS node, so the browser does the arithmetic and nothing acquires a pixel offset. A hardcoded number here is the defect card 358 already paid for once. */}
{/* Portaled into the header’s action group — the slot the assistant vacated when it moved to the crumb row. Rendered from HERE so the drawer state stays in one component and the nav keeps its handlers; only DOM parentage moves, exactly as the assistant does it. */} {navIsDrawer && headerActionsSlot ? createPortal(navMenuButton, headerActionsSlot) : null} {/* Card 148 - the invitee's discovery list, mounted ONCE for every dashboard route. It lived on THREE landing pages (clinician overview, patient portal, receptionist home) and an invitee whose default workspace is ADMIN still saw nothing: Admin lands on #/dashboard/organization and has no Overview item at all, so the "Sign in to view" email led to a blank page. A fourth landing mount would have been the same shape a fourth time - the landing page is the WRONG AXIS, because an unanswered invitation is true of the USER, not of the page. It self-gates before any fetch, so it costs nothing for anyone with nothing to answer. Do NOT re-add it to a landing page: every former host is a child route of this shell and would render it twice. */} {/* Identity matches belong in the shell shared by patient and staff routes. */} {agreementGateStatus === 'outstanding' || agreementGateStatus === 'unknown' ? null : } {/* CARD 501 — a practice is asking to add your account, and only you can say yes. Spencer, 2026-08-21: *"nobody should be able to attach your account to their organization by typing your address."* Founding a practice with an email that already had an account used to attach that account as the new practice's administrator, silently. MOUNTED HERE FOR THE REASON THE VISIT INVITATIONS ABOVE ARE, and it is the same sentence: an unanswered invitation is true of the USER, not of the page. There is no landing page that works — the invitee may be an Admin, a receptionist, a clinician or a patient, and they did not go looking for this in the first place, because somebody else named them. They see it on a portal where they ALREADY hold a role. Signing in against the NEW practice's site resolves to no roles there — that is what being uninvited means — so this can never be answered from the practice that sent it. Not a defect; it is what makes the accept meaningful. It self-gates before rendering anything, so it costs nothing for anyone with nothing to answer. */} {/* Card 272 — see the note on the other Outlet: both branches publish the patient nav. */} {/* CARD 622 — NOTHING DATA-BACKED RENDERS BEHIND A BLOCKING AGREEMENT GATE. Ed's run, a brand-new patient's first login: while both consent gates were unaccepted the dashboard painted itself behind them and its data never arrived — "Upcoming Appointments" showed two skeletons that never resolved, the fourth stat tile read " invoices unpaid" with the NUMBER MISSING while its three siblings showed 0, and that sat beside "0 scheduled appointments" contradicting itself. ONE CAUSE, FOUR SYMPTOMS. Fixing the skeleton, the missing number and the contradiction separately would be three patches over a shell that should not have rendered at all. The gate already publishes this — `FirstSignInGate` derives the status from the same state its dialog renders from, precisely so other surfaces cannot disagree with what is on screen. Nothing on the dashboard was reading it. CARD 638 REVERSES THE 'unknown' HALF OF THIS, AND THE REASON THE ORIGINAL GAVE WAS SOUND FOR WHAT IT MEASURED. It said: withholding on `unknown` "would blank the first paint for EVERY user on EVERY load to fix a first-run case." True — but the thing being weighed was PAINT, and paint is cosmetic. A browser walk on 2026-08-22 measured what else the window costs: with the Terms dialog still unanswered, the patient's Invoice (x4), Appointment, Task, CommunicationRequest and Communication/thread had ALREADY been fetched, and a websocket opened. The gate says "you must respond to continue"; the software had continued. AND `unknown` IS THE ONLY MOMENT THAT CAN CLOSE IT. The status provider starts at 'unknown', so on the first render every consumer reads it — withholding only on 'outstanding' can never prevent the first mount, and a fix that unmounts afterwards is tearing down a tree that has already asked. Proved in components/FirstSignInGate/__tests__/theUnknownWindowLetsTheFetchesThrough.test.tsx. WHAT IT COSTS, measured rather than feared: the agreements handler runs 185-320ms warm and ~870ms cold, and what is withheld is the CONTENT PANE ONLY — the frame, the practice name, the nav and the header all render throughout. An empty content area for a quarter of a second is what a page load looks like anyway; the skeletons it replaces were doing the same job less honestly. DO NOT "simplify" this to `!== 'clear'` and DO NOT widen it to the service providers — the Router lives inside those, so withholding there really would blank the whole app. The hook still defaults to 'clear' with no provider, so this fails toward rendering [[reference-a-consumer-without-its-provider-bricks-a-workspace]]. */} {closureGate === "open" ? : }
); }; export default DashboardPage;