import { useEffect, useId, useMemo, useRef, useState } from 'react'; import type { Organization } from 'fhir/r4'; import { DismissibleAlert } from '../../../../components/ui/dismissible-alert'; import { Popover, PopoverContent, PopoverTrigger } from '../../../../components/ui/popover'; import { Spinner } from '../../../../components/ui/spinner'; import { useFhirPractitioners, useRouteAuthorization } from '../../../../hooks'; import { useIsPhoneWidth } from '../../../../hooks/useIsPhoneWidth'; import { useAuthServiceContext, useOrganizationServiceContext } from '../../../../services'; import { STAFF_PERMISSION_DEFINITIONS, setOrganizationRolePermissionProfile, type StaffPermissionLevel, type StaffPermissionScope, type StaffPermissionSection, } from '../../../../utils/staffAccess.ts'; import { getWorkspaceDisplay } from '../../../../utils/staffRoleCatalog.ts'; import { MATRIX_SECTIONS, buildRoleRows, nextRoleProfile, rolePractitionerWorkspace, roleSchedulesScope, roleSchedulesScopeIsFixed, roleSectionFloor, roleSectionLevel, type RolePermissionChange, type RoleRow, } from '../../../../utils/rolePermissionMatrix.ts'; import './RolePermissionsPage.css'; type CellStatus = { state: 'saving' } | { state: 'saved' } | { state: 'failed'; retry: RolePermissionChange }; type Option = { value: string; label: string; detail?: string; disabled?: boolean }; // admin-v2 "Edit a permission in place": one line under each choice, saying what that level grants // on the server (staffAccess.ts and the route checks), with each Admin-only exception kept. const NO_ACCESS = 'No access to this section.'; // Sections answered yes or no; stored as none or manage like every other section. const isYesNo = (section: StaffPermissionSection): boolean => section === 'clinicians' || section === 'records-release'; const OPTION_DETAIL: Record>> = { organization: { none: NO_ACCESS, view: 'See practice details, locations and services.', manage: 'Change practice details, locations and services.' }, staff: { none: NO_ACCESS, view: 'See the staff directory.', manage: 'Add staff and change their profiles, roles and workspaces. Only an administrator can grant the Admin workspace or change this table.' }, schedules: { none: NO_ACCESS, view: 'See working hours, locations and time off.', manage: 'Create and change working hours, locations and time off.' }, billing: { none: NO_ACCESS, view: 'Read patient invoices.', manage: 'Create, issue, void and take payment on invoices. Configuring the payment processor stays Admin-only.' }, campaigns: { none: NO_ACCESS, view: 'See campaigns and their reporting. Audiences are counts, never patients.', manage: 'Compose and send marketing campaigns. Audiences are counts, never patients.' }, clinicians: { none: 'Cannot enable a clinician.', manage: 'May enable a clinician, which adds a paid seat to your subscription (your first clinician is free).' }, messages: { none: NO_ACCESS, view: "Read the front desk's shared patient inbox. Their own conversations are not affected.", manage: "Reply to and claim conversations in the front desk's shared inbox. Their own conversations are not affected." }, records: { none: NO_ACCESS, view: 'See records requests and their history.', manage: 'Log and work records requests, ask for outside records, and file what arrives.' }, 'records-release': { none: 'Cannot release records.', manage: 'May approve and send a records packet. Also needs Records requests: Manage.' }, diagnostics: { none: NO_ACCESS, view: 'See the lab and imaging queue. Who may order a test is not affected.', manage: 'Work the lab and imaging queue: collection, despatch and results. Who may order a test is not affected.' }, }; const RANK: Record = { none: 0, view: 1, manage: 2 }; const LEVEL_LABEL: Record = { none: 'None', view: 'View', manage: 'Manage' }; const CAPABILITY_LABEL: Record = { none: 'No', view: 'No', manage: 'Yes' }; const SCOPE_LABEL: Record = { own: 'only their own', all: "everyone's" }; const WORKSPACE_KEY = 'practitioner-workspace'; const roleName = (row: RoleRow) => row.coding.display || row.coding.code || 'Organization-defined role'; // The level a permission shows, drawn by RolePermissionsPage.css: a fill in the matrix, a coloured // word on a phone card. Required access with nothing above it is its own tone, as the deck's locked // cells are. Only the matrix fill marks it: on a phone card a granted level is a blue word, required or not. type Tone = StaffPermissionLevel | 'required'; const cellTone = (level: StaffPermissionLevel, required: boolean, layout: 'cell' | 'line' = 'cell'): Tone => (required && layout === 'cell' ? 'required' : level); type PermissionCellProps = { layout: 'cell' | 'line'; label: string; title: string; description: string; ariaLabel: string; value: string; detail?: string; tone: Tone; options: Option[]; current: string; scope?: { current: StaffPermissionScope; onChoose: (scope: StaffPermissionScope) => void }; note?: string; status?: CellStatus; onChoose: (value: string) => void; }; // One permission: a matrix cell on a laptop, a line of a role card on a phone (admin-v2 "Role // permissions on a phone"). Required access with nothing above it is plain text; anything editable // opens a picker anchored to it, as admin-v2 "Edit a permission in place" shows. const PermissionCell = ({ layout, label, title, description, ariaLabel, value, detail, tone, options, current, scope, note, status, onChoose }: PermissionCellProps) => { const [open, setOpen] = useState(false); const pickerId = useId(); // A locked level can still carry a scope to choose, as HR's required Manage does. const editable = options.filter((o) => !o.disabled).length > 1 || Boolean(scope); const saving = status?.state === 'saving'; // Ink goes on a span inside the control, never on the button: the root's `button { color }` rule // is an id selector and outranks any class on the button itself. const body = ( {value} {detail && {detail}} {saving && Saving…} {status?.state === 'saved' && Saved} ); const picker = editable && (

{title}

{!options.every((o) => o.detail) &&

{description}

}
{options.map((option) => ( ))}
{scope && (

Whose schedules

{(['own', 'all'] as StaffPermissionScope[]).map((value) => ( ))}
)} {note && (

{note}

)}
); if (layout === 'line') { return (
  • {label} {picker || {body}}
  • ); } if (!picker) return {body}; return {picker}; }; // admin-v2 "Role permissions": every role in use against every section, saved the moment a // permission is chosen (2026-09-18 ruling). Each write replaces the whole Organization, so writes // run one at a time, each built from the last confirmed organization: a slow earlier save can never // land after, and overwrite, a newer one. Cells always show the confirmed value; a failure leaves it // as it was and offers to retry what was asked. const RolePermissionsPage = () => { const { USER_ROLES, currentOrganization, setWpOrganization } = useAuthServiceContext(); const { updateOrganization } = useOrganizationServiceContext(); const { loading: loadingPractitioners, error: practitionersError, practitionersList } = useFhirPractitioners(); const phone = useIsPhoneWidth(); useRouteAuthorization({ allowedRoles: [USER_ROLES.ADMIN], redirectPath: '/dashboard/staff', }); const rows = useMemo(() => buildRoleRows(practitionersList, currentOrganization), [practitionersList, currentOrganization]); const confirmedOrganization = useRef(currentOrganization); const queue = useRef>(Promise.resolve()); const timers = useRef([]); const [status, setStatus] = useState>({}); useEffect(() => { confirmedOrganization.current = currentOrganization; }, [currentOrganization]); useEffect(() => () => timers.current.forEach((id) => window.clearTimeout(id)), []); const clearStatus = (cellKey: string) => setStatus((current) => { const next = { ...current }; delete next[cellKey]; return next; }); const requestChange = (row: RoleRow, cellKey: string, change: RolePermissionChange) => { if (status[cellKey]?.state === 'saving') return; setStatus((current) => ({ ...current, [cellKey]: { state: 'saving' } })); queue.current = queue.current.then(async () => { const base = confirmedOrganization.current; try { if (!base?.id) throw new Error('The organization is not loaded.'); const saved = await updateOrganization( setOrganizationRolePermissionProfile(base, nextRoleProfile(base, row.coding, change)), ); confirmedOrganization.current = saved; setWpOrganization(saved); setStatus((current) => ({ ...current, [cellKey]: { state: 'saved' } })); timers.current.push(window.setTimeout(() => setStatus((current) => { if (current[cellKey]?.state !== 'saved') return current; const next = { ...current }; delete next[cellKey]; return next; }), 2500)); } catch (saveError) { console.error('Failed to save a role permission:', saveError); setStatus((current) => ({ ...current, [cellKey]: { state: 'failed', retry: change } })); } }); }; const levelOptions = (section: StaffPermissionSection, floor: StaffPermissionLevel): Option[] => { const labels = isYesNo(section) ? CAPABILITY_LABEL : LEVEL_LABEL; const levels: StaffPermissionLevel[] = isYesNo(section) ? ['none', 'manage'] : ['none', 'view', 'manage']; return levels.map((level) => ({ value: level, label: labels[level], detail: OPTION_DETAIL[section]?.[level], disabled: RANK[level] < RANK[floor] })); }; const describeCurrent = (row: RoleRow, cellKey: string): string => { if (cellKey.endsWith(`:${WORKSPACE_KEY}`)) { return rolePractitionerWorkspace(currentOrganization, row.coding) ? 'Access' : 'None'; } const section = cellKey.slice(row.key.length + 1) as StaffPermissionSection; const level = roleSectionLevel(currentOrganization, row.coding, section); return isYesNo(section) ? CAPABILITY_LABEL[level] : LEVEL_LABEL[level]; }; const failures = rows.flatMap((row) => [...MATRIX_SECTIONS.map((s) => s.key as string), WORKSPACE_KEY] .map((part) => `${row.key}:${part}`) .filter((cellKey) => status[cellKey]?.state === 'failed') .map((cellKey) => ({ row, cellKey, part: cellKey.slice(row.key.length + 1) }))); const partLabel = (part: string) => part === WORKSPACE_KEY ? 'Practitioner workspace' : MATRIX_SECTIONS.find((s) => s.key === part)?.label ?? part; // Every permission of one role, as matrix cells or as the lines of a phone card. const permissionsOf = (row: RoleRow, layout: 'cell' | 'line') => { const name = roleName(row); const scope = roleSchedulesScope(currentOrganization, row.coding); const workspaceOpen = rolePractitionerWorkspace(currentOrganization, row.coding); const workspaceKey = `${row.key}:${WORKSPACE_KEY}`; return ( <> {MATRIX_SECTIONS.map((section) => { const definition = STAFF_PERMISSION_DEFINITIONS.find((d) => d.key === section.key); const level = roleSectionLevel(currentOrganization, row.coding, section.key); const floor = roleSectionFloor(row.coding, section.key); const required = floor !== 'none' && level === floor; const cellKey = `${row.key}:${section.key}`; const text = isYesNo(section.key) ? CAPABILITY_LABEL[level] : LEVEL_LABEL[level]; const showScope = section.key === 'schedules' && level !== 'none'; return ( requestChange(row, cellKey, { kind: 'scope', scope: next }), } : undefined} status={status[cellKey]} onChoose={(next) => requestChange(row, cellKey, { kind: 'level', section: section.key, level: next as StaffPermissionLevel, })} /> ); })} requestChange(row, workspaceKey, { kind: 'practitionerWorkspace', value: next === 'yes' })} /> ); }; const who = (row: RoleRow) => `${row.people} ${row.people === 1 ? 'person' : 'people'}${row.workspace ? ` · ${getWorkspaceDisplay(row.workspace)}` : ''}`; return (

    Role permissions

    {phone ? 'Changes save automatically. Patient record access is managed separately.' : 'Choose what each staff role can view or manage. Patient record access is managed separately.'}

    {!phone &&

    Changes save automatically

    }
    {practitionersError && ( Staff could not be loaded, so roles people hold may be missing below. Roles the practice has configured are shown. )} {failures.map(({ row, cellKey, part }) => { const failed = status[cellKey]; return ( clearStatus(cellKey)}> {roleName(row)} · {partLabel(part)}: Couldn’t save. Permission is still {describeCurrent(row, cellKey)}.{' '} ); })} {loadingPractitioners && rows.length === 0 ? (
    ) : phone ? ( // admin-v2 "Role permissions on a phone": one card per role, a line per permission.
    {rows.map((row) => (

    {roleName(row)}

    {who(row)}

      {permissionsOf(row, 'line')}
    ))}
    ) : (
    {MATRIX_SECTIONS.map((section) => ( ))} {rows.map((row) => ( {permissionsOf(row, 'cell')} ))}
    Role{section.label}Practitioner workspace
    {roleName(row)} {who(row)}
    )} {!phone && (
    Manage — may change it View — read only None — the section is not offered Required access — cannot be reduced
    )}

    These permissions do not grant access to patient records.

    ); }; export default RolePermissionsPage;