import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { MenuGroup, MenuItem, NavigableMenu } from "../../../../components/ui/navigable-menu"; import { formatFhirHumanName } from '../../../../utils/fhirHumanName.tsx'; import './UserPage.css'; import { Spacer } from '../../../../components/ui/spacer'; import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'; import { PractitionerWithRoles, useAuthServiceContext, usePractitionerServiceContext, useStaffPermissionServiceContext, } from '../../../../services'; import { getOrganizationSetupMissingItems, isOrganizationSetupComplete } from '../../../../utils/organizationSetup'; import { useEffect, useState } from 'react'; import { AppHeader, AppSpinner } from '../../../../components'; import { useFhirPractitioners, useFhirSchedules } from '../../../../hooks'; import { useStaffReadiness } from '../../../../hooks/useStaffReadiness'; import { useServiceCatalogueBasis } from '../../../../hooks/useServiceCatalogueBasis'; import { StaffReadinessSummary } from '../../../../components/staff/StaffReadinessSummary'; import { holdsClinicalRole } from '../../../../utils/staffRoleCatalog'; import { missingProfileItems, staffProfileStatus } from '../../../../utils/staffProfileCompleteness'; import { holdsClinicianSeat } from '../../../../utils/clinicianEnabled'; import { ClinicianSeatStatus, GIVE_CLINICIAN_SEAT_STATE } from '../../../../components/ClinicianSeat/ClinicianSeatStatus'; import { effectiveSeat, useSeatStanding } from '../../../../utils/seatStanding'; const UserPage = () => { const { practitionerId } = useParams<{ practitionerId: string }>(); const navigate = useNavigate(); const location = useLocation(); const routeState = location.state as PractitionerWithRoles | null; const { wpOrganization } = useAuthServiceContext(); const { canManage } = useStaffPermissionServiceContext(); const { fetchPractitioner, practitionerState, setPractitionerState } = usePractitionerServiceContext(); const [loading, setLoading] = useState(false); const [error, setError] = useState(); const spacerMargin = '24px'; const organizationSetupMissingItems = getOrganizationSetupMissingItems(wpOrganization.current); const organizationSetupComplete = isOrganizationSetupComplete(wpOrganization.current); // Only a provider has anything patients could book, so only a provider gets the Patient Booking tab. const isProvider = holdsClinicalRole(practitionerState?.roles); const canManageStaffProfiles = canManage('staff'); // The stored flag says what is configured; the practice's current standing says whether it is in use. Until the // person in view is the one in the route and the standing belongs to this owner, nothing is claimed. const seatStanding = useSeatStanding(); const selected = Boolean(practitionerId) && practitionerState?.practitioner?.id === practitionerId; const seat = selected ? effectiveSeat(holdsClinicianSeat(practitionerState?.roles), practitionerId, seatStanding) : 'checking'; const mayChooseSeats = canManageStaffProfiles && canManage('clinicians') && organizationSetupComplete; // The Workspaces tab owns the grant, its price confirmation and the checkout; this only takes the // admin there with the seat already asked for. const giveClinicianSeat = mayChooseSeats ? () => navigate('workspaces', { relative: 'route', state: { ...(routeState ?? {}), ...GIVE_CLINICIAN_SEAT_STATE }, }) : undefined; // A kept seat is moved with the free-seat choice on the Workspaces tab, never by buying or re-ticking the flag. const chooseFreeSeat = mayChooseSeats ? () => navigate('workspaces', { relative: 'route', state: routeState ?? undefined }) : undefined; // - what this person has not supplied yet. Booleans in, labels out; no value is ever read. const missingProfile = missingProfileItems(practitionerState?.profileCompleteness, { isProvider }); const profileStatus = staffProfileStatus(practitionerState?.profileCompleteness, { isProvider }); // - the join. Invite - role - location - schedule - NPI live on the tabs BELOW this, one // per screen, and a practitioner invisible to booking looks perfectly normal on every one of them - // the receptionist only ever meets it as "the calendar is empty". This is the one line that says // which of them is missing, so it sits ABOVE the tabs rather than on any of them. // `restricted` is why this page does not simply believe an empty list: an `own`-scoped // reader gets a 200 with only their OWN blocks, so "no schedule" would be a claim about permission // wearing the clothes of a claim about this person. const { schedulesList, restricted: scheduleHidden } = useFhirSchedules(); const serviceBasis = useServiceCatalogueBasis(); const readiness = useStaffReadiness({ entry: practitionerState, schedulesList, serviceBasis }); useFhirPractitioners(); // Access is governed by the route guards in staffRecordRoutes.tsx: staff:view opens this page and its // read-only Profile only. A delegated `staff:manage` workspace (e.g. an HR role) can open every tab // (Profile / Workspaces / Roles / Services) to onboard and manage staff. Granting the Admin workspace // itself is guarded inside StaffWorkspacesPage (non-admins cannot add or remove Admin); the org-wide // permission MATRIX (staff/permissions) stays admin-only via its own RequireAdminWorkspace route. useEffect(() => { const loadUser = async () => { if (!practitionerId) { return; } if (practitionerState?.practitioner?.id === practitionerId) { return; } // - a list row carries only the SUMMARY completeness flags, because the staff list // deliberately spends no extra reads. Reusing that row here would leave the detail page unable // to name which field is missing, which is the whole division of labour between the two // surfaces. So a summary-scoped row falls through to the by-id read instead of short-circuiting. // // Deliberately keyed on `=== 'summary'`, not on "is it full": ABSENT flags mean a non-staff // caller, whose behaviour is unchanged. Treating absent as "fetch again" would also make the // condition unsatisfiable for that caller, and this effect re-runs on `practitionerState` - // i.e. an endless refetch loop. const routeStateIsSummaryOnly = routeState?.profileCompleteness?.scope === 'summary'; if (routeState?.practitioner?.id === practitionerId && !routeStateIsSummaryOnly) { setPractitionerState(routeState); return; } setLoading(true); try { const practitionerWithRoles: PractitionerWithRoles = await fetchPractitioner(practitionerId); setPractitionerState(practitionerWithRoles); } catch (error) { console.error('Failed to load user:', error); setError(`Could not load user. Try again.`); } finally { setLoading(false); } }; loadUser(); }, [practitionerId, practitionerState, routeState, fetchPractitioner, setPractitionerState]); useEffect(() => { return () => { setPractitionerState(undefined); }; }, []); const isActive = (path: string) => { return location.pathname.includes(path); }; if (loading) { return ( ); } return (
{practitionerState?.practitioner && (

{formatFhirHumanName(practitionerState.practitioner.name) || 'Staff member'}

)}
{error && ( <> setError(undefined)}> {error} )} {!organizationSetupComplete && canManageStaffProfiles && ( <> Complete primary organization information before editing staff profiles or assigning roles and workspaces. Missing: {organizationSetupMissingItems.join(', ')}. )} {/* Card 230 — the "hound them" surface, and it is built entirely out of booleans. 🚨 There is no value on this screen and there must never be one: the personal phone and email live on this person's OWN patient profile, which an employing organisation does not get to read. What an admin gets is what is MISSING and WHY, so they can ask the person to fill it in themselves. Absent flags render nothing at all — see `missingProfileItems`. */} {missingProfile.length > 0 && ( <> {/* Card 230 / option C — the WEIGHT and the WORDS both follow the status, because they are answers to different questions. `pending` means this person has never set up a personal profile: expected for everyone who predates the feature, and not something the admin can fix, so telling them it is "incomplete" would be asking for an action that does not exist. `attention` means they engaged and left a real gap. */} {profileStatus === 'pending' ? ( <> This person has not set up their personal profile yet. It is created the first time they open their own profile and save it, so there is nothing to fix here — but until they do, Akashic cannot recognise them if they also work at, or are a patient of, another practice, and their records would become separate accounts. Ask them to sign in and complete it. ) : ( <> This person’s profile is incomplete. These details are what let Akashic recognise them if they also work at, or are a patient of, another practice — so their records stay together instead of becoming separate accounts. Ask them to add the missing items from their own profile. )}
    {missingProfile.map((item) => (
  • {item.label} — {item.why}
  • ))}
)} {/* Card 321 — ⛔ gated on `staff:manage`, like the tabs it summarises: it is a sentence one person reads ABOUT another, and every step it names is fixed on a tab a non-manager cannot open. Showing it to them would be a problem statement with no door to the fix. */} {readiness && canManageStaffProfiles && ( <> )} {isProvider && canManageStaffProfiles && practitionerState?.practitioner && ( <> )} {organizationSetupComplete &&
navigate('profile', { relative: 'route' })}> Profile {canManageStaffProfiles && ( navigate('workspaces', { relative: 'route' })}> Workspaces )} {/* Read-only for staff:view; the pages choose the editor only for staff:manage. */} navigate('roles', { relative: 'route' })}> Roles & Specialties navigate('services', { relative: 'route' })}> Services Offered {canManageStaffProfiles && ( <> {/* Providers only — for a receptionist or billing specialist there is nothing patients could book, so the tab would be a dead end. */} {isProvider && ( navigate('patient-booking', { relative: 'route' })}> Patient Booking )} )}
}
); }; export default UserPage;