import { useEffect, useMemo, useState } from 'react'; import { CheckboxControl } from "../ui/checkbox-control"; import { SelectControl } from "../ui/select-control"; import { DismissibleAlert } from '../ui/dismissible-alert'; import { Heading } from '../ui/heading'; import { Text } from '../ui/text'; import { Spacer } from '../ui/spacer'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { BUILT_IN_WORKSPACE_OPTIONS, STAFF_WORKSPACES, getCoupledOrgRoleCodeForWorkspace, getOrgRoleByCode, getWorkspaceCouplingNote, getWorkspaceFromFormValue, getWorkspaceFormValue, type StaffWorkspace, } from '../../utils/staffRoleCatalog.ts'; import { describeCoupledRemoval, type CoupledRemovalDescription } from '../../utils/coupledRemoval'; import CoupledRemovalConfirmDialog from '../CoupledRemovalConfirmDialog/CoupledRemovalConfirmDialog'; import './StaffWorkspaceAssignment.css'; interface StaffWorkspaceAssignmentProps { disabled?: boolean; headingLevel?: 3 | 4 | 5 | 6; // Workspace option keys (BUILT_IN_WORKSPACE_OPTIONS[].key, e.g. 'Admins') the current actor may not // toggle - rendered checked-but-disabled. Used to keep the Admin workspace admin-only while a delegated // staff:manage actor (e.g. HR) manages every other workspace. lockedWorkspaceKeys?: string[]; /** * Whether this practice grants the practitioner workspace to the org roles this person holds. * * RESOLVED BY THE CALLER, because the two screens that mount this read their org roles from * different places - the create form has them in a `code` field, the Workspaces tab has several * saved roles and no `code` field at all. Both call `codingsMayOpenPractitionerWorkspace`, so they * ask the identical question the token mint will ask. * * DEFAULTS TO TRUE, and that direction is deliberate: an unwired caller must not start refusing a * workspace nobody has withheld. This explains a refusal; it is not the refusal, which is at token * mint where the server can enforce it. */ practitionerWorkspaceGranted?: boolean; /** * Whether these organization roles reach Lab and imaging work. The tech workspaces show that work * only to a role the practice has given it, so without it the admin is told so beside them. * Defaults to true, so an unwired caller says nothing. */ diagnosticsGranted?: boolean; // Off where the caller's own section header already states it, so the guidance is said once. showGuidance?: boolean; } const normalizeWorkspaceValues = (value: unknown): string[] => Array.isArray(value) ? value.filter((workspace): workspace is string => Boolean(workspace)) : []; // Live two-way tick, add direction (Task 4 step 2): given the org-role coding entries currently // selected and the canonical workspace that was just ticked, returns the coding entries that must be // present so the ticked workspace's partner role is selected too. Purely additive, mirroring // getWorkspaceFormKeysForSelectedRoles in StaffRoleAssignmentFields.tsx. Built via getOrgRoleByCode so // the appended entry carries system/display exactly as the submit-time adapters build it - never a // bare `{ code }`. export const getCodeEntriesAfterWorkspaceCheck = ( currentCodes: Array<{ code?: string }>, workspace: StaffWorkspace | undefined, ): Array<{ code?: string }> => { const partnerCode = workspace ? getCoupledOrgRoleCodeForWorkspace(workspace) : undefined; if (!partnerCode) return currentCodes; const alreadyPresent = currentCodes.some((entry) => entry.code === partnerCode); if (alreadyPresent) return currentCodes; const partnerRole = getOrgRoleByCode(partnerCode); if (!partnerRole) return currentCodes; return [...currentCodes, partnerRole]; }; const PRACTITIONER_WORKSPACE_KEY = getWorkspaceFormValue(STAFF_WORKSPACES.PRACTITIONER); const DIAGNOSTICS_WORKSPACE_KEYS = [ getWorkspaceFormValue(STAFF_WORKSPACES.LAB_TECH), getWorkspaceFormValue(STAFF_WORKSPACES.IMAGING_TECH), ]; const StaffWorkspaceAssignment = ({ disabled = false, headingLevel = 5, lockedWorkspaceKeys = [], practitionerWorkspaceGranted = true, diagnosticsGranted = true, showGuidance = true, }: StaffWorkspaceAssignmentProps) => { const { control, getValues, setValue } = useFormContext(); const watchedWorkspaces = useWatch({ control, name: 'workspaces' }); const selectedWorkspaces = useMemo( () => normalizeWorkspaceValues(watchedWorkspaces), [watchedWorkspaces], ); const defaultWorkspace = useWatch({ control, name: 'defaultWorkspace', }) as string | undefined; const workspaceOptions = useMemo( () => BUILT_IN_WORKSPACE_OPTIONS.filter((option) => Boolean(option.key)), [], ); const [pendingWorkspaceRemoval, setPendingWorkspaceRemoval] = useState<{ optionKey: string; description: CoupledRemovalDescription } | undefined>(); useEffect(() => { if (selectedWorkspaces.length === 0) { if (defaultWorkspace) { setValue('defaultWorkspace', '', { shouldDirty: true, shouldValidate: true, }); } return; } if (!defaultWorkspace || !selectedWorkspaces.includes(defaultWorkspace)) { setValue('defaultWorkspace', selectedWorkspaces[0], { shouldDirty: Boolean(defaultWorkspace), shouldValidate: true, }); } }, [defaultWorkspace, selectedWorkspaces, setValue]); const defaultWorkspaceOptions = [ { label: 'Select the default workspace', value: '' }, ...workspaceOptions .filter((option) => selectedWorkspaces.includes(option.key)) .map((option) => ({ label: option.name, value: option.key, })), ]; return (
Workspaces {showGuidance && ( Choose the workspaces this person can open. Their organization roles determine what they can do. )}
normalizeWorkspaceValues(value).length > 0 || 'Assign at least one workspace.', }} render={({ field, fieldState }) => { const fieldWorkspaces = normalizeWorkspaceValues(field.value); // On confirm, apply all three effects together: remove the workspace, clear // defaultWorkspace if it pointed here (else the "default must be assigned" validator // blames the user for something the coupling did), and drop the partner role coding // when this form even has a `code` field (create form; not the Workspaces edit tab). const confirmWorkspaceRemoval = () => { if (!pendingWorkspaceRemoval) return; const { optionKey } = pendingWorkspaceRemoval; field.onChange(fieldWorkspaces.filter((workspace) => workspace !== optionKey)); if (defaultWorkspace === optionKey) { setValue('defaultWorkspace', '', { shouldDirty: true }); } const canonical = getWorkspaceFromFormValue(optionKey); const partnerCode = canonical ? getCoupledOrgRoleCodeForWorkspace(canonical) : undefined; const currentCodes = getValues('code'); if (partnerCode && Array.isArray(currentCodes)) { setValue( 'code', currentCodes.filter((entry: { code?: string }) => entry.code !== partnerCode), { shouldDirty: true }, ); } setPendingWorkspaceRemoval(undefined); }; return ( <>
{workspaceOptions.map((option) => { const checked = fieldWorkspaces.includes(option.key); const locked = lockedWorkspaceKeys.includes(option.key); // The mirror of the org-role picker's coupling note (Task 2): a coupled // workspace shows which org-role coding it auto-assigns, so the pairing is // visible from both sides. Uncoupled workspaces (Admin/Practitioner/Generic) // resolve to undefined and render nothing extra. const canonicalWorkspace = getWorkspaceFromFormValue(option.key); const couplingNote = canonicalWorkspace ? getWorkspaceCouplingNote(canonicalWorkspace) : undefined; // THE PRACTICE'S OWN GRANT, EXPLAINED WHERE THE DECISION IS MADE. // The token mint drops the practitioner workspace for a role this // practice has not granted it to, so ticking the box here would save // an assignment that never opens - the checkbox saying yes while the // product says no, with nothing on screen to reconcile them. // // IT NAMES THE SETTING IN THE WORDS THAT ARE ON THAT SCREEN. The // remedy is four seconds away on Role Permissions; an admin who is // not told where to go re-ticks this box and raises a ticket. // // IT IS THE ORG GRANT, NEVER THE SEAT. A user without a clinician // seat is meant to hold this workspace - that is the whole ruling - // and reading the seat here would weld the two grants back together. const withheld = option.key === PRACTITIONER_WORKSPACE_KEY && !practitionerWorkspaceGranted; // ALREADY TICKED STAYS TICKABLE, so somebody who holds the workspace // from before the grant existed can still take it off. Trapping them // behind a disabled box they cannot clear is a worse version of the // same defect. const withheldAndUnticked = withheld && !checked; // Choosing a tech workspace grants no Lab and imaging work; that is the role's // own permission, so the box stays usable and the admin is told where to set it. const workWithheld = DIAGNOSTICS_WORKSPACE_KEYS.includes(option.key) && !diagnosticsGranted; const baseHelp = locked ? 'Only an administrator can assign this workspace.' : withheld ? `This practice does not give these organization roles the practitioner workspace. Turn on Practitioner workspace for their role under Role Permissions first, or it will not open for them.` : workWithheld ? `Their organization roles don’t include Lab and imaging work, so this workspace will have nothing for them yet. Set Lab and imaging work for their role under Role Permissions.` : undefined; return (
{option.description} {baseHelp ? ` ${baseHelp}` : null} {couplingNote && {couplingNote}} )} checked={checked} disabled={disabled || locked || withheldAndUnticked} onChange={(nextChecked) => { if (locked || withheldAndUnticked) return; if (!nextChecked) { const canonical = getWorkspaceFromFormValue(option.key); const description = canonical ? describeCoupledRemoval({ kind: 'workspace', workspace: canonical }) : undefined; if (description) { setPendingWorkspaceRemoval({ optionKey: option.key, description }); return; } } // Task 4 step 2: ticking a coupled workspace adds its partner role // token immediately, so the create form never shows a workspace // ticked without the role that grants it. Guarded on `code` // existing - the Workspaces tab (StaffWorkspacesPage) has no // `code` field, matching the Array.isArray guard pattern used // elsewhere in this coupling. if (nextChecked) { const canonical = getWorkspaceFromFormValue(option.key); const currentCodes = getValues('code'); if (canonical && Array.isArray(currentCodes)) { const nextCodes = getCodeEntriesAfterWorkspaceCheck( currentCodes, canonical, ); if (nextCodes !== currentCodes) { setValue('code', nextCodes, { shouldDirty: true, shouldValidate: true, }); } } } const nextWorkspaces = nextChecked ? [...fieldWorkspaces, option.key] : fieldWorkspaces.filter( (workspace) => workspace !== option.key, ); field.onChange(nextWorkspaces); }} />
); })}
{fieldState.error && (

{fieldState.error.message}

)} { if (!open) setPendingWorkspaceRemoval(undefined); }} /> ); }} /> selectedWorkspaces.length === 0 || selectedWorkspaces.includes(value) || 'Choose a default workspace from the assigned workspaces.', }} render={({ field, fieldState }) => ( <> {fieldState.error && (

{fieldState.error.message}

)} )} /> {selectedWorkspaces.includes('Generic') && ( <> The Permission-based workspace starts with Profile only. Other sections are added through Role Permissions for this staff member's organization roles. )}
); }; export default StaffWorkspaceAssignment;