import { createContext, useCallback, useContext, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { useNavigate } from "react-router-dom"; import { ChevronDown, MessageSquare, Phone } from "lucide-react"; import type { Organization } from "fhir/r4"; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from "../ui/dialog"; import { type ListFreshness, useMyOrganizations } from "../../hooks/useMyOrganizations"; import { useAuthServiceContext } from "../../services"; import { getHostedPatientPortalUrl, getPatientPortalUrl } from "../../utils/patientPortalUrl"; import { formatSingleAddress } from "../../utils/fhirAddress"; import { getFirstContactPointBySystem } from "../../utils/fhirContactPoint"; import { formatPhoneNumber } from "../../utils/phoneNumber"; import { PortalDepartureCard } from "../PortalDeparture/PortalDepartureCard"; import { resolveHeaderBranding } from "../PortalFrameHeader/headerBranding"; import { isUnreadableOrganization } from "../../utils/patientOrganizations"; /** * THE PRACTICE SWITCHER, OPENED FROM THE PRACTICE MARK IN THE PORTAL HEADER. * * SWITCHING PRACTICE IS A VIEW CHANGE, NOT AN IDENTITY CHANGE, and that is the sentence this whole * component is written against. The record is the patient's and is org-agnostic; a practice * participates in it. Nothing here creates, moves or re-scopes a record, and nothing here sends a * different organization to the server as a consequence of being opened - the three actions are a * client-side route the messages page already accepts, a tel: link, and a link off to somebody else's * site. There is deliberately no "make this my current practice" control. * * WHY IT IS HOSTED AT THE FRAME. The header is outside every individual view, so whatever hosts the * sheet must be too - the same constraint that put the booking dialog here. It reuses that mechanism * rather than inventing a second one: a provider above the outlet, a null-safe hook, and content that * does not mount until it is opened. * * A PATIENT WITH ONE PRACTICE GETS NO CONTROL AT ALL. A chevron that opens a list of one implies * somewhere else to be, which is worse than no chevron. `canSwitch` is what the header asks. */ type PracticeSwitcherValue = { /** Open the switcher from anywhere under the portal frame. */ openPracticeSwitcher: () => void; /** Does this patient have somewhere else to be? False for one practice, and for staff. */ canSwitch: boolean; /** How many practices the patient belongs to; undefined until the list has answered. */ practiceCount?: number; /** Whether that list is the latest answer; see useMyOrganizations. */ practicesFreshness?: ListFreshness; /** Reads the list again now, keeping what is shown. */ checkPractices?: () => void; }; const NO_SWITCHER: PracticeSwitcherValue = { openPracticeSwitcher: () => undefined, canSwitch: false, }; const PracticeSwitcherContext = createContext(undefined); /** * Never throws. Staff render the same frame without this provider, and the brand mark is constructed * for them too - a hook that threw would take the whole chrome down for a control they cannot see. * Absent provider means `canSwitch` is false, which is exactly what a staff header wants. */ export const usePracticeSwitcher = (): PracticeSwitcherValue => useContext(PracticeSwitcherContext) ?? NO_SWITCHER; /** "Great River Health" reads GR, never GRH. The same two-letter rule the home's org badge uses. */ export const practiceInitials = (name: string): string => name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() ?? "") .join(""); // One number, plain: no "(work)" after it, and never two numbers run together into one Call link. const practicePhone = (organization: Organization): string => { const contact = getFirstContactPointBySystem(organization.telecom, "phone"); return contact?.value ? formatPhoneNumber(contact.value) : ""; }; /** "West Burlington IA - (319) 555-0142", and either half may be missing without leaving a stray dot. */ export const practiceSubtitle = (organization: Organization): string => { const address = organization.address?.[0]; const place = [address?.city?.trim(), address?.state?.trim()].filter(Boolean).join(" "); const phone = practicePhone(organization); return [place, phone].filter(Boolean).join(" ยท "); }; // Smaller than this on either side is not a picture; staging holds a 1x1 logo that draws as a colour block. const SMALLEST_USABLE_IMAGE = 8; // The shared organization mark, as the header draws the same practice: its logo, else its initials on primary. const PracticeMark = ({ name, logoUrl }: { name: string; logoUrl?: string }) => { const [logoFailed, setLogoFailed] = useState(false); return ( ); }; const PracticeRow = ({ organization, isCurrent, expanded, onToggle, onMessage, onRetry, }: { organization: Organization; isCurrent: boolean; expanded: boolean; onToggle: () => void; onMessage: () => void; onRetry: () => void; }) => { // A practice the server could not read has no name or details to act on, only its own retry. if (isUnreadableOrganization(organization)) { return (
  • One of your practices could not be loaded.

  • ); } const name = organization.name?.trim() || "This practice"; const phone = practicePhone(organization); const address = organization.address?.[0] ? formatSingleAddress(organization.address[0]) : ""; // A configured portal page first, else the hosted door the server derived for this listed practice. const portalUrl = getPatientPortalUrl(organization) || getHostedPatientPortalUrl(organization); const mark = ; const label = ( {name} {practiceSubtitle(organization)} ); // THE PRACTICE YOU ARE ALREADY IN IS NOT A DESTINATION, AND THE ROW HAS TO SAY SO. It is listed, // because the question it answers is "where am I" - but it is not a button, it is dimmed, and its // mark is desaturated. The missing chevron alone is an absence, and nobody notices an absence. if (isCurrent) { return (
  • {mark} {/* THE MARK SITS BESIDE THE NAME, NOT BESIDE THE WHOLE ROW, and that is the difference between a truncated word and a truncated phone number. The shared `label` is a two-line block; a wide sibling next to it reserves its width from BOTH lines, so the subtitle lost the same space the badge took and the practice's number was cut mid-digits - measured at 390 as "West Burlington IA - (319) 768-1...". The other rows do not show it because their trailing element is a 16px chevron rather than a badge. So this row carries its own two lines instead of the shared block: the badge competes with the NAME on the first line, and the subtitle gets the full width back. The name is the safer thing to shorten of the two - it is already in the header this sheet opened from, while the number appears nowhere else on the row. Matches the drawing, which shows the current practice's number in full. */} {name} You’re here {practiceSubtitle(organization)}
  • ); } return (
  • {/* THE ROW EXPANDS; THE LIST NEVER GOES AWAY. An earlier design replaced the list with a dedicated practice view and gave it no way back - its header held only a close. With the list still underneath there is nothing to return FROM: the tap that opened the row closes it, and the other practices stay in view the whole time. */} {expanded && (
    {address &&

    {address}

    }
    {phone && ( // A tel: link, not an integration - and the one action here that reaches nothing of // ours at all. )}
    {/* The one ruling, drawn once - see `PortalDepartureCard` for what Spencer settled and why this is not a third contact button beside Message and Call. */} {portalUrl && (
    )}
    )}
  • ); }; const PracticeSwitcherSheet = ({ open, onOpenChange, organizations, currentId, onRetry, }: { open: boolean; onOpenChange: (next: boolean) => void; organizations: Organization[]; currentId?: string; onRetry: () => void; }) => { const navigate = useNavigate(); const [expandedId, setExpandedId] = useState(); return ( {/* THE SHEET GROWS TO FIT, THEN STOPS AND THE LIST SCROLLS - not the sheet. Past the cap an `overflow:hidden` sheet simply CUT, which is how a whole practice went missing at tablet width without anything saying so. Making this a column and letting only the list scroll keeps the heading and the close control on screen however many practices there are. `overscroll-contain` is the second half: reaching the end of the list must not start scrolling the page underneath. The page itself is already locked by the dialog. */} {/* The approved laptop board is 420px wide; a named width replaces the shared content-sized one. */}
    Your practices Open one for its details, or to open its portal.
    {/* The overlay body insets the rows from the sheet edge, as the board does. */}
      {organizations.map((organization) => ( setExpandedId((current) => current === organization.id ? undefined : organization.id, ) } onMessage={() => { onOpenChange(false); // A ROUTE THE MESSAGES PAGE ALREADY ACCEPTS, and that is the whole reason this is // safe: it reads `organizationId` from the query itself and groups the patient's // threads by practice. Nothing about the session changes, and no request carries a // different organization because this was pressed. navigate(`/dashboard/messages?organizationId=${organization.id ?? ""}`); }} /> ))}
    ); }; export const PracticeSwitcherProvider = ({ children }: { children: ReactNode }) => { const [open, setOpen] = useState(false); const { currentOrganization } = useAuthServiceContext(); const { organizations, loading, error, retry, freshness, checkAgain } = useMyOrganizations({ rereadOnReturn: true }); // ONE PRACTICE IS NOT A CHOICE. A failed load reports an empty list, which lands here as "no // switcher" rather than as a control that opens onto nothing - the safe direction for a header. const canSwitch = organizations.length > 1; // Unknown while loading or failed, so the home shows no number rather than a false zero. const count = loading || error ? undefined : organizations.length; const openPracticeSwitcher = useCallback(() => setOpen(true), []); const value = useMemo( () => ({ openPracticeSwitcher, canSwitch, practiceCount: count, practicesFreshness: freshness, checkPractices: checkAgain }), [openPracticeSwitcher, canSwitch, count, freshness, checkAgain], ); return ( {children} {open && ( )} ); }; export default PracticeSwitcherProvider;