import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { invitationOutcomeNotice, type InvitationResult } from "../../../utils/staff/invitationOutcome"; import { useLocation, useNavigate } from "react-router-dom"; import { useAuthServiceContext, useStaffPermissionServiceContext, } from "../../../services"; import ProfilePhoto from "../../../components/ProfilePhoto/ProfilePhoto"; import { PersonBadge } from "../../../components/ui/person-badge"; import { EmptyState } from "../../../components/ui/empty-state"; import { Label } from "../../../components/ui/label"; import FirstClinicianPanel from "../OrganizationPage/FirstClinicianPanel"; import { RowStatus, type RowStatusTone } from "../../../components/ui/row-meta"; import "./UsersPage.css"; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Heading } from '../../../components/ui/heading'; import { Flex, FlexItem } from '../../../components/ui/flex'; import { Spacer } from "../../../components/ui/spacer"; import { FhirPractitionerDisplay } from "../../../components"; import PresenceDot from "../../../components/PresenceDot/PresenceDot"; import { heldPractitionerRoles } from '../../../utils/heldPractitionerRoles'; // `Practitioner` came out with the local PractitionerWithRoles copy below - it had no other use here, // and an unused type import is TS6133 against the ratchet. import { PractitionerRole } from "fhir/r4"; import { FhirHumanNameDisplay, formatFhirHumanName } from "../../../utils/fhirHumanName.tsx"; import { formatFhirCodeableConcept } from "../../../utils/fhirCodeableConcept.tsx"; import { useFhirLocations, useFhirPractitioners, useFhirSchedules } from "../../../hooks"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "../../../components/ui/select"; import { UserRoundPlus } from "lucide-react"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../../../components/ui/sheet"; import { OverlayFooter, OverlayFooterHost } from "../../../components/ui/overlay-actions"; import DiscardChangesDialog from "../../../components/DiscardChangesDialog/DiscardChangesDialog"; import { useSheetBackGuard } from "../../../hooks/useSheetBackGuard"; import { useDirtyGuard } from "../../../hooks/useDirtyReport"; import { Skeleton } from "../../../components/ui/skeleton.tsx"; import { Button } from "../../../components/ui/button.tsx"; import { getOrganizationSetupMissingItems, isOrganizationSetupComplete } from "../../../utils/organizationSetup"; import { getPractitionerRoleOrganizationCodings, isPatientFacingPractitionerRole, } from "../../../utils/staffRoleCatalog.ts"; import { getProviderReadiness } from "../../../utils/practitionerBookingReadiness"; import { useStaffBookabilityContext } from "../../../hooks/useStaffBookabilityContext"; import { useServiceCatalogueBasis } from "../../../hooks/useServiceCatalogueBasis"; import { onlySites } from "../../../utils/roomLocation"; import { isOrgWideRole } from "../../../utils/locationAssignments"; import { getCognitoUsername } from "../../../utils/cognitoIdentity.ts"; // - this file used to re-declare `PractitionerWithRoles` identically to the service's. Now that // the shape carries `profileCompleteness`, a local copy is a second definition that silently loses the // field, so it takes the service's type instead of duplicating it. import type { PractitionerWithRoles } from "../../../services/PractitionerService"; import { staffProfileStatus } from "../../../utils/staffProfileCompleteness"; import { agencyThatProvidedAny } from "../../../config/agencyProvided"; import { holdsClinicianSeat } from "../../../utils/clinicianEnabled"; import { GIVE_CLINICIAN_SEAT_STATE, GiveClinicianSeatButton } from "../../../components/ClinicianSeat/ClinicianSeatStatus"; import { useOrganizationContractorsService } from "../../../services/OrganizationContractorsService.tsx"; import { useOrganizationOwnerService } from "../../../services/OrganizationOwnerService.tsx"; import { referencesPractitioner } from "../../../components/OrganizationOwnerNotice/ownerReference"; import { usePracticeOwner } from "../../../components/OrganizationOwnerNotice/usePracticeOwner"; import type { ClinicianInvitation } from "../../../services/ClinicianInvitationService"; import { isAwaited, joinedNotYetListed, StaffInvitationRow, StaffInvitationSheet, useStaffInvitations } from "./StaffInvitations"; // Radix throws on an empty-string value, so the "All..." option uses this sentinel // (mapped back to '' - the no-filter state). const ALL_FILTER_VALUE = "__all__"; const UsersPage = () => { const navigate = useNavigate(); const { state: arrival } = useLocation() as { state?: { invitedName?: string; alreadyInvited?: boolean; outcome?: InvitationResult } }; // What the Add Staff press did, in its own words: a missing result is never read as "sent". const arrivalNotice = arrival?.invitedName ? invitationOutcomeNotice(arrival.outcome ?? { // An older page navigated here only once the invitation was saved; it said nothing of the email. existing: Boolean(arrival.alreadyInvited), receipt: { operationId: "", save: "saved", standing: arrival.alreadyInvited ? "reused" : "created", send: { state: arrival.alreadyInvited ? "not-requested" : "unconfirmed" }, }, }, arrival.invitedName) : undefined; const { userRole, USER_ROLES, getCognitoGroup, wpOrganization, currentUser } = useAuthServiceContext(); const { canManage } = useStaffPermissionServiceContext(); const { loading, error, practitionersList, loadPractitioners } = useFhirPractitioners(); const { locationsList } = useFhirLocations(); const { schedulesList, schedulesBundle } = useFhirSchedules(); // Gate the "Not bookable" badge until schedules have loaded, so it can't flash against an // empty schedule list on first render. const schedulesLoaded = schedulesBundle !== null; // Readiness derives from the same resolver the patient booking flow uses. No catalog is passed on purpose: // this is a list page and pulling the HealthcareService catalog here would add a fetch to it. Service names // fall back to the schedule actor's display, which is all the badge needs. // The badge stands on the verified catalogue the owner already holds; without one it says booking is not checked. const serviceBasis = useServiceCatalogueBasis(); const bookabilityCtx = useStaffBookabilityContext({ practitioners: practitionersList, locationCount: locationsList?.length ?? 0, serviceBasis, }); //: there is no staff seat gate here any more. Non-clinician staff are FREE and // UNCAPPED; billing is per CLINICIAN, first free. This read used to be // `entitlements?.staffSeatLimit ?? 2` feeding an `atSeatLimit` that DISABLED the Add button - so once // the server stopped sending the field, the fallback would have hard-blocked staff creation for every // org with two or more staff. The field is deleted rather than unrendered for exactly that reason. const organizationSetupMissingItems = getOrganizationSetupMissingItems(wpOrganization.current); const organizationSetupComplete = isOrganizationSetupComplete(wpOrganization.current); const [selectedPractitionerWithRoles, setSelectedPractitionerWithRoles] = useState(null); const [nameQuery, setNameQuery] = useState(""); const [inviting, setInviting] = useState(false); const [clinicianSetup, setClinicianSetup] = useState(false); const [inviteMode, setInviteMode] = useState<"invite" | "self">("invite"); const inviteDirty = useDirtyGuard(); const [confirmDiscardInvite, setConfirmDiscardInvite] = useState(false); const [leavingTo, setLeavingTo] = useState(null); const closeInvite = () => { inviteDirty.reset(); setClinicianSetup(false); setInviting(false); }; const requestCloseInvite = () => (inviteDirty.isDirty() ? setConfirmDiscardInvite(true) : closeInvite()); const sheetGuard = useSheetBackGuard(inviting, inviteDirty.isDirty, (to) => { setLeavingTo(to ?? null); requestCloseInvite(); }); const [roleFilter, setRoleFilter] = useState(""); const [specialtyFilter, setSpecialtyFilter] = useState(""); const [locationFilter, setLocationFilter] = useState(""); const [statusFilter, setStatusFilter] = useState<"active" | "invited" | "deactivated" | "all">("active"); const [openInvitation, setOpenInvitation] = useState(null); /* * The agencies this practice has linked, so a row can NAME the one that provided somebody rather than * printing a contractor id. The tag on the role carries the id; this turns it into the name the * practice already sees on its own organization screen, from the same list that screen reads. * * Failing quietly is correct here. This is one line of context on a staff directory, and a directory * that will not render because an unrelated list did not load would be a far worse outcome than a row * that says an agency provided somebody without naming which. */ const { listContractors } = useOrganizationContractorsService(); const [agencyNames, setAgencyNames] = useState>({}); const isPatient = [USER_ROLES.PATIENT, USER_ROLES.RELATED_PERSON].includes( userRole as string, ); const canManageStaff = canManage("staff"); const canGiveClinicianSeat = canManageStaff && canManage("clinicians"); // Login email is deliberately NOT rendered in this list - it stays on the staff member's profile. It is // still searchable for staff managers, so a manager who half-remembers the address a seat was created with // can find the person. Patients search name only; matching on a field they cannot see would let them probe // for staff emails by trial and error. const canSearchLoginEmail = !isPatient && canManageStaff; const { invitations, unreadable: invitationsUnreadable, replace: replaceInvitation } = useStaffInvitations(!isPatient && canManageStaff); /* * Somebody who has just joined is on neither list without this: their invitation leaves once they hold a * role, and the staff list is cached for the session. Their invitation row stays until the role arrives, * and the staff list is re-read once per newly joined role (ed-1 J1, 2026-10-03). */ const listedRoleIds = useMemo( () => new Set(practitionersList.flatMap(({ roles }) => roles.map((role) => role.id).filter((id): id is string => Boolean(id)))), [practitionersList], ); const joinedUnlisted = useMemo(() => joinedNotYetListed(invitations, listedRoleIds), [invitations, listedRoleIds]); const reReadFor = useRef(new Set()); useEffect(() => { const fresh = joinedUnlisted.map((invitation) => invitation.practitionerRoleId as string).filter((id) => !reReadFor.current.has(id)); if (fresh.length === 0 || !loadPractitioners) return; fresh.forEach((id) => reReadFor.current.add(id)); void loadPractitioners(true); }, [joinedUnlisted, loadPractitioners]); /* * Asked once, for anyone who is not a patient. Reaching this page at all already requires permission * to view staff, so the question of who may know is answered before this runs - and the agency doc * names the agency to practice staff by design. If the server refuses the list anyway, the catch * below handles it and the row still states the half that matters. * * Gating this on MANAGE rather than view would have made the same row read differently for two people * looking at the same directory, which is a distinction nobody asked for. */ useEffect(() => { if (isPatient) return; let cancelled = false; void (async () => { try { const result = await listContractors(); if (cancelled) return; const named: Record = {}; for (const row of result?.contractors ?? []) { const name = row.name?.trim(); if (row.contractorId && name) named[row.contractorId] = name; } setAgencyNames(named); } catch { // Deliberately silent - see the note on the state above. A row still says an agency provided // this person; it just cannot say which. } })(); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPatient]); // Who owns the practice, for the row that says so; an unread owner marks nobody. const { fetchOwner } = useOrganizationOwnerService(); const ownerPractitionerId = usePracticeOwner(wpOrganization.current?.id, fetchOwner, isPatient); const viewUser = (practitionerWithRoles: PractitionerWithRoles) => { setSelectedPractitionerWithRoles(practitionerWithRoles); }; // - `SyntheticEvent`, not `MouseEvent`, so the row can be opened from the keyboard too. // The only thing done with the event here is `stopPropagation()`, which every synthetic event has, so // widening the type costs nothing and is what lets one handler serve both a click and an Enter/Space. const editUser = ( event: React.SyntheticEvent, practitionerWithRoles: PractitionerWithRoles, ) => { event.stopPropagation(); navigate(practitionerWithRoles.practitioner.id as string, { relative: "route", state: { practitioner: practitionerWithRoles.practitioner, roles: practitionerWithRoles.roles, }, }); }; const handlePractitionerClick = ( event: React.SyntheticEvent, practitionerWithRoles: PractitionerWithRoles, ) => { if (isPatient) { viewUser(practitionerWithRoles); return; } // The record's tabs need the practice set up; until then a staff:view reader keeps the summary sheet. if (!organizationSetupComplete) { if (!canManageStaff) viewUser(practitionerWithRoles); return; } // staff:view opens the same record read-only; the route and the Profile tab decide what it may change. editUser(event, practitionerWithRoles); }; const addUser = () => { if (isPatient || !canManageStaff || !organizationSetupComplete) return; navigate("new", { relative: "route", state: { user: null, }, }); }; const getRolesSummary = (allRoles: PractitionerRole[]): string => { const roles = heldPractitionerRoles(allRoles); if (roles.length === 0) { return "No roles assigned"; } const filteredRoles = isPatient ? roles.filter((practitionerRole: PractitionerRole) => isPatientFacingPractitionerRole( practitionerRole, getCognitoGroup(practitionerRole), ), ) : roles; if (!filteredRoles || filteredRoles.length === 0) { return "No roles assigned"; } if (filteredRoles.length === 1) { const role = filteredRoles[0]; // A role coded without free text still has a name: the coding's display, as the Role filter reads it. const code = (role.code && role.code.length > 0 ? formatFhirCodeableConcept(role.code[0]) : "") || getPractitionerRoleOrganizationCodings(role)[0]?.display || ""; const specialty = role.specialty && role.specialty.length > 0 ? formatFhirCodeableConcept(role.specialty[0]) : ""; if (code && specialty) { return `${code} (${specialty})`; } else if (code) { return code; } else if (specialty) { return specialty; } return "1 role"; } return `${filteredRoles.length} roles`; }; // The directory is already one practice's, so the line names where the person works rather than the // practice. Held roles only: a deactivated role is how somebody stops working somewhere. const getLocationsSummary = (allRoles: PractitionerRole[]): string => { const roles = heldPractitionerRoles(allRoles); if (roles.some((role) => (role.location ?? []).length === 0 && isOrgWideRole(role))) { return "All locations"; } const ids = new Set( roles.flatMap((role) => (role.location ?? []) .map((loc) => loc.reference?.split("/").pop()) .filter((id): id is string => Boolean(id)), ), ); if (ids.size === 0) return ""; if (ids.size > 1) return `${ids.size} locations`; const [only] = ids; return locationFilterOptions.find((opt) => opt.id === only)?.display ?? ""; }; const isUserActive = (roles: PractitionerRole[]): boolean => { if (!roles || roles.length === 0) { return false; } return roles.some((role) => role.active !== false); }; // Filter options are derived from the staff actually present (so every option matches someone), // except locations which come from the org's real Location list (an "All locations" role - empty // PractitionerRole.location - has no explicit refs but matches any chosen location below). const roleFilterOptions = useMemo(() => { const seen = new Set(); const options: { code: string; display: string }[] = []; for (const { roles } of practitionersList) { for (const role of roles) { for (const coding of getPractitionerRoleOrganizationCodings(role)) { if (!coding.code || seen.has(coding.code)) continue; seen.add(coding.code); options.push({ code: coding.code, display: coding.display || coding.code }); } } } return options.sort((a, b) => a.display.localeCompare(b.display)); }, [practitionersList]); const specialtyFilterOptions = useMemo(() => { const seen = new Set(); const options: { code: string; display: string }[] = []; for (const { roles } of practitionersList) { for (const role of roles) { for (const specialty of role.specialty ?? []) { for (const coding of specialty.coding ?? []) { if (!coding.code || seen.has(coding.code)) continue; seen.add(coding.code); options.push({ code: coding.code, display: coding.display || formatFhirCodeableConcept(specialty) || coding.code, }); } } } } return options.sort((a, b) => a.display.localeCompare(b.display)); }, [practitionersList]); const locationFilterOptions = useMemo( () => onlySites(locationsList) .filter((loc) => loc.id) .map((loc) => ({ id: loc.id as string, display: loc.name || (loc.id as string) })) .sort((a, b) => a.display.localeCompare(b.display)), [locationsList], ); // A staff member or an invitation matches when a role satisfies every active filter. const roleMatchesFilters = useCallback((role: PractitionerRole): boolean => { if ( roleFilter && !getPractitionerRoleOrganizationCodings(role).some((c) => c.code === roleFilter) ) { return false; } if ( specialtyFilter && !(role.specialty ?? []).some((s) => (s.coding ?? []).some((c) => c.code === specialtyFilter), ) ) { return false; } if (locationFilter) { const roleLocations = role.location ?? []; // - an "All locations" (org-wide) role matches any chosen location. Routed through // the shared predicate rather than re-deriving "empty means everywhere" here, because that // rule is migrating to an explicit extension and a copy left behind would keep answering the // old way after the others had moved. if ( roleLocations.length > 0 && !roleLocations.some((loc) => loc.reference?.split("/").pop() === locationFilter) ) { return false; } if (roleLocations.length === 0 && !isOrgWideRole(role)) { return false; } } return true; }, [roleFilter, specialtyFilter, locationFilter]); const filteredPractitioners = useMemo(() => { const query = nameQuery.trim().toLowerCase(); const isActive = (rs: PractitionerRole[]) => rs.some((role) => role.active !== false); const matches = practitionersList.filter(({ practitioner, roles }) => { if (statusFilter === "invited") return false; if (query) { const nameMatch = formatFhirHumanName(practitioner.name) .toLowerCase() .includes(query); const emailMatch = canSearchLoginEmail && (getCognitoUsername(practitioner)?.toLowerCase().includes(query) ?? false); if (!nameMatch && !emailMatch) { return false; } } // Status filter - Active is the default; Deactivated staff are hidden unless requested. if (statusFilter !== "all") { const active = isActive(roles); if (statusFilter === "active" && !active) return false; if (statusFilter === "deactivated" && active) return false; } if (!roleFilter && !specialtyFilter && !locationFilter) return true; // A staff member matches when at least one of their roles satisfies every active filter. return roles.some(roleMatchesFilters); }); // Under "All", bury deactivated staff at the bottom (active first; stable within each group). if (statusFilter === "all") { return [...matches].sort( (a, b) => Number(isActive(b.roles)) - Number(isActive(a.roles)), ); } return matches; }, [practitionersList, nameQuery, roleFilter, specialtyFilter, locationFilter, statusFilter, canSearchLoginEmail, roleMatchesFilters]); // Somebody still to accept is on the staff the practice is building, so they list under Active too. const invitationRows = useMemo(() => { if (statusFilter === "deactivated") return []; const query = nameQuery.trim().toLowerCase(); // Somebody who has joined is staff, not a pending invitation, while the staff list catches up. const shown = statusFilter === "invited" ? invitations.filter(isAwaited) : invitations.filter((invitation) => isAwaited(invitation) || joinedUnlisted.includes(invitation)); return shown.filter((invitation) => { const name = `${invitation.firstname} ${invitation.lastname}`.toLowerCase(); if (query && !name.includes(query) && !(canSearchLoginEmail && invitation.email.toLowerCase().includes(query))) { return false; } if (!roleFilter && !specialtyFilter && !locationFilter) return true; return invitation.practitionerRole ? roleMatchesFilters(invitation.practitionerRole) : false; }); }, [invitations, joinedUnlisted, nameQuery, roleFilter, specialtyFilter, locationFilter, statusFilter, canSearchLoginEmail, roleMatchesFilters]); const invitationRoleSummary = (invitation: ClinicianInvitation): string => invitation.practitionerRole ? getRolesSummary([invitation.practitionerRole]) : "Clinician"; const hasActiveFilters = Boolean( nameQuery || roleFilter || specialtyFilter || locationFilter || statusFilter !== "active", ); const clearFilters = () => { setNameQuery(""); setRoleFilter(""); setSpecialtyFilter(""); setLocationFilter(""); setStatusFilter("active"); }; return (
{!isPatient && ( <> Staff {/* Card 154 slice 6b: the "N / M seats used" readout is gone with the dimension. Staff are free and uncapped, so there is no denominator to show. */} {/* Inviting a clinician is the same panel as setup step 5, so a practice that finished setup still has a door to it. It creates nothing until the person accepts. */} {canManageStaff && ( )} {canManageStaff && ( )} (open ? setInviting(true) : requestCloseInvite())}> {clinicianSetup ? "Set up clinician appointments" : inviteMode === "self" ? "Make myself the first clinician" : "Invite a clinician"} {clinicianSetup && Seat assigned}
{inviting && }
{confirmDiscardInvite && ( { setConfirmDiscardInvite(false); setLeavingTo(null); }} onDiscard={() => { setConfirmDiscardInvite(false); closeInvite(); if (leavingTo !== null) sheetGuard.leaveTo(leavingTo); setLeavingTo(null); }} /> )} )} {!isPatient && canManageStaff && !organizationSetupComplete && ( <> Complete primary organization information before adding staff, editing staff profiles, or assigning roles and workspaces. Missing: {organizationSetupMissingItems.join(', ')}. )} {arrivalNotice && ( <> {arrivalNotice.text} )} {/* The list flows with the page, as the approved directory frame does; no inner scroll box. */}

Staff directory

{canManageStaff ? "Find and manage staff members for this organization." : "View staff members for this organization."}

{!isPatient && (practitionersList.length > 0 || invitations.length > 0) && (
{/* Every filter is drawn even with nothing to narrow, so the row keeps the frame's shape. */}
{hasActiveFilters && (
)}
)}
{invitationsUnreadable && ( Invitations could not be read just now, so anybody still to accept is not listed here. )} {/* A re-read keeps the list on screen; only a first read has nothing to show yet. */} {loading && practitionersList.length === 0 ? (
{Array.from({ length: 5 }).map((_, i) => (
))}
) : error ? ( window.location.reload()}>Retry} /> ) : !loading && practitionersList.length === 0 && invitationRows.length === 0 ? ( ) : ( filteredPractitioners.length === 0 && invitationRows.length === 0 ? ( Clear filters} /> ) : (
{invitationRows.map((invitation) => ( setOpenInvitation(invitation)} /> ))} {filteredPractitioners.map( (practitionerWithRoles: PractitionerWithRoles) => { const rolesSummary = getRolesSummary( practitionerWithRoles.roles, ); const locationsSummary = getLocationsSummary( practitionerWithRoles.roles, ); const isActive = isUserActive(practitionerWithRoles.roles); const readiness = getProviderReadiness( practitionerWithRoles, schedulesList, bookabilityCtx, ); // - the nudge, not the value. `profileCompleteness` is absent for a non-staff // caller, and the helper treats absent as "nothing to chase" rather than "everything is // missing". Deliberately only a summary here: the list resolves what is free off the // Practitioner, and the detail page names which field is actually missing. const profileStatus = staffProfileStatus( practitionerWithRoles.profileCompleteness, { isProvider: readiness.isProvider }, ); // - the row is inert while setup is incomplete: `handlePractitionerClick` // returns early in exactly this case, so it must not be a tab stop either. A focusable // control that does nothing is worse than no control. const rowIsActionable = !(canManageStaff && !organizationSetupComplete); const lacksClinicianSeat = isActive && readiness.isProvider && !holdsClinicianSeat(practitionerWithRoles.roles); /* * Read off the tag the server writes onto the role, never a second source: the * removal cascade's safety argument rests on that tag and the assignment row being * two independent facts that have to agree. * * The id resolves to a name when the practice's agency list is to hand. When it is * not, the row still states the true and useful half rather than going quiet - the * person WAS provided by an agency, and that is the fact an administrator is missing. */ const providedByContractorId = agencyThatProvidedAny(practitionerWithRoles.roles); const providedBy = providedByContractorId ? (agencyNames[providedByContractorId] ?? 'an outside contractor') : undefined; return (
handlePractitionerClick(event, practitionerWithRoles) } // Enter AND Space, because `role="button"` promises both - a native button fires on // either, and a screen reader announces this as a button. Space is also prevented from // scrolling the page, which is what it would otherwise do. onKeyDown={(event: React.KeyboardEvent) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); handlePractitionerClick(event, practitionerWithRoles); }} className={`ahr-list-card staff-directory-row group focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${canManageStaff && !organizationSetupComplete ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'}`} >
{/* A photo when the person has one, their initials otherwise. */} {practitionerWithRoles.practitioner.photo?.[0] ? ( ) : ( )}

{/* CARD 643 — ONE SEPARATOR, AND IT IS THE MIDDOT. The pipe here was a genuine U+007C, checked at the codepoint — but at 13px in the fallback face it reads as a capital I, so "Billing Specialist | at Great River" renders as *"Billing Specialist I"*. On a STAFF DIRECTORY that reads as a seniority grade, which is a plausible misreading rather than a stretch. Every other list in the app already uses `·`. */} {/* An agency-provided person read exactly like somebody the practice hired. The fact has been on the role since August and no screen had ever shown it. It goes on the line the row already has, with the separator that line already uses - no badge, no colour, no new visual language for one fact. Naming the agency to practice staff is by design; the constraint is that it never reaches a patient-facing view, and this list is behind the staff permission. Absence stays silent. Somebody with no tag gains no wording at all - calling them internal would be a claim nobody wrote, and a practice hiring directly is the ordinary case rather than a state worth labelling. */} {referencesPractitioner(ownerPractitionerId, practitionerWithRoles.practitioner.id) && "Practice owner · "} {rolesSummary} {locationsSummary && ` · ${locationsSummary}`} {providedBy && ` · Provided by ${providedBy}`}
{/* THE ROW'S STATE, IN WORDS AT THE RIGHT, never an icon or a pill. Amber only for a real gap; a provider deliberately kept staff-book-only is not broken, so it reads plain. Deactivated outranks everything, because nothing else applies. */} {(() => { const words: string[] = []; let tone: RowStatusTone = 'plain'; if (!isActive) return Deactivated; if (lacksClinicianSeat) { words.push('No clinician seat'); tone = 'attention'; } else if (schedulesLoaded && readiness.isProvider && readiness.basis !== 'confirmed') { words.push(readiness.basis === 'checking' ? 'Checking booking' : 'Booking not checked'); } else if (schedulesLoaded && readiness.isProvider && !readiness.staffBookable) { words.push('Can’t take appointments'); tone = 'attention'; } if (profileStatus === 'attention') { words.push('Profile incomplete'); tone = 'attention'; } if (schedulesLoaded && readiness.isProvider && readiness.staffBookable && readiness.basis === 'confirmed') { words.push(readiness.patientSelfBookable ? 'Patients can book' : 'Staff-booking only'); if (tone === 'plain' && readiness.patientSelfBookable) tone = 'done'; } if (profileStatus === 'pending') words.push('Personal profile not set up'); if (words.length === 0) return null; const text = words.join(' · '); // On a phone a short state keeps the name line, as the frame shows; a long one drops below. return ( {text} ); })()} {lacksClinicianSeat && canGiveClinicianSeat && rowIsActionable && organizationSetupComplete && !isPatient && ( navigate(String(practitionerWithRoles.practitioner.id) + "/workspaces", { relative: "route", state: { practitioner: practitionerWithRoles.practitioner, roles: practitionerWithRoles.roles, ...GIVE_CLINICIAN_SEAT_STATE, }, })} /> )}
); }, )}
) )}
{/* A staff:view reader has the directory without its acts; say who has them, as admin#s-staff "Without permission to manage staff" does. */} {!isPatient && !canManageStaff && (

Adding a person, changing a role or a workspace, and enabling a clinician seat need an administrator. Ask one at this practice.

)} {openInvitation && ( setOpenInvitation(null)} onChanged={(next) => { replaceInvitation(next); setOpenInvitation(next); }} /> )} {selectedPractitionerWithRoles && (isPatient || !canManageStaff) && ( { if (!open) setSelectedPractitionerWithRoles(null); }} > Practitioner details This practitioner's details and roles.
setSelectedPractitionerWithRoles(null)} />
)}
); }; export default UsersPage;