diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000000..3d9f36c375 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,44 @@ +# Ward Flow constellation design QA + +## Comparison setup + +- Visual target: `C:\Users\joshs\.codex\generated_images\01a00060-be1a-7252-922f-b9dfc7a496b3\exec-c93f388a-175c-463d-89bd-db70dadc014f.png` +- Implementation: `artifacts/ward-management/ward-constellation-final-1440x1024.png` +- Combined source-and-implementation view: `artifacts/ward-management/design-qa-constellation-comparison.png` +- Source dimensions: 1487 x 1058 pixels, normalized to 1440 x 1024 for comparison. +- Implementation viewport: 1440 x 1024 CSS pixels at density 1. +- Compared state: Flow coordinator, WF-204 selected, Fiona Stanley proposed, placement awaiting authorised confirmation. + +## Fidelity review + +| Surface | Result | Evidence | +| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Typography | Passed | The graphite hierarchy, tabular operational figures, compact labels, and restrained microcopy preserve the reference's clinical command-centre character with stronger scanning contrast. | +| Spacing and layout | Passed | The slim independent priority queue, central statewide network, and right-hand decision rail keep the reference's three-zone structure. The added local mode strip and role-focus band make the broader system legible without crowding the decision surface. | +| Colour and state | Passed | Clinical blue remains the only structural accent. Green, amber, and red are reserved for capacity and exception meaning and are always reinforced by text, rank, or iconography. | +| Assets and icons | Passed | Existing Clinical KB branding and product iconography are used consistently. The constellation remains a schematic operational view rather than implying geographic precision. | +| Copy and data | Passed | Synthetic identifiers, WA service names, catchment, legal status, setting, wait, transport readiness, and capacity evidence are clear. AI is labelled as a best-fit proposal and requires an authorised human confirmation. | + +## Intentional improvements from the source + +1. Replaced literal route-line density with four stable service clusters around a statewide coordination hub. +2. Added eight persistent Ward Flow modes so every major operational task is reachable from the same local navigation. +3. Made the priority queue independently scrollable and kept role-based ordering visible rather than hiding it behind controls. +4. Turned the shortlist into an explainable, ranked proposal with catchment, setting, availability, transport, and wait-time evidence. +5. Added a plain-language no-automatic-allocation boundary directly beside the confirmation action. +6. Kept the full constellation as a dedicated wide command view while preserving the denser command home for routine work. + +## Functional and responsive evidence + +- All eight Ward Flow routes were opened and checked at 1440 x 1024 with no page-level horizontal overflow. +- Flow coordinator, ED clinician, and ward coordinator role changes alter the operational focus and queue order. +- Queue selection, ranked fit review, and authorised confirmation were exercised in the browser. +- The 390 x 844 phone fallback was checked across every mode with no page-level horizontal overflow. +- Fresh browser console on the final constellation route contained no warnings or errors. +- Focused Chromium coverage includes role switching, queue collapse, all mode links, confirmation, phone layout, dark mode, forced colours, and print. + +## Final review + +The implementation retains the reference's visual intent and decision hierarchy while improving system navigation, privacy signalling, role clarity, and AI governance. No P0, P1, or actionable P2 fidelity issue remains. + +final result: passed diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 00387832ee..353e9ae08f 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -67,31 +67,32 @@ Smaller top-level directories that are easy to miss: ### Product pages (`src/app/`) -| Route | File | -| --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `/` | `src/app/(search-app)/page.tsx` | -| Shared mode-home route group (`/(search-app)`) | `src/app/(search-app)/` | -| Mode homes (`/services`, `/dsm`, `/documents/…`, …) | `src/app/(search-app)/` shared shell group | -| `/applications` | `src/app/applications/route.ts` | -| `/differentials`, `/diagnoses`, `/presentations`, `/compare` | `src/app/(search-app)/differentials/` | -| `/dsm`, `/dsm/search`, `/dsm/compare`, `/dsm/diagnoses/[slug]` | `src/app/(search-app)/dsm/` | -| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/(search-app)/documents/` | -| `/factsheets`, `/factsheets/search`, `/factsheets/[slug]` | `src/app/(search-app)/factsheets/` | -| `/dictionary`, Search, Browse, Topics, Definition, Compare, Sources | `src/app/(search-app)/dictionary/` | -| `/favourites` | `src/app/(search-app)/favourites/page.tsx` | -| `/forms`, `/forms/[slug]` | `src/app/(search-app)/forms/` | -| `/medications`, `/medications/[slug]` | `src/app/(search-app)/medications/` | -| `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | -| `/reference/colour-coding` | `src/app/reference/` | -| `/safety-plan` | `src/app/safety-plan/page.tsx` | -| `/calculators` | `src/app/(search-app)/calculators/page.tsx` | -| `/services`, `/services/[slug]` | `src/app/(search-app)/services/` | -| `/therapy-compass` | `src/app/(search-app)/therapy-compass/` | -| `/tools` | `src/app/(search-app)/tools/` | -| `/specifiers`, `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, `/specifiers/map` | `src/app/(search-app)/specifiers/` | -| `/formulation`, `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, `/formulation/map` | `src/app/(search-app)/formulation/` | -| `/mockups/*` | `src/app/mockups/` (404 in production) | -| `/auth/callback` | `src/app/auth/callback/route.ts` | +| Route | File | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `/` | `src/app/(search-app)/page.tsx` | +| Shared mode-home route group (`/(search-app)`) | `src/app/(search-app)/` | +| Mode homes (`/services`, `/dsm`, `/documents/…`, …) | `src/app/(search-app)/` shared shell group | +| `/applications` | `src/app/applications/route.ts` | +| `/differentials`, `/diagnoses`, `/presentations`, `/compare` | `src/app/(search-app)/differentials/` | +| `/dsm`, `/dsm/search`, `/dsm/compare`, `/dsm/diagnoses/[slug]` | `src/app/(search-app)/dsm/` | +| `/documents/search`, `/source`, `/evidence`, `/[id]` | `src/app/(search-app)/documents/` | +| `/factsheets`, `/factsheets/search`, `/factsheets/[slug]` | `src/app/(search-app)/factsheets/` | +| `/dictionary`, Search, Browse, Topics, Definition, Compare, Sources | `src/app/(search-app)/dictionary/` | +| `/favourites` | `src/app/(search-app)/favourites/page.tsx` | +| `/forms`, `/forms/[slug]` | `src/app/(search-app)/forms/` | +| `/medications`, `/medications/[slug]` | `src/app/(search-app)/medications/` | +| `/privacy` | `src/app/privacy/page.tsx` → `privacy-quiet-signal-page.tsx` + `privacy-page-content.tsx` | +| `/reference/colour-coding` | `src/app/reference/` | +| `/safety-plan` | `src/app/safety-plan/page.tsx` | +| `/calculators` | `src/app/(search-app)/calculators/page.tsx` | +| `/services`, `/services/[slug]` | `src/app/(search-app)/services/` | +| `/therapy-compass` | `src/app/(search-app)/therapy-compass/` | +| `/tools` | `src/app/(search-app)/tools/` | +| `/specifiers`, `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, `/specifiers/map` | `src/app/(search-app)/specifiers/` | +| `/formulation`, `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, `/formulation/map` | `src/app/(search-app)/formulation/` | +| `/ward-management`, `/constellation`, `/network`, `/queue`, `/capacity`, `/movements`, `/exceptions`, `/transport`, `/governance`, `/patients/[patientId]` | `src/app/ward-management/` — Ward Flow synthetic patient-flow prototype | +| `/mockups/*` | `src/app/mockups/` (404 in production) | +| `/auth/callback` | `src/app/auth/callback/route.ts` | ### API routes (`src/app/api/`) @@ -323,6 +324,25 @@ sequenceDiagram - Registry modes: services, forms, medications, differentials; Formulation is a local mechanism and structured-draft workspace - Demo mode: synthetic data when Supabase unavailable (`demo-data.ts`, `isDemoMode()` in `env.ts`) +### Ward Flow (`src/app/ward-management/`, `src/components/ward-management/`) + +Synthetic prototype for WA metro psychiatry patient flow: getting a patient from an emergency +department to an inpatient psychiatric bed. Offline and fixture-backed — no provider calls, no +persistence, no patient-identifiable data. Advisory only: the system proposes destinations with +visible reasons and a human confirms or overrides. + +- **Design spec:** `docs/superpowers/specs/2026-08-18-ward-flow-metro-patient-flow-design.md` +- **Glossary:** `docs/ward-management-context.md` · **Decisions:** `docs/ward-management-decisions.md` +- **Route/role map:** `docs/ward-management-mode-map.md` +- **Model:** `src/components/ward-management/ward-clock.ts` (the only module that reads the + wall clock), `ward-model.ts` (domain types only), `ward-eligibility.ts` (the eight + placement gates), `ward-sites.ts` (17 sites, 8 emergency departments, 22 units), + `ward-movements.ts` (48 movements, 6 bed releases), `ward-derivations.ts` (shared pure UI + derivations) +- **Surfaces:** `ward-management-console.tsx` (command), `ward-management-modes.tsx` (mode + workspaces), `ward-management-network.tsx` (network diagram), `ward-management-navigation.tsx` +- **Tests:** `tests/ward-management.test.ts`, `tests/ui-ward-management.spec.ts` + ### Global search composer placement rules One shared composer (`master-search-header.tsx`) serves every mode. Placement: diff --git a/docs/design-system/ADOPTION.md b/docs/design-system/ADOPTION.md index 1d15bb25a2..68f9a634f0 100644 --- a/docs/design-system/ADOPTION.md +++ b/docs/design-system/ADOPTION.md @@ -334,15 +334,13 @@ chrome differs between them. This PR records executable evidence rather than committing image baselines. The generated adoption manifest remains `baseline: not-committed`, and no Playwright snapshot PNG is an adoption claim. -| Surface | Current adopted evidence | Focused owner/check | -| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| forms | `FormField` is mounted by `TextField`/`Select`/`Checkbox`/`RadioGroup`; required labels carry a text marker and optional labels remain unmarked; validation errors associate via `aria-describedby`; form state is preserved across back-navigation. | `tests/ui-v2-form-field.dom.test.tsx`, `tests/forms-back-navigation.dom.test.tsx` | -| catalogues | Shared search, sort, and filter action bars across Differentials, Formulations, and Specifiers are standardized under ``; shared mode status notices delegate to DS `EmptyState`; therapy loading/empty wrappers delegate to `LoadingPanel`/`EmptyState`; differential and favourites chip wrappers delegate to DS `Chip` while retaining their surface-specific density/tone mapping. | `tests/design-system-target-evidence.test.ts`, `tests/catalogue-toolbar.dom.test.tsx`, `tests/mode-home-status-notice.dom.test.tsx` | -| docs | `DocumentFrame` serves as single canonical viewing chrome across document details and viewer surfaces; document section nav supports keyboard & anchor navigation; document search filter shelf and clinical summary toggle are integrated. | `tests/document-section-nav.dom.test.tsx`, `tests/document-frame-contract.test.ts` | -| provenance | `DoseLine` and `AnswerFooter` wire source provenance badges (`SourceDesignationBadge`, `SourceStatusBadge`, `SourceProvenance`) so dose rows, guideline citations, and answer footer strips display source authority and currency cleanly when metadata is present in the payload. | `tests/dose-line.dom.test.tsx`, `tests/ui-v2-answer-safety.dom.test.tsx` | -| overlays | `Sheet` portals to `OverlayRoot`'s modal host by default. Settings, the mobile Clinical Guide sidebar, and the three answer-review sheets use that default; none opts out with `portal={false}`. | `tests/sheet.dom.test.tsx`, `tests/design-system-target-evidence.test.ts` | -| answer | The three product copy paths share the payload builder described in §2.6; answer-review overlays retain their existing content, dismissal, and focus-return props while using the portal default. | `tests/answer-copy-payload.test.ts`, `tests/answer-clipboard-product-path.dom.test.tsx` | -| cross-mode links | `responsive-compact` deliberately mounts a phone chip rail and an `md+` card rail so SSR and hydration agree. CSS makes only one rail visible/in the accessibility tree; selectors and analytics must target the variant rail, while `cross-mode-links-rail` remains the phone-only contract. | `tests/design-system-target-evidence.test.ts`, focused `tests/ui-smoke.spec.ts` CrossModeLinks journeys | +| Surface | Current adopted evidence | Focused owner/check | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| forms | `FormField` is mounted by `TextField`/`Select`; required labels carry a text marker and optional labels remain unmarked. | `tests/ui-v2-form-field.dom.test.tsx` | +| headers/catalogues | Shared mode status notices now delegate to DS `EmptyState`; therapy loading/empty wrappers delegate to `LoadingPanel`/`EmptyState`; differential and favourites chip wrappers delegate to DS `Chip` while retaining their surface-specific density/tone mapping. | `tests/design-system-target-evidence.test.ts`, `tests/mode-home-status-notice.dom.test.tsx` | +| overlays | `Sheet` portals to `OverlayRoot`'s modal host by default. Settings, the mobile Clinical Guide sidebar, and the three answer-review sheets use that default; none opts out with `portal={false}`. | `tests/sheet.dom.test.tsx`, `tests/design-system-target-evidence.test.ts` | +| answer | The three product copy paths share the payload builder described in §2.6; answer-review overlays retain their existing content, dismissal, and focus-return props while using the portal default. | `tests/answer-copy-payload.test.ts`, `tests/answer-clipboard-product-path.dom.test.tsx` | +| cross-mode links | `responsive-compact` deliberately mounts a phone chip rail and an `md+` card rail so SSR and hydration agree. CSS makes only one rail visible/in the accessibility tree; selectors and analytics must target the variant rail, while `cross-mode-links-rail` remains the phone-only contract. | `tests/design-system-target-evidence.test.ts`, focused `tests/ui-smoke.spec.ts` CrossModeLinks journeys | **Residual evidence requirement.** Static and DOM checks establish portal ownership and preserve focus-return inputs, but they do not prove every nested overlay's keyboard sequence in a real browser. @@ -361,12 +359,12 @@ product exclusions; the only route-only disposition is the documented legacy doc redirect. Shared shell/component roots carry their own explicit `shared-shell` disposition. Registered public components: 54 -Declared product roots: 67 +Declared product roots: 82 Roots with a literal `.ckb-v2` opt-in: 1 -Roots inheriting `.ckb-v2` from the global ``: 66 -Production surfaces observed under v2: 14/14 +Roots inheriting `.ckb-v2` from the global ``: 81 +Production surfaces observed under v2: 15/15 Dynamic `ckb-v2` constructions: 0 -Declared production page routes: 59/59 +Declared production page routes: 69/69 Source observation and contract declaration are independent. A literal `ckb-v2` on the global `` makes every production surface inherit v2, but it does not approve that adoption. The Proof column summarizes each surface's dark, forced-colours, 320px, print and browser declarations; exact statuses and evidence paths live in the manifest. @@ -384,6 +382,7 @@ Observed v2 under a compatibility declaration fails closed. A declared v2 shell | `documents-source-legacy-redirect` | legacy-redirect | 1 | 0 | v2 | v2 (inherited-global-root) | not-applicable | not-applicable | | `favourites` | owned | 1 | 1 | v2 | v2 (inherited-global-root) | passed | not-committed | | `tools-and-calculators` | owned | 2 | 2 | v2 | v2 (inherited-global-root) | passed | not-committed | +| `ward-management` | owned | 10 | 15 | v2 | v2 (inherited-global-root) | passed | not-committed | | `privacy-safety-and-reference` | owned | 3 | 3 | v2 | v2 (inherited-global-root) | passed | not-committed | | `search-results-shared` | shared-shell | 0 | 1 | v2 | v2 (inherited-global-root) | passed | not-committed | | `answers-shared` | shared-shell | 0 | 2 | v2 | v2 (inherited-global-root) | passed | not-committed | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 5951d5361d..3bbbb0a48b 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -1001,7 +1001,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 9 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 26 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 4 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-contract.json b/docs/design-system/adoption-contract.json index db2af5e638..71e9293e0e 100644 --- a/docs/design-system/adoption-contract.json +++ b/docs/design-system/adoption-contract.json @@ -494,6 +494,68 @@ }, "sanctionedSpecialPatterns": ["ToolsSearchResultsPage", "CalculatorsSearchPage"] }, + { + "id": "ward-management", + "disposition": "owned", + "routes": [ + "src/app/ward-management/page.tsx", + "src/app/ward-management/capacity/page.tsx", + "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/exceptions/page.tsx", + "src/app/ward-management/governance/page.tsx", + "src/app/ward-management/movements/page.tsx", + "src/app/ward-management/network/page.tsx", + "src/app/ward-management/patients/[patientId]/page.tsx", + "src/app/ward-management/queue/page.tsx", + "src/app/ward-management/transport/page.tsx" + ], + "routeRoots": true, + "roots": [ + "src/components/ward-management/coordinator/coordinator-screen.tsx", + "src/components/ward-management/ward-management-console.tsx", + "src/components/ward-management/ward-management-modes.tsx", + "src/components/ward-management/ward-management-navigation.tsx", + "src/components/ward-management/ward-management-network.tsx" + ], + "permittedComponentFamilies": ["controls", "feedback", "layout"], + "expectedShellState": "v2", + "proof": { + "dark": { + "status": "passed", + "evidence": ["tests/ui-style-contract.spec.ts"] + }, + "forcedColours": { + "status": "passed", + "evidence": ["tests/ckb-v2-token-contract.test.ts"] + }, + "compact320": { + "status": "passed", + "evidence": ["tests/ui-smoke.spec.ts"] + }, + "print": { + "status": "passed", + "evidence": ["tests/global-v2-activation.test.ts"] + }, + "browser": { + "status": "passed", + "evidence": ["tests/ui-visual-baseline.spec.ts"] + } + }, + "baseline": { + "status": "not-committed", + "files": [] + }, + "sanctionedSpecialPatterns": [ + "CoordinatorScreen", + "WardManagementConsole", + "PatientMovementWorkspace", + "WardModeWorkspace", + "WardModeNavigation", + "ClinicalRail", + "WardNetworkWorkspace" + ], + "documentedDisposition": "Ward Flow is a synthetic operational coordination surface with a command home, full-screen constellation, role-aware queue-first phone fallback, linked operational workspaces, and an explainable human-confirmed destination workflow." + }, { "id": "privacy-safety-and-reference", "disposition": "owned", diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index b80800725b..37bd61e35a 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1572,7 +1572,8 @@ "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", "src/components/tools/tools-search-results-page.tsx", - "src/components/ui/confirm-dialog.tsx" + "src/components/ui/confirm-dialog.tsx", + "src/components/ward-management/ward-management-console.tsx" ], "productImportFiles": [ "src/components/AccessibleTable.tsx", @@ -1599,7 +1600,8 @@ "src/components/forms/form-detail-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", - "src/components/tools/tools-search-results-page.tsx" + "src/components/tools/tools-search-results-page.tsx", + "src/components/ward-management/ward-management-console.tsx" ], "designSync": { "listedInSourceMap": true, @@ -3206,6 +3208,225 @@ } ] }, + { + "id": "ward-management", + "disposition": "owned", + "documentedDisposition": "Ward Flow is a synthetic operational coordination surface with a command home, full-screen constellation, role-aware queue-first phone fallback, linked operational workspaces, and an explainable human-confirmed destination workflow.", + "routes": [ + "src/app/ward-management/capacity/page.tsx", + "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/exceptions/page.tsx", + "src/app/ward-management/governance/page.tsx", + "src/app/ward-management/movements/page.tsx", + "src/app/ward-management/network/page.tsx", + "src/app/ward-management/page.tsx", + "src/app/ward-management/patients/[patientId]/page.tsx", + "src/app/ward-management/queue/page.tsx", + "src/app/ward-management/transport/page.tsx" + ], + "routeRoots": true, + "proofApplicability": "required", + "nonVisualRoute": null, + "declaredShellState": "v2", + "observedShellState": "v2", + "v2ShellMounted": true, + "v2MountMode": "inherited-global-root", + "inheritedFrom": "src/app/layout.tsx", + "directV2MountFiles": [], + "proof": { + "browser": { + "status": "passed", + "evidence": ["tests/ui-visual-baseline.spec.ts"] + }, + "compact320": { + "status": "passed", + "evidence": ["tests/ui-smoke.spec.ts"] + }, + "dark": { + "status": "passed", + "evidence": ["tests/ui-style-contract.spec.ts"] + }, + "forcedColours": { + "status": "passed", + "evidence": ["tests/ckb-v2-token-contract.test.ts"] + }, + "print": { + "status": "passed", + "evidence": ["tests/global-v2-activation.test.ts"] + } + }, + "baseline": { + "status": "not-committed", + "files": [] + }, + "permittedComponentFamilies": ["controls", "feedback", "layout"], + "sanctionedSpecialPatterns": [ + "ClinicalRail", + "CoordinatorScreen", + "PatientMovementWorkspace", + "WardManagementConsole", + "WardModeNavigation", + "WardModeWorkspace", + "WardNetworkWorkspace" + ], + "roots": [ + { + "file": "src/app/ward-management/capacity/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/constellation/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/exceptions/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/governance/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/movements/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/network/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["CoordinatorScreen"] + }, + { + "file": "src/app/ward-management/patients/[patientId]/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [] + }, + { + "file": "src/app/ward-management/queue/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/app/ward-management/transport/page.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeWorkspace"] + }, + { + "file": "src/components/ward-management/coordinator/coordinator-screen.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["CoordinatorScreen", "WardModeNavigation", "ClinicalRail"] + }, + { + "file": "src/components/ward-management/ward-management-console.tsx", + "exists": true, + "imports": ["Sheet"], + "importedFamilies": ["layout"], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardManagementConsole", "WardModeNavigation", "ClinicalRail"] + }, + { + "file": "src/components/ward-management/ward-management-modes.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": [ + "WardModeWorkspace", + "WardModeNavigation", + "ClinicalRail", + "WardNetworkWorkspace" + ] + }, + { + "file": "src/components/ward-management/ward-management-navigation.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardModeNavigation", "ClinicalRail"] + }, + { + "file": "src/components/ward-management/ward-management-network.tsx", + "exists": true, + "imports": [], + "importedFamilies": [], + "literalCkbV2": false, + "dynamicCkbV2": false, + "v2MountMode": "inherited-global-root", + "sanctionedPatternsPresent": ["WardNetworkWorkspace"] + } + ] + }, { "id": "privacy-safety-and-reference", "disposition": "owned", @@ -3521,7 +3742,17 @@ "src/app/(search-app)/tools/page.tsx", "src/app/privacy/page.tsx", "src/app/reference/colour-coding/page.tsx", - "src/app/safety-plan/page.tsx" + "src/app/safety-plan/page.tsx", + "src/app/ward-management/capacity/page.tsx", + "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/exceptions/page.tsx", + "src/app/ward-management/governance/page.tsx", + "src/app/ward-management/movements/page.tsx", + "src/app/ward-management/network/page.tsx", + "src/app/ward-management/page.tsx", + "src/app/ward-management/patients/[patientId]/page.tsx", + "src/app/ward-management/queue/page.tsx", + "src/app/ward-management/transport/page.tsx" ], "declared": [ "src/app/(search-app)/calculators/page.tsx", @@ -3582,7 +3813,17 @@ "src/app/(search-app)/tools/page.tsx", "src/app/privacy/page.tsx", "src/app/reference/colour-coding/page.tsx", - "src/app/safety-plan/page.tsx" + "src/app/safety-plan/page.tsx", + "src/app/ward-management/capacity/page.tsx", + "src/app/ward-management/constellation/page.tsx", + "src/app/ward-management/exceptions/page.tsx", + "src/app/ward-management/governance/page.tsx", + "src/app/ward-management/movements/page.tsx", + "src/app/ward-management/network/page.tsx", + "src/app/ward-management/page.tsx", + "src/app/ward-management/patients/[patientId]/page.tsx", + "src/app/ward-management/queue/page.tsx", + "src/app/ward-management/transport/page.tsx" ], "undeclared": [], "missing": [], @@ -3590,17 +3831,17 @@ }, "adoption": { "literalCkbV2RootCount": 1, - "inheritedCkbV2RootCount": 66, + "inheritedCkbV2RootCount": 81, "dynamicCkbV2RootCount": 0, - "v2MountedSurfaceCount": 14, - "declaredV2SurfaceCount": 14 + "v2MountedSurfaceCount": 15, + "declaredV2SurfaceCount": 15 }, "summary": { "registeredComponentCount": 54, "previewCount": 54, "productImportedComponentCount": 34, - "rootCount": 67, - "productionRouteCount": 59, - "nextUiEntryCount": 112 + "rootCount": 82, + "productionRouteCount": 69, + "nextUiEntryCount": 122 } } diff --git a/docs/site-map.md b/docs/site-map.md index 2f546effe1..d27de43d4e 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -53,6 +53,16 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/therapy-compass/review` - Therapy records awaiting qualified-clinician source review. Source: `src/app/(search-app)/therapy-compass/review/page.tsx`. - `/therapy-compass/search` - Therapy library search surface. Source: `src/app/(search-app)/therapy-compass/search/page.tsx`. - `/tools` - Route discovered from app directory Source: `src/app/(search-app)/tools/page.tsx`. +- `/ward-management` - Route discovered from app directory Source: `src/app/ward-management/page.tsx`. +- `/ward-management/capacity` - Route discovered from app directory Source: `src/app/ward-management/capacity/page.tsx`. +- `/ward-management/constellation` - Route discovered from app directory Source: `src/app/ward-management/constellation/page.tsx`. +- `/ward-management/exceptions` - Route discovered from app directory Source: `src/app/ward-management/exceptions/page.tsx`. +- `/ward-management/governance` - Route discovered from app directory Source: `src/app/ward-management/governance/page.tsx`. +- `/ward-management/movements` - Route discovered from app directory Source: `src/app/ward-management/movements/page.tsx`. +- `/ward-management/network` - Route discovered from app directory Source: `src/app/ward-management/network/page.tsx`. +- `/ward-management/patients/[patientId]` - Route discovered from app directory Source: `src/app/ward-management/patients/[patientId]/page.tsx`. +- `/ward-management/queue` - Route discovered from app directory Source: `src/app/ward-management/queue/page.tsx`. +- `/ward-management/transport` - Route discovered from app directory Source: `src/app/ward-management/transport/page.tsx`. ## Mode/query routes diff --git a/docs/superpowers/plans/2026-08-14-ward-management-mockups.md b/docs/superpowers/plans/2026-08-14-ward-management-mockups.md new file mode 100644 index 0000000000..675c189233 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-ward-management-mockups.md @@ -0,0 +1,202 @@ +# Ward Management Mockup Generation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: `superpowers:executing-plans` (inline execution). Use Product Design `ideate` for image directions. Do not use subagents for this plan. + +**Goal:** Produce and validate three independent desktop visual directions for the approved synthetic WA mental-health ward-management command map, then create responsive and role-specific companions only after the user selects a direction. + +**Architecture:** Visual discovery is separate from product implementation. The approved specification fixes the clinical workflow, information architecture, responsive behaviour, state semantics, and safety boundaries. Image generation may vary visual hierarchy, density, and interaction treatment, but it may not vary those approved product contracts. No production source code is changed until a visual target is selected and a separate production implementation plan is approved. + +**Tech stack:** Clinical KB design tokens and shared shell as visual references; Product Design `ideate`; built-in Image Generation; local Clinical KB preview for source capture; synthetic fixtures only. + +## Global constraints + +- Approved source of truth: `docs/superpowers/specs/2026-08-14-ward-management-design.md`. +- Planned production route: `/ward-management`; do not create it during image ideation. +- Desktop foundation: schematic hospital network grouped by North, East, South, and Country coordinating services. +- Phone foundation: role-specific operational queue; `Hospitals` and `Map` are secondary views. +- Roles: flow coordinator, ED team, ward manager. +- Cohorts: general adult and older adult. +- Data: visibly synthetic; no real names, MRNs, dates of birth, addresses, contacts, narrative histories, or real-time bed counts. +- Bed states: occupied, allocatable now, held, potential after action, blocked/unavailable. +- Movement stages: placement requested, destination review, accepted/bed held, handover and transport ready, moving, arrived/closed. +- Matching: catchment first, explainable statewide escalation, human confirmation required. +- Priority: human urgency tier first; algorithmic score only orders within a tier and exposes its factors. +- Legal state: plain language first with form and timing detail available. +- Visual system: Clinical White / Sky Graphite; graphite commands, clinical-blue focus/movement, green ready/complete only, amber held/potential/time-sensitive, red blocked/overdue only. +- Do not use colour as the sole state signal. +- Current-date anchor for every generated concept: 14 August 2026, Australia/Perth. +- Do not deploy, publish, call live clinical providers, or use patient-identifiable information. + +## Artifact map + +### Existing sources to inspect + +- `docs/superpowers/specs/2026-08-14-ward-management-design.md` — approved product and safety contract. +- `docs/design-system.md` — current reusable design-system rules. +- `docs/redesign/02-design-direction.md` — Clinical White / Sky Graphite rationale. +- `docs/search-chrome-behaviour.md` — shared responsive shell and phone-chrome ownership. +- `src/app/globals.css` — implemented role tokens and typography. +- `src/components/clinical-dashboard/global-search-shell.tsx` — production shell owner. +- `src/app/mockups/layout.tsx` and `src/app/mockups/mockups-layout-client.tsx` — design-scratch route ownership for any later coded prototype. + +### Source captures outside Git + +- `C:/Users/joshs/.codex/visualizations/2026/08/14/01a00060-be1a-7252-922f-b9dfc7a496b3/ward-management/sources/clinical-kb-shell-desktop.png` +- `C:/Users/joshs/.codex/visualizations/2026/08/14/01a00060-be1a-7252-922f-b9dfc7a496b3/ward-management/sources/clinical-kb-shell-phone.png` + +### Generated visual set + +- Three independent `1440 × 1024` desktop coordinator concepts, shown in the conversation as separate generated images. +- After selection: one refined desktop coordinator target, one `390 × 844` phone coordinator target, and representative ED and ward-manager companion screens. +- Generated images stay outside the Git worktree unless the user later approves a coded mockup or asset package. + +### Possible later coded mockup files — not part of this plan's execution + +- `src/app/mockups/ward-management/page.tsx` +- `src/components/ward-management-mockups/ward-management-mockup-page.tsx` +- `src/components/ward-management-mockups/synthetic-fixtures.ts` +- `tests/ward-management-mockup.contract.test.ts` + +These paths are reserved only to make the next production or coded-prototype plan concrete. Do not create them during image ideation. + +## Task 1: Capture the current Clinical KB visual source + +**Files** + +- Read: the existing sources listed in the artifact map. +- Create outside Git: the two source captures listed above. + +**Produces** + +- A desktop and phone reference showing the real Clinical KB shell, typography, surfaces, spacing, header behaviour, and role-token use. +- A brief visual-source checklist recorded in the execution notes: shell, typography, colour roles, radius, border, density, and phone behaviour. + +**Steps** + +- [ ] Confirm the isolated worktree remains on `codex/ward-management-design` and contains no unrelated changes. +- [ ] Run `node scripts/setup-codex-worktree.mjs` so the isolated worktree has a lockfile-identical dependency runtime. +- [ ] Run `npm run workflow:design-sweep -- --write-evidence` and retain its evidence path. +- [ ] Run `npm run ensure`; use only the URL it prints. +- [ ] Verify `/api/local-project-id` identifies this Clinical KB checkout before capture. +- [ ] Capture the production shell at `1440 × 1024` and `390 × 844` using synthetic/demo content. +- [ ] Inspect both captures directly; do not infer design details from filenames. +- [ ] Confirm neither capture contains patient-identifiable or secret-bearing data. + +**Gate** + +- Both source images exist, open successfully, match the specified dimensions, and visibly represent the current Clinical KB design system. + +## Task 2: Generate three independent desktop coordinator directions + +**Consumes** + +- Approved specification. +- Both inspected source captures. +- Global constraints in this plan. + +**Produces** + +- Three separate Image Generation results at `1440 × 1024`. +- Each result is a focused primary coordinator screen, not a feature inventory. + +**Direction briefs** + +### Signal Spine + +- Schematic HSP columns form a quiet central network spine. +- Priority movement and action inbox occupy one restrained side rail. +- Highest glanceability and greatest whitespace. + +### Network Ledger + +- Hospitals read as a structured operational ledger connected by selective movement lines. +- Strongest comparison of demand, bed state, ownership, and last-confirmed time. +- Highest information density without turning every row into a card. + +### Operational Constellation + +- Coordinating-service groups form a more spatial network with selected movement paths. +- A contextual patient-to-bed panel appears only for the selected synthetic patient. +- Strongest visual sense of statewide coordination while remaining schematic rather than geographic. + +**Steps** + +- [ ] Read Product Design `ideate` and Image Generation instructions before the first generation. +- [ ] Attach both actual source captures to every generation; do not claim they were attached unless the tool receives them. +- [ ] Generate `Signal Spine` as one independent image call. +- [ ] Generate `Network Ledger` as one independent image call. +- [ ] Generate `Operational Constellation` as one independent image call. +- [ ] Do not batch the three calls or place several concepts in one image. +- [ ] Use the exact current-date anchor, approved workflow states, and clearly synthetic data in every prompt. +- [ ] Ensure every concept shows the flow-coordinator role, four coordinating-service groups, five bed states, a priority movement, one visible action exception, catchment-first/statewide escalation, and last-confirmed time. +- [ ] Ensure no concept shows automatic allocation, a clinical severity score, real patient data, literal WA travel distance as the matching rule, or a phone device bezel. + +**Gate** + +- Exactly three generated images are visible once each in the main conversation. +- No screen is clipped, illegible, crowded, or inconsistent with Clinical KB's source captures. +- Every state remains understandable without colour alone. + +## Task 3: Stop for visual selection + +**Consumes** + +- The three visible generated-image results in their displayed order. + +**Produces** + +- One user-selected direction or a precise combination/revision brief. + +**Steps** + +- [ ] Number options only by their displayed order after all three image results are visible. +- [ ] Ask only: `Which option should I build: 1, 2, or 3? Or tell me what you'd like to refine or personalize first.` +- [ ] Do not describe or rename the options in the selection message. +- [ ] Stop. Do not generate companion screens or write code until the user selects or requests a revision. + +**Gate** + +- The selected visual target is unambiguous. If the user combines directions, generate and obtain approval for the combined target before continuing. + +## Task 4: Generate the selected responsive and role companions + +**Runs only after Task 3 approval.** + +**Produces** + +- One refined desktop coordinator target at `1440 × 1024`. +- One phone coordinator target at `390 × 844` using the approved role-first queue. +- One representative ED-team screen. +- One representative ward-manager screen. +- One patient-movement detail treatment shared across roles. + +**Steps** + +- [ ] Resolve the selected target against the displayed result, never the original request order. +- [ ] Carry forward the selected typography, spacing, network treatment, state language, and interaction hierarchy. +- [ ] Keep the phone default queue-first; Map remains secondary. +- [ ] Preserve role ownership: ED readiness/referral/legal/handover, ward capacity/release/acceptance/hold, coordinator match/escalation/movement. +- [ ] Check all outputs against the specification's acceptance criteria. +- [ ] Stop for user approval of the complete visual set. + +## Task 5: Hand off to production planning + +**Runs only after the complete visual set is approved.** + +**Produces** + +- A separate, repository-grounded production implementation plan with exact source files, interfaces, tests, accessibility evidence, privacy controls, and rollout boundaries. + +**Steps** + +- [ ] Do not treat image approval as production-readiness evidence. +- [ ] Reinspect current `origin/main`, applicable Next 16 local documentation, shared shell ownership, app-mode registration, route reachability, and design-system contracts. +- [ ] Plan a synthetic fixture-backed slice before any real integration or patient-data work. +- [ ] Keep real clinical integrations, RBAC, algorithm validation, privacy assessment, and production governance as separately authorised work. + +## Plan verification + +- [ ] Run the standard specification placeholder scan and resolve every hit. +- [ ] Run `git diff --check`. +- [ ] Run Prettier against this plan with a byte-identical lockfile installation. +- [ ] Confirm this plan changes documentation only and leaves tests, builds, providers, commits, and pushes unrun. diff --git a/docs/superpowers/plans/2026-08-18-ward-flow-model-and-modes.md b/docs/superpowers/plans/2026-08-18-ward-flow-model-and-modes.md new file mode 100644 index 0000000000..4be01810e5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-ward-flow-model-and-modes.md @@ -0,0 +1,972 @@ +# Ward Flow model correction and missing modes — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the four model defects that let Ward Flow propose an unlawful or impossible placement, then add the four modes the WA pathway needs but the prototype cannot currently express. + +**Architecture:** Ward Flow is a synthetic, offline product wireframe: one fixture module (`synthetic-fixtures.ts`) feeds every route, each route is a thin server component that renders a client workspace, and every workspace is proved by one Vitest contract test plus one Chromium journey. This plan keeps that shape. Model corrections land first as fixture + type changes with contract tests, because every new mode reads the corrected model. New modes follow, one route per task. No mode performs an allocation; all remain advisory with human confirmation, per ADR 2. + +**Tech Stack:** Next.js 16 App Router (server component route → `"use client"` workspace), React 19, TypeScript 6 strict, CSS Modules with local `--net-*`/`--ward-*` token scales, Vitest for contracts, Playwright Chromium for journeys. + +**Spec:** [`docs/ward-management-mode-map.md`](../../ward-management-mode-map.md) (route/role model), [`docs/ward-management-context.md`](../../ward-management-context.md) (glossary), [`docs/ward-management-decisions.md`](../../ward-management-decisions.md) (ADRs 1–3) + +## Global Constraints + +- **Synthetic only.** No record may carry name, date of birth, MRN, address, diagnosis, narrative history or treatment. `tests/ward-management.test.ts` asserts absence of those keys; extend that assertion, never relax it. +- **Advisory only.** No surface may allocate, auto-accept, or default after a timeout (ADR 2). Every placement control is a confirm/override pair. +- **No provider calls.** Everything runs offline against fixtures. Never add a fetch, Supabase call, or OpenAI call to any Ward Flow file. +- **Design tokens only.** No raw hex, and no raw padding/gap/z-index/line-height literals in CSS Modules — declare a local token in the module's root block first. `npm run check:design-system-contract` ratchets these counts and fails on any increase. +- **Tap targets are `3rem` (48px) minimum** on every interactive element. Never reduce to `2.75rem` to satisfy generic WCAG advice. +- **Button wiring.** Every ` +

+ ) : null} + + + + +
+
+

Statewide flow

+ {selectedUnitId ? ( + // Task 6 renders the real network diagram and calls `setSelectedUnitId` from a + // unit node click; this button is the one interaction this stub owns itself — + // clearing a selection made elsewhere (e.g. from a shortlist row) without + // requiring the not-yet-built diagram to be present. + + ) : null} +
+

+ {selectedUnitId + ? `Network diagram centred on unit ${selectedUnitId}. Built in a later task.` + : "Network diagram of the hospital network. Built in a later task."} +

+
+ +
+ +
+ + + +
+ + {exceptionsOpen ?

Exceptions inbox. Built in a later task.

: null} +
+ + + ); +} diff --git a/src/components/ward-management/coordinator/coordinator.module.css b/src/components/ward-management/coordinator/coordinator.module.css new file mode 100644 index 0000000000..2ae17761b5 --- /dev/null +++ b/src/components/ward-management/coordinator/coordinator.module.css @@ -0,0 +1,387 @@ +.screen { + --co-space-2: 0.125rem; + --co-space-6: 0.375rem; + --co-space-8: 0.5rem; + --co-space-10: 0.625rem; + --co-space-12: 0.75rem; + --co-space-14: 0.875rem; + --co-space-20: 1.25rem; + --co-space-48: 3rem; + --co-leading-compact: 1.1; + --co-leading-tight: 1.2; + --co-leading-body: 1.4; + --co-leading-prose: 1.5; + display: grid; + height: 100dvh; + min-height: 44rem; + grid-template-columns: 4.5rem minmax(0, 1fr); + overflow: hidden; + background: var(--surface); + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.main { + grid-column: 2; + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; +} + +/* + * `ClinicalRail` (from the shared ward-management.module.css) sets `display: none` on itself at + * <=40rem, but that only removes the element — a fixed `4.5rem` track in `.screen`'s own + * grid-template-columns still reserves the width regardless of what occupies it, leaving a + * blank 72px band down the left edge of the narrowest supported layout. Collapse to one column + * at the same breakpoint the rail already hides at. + */ +@media (max-width: 40rem) { + .screen { + grid-template-columns: minmax(0, 1fr); + } + + .main { + grid-column: 1; + } +} + +.governanceBanner { + display: flex; + min-height: var(--co-space-48); + flex-wrap: wrap; + align-items: center; + gap: var(--co-space-12); + border-bottom: 0.0625rem solid var(--border); + background: var(--surface-chrome); + padding: var(--co-space-8) var(--co-space-20); +} + +.governanceBanner p { + min-width: 0; + flex: 1 1 12rem; + margin: 0; + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: var(--co-leading-body); +} + +.governanceBanner strong { + color: var(--text-heading); +} + +.prototypeBadge { + display: inline-flex; + min-height: 1.5rem; + flex: 0 0 auto; + align-items: center; + gap: var(--co-space-6); + border: 0.0625rem solid var(--clinical-accent-border); + border-radius: var(--radius-pill, 9999px); + background: var(--clinical-accent-soft); + padding: var(--co-space-2) var(--co-space-8); + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 680; + line-height: var(--co-leading-compact); + white-space: nowrap; +} + +.body { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--co-space-10); + overflow: auto; + padding: var(--co-space-12) var(--co-space-20); + background: var(--surface-subtle); +} + +.pressureStrip, +.queueRegion, +.diagramRegion, +.shortlistRegion { + min-width: 0; + border: 0.0625rem solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--e0); +} + +.pressureStrip { + padding: var(--co-space-12) var(--co-space-14); +} + +.regionHeader { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--co-space-12); + margin-bottom: var(--co-space-8); +} + +.regionHeader h2 { + color: var(--text-heading); + font-size: var(--text-sm); + font-weight: 700; + line-height: var(--co-leading-tight); +} + +.regionCount { + color: var(--text-muted); + font-size: var(--text-3xs); + font-weight: 650; + white-space: nowrap; +} + +.clearSelectionButton { + display: inline-flex; + min-height: var(--co-space-48); + flex: 0 0 auto; + align-items: center; + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: 0 var(--co-space-12); + color: var(--clinical-accent); + font-size: var(--text-3xs); + font-weight: 650; +} + +/* + * Grid, not the old flex/overflow-x strip: a fixed per-card min-width plus `overflow-x: auto` + * fit about five and a half of eight departments at 1600px and sliced the sixth mid-word behind + * a scroller (Task 4 ruling 1 — every department must be visible at once at that width, since + * the strip's only job is a one-second read). `auto-fit` sizes eight columns comfortably at + * 1600px and wraps onto more rows as the region narrows, without ever clipping a card. + */ +.pressureList { + display: grid; + min-width: 0; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: var(--co-space-8); +} + +.pressureCard, +.pressureCardSelected { + display: grid; + width: 100%; + min-width: 0; + min-height: var(--co-space-48); + align-content: start; + gap: var(--co-space-2); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + padding: var(--co-space-8) var(--co-space-10); + background: var(--surface-subtle); + color: var(--text); + text-align: left; +} + +.pressureCard:hover { + background: var(--surface); +} + +.pressureCardSelected { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + box-shadow: var(--shadow-inset); +} + +/* + * The worst department leads the list by data (breach count beats wait length beats volume — + * see `edPressure`), but ordering alone reads as arbitrary at a glance. A department with a + * breached legal deadline gets the danger tint regardless of selection state, so the reason it + * leads is visible on the card itself, not just implied by its position (Task 4 ruling — "the + * worst card must say why it is worst"). + */ +.pressureCard[data-breaching="true"], +.pressureCardSelected[data-breaching="true"] { + border-color: var(--danger-border); + background: var(--danger-bg); +} + +.pressureSiteCode { + overflow: hidden; + color: var(--text-heading); + font-size: var(--text-2xs); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pressureStats { + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.filterNotice { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: center; + gap: var(--co-space-8); + margin: 0; + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.pressureBreach { + color: var(--danger-text); + font-weight: 700; +} + +/* + * Mobile-first: below 90rem (1440px, the brief's "Layout at >=1440px" threshold) the three + * regions stack full-width so nothing is ever forced narrower than its content — the previous + * `max-width: 90rem` rule only shrank the three tracks' minimums, which still overflowed well + * above the 320px floor this shell must survive (Task 3 review Important 2). Task 8 owns the + * real phone composition (hiding the diagram, one-tap confirm); this only keeps the frame from + * breaking in the meantime. + */ +.regionGrid { + display: grid; + min-width: 0; + min-height: 0; + grid-template-columns: minmax(0, 1fr); + gap: var(--co-space-10); +} + +@media (min-width: 90rem) { + .regionGrid { + grid-template-columns: 14rem minmax(38rem, 1fr) 23rem; + } +} + +.queueRegion { + display: flex; + min-height: 0; + flex-direction: column; + padding: var(--co-space-12); +} + +/* The 14rem queue column is too narrow for "Priority queue" and the open-movement count to + share one line without the heading wrapping mid-word; stack them instead of fighting for + width. */ +.queueRegion .regionHeader { + flex-direction: column; + align-items: flex-start; + gap: var(--co-space-2); +} + +.queueList { + display: grid; + min-height: 0; + gap: var(--co-space-6); + overflow-y: auto; +} + +.queueRow, +.queueRowSelected { + display: grid; + width: 100%; + min-height: var(--co-space-48); + gap: var(--co-space-2); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + padding: var(--co-space-6) var(--co-space-8); + background: var(--surface); + color: var(--text); + text-align: left; +} + +.queueRow:hover { + background: var(--surface-subtle); +} + +.queueRowSelected { + border-color: var(--clinical-accent-border); + background: var(--clinical-accent-soft); + box-shadow: var(--shadow-inset); +} + +.queueRow strong, +.queueRowSelected strong { + color: var(--text-heading); + font-size: var(--text-2xs); +} + +.queueRow span, +.queueRowSelected span { + color: var(--text-muted); + font-size: var(--text-3xs); + line-height: var(--co-leading-body); +} + +.diagramRegion, +.shortlistRegion { + display: flex; + min-height: 0; + flex-direction: column; + padding: var(--co-space-14); +} + +.shortlistColumn { + display: flex; + min-height: 0; + min-width: 0; +} + +.shortlistColumn > .shortlistRegion { + width: 100%; +} + +.placeholder { + margin: 0; + color: var(--text-muted); + font-size: var(--text-2xs); + line-height: var(--co-leading-prose); +} + +.exceptionsDrawer { + border-top: 0.0625rem solid var(--border); + background: var(--surface-chrome); + padding: var(--co-space-8) var(--co-space-20); +} + +.exceptionsToggle { + display: inline-flex; + min-height: var(--co-space-48); + align-items: center; + gap: var(--co-space-8); + border: 0.0625rem solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + padding: 0 var(--co-space-14); + color: var(--text-heading); + font-size: var(--text-xs); + font-weight: 700; +} + +.exceptionsToggle svg { + width: 1rem; + height: 1rem; + color: var(--clinical-accent); +} + +.exceptionsDrawer[data-open="true"] .placeholder { + margin-top: var(--co-space-8); +} + +@media (prefers-reduced-motion: reduce) { + .screen * { + scroll-behavior: auto !important; + transition: none !important; + } +} + +@media (forced-colors: active) { + .queueRowSelected { + border: 0.125rem solid Highlight; + } +} diff --git a/src/components/ward-management/coordinator/pressure-strip.tsx b/src/components/ward-management/coordinator/pressure-strip.tsx new file mode 100644 index 0000000000..76463a053e --- /dev/null +++ b/src/components/ward-management/coordinator/pressure-strip.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { splitDuration, type Instant } from "@/components/ward-management/ward-clock"; +import { edPressure } from "@/components/ward-management/ward-pressure"; + +import styles from "./coordinator.module.css"; + +type PressureStripProps = { + now: Instant; + selectedEdId: string | undefined; + onSelectEd: (edId: string | undefined) => void; +}; + +/** + * The coordinator's one-second read on "which emergency department is worst". Worst-first + * ordering comes from `edPressure` (a passed legal deadline outranks a long wait, which + * outranks sheer volume) — this component only renders that order, it never re-derives it. + * + * The visible label is `ed.siteCode`, never a name shortened by string surgery: `ed.name` is + * carried as the accessible name and `title` instead, so the unabbreviated hospital reaches a + * screen reader and a hover without ever displaying a plausible-but-wrong guess. + */ +export function PressureStrip({ now, selectedEdId, onSelectEd }: PressureStripProps) { + const pressure = edPressure(now); + + return ( +
+
+

Emergency department pressure

+ {pressure.length} departments +
+ +
+ ); +} diff --git a/src/components/ward-management/ward-clock.ts b/src/components/ward-management/ward-clock.ts new file mode 100644 index 0000000000..1a1d335ea6 --- /dev/null +++ b/src/components/ward-management/ward-clock.ts @@ -0,0 +1,59 @@ +/** + * Minutes elapsed since midnight on the synthetic operating day. + * + * The whole model stores durations rather than fixed times so the board can tick. This is + * the ONLY module permitted to read the wall clock: everything else receives `now` as a + * parameter, which is what keeps tests and screenshots deterministic. + */ +export type Instant = number; + +export type ClockState = "breached" | "critical" | "due" | "clear"; + +export function wallClockNow(): Instant { + const date = new Date(); + return date.getHours() * 60 + date.getMinutes(); +} + +export function minutesUntil(due: Instant, now: Instant) { + return due - now; +} + +export function clockState(due: Instant, now: Instant): ClockState { + const remaining = minutesUntil(due, now); + if (remaining < 0) return "breached"; + if (remaining < 60) return "critical"; + if (remaining < 180) return "due"; + return "clear"; +} + +export function splitDuration(totalMinutes: number) { + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return hours > 0 ? `${hours}h ${String(minutes).padStart(2, "0")}m` : `${minutes}m`; +} + +export function formatRemaining(minutes: number) { + if (minutes < 0) return `${splitDuration(Math.abs(minutes))} overdue`; + return `${splitDuration(minutes)} left`; +} + +/** + * Formats a duration that has already elapsed (e.g. time since a movement opened). + * `formatRemaining` is a countdown formatter — "left"/"overdue" against a future deadline — + * and must not be reused for elapsed time, which has no deadline to be overdue against. + */ +export function formatElapsed(minutes: number) { + return `${splitDuration(Math.max(minutes, 0))} waiting`; +} + +/** + * A synthetic movement can be authored with a negative instant (e.g. `openedAt` computed + * before the day began) — this must still render as a valid wall-clock time rather than + * `-1:-14`, so wrap into the 0–1439 range before splitting into hours and minutes. + */ +export function formatInstant(instant: Instant) { + const wrapped = ((instant % 1440) + 1440) % 1440; + const hours = Math.floor(wrapped / 60); + const minutes = wrapped % 60; + return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; +} diff --git a/src/components/ward-management/ward-derivations.ts b/src/components/ward-management/ward-derivations.ts new file mode 100644 index 0000000000..4f52de8fd5 --- /dev/null +++ b/src/components/ward-management/ward-derivations.ts @@ -0,0 +1,273 @@ +/** + * Pure derivations shared by the three Ward Flow view files (console, modes, network). Nothing + * here depends on React — no hooks, no browser APIs — so this module carries no "use client" + * directive and cannot itself become a client/server boundary problem. It is the single place + * these three files can drift out of sync if a formula changes, so a change here changes every + * consumer at once rather than needing three coordinated edits. + */ +import type { LucideIcon } from "lucide-react"; +import { CircleAlert, Truck } from "lucide-react"; + +import { + clockState, + formatElapsed, + formatInstant, + formatRemaining, + minutesUntil, + type Instant, +} from "@/components/ward-management/ward-clock"; +import { eligibility, type EligibilityVerdict } from "@/components/ward-management/ward-eligibility"; +import { + MOVEMENT_STAGES, + PARALLEL_REFERRAL_CAP, + type HealthService, + type Movement, + type MovementStage, + type TransportJob, + type Unit, +} from "@/components/ward-management/ward-model"; +import { bedReleases, wardMovements } from "@/components/ward-management/ward-movements"; +import { allEmergencyDepartments, allUnits, siteByCode, unitById } from "@/components/ward-management/ward-sites"; + +/** UI-only role concept; not part of the domain model. */ +export type WardRole = "flow" | "ed" | "ward"; + +export const stageCopy: Record = { + placement_requested: { label: "Placement requested", shortLabel: "Requested" }, + destination_review: { label: "Destination review", shortLabel: "Review" }, + accepted_awaiting_bed: { label: "Accepted, awaiting bed", shortLabel: "Accepted" }, + bed_held: { label: "Bed held", shortLabel: "Held" }, + handover_ready: { label: "Handover ready", shortLabel: "Ready" }, + moving: { label: "Moving", shortLabel: "Moving" }, + arrived: { label: "Arrived", shortLabel: "Arrived" }, +}; + +/** Counts are derived from `wardMovements` so the pipeline strip can never advertise a + * count no other surface can show. */ +export function stageSummaries(movements: Movement[]) { + return MOVEMENT_STAGES.map((id) => ({ + id, + ...stageCopy[id], + count: movements.filter((movement) => movement.stage === id).length, + })); +} + +export const movementStageSummary = stageSummaries(wardMovements); + +export const wardServiceOrder: HealthService[] = ["North Metro", "East Metro", "South Metro", "WACHS", "Private"]; + +export const roleLabels: Record = { + flow: "Flow coordinator", + ed: "ED mental health", + ward: "Ward manager", +}; + +export const roleTaskLabel: Record = { + flow: "Review & confirm", + ed: "Confirm ED readiness", + ward: "Accept and hold bed", +}; + +/** + * The health service that owns the ED a movement originated in. This is the origin service, + * not the patient's catchment — catchment is determined by where a patient lives, not where + * they presented (see the glossary and Accepted ADR 3). `Movement` has no catchment field; + * adding one is Phase 2 model work, not a derivation this module can safely invent. + */ +export function movementHealthService(movement: Movement): HealthService | undefined { + const ed = allEmergencyDepartments().find((candidate) => candidate.id === movement.originEdId); + return ed ? siteByCode(ed.siteCode)?.service : undefined; +} + +/** + * Duration since the movement opened. This is elapsed wait time, not a countdown to a + * deadline — `formatElapsed` (never `formatRemaining`) is what keeps it from reading as a + * breach on every row of every queue. + */ +export function elapsedLabel(movement: Movement, now: Instant) { + return formatElapsed(minutesUntil(now, movement.openedAt)); +} + +/** + * A movement is open while it is still travelling through the pathway. Per spec §7, arrival + * closes the record and the patient leaves the system — so a movement is closed once it + * carries a `closure` (whatever the outcome) or has reached the `arrived` stage, and open + * counts/tables must never include it. `closure` is checked independently of `stage` because + * a movement can close before ever reaching `arrived` (e.g. self-discharge from ED). + */ +export function isOpen(movement: Movement): boolean { + return !movement.closure && movement.stage !== "arrived"; +} + +/** + * The unit a movement is *actually* recorded against — accepted, or else the first live + * referral. Never falls back to a different unit, and never returns a merely-suggested + * candidate: callers that want a suggestion when this is `undefined` must ask for one + * explicitly (see `eligibleCandidates`) and label it as a suggestion, not a destination. + */ +export function destinationUnit(movement: Movement): Unit | undefined { + const id = movement.acceptedUnitId ?? movement.referredUnitIds[0]; + return id ? unitById(id) : undefined; +} + +export function unitSiteCode(unit: Unit) { + return siteByCode(unit.siteCode)?.code ?? unit.siteCode; +} + +export function transportStatusLabel(transport: TransportJob | undefined) { + if (!transport) return "Not yet requested"; + if (transport.cancelledAt !== undefined) return "Cancelled"; + if (transport.arrivedAt !== undefined) return "Arrived"; + if (transport.collectedAt !== undefined) return "Collected"; + if (transport.enRouteAt !== undefined) return "En route"; + if (transport.acceptedAt !== undefined) return `${transport.provider} accepted, awaiting departure`; + return `${transport.provider} requested`; +} + +/** + * The five-state bed grid, built entirely from real unit and bed-release fields. + * + * The glossary requires every bed to carry exactly one of the five states, so this must + * partition `unit.beds` exactly: `available + held + blocked + occupied === unit.beds`. + * `available` and `held` are both drawn from within the physically-empty pool (`unit.empty`) + * — `available` is the ward-confirmed allocatable subset, and whatever empty capacity is not + * yet confirmed allocatable is `held` rather than silently uncounted. `blocked` is drawn from + * within the non-empty remainder, with whatever is left over being `occupied`. Both splits + * are clamped so authored data that already over- or under-counts (e.g. a stale `unit.held` + * literal that no longer fits once `available` is subtracted) can never push the total past + * `unit.beds` or leave a bed unaccounted for. + */ +export function unitCapacity(unit: Unit) { + const available = Math.min(unit.allocatable.value, unit.empty.value); + const held = Math.max(unit.empty.value - available, 0); + const notEmpty = Math.max(unit.beds - unit.empty.value, 0); + const blocked = Math.min(Math.max(unit.blocked, 0), notEmpty); + const occupied = Math.max(notEmpty - blocked, 0); + return { + available, + held, + potential: bedReleases.filter((release) => release.unitId === unit.id).length, + blocked, + occupied, + }; +} + +/** + * Cohort-matching units ranked eligible-first, using the real eligibility gates. This is a + * shortlist of candidates, never a destination — a unit appearing here has not been referred + * or accepted; see `destinationUnit` for the movement's actual recorded destination. + */ +export function eligibleCandidates(movement: Movement, now: Instant, limit = 3) { + return allUnits() + .filter((unit) => unit.cohort === movement.cohort) + .map((unit) => ({ unit, verdict: eligibility(movement, unit, now) })) + .sort((a, b) => Number(b.verdict.eligible) - Number(a.verdict.eligible)) + .slice(0, limit); +} + +/** + * A binary, non-ordinal description of a verdict: eligible, or the specific gate that failed. + * Eligibility gates are not commensurable (failing `authorisation` is a legal hard stop; + * failing `capacity_freshness` is a staleness warning), so this deliberately never collapses + * them into a "N of M passed" fraction — that shape reads as a score, and higher/lower + * comparisons across two verdicts are not meaningful. + */ +export function candidateReason(verdict: EligibilityVerdict) { + if (verdict.eligible) return "Eligible now"; + const failed = verdict.gates.find((gate) => !gate.pass); + return failed ? failed.detail : "Not eligible"; +} + +export type InboxTone = "danger" | "warning"; +export type InboxItem = { + id: string; + tone: InboxTone; + icon: LucideIcon; + title: string; + detail: string; + owner: string; + movementId: string; +}; + +/** Every item here is computed from real movement fields — nothing is authored. */ +export function buildActionInbox(movements: Movement[], now: Instant): InboxItem[] { + const items: InboxItem[] = []; + + const breachedLegal = movements.find( + (movement) => movement.legalForm && clockState(movement.legalForm.dueAt, now) === "breached", + ); + if (breachedLegal?.legalForm) { + items.push({ + id: `legal-${breachedLegal.id}`, + tone: "danger", + icon: CircleAlert, + title: "Legal timing breached", + detail: `${breachedLegal.id} · ${formatRemaining(minutesUntil(breachedLegal.legalForm.dueAt, now))}`, + owner: breachedLegal.owner, + movementId: breachedLegal.id, + }); + } + + // This only detects that three parallel referrals were declined, not that the network is + // actually exhausted — a movement can hit the cap with eligible units still untried. Name + // it for what it measures rather than implying a broader search than was actually run. + const exhaustedReferrals = movements.find((movement) => movement.declines.length >= PARALLEL_REFERRAL_CAP); + if (exhaustedReferrals) { + items.push({ + id: `declines-${exhaustedReferrals.id}`, + tone: "danger", + icon: CircleAlert, + title: "Parallel referral cap reached", + detail: `${exhaustedReferrals.id} · ${exhaustedReferrals.declines.length} declines`, + owner: exhaustedReferrals.owner, + movementId: exhaustedReferrals.id, + }); + } + + const stalledTransport = movements.find( + (movement) => + movement.transport?.acceptedAt !== undefined && + movement.transport.enRouteAt === undefined && + movement.transport.cancelledAt === undefined, + ); + if (stalledTransport?.transport) { + items.push({ + id: `transport-${stalledTransport.id}`, + tone: "warning", + icon: Truck, + title: "Transport awaiting departure", + detail: `${stalledTransport.id} · accepted ${formatInstant(stalledTransport.transport.acceptedAt as Instant)}`, + owner: stalledTransport.owner, + movementId: stalledTransport.id, + }); + } + + return items; +} + +/** A real, per-movement audit trail built from actual fields — never generic flavour text. */ +export function movementTimeline(movement: Movement) { + const events: Array<{ at: Instant; label: string }> = [{ at: movement.openedAt, label: "Movement opened" }]; + for (const change of movement.statusChanges) { + events.push({ at: change.at, label: `Legal status changed: ${change.from} → ${change.to} (${change.by})` }); + } + for (const decline of movement.declines) { + events.push({ at: decline.at, label: `Declined by referral: ${decline.reason.replace(/_/g, " ")}` }); + } + if (movement.transport?.acceptedAt !== undefined) { + events.push({ at: movement.transport.acceptedAt, label: `Transport accepted by ${movement.transport.provider}` }); + } + if (movement.transport?.enRouteAt !== undefined) { + events.push({ at: movement.transport.enRouteAt, label: "Transport en route" }); + } + if (movement.transport?.collectedAt !== undefined) { + events.push({ at: movement.transport.collectedAt, label: "Patient collected" }); + } + if (movement.transport?.arrivedAt !== undefined) { + events.push({ at: movement.transport.arrivedAt, label: "Arrived at destination" }); + } + if (movement.closure) { + events.push({ at: movement.closure.at, label: movement.closure.reason }); + } + return events.sort((a, b) => a.at - b.at); +} diff --git a/src/components/ward-management/ward-eligibility.ts b/src/components/ward-management/ward-eligibility.ts new file mode 100644 index 0000000000..ebf29301b5 --- /dev/null +++ b/src/components/ward-management/ward-eligibility.ts @@ -0,0 +1,93 @@ +import type { Instant } from "@/components/ward-management/ward-clock"; +import type { LegalStatus, Movement, Unit } from "@/components/ward-management/ward-model"; + +export type GateResult = { gate: string; pass: boolean; detail: string }; +export type EligibilityVerdict = { eligible: boolean; gates: GateResult[] }; + +/** + * Every status other than Voluntary carries a detention authority, so the receiving unit must + * be authorised. This governs the DESTINATION only — detaining a referred patient in an + * unauthorised emergency department is lawful and is the normal state while they wait. + */ +export function requiresAuthorisedDestination(status: LegalStatus | undefined) { + return status !== "Voluntary"; +} + +function capacityIsFresh(unit: Unit, now: Instant) { + return now - unit.allocatable.confirmedAt <= unit.allocatable.staleAfterMinutes; +} + +/** "a unit" vs "an older adult unit" — the only two cohort/security values start with a + * vowel or a consonant, so this is a plain vowel check rather than a lookup table. */ +function article(word: string) { + return /^[aeiou]/i.test(word) ? "an" : "a"; +} + +export function eligibility(movement: Movement, unit: Unit, now: Instant): EligibilityVerdict { + const authorisationNeeded = requiresAuthorisedDestination(movement.legalStatus); + const declined = movement.declines.some((decline) => decline.unitId === unit.id); + const fresh = capacityIsFresh(unit, now); + const sameSexOccupants = unit.sexMix[movement.sex] ?? 0; + + const gates: GateResult[] = [ + { + gate: "authorisation", + pass: !authorisationNeeded || unit.authorised, + detail: authorisationNeeded + ? unit.authorised + ? "Authorised to receive an involuntary admission" + : `${unit.name} is not authorised under the Mental Health Act` + : "Voluntary admission needs no authorisation", + }, + { + gate: "cohort", + pass: unit.cohort === movement.cohort, + detail: + unit.cohort === movement.cohort + ? `${unit.cohort} unit matches ${article(movement.cohort)} ${movement.cohort.toLowerCase()} movement` + : `${unit.cohort} unit does not match ${article(movement.cohort)} ${movement.cohort.toLowerCase()} movement`, + }, + { + gate: "security", + pass: movement.security === "Open" || unit.security === "Secure", + detail: + movement.security === "Open" || unit.security === "Secure" + ? `${unit.security} ward meets ${article(movement.security)} ${movement.security.toLowerCase()} requirement` + : `${unit.security} ward does not meet ${article(movement.security)} ${movement.security.toLowerCase()} requirement`, + }, + { + gate: "sex_mix", + pass: sameSexOccupants > 0 || unit.allocatable.value > 1, + detail: + sameSexOccupants > 0 + ? `${sameSexOccupants} ${movement.sex.toLowerCase()} occupants already` + : "No same-sex occupants; needs more than one free bed", + }, + { + gate: "specialling", + pass: !movement.specialling || unit.speciallingCapacity > 0, + detail: movement.specialling + ? `${unit.speciallingCapacity} specialling slots available` + : "No specialling required", + }, + { + gate: "prior_decline", + pass: !declined, + detail: declined ? "Already declined this movement" : "No prior decline", + }, + { + gate: "capacity_freshness", + pass: fresh, + detail: fresh + ? `Confirmed ${now - unit.allocatable.confirmedAt} min ago` + : `Last confirmed ${now - unit.allocatable.confirmedAt} min ago — stale`, + }, + { + gate: "allocatable_bed", + pass: unit.allocatable.value > 0, + detail: `${unit.allocatable.value} allocatable`, + }, + ]; + + return { eligible: gates.every((gate) => gate.pass), gates }; +} diff --git a/src/components/ward-management/ward-management-console.tsx b/src/components/ward-management/ward-management-console.tsx new file mode 100644 index 0000000000..dd5955a890 --- /dev/null +++ b/src/components/ward-management/ward-management-console.tsx @@ -0,0 +1,963 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowLeft, + ArrowRight, + BedSingle, + Building2, + CalendarDays, + Check, + CheckCircle2, + ChevronDown, + ChevronsLeft, + ChevronsRight, + CircleAlert, + Clock3, + FileCheck2, + Filter, + MapPin, + Search, + ShieldCheck, + Sparkles, + Truck, + UserRound, + X, + type LucideIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { ContextualBackLink } from "@/components/contextual-back-link"; +import { Sheet } from "@/components/ui/sheet"; +import { clockState, formatInstant } from "@/components/ward-management/ward-clock"; +import { eligibility } from "@/components/ward-management/ward-eligibility"; +import { + buildActionInbox, + candidateReason, + destinationUnit, + eligibleCandidates, + elapsedLabel, + movementHealthService, + movementStageSummary, + movementTimeline, + roleLabels, + roleTaskLabel, + stageCopy, + transportStatusLabel, + unitCapacity, + unitSiteCode, + wardServiceOrder, + type InboxItem, + type WardRole, +} from "@/components/ward-management/ward-derivations"; +import { + MOVEMENT_STAGES, + type Movement, + type MovementStage, + type TransportJob, + type Unit, +} from "@/components/ward-management/ward-model"; +import { movementById, wardMovements } from "@/components/ward-management/ward-movements"; +import { ClinicalRail, WardModeNavigation } from "@/components/ward-management/ward-management-navigation"; +import { NOW_ANCHOR, allUnits, siteByCode, unitById } from "@/components/ward-management/ward-sites"; + +import styles from "./ward-management.module.css"; + +const stageIcons = { + placement_requested: FileCheck2, + destination_review: Search, + accepted_awaiting_bed: BedSingle, + bed_held: CalendarDays, + handover_ready: ShieldCheck, + moving: Truck, + arrived: CheckCircle2, +} satisfies Record; + +const roleQueueHint: Record = { + flow: "Tier first · eligibility within tier", + ed: "Readiness and referral tasks", + ward: "Capacity and acceptance tasks", +}; + +function QueueBadge({ children, tone = "neutral" }: { children: React.ReactNode; tone?: "neutral" | "danger" }) { + return {children}; +} + +function PatientQueueItem({ + patient, + position, + selected, + onSelect, +}: { + patient: Movement; + position?: number; + selected: boolean; + onSelect: () => void; +}) { + const destination = destinationUnit(patient); + const service = movementHealthService(patient); + return ( + + ); +} + +function CapacityLine({ + state, + count, + label, +}: { + state: "available" | "held" | "potential" | "blocked" | "occupied"; + count: number; + label: string; +}) { + return ( + + + ); +} + +function HospitalNode({ unit, selected, onSelect }: { unit: Unit; selected: boolean; onSelect: () => void }) { + const capacity = unitCapacity(unit); + return ( + + ); +} + +function QueuePanel({ + role, + patients, + selectedPatient, + collapsed, + onCollapse, + onSelectPatient, +}: { + role: WardRole; + patients: Movement[]; + selectedPatient: Movement; + collapsed: boolean; + onCollapse: () => void; + onSelectPatient: (patient: Movement) => void; +}) { + const [filtersOpen, setFiltersOpen] = useState(false); + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} + +function ActionInbox({ items, onOpen }: { items: InboxItem[]; onOpen: (item: InboxItem | null) => void }) { + return ( +
+ {items.map((item) => { + const Icon = item.icon; + return ( + + ); + })} + +
+ ); +} + +function MovementPipeline({ + activeStage, + onStageChange, +}: { + activeStage: MovementStage; + onStageChange: (stage: MovementStage) => void; +}) { + return ( + + ); +} + +function WardNetwork({ + selectedUnitId, + onSelectUnit, +}: { + selectedUnitId: string | undefined; + onSelectUnit: (id: string) => void; +}) { + return ( +
+

+ WA psychiatry bed network +

+
+ {wardServiceOrder.map((service) => { + const units = allUnits().filter((unit) => siteByCode(unit.siteCode)?.service === service); + const totalBeds = units.reduce((sum, unit) => sum + unit.beds, 0); + const totalAvailable = units.reduce((sum, unit) => sum + unit.allocatable.value, 0); + return ( +
+
+ + + +

{service}

+

+ {totalBeds} beds · {totalAvailable} available +

+
+
+ {units.map((unit) => ( + onSelectUnit(unit.id)} + /> + ))} +
+
+ ); + })} +
+
+ + + + + + + +
+
+ ); +} + +/** A transport job only counts as ready once it has actually moved, not merely been + * requested or accepted — "requested"/"accepted, awaiting departure"/"cancelled" all still + * need a coordinator's attention, the same as no transport job at all. */ +function transportReady(transport: TransportJob | undefined) { + return ( + transport !== undefined && + (transport.enRouteAt !== undefined || transport.collectedAt !== undefined || transport.arrivedAt !== undefined) + ); +} + +function DecisionDock({ + patient, + role, + selectedUnitId, + confirmed, + onConfirm, + onClose, + onSelectUnit, +}: { + patient: Movement; + role: WardRole; + selectedUnitId: string | undefined; + confirmed: boolean; + onConfirm: () => void; + onClose: () => void; + onSelectUnit: (id: string) => void; +}) { + const recordedDestination = destinationUnit(patient); + const displayedUnit = selectedUnitId ? unitById(selectedUnitId) : undefined; + // The picker may be pre-seeded (or the coordinator may be previewing) a unit that is not yet + // the movement's recorded destination. That is a legitimate affordance, but it must never be + // presented as the destination unlabelled — see Task 6 Critical 2. + const isSuggested = displayedUnit !== undefined && displayedUnit.id !== recordedDestination?.id; + const verdict = displayedUnit ? eligibility(patient, displayedUnit, NOW_ANCHOR) : undefined; + const candidates = useMemo(() => eligibleCandidates(patient, NOW_ANCHOR), [patient]); + const alternatives = candidates.filter((candidate) => candidate.unit.id !== displayedUnit?.id).slice(0, 2); + + return ( +
+ +
+

+ {patient.id} + · + {stageCopy[patient.stage].label} +

+ + +

+ {displayedUnit ? displayedUnit.name : "No destination selected"}{" "} + + {patient.cohort} {patient.security} + +

+ {isSuggested ? ( +

+ Not yet referred to this unit —{" "} + {verdict?.eligible ? "showing an eligible candidate." : "this candidate is not currently eligible."} +

+ ) : null} +
+
+ + + + + {displayedUnit ? ( + + + ) : null} + + + +
+
+

+ {verdict ? ( + <> + {candidateReason(verdict)} + · + + ) : null} + Tier {patient.urgency} +

+ Alternatives + {alternatives.map((candidate) => ( + + ))} +
+
+ Readiness + + {!patient.legalForm || clockState(patient.legalForm.dueAt, NOW_ANCHOR) !== "breached" ? ( + + + {transportReady(patient.transport) ? + {displayedUnit ? ( + + + ) : null} + + +
+
+ + + Open full patient workspace
+

+ Eligibility checked automatically · coordinator confirms · not clinical severity +

+
+ ); +} + +export function WardManagementConsole() { + const [role, setRole] = useState("flow"); + const [selectedPatient, setSelectedPatient] = useState(wardMovements[0]); + const [selectedUnitId, setSelectedUnitId] = useState( + destinationUnit(wardMovements[0])?.id ?? eligibleCandidates(wardMovements[0], NOW_ANCHOR)[0]?.unit.id, + ); + const [queueCollapsed, setQueueCollapsed] = useState(false); + const [activeStage, setActiveStage] = useState(wardMovements[0].stage); + const [decisionOpen, setDecisionOpen] = useState(true); + const [confirmedPatientId, setConfirmedPatientId] = useState(null); + const [inboxOpen, setInboxOpen] = useState(false); + const actionInbox = useMemo(() => buildActionInbox(wardMovements, NOW_ANCHOR), []); + const [selectedInboxItem, setSelectedInboxItem] = useState(actionInbox[0] ?? null); + + const rolePatients = useMemo(() => { + if (role === "flow") return wardMovements; + if (role === "ed") + return [...wardMovements].sort((a, b) => Number(b.owner.includes("ED")) - Number(a.owner.includes("ED"))); + return [...wardMovements].sort((a, b) => Number(b.owner.includes("Ward")) - Number(a.owner.includes("Ward"))); + }, [role]); + + function selectPatient(patient: Movement) { + const rolePatient = rolePatients.find((candidate) => candidate.id === patient.id) ?? patient; + setSelectedPatient(rolePatient); + setSelectedUnitId(destinationUnit(rolePatient)?.id ?? eligibleCandidates(rolePatient, NOW_ANCHOR)[0]?.unit.id); + setActiveStage(rolePatient.stage); + setDecisionOpen(true); + setConfirmedPatientId(null); + } + + function openInbox(item: InboxItem | null) { + setSelectedInboxItem(item); + setInboxOpen(true); + } + + return ( +
+ +
+
+ + Clinical KB + Source-backed clinical search + +
+
+

Ward Flow

+ +
+
+ Synthetic prototype + + + + + + WA +
+
+ + setQueueCollapsed((collapsed) => !collapsed)} + onSelectPatient={selectPatient} + /> + +
+ + + + { + setSelectedUnitId(id); + setDecisionOpen(true); + setConfirmedPatientId(null); + }} + /> + {decisionOpen ? ( + setConfirmedPatientId(selectedPatient.id)} + onClose={() => setDecisionOpen(false)} + onSelectUnit={(id) => { + setSelectedUnitId(id); + setConfirmedPatientId(null); + }} + /> + ) : ( + + )} + + {confirmedPatientId ? `Destination confirmed for ${confirmedPatientId}` : ""} + +
+ + setInboxOpen(false)} + title={selectedInboxItem ? selectedInboxItem.title : "Action inbox"} + description={ + selectedInboxItem + ? `${selectedInboxItem.detail}. Owned by ${selectedInboxItem.owner}.` + : "Operational exceptions requiring an owned next action." + } + contentClassName={styles.inboxSheet} + > +
+ Synthetic prototype + {selectedInboxItem ? ( + <> +

Required action

+

+ Open the matching patient movement, check the current blocker and timing, then record the next owned + action. +

+ + + ) : ( + actionInbox.map((item) => ( + + )) + )} +
+
+
+ ); +} + +export function WardPatientWorkspace({ patientId }: { patientId: string }) { + const patient = movementById(patientId); + const [confirmed, setConfirmed] = useState(false); + const [activeSection, setActiveSection] = useState<"overview" | "legal" | "transport" | "timeline">("overview"); + const [activeStage, setActiveStage] = useState(patient?.stage ?? MOVEMENT_STAGES[0]); + + if (!patient) { + return ( +
+ +
+ + +
+ Ward Flow +

Movement not found

+
+ Synthetic prototype +
+
+

+ No synthetic movement matches “{patientId}”. It may have arrived and closed, or the id is + incorrect. +

+
+
+ ); + } + + // This workspace shows the movement's own record only — it never falls back to a + // suggested/top-eligible unit, so `destination` here is always the real recorded destination + // or nothing. + const destination = destinationUnit(patient); + const verdict = destination ? eligibility(patient, destination, NOW_ANCHOR) : undefined; + const candidates = eligibleCandidates(patient, NOW_ANCHOR).filter( + (candidate) => candidate.unit.id !== destination?.id, + ); + const timeline = movementTimeline(patient); + + return ( +
+ +
+ + +
+ Ward Flow +

{patient.id} movement workspace

+
+ Synthetic prototype +
+
+
+
+ + +

{destination ? destination.name : "No destination selected"}

+

+ {movementHealthService(patient) ?? "Unknown service"} · {patient.cohort} {patient.security} +

+
+
+ Eligibility + {verdict ? candidateReason(verdict) : "—"} + Tier {patient.urgency} leads +
+ +
+ + + + + +
+
+

Movement facts

+
+
+
Current stage
+
{stageCopy[patient.stage].label}
+
+
+
Owner
+
{patient.owner}
+
+
+
Referral
+
{destination ? destination.name : `${patient.referredUnitIds.length} referred`}
+
+
+
Response
+
{patient.blocker}
+
+
+
Health service
+
{movementHealthService(patient) ?? "Unknown"}
+
+
+
Setting
+
+ {patient.cohort} · {patient.security} +
+
+
+
+ +
+

Why this match

+ {verdict ? ( +
    + {verdict.gates.map((gate) => ( +
  • + {gate.pass ?
  • + ))} +
+ ) : ( +

Select a destination to see eligibility checks.

+ )} +

Alternatives

+ {candidates.map((candidate) => ( +
+ + {candidate.unit.name} + {candidateReason(candidate.verdict)} + + {candidate.verdict.eligible ? "Eligible" : "Not eligible"} +
+ ))} +
+ +
+

Readiness

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+
+ + {activeSection === "legal" ? ( +
+

Legal and forms

+

{patient.legalStatus}

+

+ {patient.legalForm + ? `${patient.legalForm.label} (${patient.legalForm.code}) · due ${formatInstant(patient.legalForm.dueAt)}` + : "No Mental Health Act transport form required"} +

+
+ ) : null} + {activeSection === "transport" ? ( +
+

Transport chain

+

{transportStatusLabel(patient.transport)}

+

+ Provider, ETA, risk documentation and legal-form readiness are visible here; dispatch and live vehicle + tracking are not part of this prototype. +

+
+ ) : null} + {activeSection === "timeline" ? ( +
+

Synthetic audit timeline

+
    + {timeline.map((event, index) => ( +
  1. + + {event.label} +
  2. + ))} +
+
+ ) : null} + +

+ Synthetic prototype only. Eligibility is checked automatically; an authorised human confirms every + destination. This is not clinical severity. +

+ + {confirmed ? `Destination confirmed for ${patient.id}` : ""} + +
+
+ ); +} diff --git a/src/components/ward-management/ward-management-modes.module.css b/src/components/ward-management/ward-management-modes.module.css new file mode 100644 index 0000000000..3470e6a234 --- /dev/null +++ b/src/components/ward-management/ward-management-modes.module.css @@ -0,0 +1,1384 @@ +.modeShell { + --ward-blue: var(--clinical-accent); + --ward-blue-soft: var(--clinical-accent-soft); + --ward-blue-border: var(--clinical-accent-border); + --ward-canvas: var(--surface); + --ward-chrome: var(--surface-chrome); + --ward-subtle: var(--surface-subtle); + --ward-border: var(--border); + --ward-border-strong: var(--border-strong); + --ward-text: var(--text); + --ward-heading: var(--text-heading); + --ward-muted: var(--text-muted); + --ward-success: var(--success-text); + --ward-success-soft: var(--success-bg); + --ward-warning: var(--warning-text); + --ward-warning-soft: var(--warning-bg); + --ward-danger: var(--danger-text); + --ward-danger-soft: var(--danger-bg); + --ward-space-2: 0.125rem; + --ward-space-3: 0.1875rem; + --ward-space-4: 0.25rem; + --ward-space-6: 0.375rem; + --ward-space-7: 0.4375rem; + --ward-space-8: 0.5rem; + --ward-space-10: 0.625rem; + --ward-space-12: 0.75rem; + --ward-space-14: 0.875rem; + --ward-space-16: 1rem; + --ward-space-20: 1.25rem; + --ward-leading-compact: 1; + --ward-leading-tight: 1.2; + --ward-leading-body: 1.4; + --ward-leading-relaxed: 1.45; + --ward-leading-prose: 1.5; + --ward-radius-round: 50%; + --ward-z-base: 1; + --ward-z-phone: 20; + display: grid; + height: 100dvh; + min-height: 44rem; + grid-template-columns: 4.5rem minmax(0, 1fr); + grid-template-rows: 4rem auto minmax(0, 1fr); + overflow: hidden; + background: var(--ward-canvas); + color: var(--ward-text); + font-variant-numeric: tabular-nums; +} + +.modeHeader { + grid-column: 2; + grid-row: 1; + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: var(--ward-space-16); + border-bottom: 0.0625rem solid var(--ward-border); + padding: 0 var(--ward-space-20); +} + +.modeIdentity { + display: flex; + min-width: 0; + align-items: center; + gap: var(--ward-space-16); +} + +.modeIdentity span:first-child { + min-width: 8rem; + padding-right: var(--ward-space-16); + border-right: 0.0625rem solid var(--ward-border); +} + +.modeIdentity strong, +.modeIdentity small { + display: block; +} + +.modeIdentity strong { + color: var(--ward-heading); + font-size: var(--text-lg); + line-height: var(--ward-leading-tight); +} + +.modeIdentity small { + overflow: hidden; + color: var(--ward-muted); + font-size: var(--text-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.modeTitle h1 { + color: var(--ward-heading); + font-size: var(--text-lg); + font-weight: 680; + letter-spacing: -0.02em; + line-height: var(--ward-leading-tight); +} + +.modeTitle p { + margin-top: 0.125rem; + color: var(--ward-muted); + font-size: var(--text-2xs); +} + +.roleSelect { + display: flex; + height: 2.5rem; + align-items: center; + gap: var(--ward-space-8); + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + padding: 0 var(--ward-space-12); + background: var(--ward-canvas); + color: var(--ward-heading); +} + +.roleSelect svg { + width: 1rem; + height: 1rem; + color: var(--ward-blue); +} + +.roleSelect select { + min-width: 9rem; + appearance: none; + border: 0; + background: transparent; + color: inherit; + font-size: var(--text-xs); + font-weight: 650; + outline: 0; +} + +.headerMeta { + display: flex; + align-items: center; + gap: var(--ward-space-12); + color: var(--ward-muted); + font-size: var(--text-2xs); + white-space: nowrap; +} + +.prototypeBadge, +.aiBadge, +.statusGood, +.statusWarning, +.statusDanger, +.statusNeutral { + display: inline-flex; + min-height: 1.5rem; + align-items: center; + gap: var(--ward-space-6); + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-pill, 9999px); + padding: var(--ward-space-3) var(--ward-space-8); + font-size: var(--text-3xs); + font-weight: 680; + line-height: var(--ward-leading-compact); +} + +.prototypeBadge, +.aiBadge { + border-color: var(--ward-blue-border); + background: var(--ward-blue-soft); + color: var(--ward-blue); +} + +.aiBadge svg { + width: 0.875rem; + height: 0.875rem; +} + +.statusGood { + border-color: color-mix(in srgb, var(--ward-success) 34%, var(--ward-border)); + background: var(--ward-success-soft); + color: var(--ward-success); +} + +.statusWarning { + border-color: color-mix(in srgb, var(--ward-warning) 38%, var(--ward-border)); + background: var(--ward-warning-soft); + color: var(--ward-warning); +} + +.statusDanger { + border-color: color-mix(in srgb, var(--ward-danger) 34%, var(--ward-border)); + background: var(--ward-danger-soft); + color: var(--ward-danger); +} + +.statusNeutral { + color: var(--ward-muted); +} + +.modeNavigation { + display: flex; + min-width: 0; + min-height: 2.75rem; + align-items: stretch; + gap: var(--ward-space-2); + overflow-x: auto; + border-bottom: 0.0625rem solid var(--ward-border); + background: var(--ward-chrome); + padding: var(--ward-space-4) var(--ward-space-16); + scrollbar-width: thin; +} + +.modeShell > .modeNavigation { + grid-column: 2; + grid-row: 2; +} + +.modeLink, +.modeLinkActive { + display: inline-flex; + min-width: max-content; + align-items: center; + gap: var(--ward-space-7); + border: 0.0625rem solid transparent; + border-radius: var(--radius-md); + padding: 0 var(--ward-space-12); + color: var(--ward-muted); + font-size: var(--text-2xs); + font-weight: 650; +} + +.modeLink:hover { + border-color: var(--ward-border); + background: var(--ward-canvas); + color: var(--ward-heading); +} + +.modeLinkActive { + border-color: var(--ward-blue-border); + background: var(--ward-blue-soft); + color: var(--ward-blue); + box-shadow: var(--shadow-inset); +} + +.modeLink svg, +.modeLinkActive svg { + width: 1rem; + height: 1rem; +} + +.modeShortLabel { + display: none; +} + +.modeContent { + grid-column: 2; + grid-row: 3; + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--ward-space-8); + overflow: auto; + padding: var(--ward-space-12); + background: var(--ward-subtle); +} + +.roleFocus { + display: flex; + min-height: 2.5rem; + align-items: center; + gap: var(--ward-space-10); + border: 0.0625rem solid var(--ward-blue-border); + border-radius: var(--radius-md); + background: var(--ward-blue-soft); + padding: var(--ward-space-6) var(--ward-space-12); + color: var(--ward-blue); + font-size: var(--text-2xs); +} + +.roleFocus strong { + color: var(--ward-heading); +} + +.roleFocus span { + color: var(--ward-muted); +} + +.pageGrid, +.constellationGrid { + display: grid; + min-height: 100%; + gap: var(--ward-space-10); +} + +.pageGrid { + grid-template-columns: minmax(34rem, 1.55fr) minmax(19rem, 0.75fr); +} + +.constellationGrid { + grid-template-columns: 14rem minmax(38rem, 1fr) 22rem; +} + +.panel, +.panelInset, +.regionCluster, +.summaryCard, +.stageColumn, +.exceptionRow, +.transportRow, +.governanceCard { + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-lg); + background: var(--ward-canvas); + box-shadow: var(--e0); +} + +.panel { + min-width: 0; + padding: var(--ward-space-14); +} + +.panelHeader, +.sectionHeader, +.rowTop, +.decisionHeader { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: var(--ward-space-12); +} + +.panelHeader { + margin-bottom: 0.75rem; +} + +.panelHeader h2, +.sectionHeader h2 { + color: var(--ward-heading); + font-size: var(--text-sm); + font-weight: 700; +} + +.panelHeader p, +.sectionHeader p { + margin-top: 0.125rem; + color: var(--ward-muted); + font-size: var(--text-2xs); +} + +.compactQueue { + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; +} + +.queueList, +.dataList, +.exceptionList, +.transportList { + display: grid; + min-height: 0; + gap: var(--ward-space-6); + overflow: auto; +} + +.queueRow, +.queueRowSelected { + display: grid; + min-width: 0; + gap: var(--ward-space-6); + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + padding: var(--ward-space-10); + background: var(--ward-canvas); + color: var(--ward-text); + text-align: left; +} + +.queueRow:hover { + background: var(--ward-subtle); +} + +.queueRowSelected { + border-color: var(--ward-blue-border); + background: var(--ward-blue-soft); + box-shadow: var(--shadow-inset); +} + +.rowTop strong { + color: var(--ward-heading); + font-size: var(--text-xs); +} + +.rowMeta, +.microCopy { + color: var(--ward-muted); + font-size: var(--text-3xs); + line-height: var(--ward-leading-body); +} + +.score { + color: var(--ward-success); + font-size: var(--text-xs); + font-weight: 750; +} + +.networkCanvas { + display: grid; + min-width: 0; + min-height: 38rem; + grid-template-columns: repeat(2, minmax(15rem, 1fr)); + grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr); + gap: var(--ward-space-10); +} + +.regionCluster { + min-width: 0; + padding: var(--ward-space-12); +} + +.regionCluster:nth-of-type(3) { + grid-column: 1; + grid-row: 3; +} + +.regionCluster:nth-of-type(4) { + grid-column: 2; + grid-row: 3; +} + +.regionCluster header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ward-space-8); + margin-bottom: 0.5rem; +} + +.regionTitle { + display: flex; + align-items: center; + gap: var(--ward-space-8); + color: var(--ward-heading); +} + +.regionTitle svg { + width: 1rem; + height: 1rem; + color: var(--ward-blue); +} + +.regionCluster header small { + color: var(--ward-muted); + font-size: var(--text-3xs); +} + +.hospitalChipGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ward-space-6); +} + +.hospitalChip, +.hospitalChipSelected { + display: grid; + min-width: 0; + gap: var(--ward-space-4); + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + padding: var(--ward-space-8); + background: var(--ward-canvas); + color: var(--ward-text); + text-align: left; +} + +.hospitalChipSelected { + border-color: var(--ward-blue); + background: var(--ward-blue-soft); + box-shadow: var(--shadow-inset); +} + +.hospitalChip strong, +.hospitalChipSelected strong { + overflow: hidden; + color: var(--ward-heading); + font-size: var(--text-2xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.hospitalState { + display: flex; + align-items: center; + gap: var(--ward-space-4); + color: var(--ward-muted); + font-size: var(--text-3xs); +} + +.hospitalState b { + color: var(--ward-success); +} + +.flowHub { + grid-column: 1 / -1; + grid-row: 2; + display: grid; + min-height: 7.25rem; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--ward-space-12); + border: 0.0625rem solid var(--ward-blue-border); + border-radius: var(--radius-lg); + background: var(--ward-blue-soft); + padding: var(--ward-space-12) var(--ward-space-16); + color: var(--ward-blue); +} + +.flowHub > div:nth-child(2) { + min-width: 11rem; + text-align: center; +} + +.flowHub strong, +.flowHub span { + display: block; +} + +.flowHub strong { + color: var(--ward-heading); + font-size: var(--text-sm); +} + +.flowHub span { + margin-top: 0.25rem; + font-size: var(--text-3xs); +} + +.routeSignal { + display: flex; + align-items: center; + gap: var(--ward-space-6); + color: var(--ward-blue); + font-size: var(--text-3xs); + font-weight: 700; +} + +.routeSignal:last-child { + justify-content: flex-end; +} + +.routeSignal svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; +} + +.decisionPanel { + display: flex; + min-height: 0; + flex-direction: column; + gap: var(--ward-space-12); +} + +.decisionHeader h2 { + color: var(--ward-heading); + font-size: var(--text-sm); +} + +.patientFacts, +.reasonList, +.auditList, +.sourceList { + display: grid; + gap: var(--ward-space-8); +} + +.patientFacts { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.patientFacts div, +.factorRow { + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + padding: var(--ward-space-8); +} + +.patientFacts dt, +.factorRow span, +.candidateRow small { + color: var(--ward-muted); + font-size: var(--text-3xs); +} + +.patientFacts dd { + margin-top: 0.125rem; + color: var(--ward-heading); + font-size: var(--text-2xs); + font-weight: 650; +} + +.candidateTable { + display: grid; + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.candidateRow, +.candidateRowSelected { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--ward-space-8); + border: 0; + border-bottom: 0.0625rem solid var(--ward-border); + background: var(--ward-canvas); + padding: var(--ward-space-10); + color: var(--ward-text); + text-align: left; +} + +.candidateRow:last-child, +.candidateRowSelected:last-child { + border-bottom: 0; +} + +.candidateRowSelected { + background: var(--ward-blue-soft); +} + +.candidateRank { + display: grid; + width: 1.5rem; + height: 1.5rem; + place-items: center; + border-radius: var(--radius-pill, 9999px); + background: var(--ward-subtle); + color: var(--ward-muted); + font-size: var(--text-3xs); + font-weight: 750; +} + +.candidateRowSelected .candidateRank { + background: var(--ward-blue); + color: var(--ward-canvas); +} + +.candidateRow strong, +.candidateRowSelected strong { + display: block; + color: var(--ward-heading); + font-size: var(--text-2xs); +} + +.primaryButton, +.secondaryButton { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + gap: var(--ward-space-8); + border: 0.0625rem solid var(--ward-blue); + border-radius: var(--radius-md); + padding: 0 var(--ward-space-14); + font-size: var(--text-xs); + font-weight: 700; +} + +.primaryButton { + background: var(--ward-blue); + color: var(--ward-canvas); +} + +.secondaryButton { + border-color: var(--ward-border); + background: var(--ward-canvas); + color: var(--ward-heading); +} + +.buttonRow { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--ward-space-8); + margin-top: auto; +} + +.dataTable { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: var(--text-2xs); +} + +.dataTable th, +.dataTable td { + border-bottom: 0.0625rem solid var(--ward-border); + padding: var(--ward-space-10) var(--ward-space-8); + text-align: left; + vertical-align: middle; +} + +.dataTable th { + position: sticky; + top: 0; + z-index: var(--ward-z-base); + background: var(--ward-chrome); + color: var(--ward-muted); + font-size: var(--text-3xs); + font-weight: 700; + text-transform: uppercase; +} + +.dataTable tbody tr { + cursor: pointer; +} + +.dataTable tbody tr:hover, +.dataTable tbody tr[data-selected="true"] { + background: var(--ward-blue-soft); +} + +.capacitySummary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: var(--ward-space-8); + margin-bottom: 0.75rem; +} + +.summaryCard { + padding: var(--ward-space-12); +} + +.summaryCard span, +.summaryCard small { + display: block; + color: var(--ward-muted); + font-size: var(--text-3xs); +} + +.summaryCard strong { + display: block; + margin: 0.25rem 0 0.125rem; + color: var(--ward-heading); + font-size: var(--text-xl); +} + +.bedStates { + display: grid; + grid-template-columns: repeat(5, minmax(3rem, 1fr)); + gap: var(--ward-space-4); +} + +.bedStates span { + display: grid; + min-height: 2.75rem; + place-items: center; + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-sm); + color: var(--ward-muted); + font-size: var(--text-3xs); +} + +.bedStates strong { + display: block; + color: var(--ward-heading); + font-size: var(--text-xs); +} + +.stageBoard { + display: grid; + min-width: 68rem; + grid-template-columns: repeat(6, minmax(10rem, 1fr)); + gap: var(--ward-space-8); +} + +.stageColumn { + display: flex; + min-height: 32rem; + flex-direction: column; + gap: var(--ward-space-8); + padding: var(--ward-space-10); +} + +.stageColumn header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ward-space-6); + border-bottom: 0.0625rem solid var(--ward-border); + padding-bottom: var(--ward-space-8); +} + +.stageColumn h2 { + color: var(--ward-heading); + font-size: var(--text-2xs); +} + +.stageColumn header strong { + color: var(--ward-blue); + font-size: var(--text-sm); +} + +.movementCard { + display: grid; + gap: var(--ward-space-6); + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + padding: var(--ward-space-10); + background: var(--ward-canvas); +} + +.movementCard strong { + color: var(--ward-heading); + font-size: var(--text-2xs); +} + +.exceptionRow, +.transportRow { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1.4fr) minmax(9rem, 0.7fr) auto; + align-items: center; + gap: var(--ward-space-12); + padding: var(--ward-space-12); +} + +.exceptionRow > svg, +.transportRow > svg { + width: 1.25rem; + height: 1.25rem; + color: var(--ward-warning); +} + +.exceptionRow strong, +.transportRow strong { + display: block; + color: var(--ward-heading); + font-size: var(--text-xs); +} + +.exceptionRow small, +.transportRow small { + display: block; + margin-top: 0.125rem; + color: var(--ward-muted); + font-size: var(--text-3xs); +} + +.assuranceGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--ward-space-10); +} + +.governanceLowerGrid { + margin-top: 0.625rem; +} + +.governanceCard { + padding: var(--ward-space-14); +} + +.governanceCard svg { + width: 1.25rem; + height: 1.25rem; + color: var(--ward-blue); +} + +.governanceCard h2 { + margin-top: 0.75rem; + color: var(--ward-heading); + font-size: var(--text-sm); +} + +.governanceCard p { + margin-top: 0.375rem; + color: var(--ward-muted); + font-size: var(--text-2xs); + line-height: var(--ward-leading-prose); +} + +.auditList li, +.sourceList li, +.reasonList li { + display: flex; + align-items: flex-start; + gap: var(--ward-space-8); + border-bottom: 0.0625rem solid var(--ward-border); + padding: var(--ward-space-8) 0; + color: var(--ward-text); + font-size: var(--text-2xs); + line-height: var(--ward-leading-relaxed); +} + +.reasonList svg, +.auditList svg, +.sourceList svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + margin-top: 0.125rem; + color: var(--ward-success); +} + +.sourceList a { + color: var(--ward-blue); + text-decoration: underline; + text-underline-offset: 0.18em; +} + +.notice { + margin-top: 0.75rem; + border-left: 0.1875rem solid var(--ward-blue); + background: var(--ward-blue-soft); + padding: var(--ward-space-10) var(--ward-space-12); + color: var(--ward-text); + font-size: var(--text-2xs); + line-height: var(--ward-leading-prose); +} + +@media (max-width: 78rem) { + .headerMeta > span:not(.prototypeBadge) { + display: none; + } + + .constellationGrid { + grid-template-columns: 12rem minmax(32rem, 1fr) 19rem; + } + + .modeLink, + .modeLinkActive { + padding-inline: var(--ward-space-10); + } +} + +@media (max-width: 62rem) { + .modeShell { + grid-template-columns: 4rem minmax(0, 1fr); + } + + .modeHeader, + .modeShell > .modeNavigation, + .modeContent { + display: block; + grid-column: 2; + } + + .roleFocus { + display: grid; + gap: var(--ward-space-2); + margin-bottom: 0.5rem; + } + + .modeHeader { + grid-template-columns: minmax(0, 1fr) auto; + } + + .headerMeta { + display: none; + } + + .modeIdentity span:first-child { + display: none; + } + + .constellationGrid, + .pageGrid { + grid-template-columns: 1fr; + } + + .constellationGrid { + min-width: 48rem; + } + + .compactQueue, + .decisionPanel { + min-height: 24rem; + } + + .assuranceGrid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 40rem) { + .modeShell { + display: block; + min-height: 100dvh; + height: auto; + overflow: visible; + } + + .modeShell > aside { + display: none; + } + + .modeHeader { + position: sticky; + top: 0; + z-index: var(--ward-z-phone); + display: grid; + min-height: 4.25rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: var(--ward-space-8) var(--ward-space-12); + background: var(--ward-canvas); + } + + .modeTitle p { + display: none; + } + + .roleSelect { + width: 2.75rem; + padding: 0; + justify-content: center; + } + + .roleSelect select { + position: absolute; + width: 2.75rem; + min-width: 0; + height: 2.75rem; + opacity: 0; + } + + .modeShell > .modeNavigation { + position: sticky; + top: 4.25rem; + z-index: calc(var(--ward-z-phone) - 1); + min-height: 3.25rem; + padding-inline: var(--ward-space-8); + } + + .modeLink, + .modeLinkActive { + min-width: 4.25rem; + min-height: 2.75rem; + justify-content: center; + padding: 0 var(--ward-space-8); + } + + .modeLabel { + display: none; + } + + .modeShortLabel { + display: inline; + } + + .modeContent { + min-height: calc(100dvh - 7.5rem); + overflow: visible; + padding: var(--ward-space-8); + } + + .pageGrid, + .constellationGrid { + display: block; + min-width: 0; + } + + .pageGrid > *, + .constellationGrid > * { + margin-bottom: 0.5rem; + } + + .networkCanvas { + min-width: 42rem; + min-height: 36rem; + } + + .panel:has(.networkCanvas) { + overflow-x: auto; + } + + .capacitySummary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dataTable { + min-width: 48rem; + } + + .panel:has(.dataTable), + .panel:has(.stageBoard) { + overflow-x: auto; + } + + .exceptionRow, + .transportRow { + grid-template-columns: auto minmax(0, 1fr); + } + + .exceptionRow > :nth-child(n + 3), + .transportRow > :nth-child(n + 3) { + grid-column: 2; + } + + .assuranceGrid { + grid-template-columns: 1fr; + } + + .patientFacts { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .modeShell * { + scroll-behavior: auto !important; + transition: none !important; + } +} + +@media (forced-colors: active) { + .modeLinkActive, + .queueRowSelected, + .hospitalChipSelected, + .candidateRowSelected, + .primaryButton { + border: 0.125rem solid Highlight; + } +} + +.networkLayout { + display: grid; + min-height: 100%; + gap: var(--ward-space-10); + grid-template-columns: minmax(34rem, 1.6fr) minmax(20rem, 0.7fr); +} + +.networkDiagram { + position: relative; + min-height: 30rem; + height: 100%; + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-lg); + background: var(--ward-subtle); +} + +.networkEdges { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.networkEdge, +.networkEdgeEscalated { + fill: none; + vector-effect: non-scaling-stroke; +} + +.networkEdge { + stroke: var(--ward-border-strong); + stroke-width: 0.0625rem; +} + +.networkEdgeEscalated { + stroke: var(--ward-warning); + stroke-width: 0.125rem; + stroke-dasharray: 0.25rem 0.1875rem; +} + +.networkEdge[data-dimmed="true"], +.networkEdgeEscalated[data-dimmed="true"] { + opacity: 0.18; +} + +.networkHub { + position: absolute; + left: 50%; + top: 50%; + display: grid; + width: 9.5rem; + justify-items: center; + gap: var(--ward-space-2); + transform: translate(-50%, -50%); + border: 0.0625rem solid var(--ward-blue-border); + border-radius: var(--radius-md); + background: var(--ward-canvas); + padding: var(--ward-space-8); + color: var(--ward-blue); + text-align: center; +} + +.networkHub svg { + width: 1rem; + height: 1rem; +} + +.networkHub strong { + color: var(--ward-heading); + font-size: var(--text-2xs); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.networkHub span { + color: var(--ward-muted); + font-size: var(--text-3xs); + line-height: var(--ward-leading-tight); +} + +.networkAnchor { + position: absolute; + transform: translate(-50%, -50%); + border-radius: var(--radius-sm); + background: var(--ward-canvas); + padding: var(--ward-space-2) var(--ward-space-6); + color: var(--ward-muted); + font-size: var(--text-3xs); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.networkNodes { + position: absolute; + inset: 0; + pointer-events: none; +} + +.networkNode { + position: absolute; + pointer-events: auto; + display: grid; + width: 3.25rem; + min-height: 3.25rem; + place-content: center; + justify-items: center; + gap: var(--ward-space-2); + transform: translate(-50%, -50%); + border: 0.125rem solid var(--ward-border-strong); + border-radius: var(--ward-radius-round); + background: var(--ward-canvas); + color: var(--ward-heading); +} + +.networkNode strong { + font-size: var(--text-sm); + line-height: var(--ward-leading-compact); +} + +.networkNode span { + font-size: var(--text-3xs); + font-weight: 700; + letter-spacing: 0.04em; +} + +.networkNode[data-band="critical"] { + border-color: var(--ward-danger); + background: var(--ward-danger-soft); + color: var(--ward-danger); +} + +.networkNode[data-band="tight"] { + border-color: var(--ward-warning); + background: var(--ward-warning-soft); + color: var(--ward-warning); +} + +.networkNode[data-band="moderate"] { + border-color: var(--ward-blue-border); + background: var(--ward-blue-soft); + color: var(--ward-blue); +} + +.networkNode[data-band="open"] { + border-color: var(--ward-success); + background: var(--ward-success-soft); + color: var(--ward-success); +} + +.networkNode[aria-pressed="true"] { + box-shadow: var(--e2); + outline: 0.1875rem solid var(--ward-blue); + outline-offset: 0.125rem; +} + +.networkLegend { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--ward-space-12); + padding-top: var(--ward-space-10); +} + +.networkLegendGroup { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ward-space-12); +} + +.networkLegendItem { + display: inline-flex; + align-items: center; + gap: var(--ward-space-6); + color: var(--ward-muted); + font-size: var(--text-3xs); + font-weight: 600; +} + +.networkSwatch { + width: 0.75rem; + height: 0.75rem; + border: 0.125rem solid var(--ward-border-strong); + border-radius: var(--ward-radius-round); +} + +.networkSwatch[data-band="critical"] { + border-color: var(--ward-danger); + background: var(--ward-danger-soft); +} + +.networkSwatch[data-band="tight"] { + border-color: var(--ward-warning); + background: var(--ward-warning-soft); +} + +.networkSwatch[data-band="moderate"] { + border-color: var(--ward-blue-border); + background: var(--ward-blue-soft); +} + +.networkSwatch[data-band="open"] { + border-color: var(--ward-success); + background: var(--ward-success-soft); +} + +.networkSwatchLine, +.networkSwatchLineEscalated { + width: 1.25rem; + height: 0; +} + +.networkSwatchLine { + border-top: 0.0625rem solid var(--ward-border-strong); +} + +.networkSwatchLineEscalated { + border-top: 0.125rem dashed var(--ward-warning); +} + +.networkDetail { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--ward-space-10); + overflow-y: auto; +} + +.networkDetailHeading { + color: var(--ward-heading); + font-size: var(--text-2xs); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.networkRouteList { + display: grid; + gap: var(--ward-space-8); + list-style: none; +} + +.networkRouteLink { + display: grid; + gap: var(--ward-space-4); + border: 0.0625rem solid var(--ward-border); + border-radius: var(--radius-md); + padding: var(--ward-space-10); +} + +@media (max-width: 68rem) { + .networkLayout { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/components/ward-management/ward-management-modes.tsx b/src/components/ward-management/ward-management-modes.tsx new file mode 100644 index 0000000000..9b0d1e6ef5 --- /dev/null +++ b/src/components/ward-management/ward-management-modes.tsx @@ -0,0 +1,896 @@ +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + Building2, + CalendarDays, + Check, + CheckCircle2, + ChevronDown, + CircleAlert, + Clock3, + FileCheck2, + FileClock, + Fingerprint, + Info, + LockKeyhole, + MapPin, + Route, + Scale, + ShieldCheck, + Sparkles, + Truck, + UserRound, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { eligibility } from "@/components/ward-management/ward-eligibility"; +import { + buildActionInbox, + candidateReason, + destinationUnit, + eligibleCandidates, + elapsedLabel, + isOpen, + movementHealthService, + movementStageSummary, + movementTimeline, + roleLabels, + roleTaskLabel, + unitCapacity, + wardServiceOrder, + type InboxItem, + type WardRole, +} from "@/components/ward-management/ward-derivations"; +import { WardNetworkWorkspace } from "@/components/ward-management/ward-management-network"; +import { + ClinicalRail, + WardModeNavigation, + type WardMode, +} from "@/components/ward-management/ward-management-navigation"; +import { formatInstant } from "@/components/ward-management/ward-clock"; +import type { Movement, Unit } from "@/components/ward-management/ward-model"; +import { wardMovements } from "@/components/ward-management/ward-movements"; +import { NOW_ANCHOR, allUnits, siteByCode, unitById } from "@/components/ward-management/ward-sites"; + +import styles from "./ward-management-modes.module.css"; + +const roleFocusCopy: Record = { + flow: { + title: "Statewide coordination focus", + detail: "Review matches, cross-catchment escalation, holds and owned exceptions.", + }, + ed: { + title: "ED readiness focus", + detail: "Prioritise referral, legal/form timing, handover and transport request readiness.", + }, + ward: { + title: "Ward capacity focus", + detail: "Prioritise capacity freshness, suitability response, acceptance and time-limited holds.", + }, +}; + +const modeCopy: Record = { + command: { title: "Ward Flow", description: "Statewide command view" }, + constellation: { + title: "Operational constellation", + description: "Statewide demand, capacity and selected movement routes", + }, + network: { + title: "Network diagram", + description: "Services as nodes · movements as routes · fill shows bed pressure", + }, + queue: { title: "Priority queue", description: "Human urgency first · eligibility within tier" }, + capacity: { title: "Capacity", description: "Ward-confirmed state, capability and freshness" }, + movements: { title: "Movements", description: "Seven-stage patient movement board" }, + exceptions: { title: "Action inbox", description: "Owned exceptions, deadlines and stale state" }, + transport: { title: "Transport", description: "Legal, document, booking and handover readiness" }, + governance: { title: "Governance", description: "Assurance, audit and synthetic data boundary" }, +}; + +/** Same role-ordering rule as the command console: human urgency order stays, role just re-sorts by owner. */ +function sortByRole(movements: Movement[], role: WardRole) { + if (role === "flow") return movements; + if (role === "ed") + return [...movements].sort((a, b) => Number(b.owner.includes("ED")) - Number(a.owner.includes("ED"))); + return [...movements].sort((a, b) => Number(b.owner.includes("Ward")) - Number(a.owner.includes("Ward"))); +} + +function toneClass(tone: "good" | "warning" | "danger" | "neutral") { + if (tone === "good") return styles.statusGood; + if (tone === "warning") return styles.statusWarning; + if (tone === "danger") return styles.statusDanger; + return styles.statusNeutral; +} + +/** A short, honest action phrase for each real inbox category. */ +function inboxAction(item: InboxItem) { + if (item.id.startsWith("legal-")) return "Escalate legal timing"; + if (item.id.startsWith("declines-")) return "Expand destination search"; + if (item.id.startsWith("transport-")) return "Follow up transport provider"; + return "Review movement"; +} + +function ModeHeader({ + mode, + role, + onRoleChange, +}: { + mode: WardMode; + role: WardRole; + onRoleChange: (role: WardRole) => void; +}) { + const copy = modeCopy[mode]; + return ( +
+
+ + Clinical KB + Source-backed clinical search + +
+

{copy.title}

+

{copy.description}

+
+
+ +
+ Synthetic prototype + Updated {formatInstant(NOW_ANCHOR)} + 15 Aug 2026 · WA +
+
+ ); +} + +function CompactQueue({ + patients, + selected, + onSelect, +}: { + patients: Movement[]; + selected: Movement; + onSelect: (patient: Movement) => void; +}) { + return ( +
+
+
+

Priority queue

+

Tier first · eligibility orders within tier

+
+ {patients.length} +
+
+ {patients.slice(0, 9).map((patient, index) => ( + + ))} +
+
+ ); +} + +function HospitalChip({ unit, selected, onSelect }: { unit: Unit; selected: boolean; onSelect: () => void }) { + const capacity = unitCapacity(unit); + return ( + + ); +} + +function NetworkCanvas({ selectedId, onSelect }: { selectedId: string | undefined; onSelect: (id: string) => void }) { + return ( +
+
+
+

Statewide mental health flow

+

Schematic service network · selective movement routes only

+
+ Not geographic +
+
+ {wardServiceOrder.map((service) => { + const units = allUnits().filter((unit) => siteByCode(unit.siteCode)?.service === service); + const available = units.reduce((total, unit) => total + unit.allocatable.value, 0); + return ( +
+
+ + + {available} available now +
+
+ {units.map((unit) => ( + onSelect(unit.id)} + /> + ))} +
+
+ ); + })} +
+
+ North / country demand
+
+
+
+
+
+
+
+ ); +} + +function DecisionPanel({ + patient, + role, + selectedId, + onSelectId, +}: { + patient: Movement; + role: WardRole; + selectedId: string | undefined; + onSelectId: (id: string) => void; +}) { + const [confirmed, setConfirmed] = useState(false); + const candidates = useMemo(() => eligibleCandidates(patient, NOW_ANCHOR, 3), [patient]); + // Never fall back to candidates[0] when the id in play isn't one of this movement's own + // shortlisted candidates — that would silently describe the wrong unit (Task 6 Critical 1). + const selected = candidates.find((candidate) => candidate.unit.id === selectedId); + const offShortlistUnit = !selected && selectedId ? unitById(selectedId) : undefined; + const offShortlistVerdict = offShortlistUnit ? eligibility(patient, offShortlistUnit, NOW_ANCHOR) : undefined; + const recordedDestination = destinationUnit(patient); + const isSuggested = selected !== undefined && selected.unit.id !== recordedDestination?.id; + + return ( + + ); +} + +function ConstellationView({ role }: { role: WardRole }) { + const [selectedPatient, setSelectedPatient] = useState(wardMovements[0]); + const [selectedId, setSelectedId] = useState( + destinationUnit(wardMovements[0])?.id ?? eligibleCandidates(wardMovements[0], NOW_ANCHOR)[0]?.unit.id, + ); + const rolePatients = useMemo(() => sortByRole(wardMovements, role), [role]); + + function selectPatient(patient: Movement) { + setSelectedPatient(patient); + setSelectedId(destinationUnit(patient)?.id ?? eligibleCandidates(patient, NOW_ANCHOR)[0]?.unit.id); + } + + return ( +
+ + + +
+ ); +} + +function QueueView({ role }: { role: WardRole }) { + const [selected, setSelected] = useState(wardMovements[0]); + const rolePatients = useMemo(() => sortByRole(wardMovements, role).filter(isOpen), [role]); + return ( +
+
+
+
+

Placement-ready movements

+

Human tier remains primary. Eligibility explains ordering within tier.

+
+ {rolePatients.length} synthetic records +
+ + + + + + + + + + + + + + {rolePatients.map((patient) => { + const top = eligibleCandidates(patient, NOW_ANCHOR, 1)[0]; + return ( + + + + + + + + + + ); + })} + +
PatientPriorityWaitNeedHealth serviceBlockerTop candidate
+ + P{patient.urgency}{elapsedLabel(patient, NOW_ANCHOR)} + {patient.cohort} · {patient.security} + {movementHealthService(patient) ?? "Unknown"}{patient.blocker} + {top ? (top.verdict.eligible ? top.unit.name : "None eligible") : "None eligible"} +
+
+ undefined} + /> +
+ ); +} + +function CapacityView() { + const units = allUnits(); + const capacities = units.map((unit) => ({ unit, capacity: unitCapacity(unit) })); + const totals = { + available: capacities.reduce((sum, entry) => sum + entry.capacity.available, 0), + held: capacities.reduce((sum, entry) => sum + entry.capacity.held, 0), + potential: capacities.reduce((sum, entry) => sum + entry.capacity.potential, 0), + blocked: capacities.reduce((sum, entry) => sum + entry.capacity.blocked, 0), + occupied: capacities.reduce((sum, entry) => sum + entry.capacity.occupied, 0), + }; + return ( +
+
+
+

Ward-confirmed capacity

+

Availability is not suitability. Every count includes a freshness signal.

+
+ Synthetic counts +
+
+ {Object.entries(totals).map(([label, value]) => ( +
+ {label.replace(/^./, (character) => character.toUpperCase())} + {value} + Across {units.length} synthetic units +
+ ))} +
+ + + + + + + + + + + + {capacities.map(({ unit, capacity }) => { + const fresh = NOW_ANCHOR - unit.allocatable.confirmedAt <= unit.allocatable.staleAfterMinutes; + return ( + + + + + + + + ); + })} + +
UnitHealth serviceCapability cueFive bed statesFreshness
+ {unit.name} +
{unit.beds} total beds
+
{siteByCode(unit.siteCode)?.service ?? "Unknown"} + {unit.cohort} · {unit.security} {unit.authorised ? "" : "· not MHA-authorised"} + +
+ + {capacity.available}Now + + + {capacity.held}Held + + + {capacity.potential}Potential + + + {capacity.blocked}Blocked + + + {capacity.occupied}Occupied + +
+
+ + {fresh ? "Current" : "Review soon"} · {formatInstant(unit.allocatable.confirmedAt)} + +
+

+ Potential capacity requires a named action and estimate. No departing-patient details are shown in this + prototype. +

+
+ ); +} + +function MovementsView() { + return ( +
+
+
+

Patient movement board

+

Cross-catchment escalation remains a flag, not an extra stage.

+
+ Shared record · role-owned actions +
+
+ {movementStageSummary.map((stage) => ( +
+
+

{stage.label}

+ {stage.count} +
+ {wardMovements + .filter((patient) => patient.stage === stage.id) + .slice(0, 4) + .map((patient) => ( + + + {patient.id} + P{patient.urgency} + + + {elapsedLabel(patient, NOW_ANCHOR)} · {movementHealthService(patient) ?? "Unknown"} ·{" "} + {patient.security} + + {patient.owner} + + ))} +
+ ))} +
+
+ ); +} + +function ExceptionsView() { + const items = buildActionInbox(wardMovements, NOW_ANCHOR); + const overdue = items.filter((item) => item.tone === "danger").length; + return ( +
+
+
+
+

Action exceptions

+

Only items with an owner and required next action appear here.

+
+ {overdue} overdue +
+
+ {items.map((item) => ( +
+
+ ))} +
+
+ +
+ ); +} + +function TransportView() { + const transportPatients = wardMovements + .filter((patient) => patient.stage !== "arrived" && patient.transport) + .slice(0, 8); + return ( +
+
+
+
+

Transport readiness

+

Metro and country pathways retain distinct documentation and escalation cues.

+
+ No dispatch integration +
+
+ {transportPatients.map((patient) => { + const stalled = + patient.transport?.acceptedAt !== undefined && + patient.transport.enRouteAt === undefined && + patient.transport.cancelledAt === undefined; + return ( +
+
+ ); + })} +
+
+ +
+ ); +} + +function GovernanceView() { + const sources = [ + ["WA Health System Flow Centre", "https://www.health.wa.gov.au/Improving-WA-Health/System-Flow-Centre"], + [ + "Mental Health Act 2014 forms", + "https://www.chiefpsychiatrist.wa.gov.au/laws-and-rights/legislation/mental-health-act-2014-forms/", + ], + ["WA mental health patient transport", "https://www.health.wa.gov.au/Articles/J_M/Mental-health-patient-transport"], + [ + "WA Health Artificial Intelligence Policy", + "https://www.health.wa.gov.au/about-us/policy-frameworks/digital-health/mandatory-requirements/artificial-intelligence-policy", + ], + ]; + const sample = wardMovements[0]; + const timeline = movementTimeline(sample); + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Synthetic decision audit

+

Representative review trail for {sample.id}

+
+
+
    + {timeline.map((event, index) => ( +
  1. + {index === 0 ? ( +
  2. + ))} +
+
+ +
+
+ ); +} + +function RoleFocus({ role }: { role: WardRole }) { + const focus = roleFocusCopy[role]; + return ( +
+ {focus.title} + {focus.detail} +
+ ); +} + +function ModeBody({ mode, role }: { mode: Exclude; role: WardRole }) { + if (mode === "constellation") return ; + if (mode === "network") return ; + if (mode === "queue") return ; + if (mode === "capacity") return ; + if (mode === "movements") return ; + if (mode === "exceptions") return ; + if (mode === "transport") return ; + return ; +} + +export function WardModeWorkspace({ mode }: { mode: Exclude }) { + const [role, setRole] = useState("flow"); + return ( +
+ + + +
+ + +
+
+ ); +} diff --git a/src/components/ward-management/ward-management-navigation.tsx b/src/components/ward-management/ward-management-navigation.tsx new file mode 100644 index 0000000000..ec88d2fadf --- /dev/null +++ b/src/components/ward-management/ward-management-navigation.tsx @@ -0,0 +1,220 @@ +"use client"; + +import Link from "next/link"; +import { useId } from "react"; +import { + Activity, + BedSingle, + CircleAlert, + FileCheck2, + HeartPulse, + LayoutDashboard, + LayoutGrid, + ListFilter, + MapPinned, + MessageSquarePlus, + Pill, + Route, + Settings, + ShieldCheck, + SlidersHorizontal, + Truck, + Waypoints, + Wrench, +} from "lucide-react"; + +import { BrandMark } from "@/components/clinical-dashboard/brand"; +import { ignoreUnavailableActivation } from "@/components/ui-primitives"; + +import shellStyles from "./ward-management.module.css"; +import modeStyles from "./ward-management-modes.module.css"; + +export type WardMode = + | "command" + | "constellation" + | "network" + | "queue" + | "capacity" + | "movements" + | "exceptions" + | "transport" + | "governance"; + +function RailLink({ + href, + label, + active, + children, +}: { + href: string; + label: string; + active?: boolean; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function RailPlaceholder({ label, children }: { label: string; children: React.ReactNode }) { + const unavailableId = useId(); + return ( + <> + + + {label} is not available in this prototype yet. + + + ); +} + +export function ClinicalRail() { + return ( + + ); +} + +export function WardModeNavigation({ active }: { active: WardMode }) { + return ( + + ); +} diff --git a/src/components/ward-management/ward-management-network.module.css b/src/components/ward-management/ward-management-network.module.css new file mode 100644 index 0000000000..7fc95818ec --- /dev/null +++ b/src/components/ward-management/ward-management-network.module.css @@ -0,0 +1,753 @@ +.networkPage { + --net-canvas: var(--surface); + --net-subtle: var(--surface-subtle); + --net-chrome: var(--surface-chrome); + --net-border: var(--border); + --net-border-strong: var(--border-strong); + --net-text: var(--text); + --net-heading: var(--text-heading); + --net-muted: var(--text-muted); + --net-blue: var(--clinical-accent); + --net-blue-soft: var(--clinical-accent-soft); + --net-blue-border: var(--clinical-accent-border); + --net-good: var(--success-text); + --net-good-soft: var(--success-bg); + --net-warn: var(--warning-text); + --net-warn-soft: var(--warning-bg); + --net-danger: var(--danger-text); + --net-danger-soft: var(--danger-bg); + --net-space-2: 0.125rem; + --net-space-4: 0.25rem; + --net-space-6: 0.375rem; + --net-space-8: 0.5rem; + --net-space-10: 0.625rem; + --net-space-12: 0.75rem; + --net-space-16: 1rem; + --net-space-20: 1.25rem; + --net-leading-compact: 1; + --net-leading-tight: 1.25; + --net-leading-body: 1.45; + --net-z-canvas: 1; + --net-z-node: 2; + --net-round: 50%; + display: grid; + min-height: 100%; + gap: var(--net-space-10); + grid-template-rows: auto minmax(0, 1fr); + color: var(--net-text); + font-variant-numeric: tabular-nums; +} + +.pipeline { + display: grid; + align-items: center; + gap: var(--net-space-8); + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-lg); + background: var(--net-canvas); + padding: var(--net-space-12) var(--net-space-16); + box-shadow: var(--e0); +} + +.pipelineStage { + display: grid; + min-width: 0; + gap: var(--net-space-2); + border-left: 0.0625rem solid var(--net-border); + padding-left: var(--net-space-12); +} + +.pipelineStage:first-child { + border-left: 0; + padding-left: 0; +} + +.pipelineLabel { + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-tight); +} + +.pipelineStage strong { + color: var(--net-heading); + font-size: var(--text-lg); + line-height: var(--net-leading-compact); +} + +.networkGrid { + display: grid; + min-height: 0; + gap: var(--net-space-10); + grid-template-columns: 13.5rem minmax(38rem, 1fr) 23rem; +} + +/* Focus mode hands the shortlist's width to the diagram without unmounting it. */ +.networkPage[data-shortlist="collapsed"] .networkGrid { + grid-template-columns: 13.5rem minmax(38rem, 1fr); +} + +.networkPage[data-shortlist="collapsed"] .shortlistPanel { + display: none; +} + +.headerActions { + display: inline-flex; + align-items: center; + gap: var(--net-space-8); +} + +.focusToggle { + display: inline-flex; + min-height: 3rem; + align-items: center; + gap: var(--net-space-4); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-sm); + background: var(--net-canvas); + padding: 0 var(--net-space-10); + color: var(--net-heading); + font-size: var(--text-3xs); + font-weight: 700; +} + +.focusToggle svg { + width: 0.875rem; + height: 0.875rem; +} + +.queuePanel, +.canvasPanel, +.shortlistPanel { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--net-space-10); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-lg); + background: var(--net-canvas); + padding: var(--net-space-12); + box-shadow: var(--e0); +} + +.queuePanel, +.shortlistPanel { + overflow-y: auto; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--net-space-8); +} + +.panelHeader h2 { + display: inline-flex; + align-items: center; + gap: var(--net-space-6); + color: var(--net-heading); + font-size: var(--text-sm); + font-weight: 700; + line-height: var(--net-leading-tight); +} + +.panelHeader h2 svg { + width: 1rem; + height: 1rem; + color: var(--net-blue); +} + +.count { + border-radius: var(--radius-sm); + background: var(--net-subtle); + padding: var(--net-space-2) var(--net-space-8); + color: var(--net-muted); + font-size: var(--text-3xs); + font-weight: 700; +} + +.schematicBadge { + display: inline-flex; + align-items: center; + gap: var(--net-space-4); + border: 0.0625rem solid var(--net-blue-border); + border-radius: var(--radius-sm); + background: var(--net-blue-soft); + padding: var(--net-space-2) var(--net-space-8); + color: var(--net-blue); + font-size: var(--text-3xs); + font-weight: 700; +} + +.schematicBadge svg { + width: 0.75rem; + height: 0.75rem; +} + +/* --- queue ------------------------------------------------------------- */ + +.queueList { + display: grid; + gap: var(--net-space-6); +} + +.queueRow { + display: grid; + min-height: 3rem; + gap: var(--net-space-2); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-md); + background: var(--net-canvas); + padding: var(--net-space-8); + text-align: left; +} + +.queueRow[aria-pressed="true"] { + border-color: var(--net-blue); + background: var(--net-blue-soft); +} + +.queueTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--net-space-6); +} + +.queueTop strong { + color: var(--net-heading); + font-size: var(--text-xs); +} + +.elapsed { + color: var(--net-warn); + font-size: var(--text-3xs); + font-weight: 700; +} + +.queueMeta { + display: flex; + align-items: center; + gap: var(--net-space-4); + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-tight); +} + +.tier { + display: inline-grid; + width: 1.125rem; + height: 1.125rem; + place-items: center; + border-radius: var(--radius-xs); + font-size: var(--text-3xs); + font-weight: 700; +} + +.tier[data-tier="1"] { + background: var(--net-danger-soft); + color: var(--net-danger); +} + +.tier[data-tier="2"] { + background: var(--net-warn-soft); + color: var(--net-warn); +} + +.tier[data-tier="3"] { + background: var(--net-subtle); + color: var(--net-muted); +} + +/* --- canvas ------------------------------------------------------------ */ + +.canvas { + position: relative; + display: grid; + min-height: 34rem; + flex: 1; + align-items: center; + gap: var(--net-space-16); + grid-template-columns: minmax(0, 1fr) 12.5rem minmax(0, 1fr); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-lg); + background: var(--net-subtle); + padding: var(--net-space-16); +} + +.connectorLayer { + position: absolute; + z-index: var(--net-z-canvas); + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + pointer-events: none; +} + +.connector, +.connectorRoute { + fill: none; + vector-effect: non-scaling-stroke; +} + +.connector { + stroke: var(--net-border-strong); + stroke-width: 0.0625rem; +} + +.connectorRoute { + stroke: var(--net-blue); + stroke-width: 0.125rem; +} + +.arrowHead { + fill: var(--net-border-strong); +} + +.arrowHeadRoute { + fill: var(--net-blue); +} + +.column { + z-index: var(--net-z-node); + display: grid; + align-content: center; + gap: var(--net-space-16); +} + +.column[data-side="left"] { + grid-column: 1; +} + +.column[data-side="right"] { + grid-column: 3; +} + +.cluster { + display: grid; + gap: var(--net-space-6); +} + +.clusterHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--net-space-8); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-md); + background: var(--net-canvas); + padding: var(--net-space-6) var(--net-space-10); +} + +.clusterHeader strong { + color: var(--net-heading); + font-size: var(--text-3xs); + font-weight: 700; + letter-spacing: 0.08em; +} + +.clusterHeader span { + color: var(--net-muted); + font-size: var(--text-3xs); +} + +.clusterCards { + display: grid; + gap: var(--net-space-6); +} + +.serviceCard { + display: grid; + min-height: 3rem; + gap: var(--net-space-2); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-md); + background: var(--net-canvas); + padding: var(--net-space-8) var(--net-space-10); + text-align: left; + box-shadow: var(--e0); +} + +.serviceCard[data-routed="true"] { + border-color: var(--net-blue); + box-shadow: var(--e1); +} + +.serviceCard[aria-pressed="true"] { + outline: 0.125rem solid var(--net-blue); + outline-offset: 0.0625rem; +} + +.serviceName { + color: var(--net-heading); + font-size: var(--text-xs); + font-weight: 700; + line-height: var(--net-leading-tight); +} + +.serviceCapability { + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-tight); +} + +.bedChips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--net-space-4); +} + +.bedChip { + display: inline-grid; + min-width: 1.125rem; + height: 1.125rem; + place-items: center; + border: 0.0625rem solid var(--net-border-strong); + border-radius: var(--radius-xs); + padding: 0 var(--net-space-2); + font-size: var(--text-3xs); + font-weight: 700; +} + +.bedChip[data-state="available"] { + border-color: var(--net-good); + background: var(--net-good-soft); + color: var(--net-good); +} + +.bedChip[data-state="held"] { + border-color: var(--net-warn); + background: var(--net-warn-soft); + color: var(--net-warn); +} + +.bedChip[data-state="potential"] { + border-style: dashed; + border-color: var(--net-blue); + color: var(--net-blue); +} + +.bedChip[data-state="blocked"] { + border-color: var(--net-danger); + background: var(--net-danger-soft); + color: var(--net-danger); +} + +.bedTime { + color: var(--net-muted); + font-size: var(--text-3xs); +} + +.hub { + z-index: var(--net-z-node); + grid-column: 2; + grid-row: 1; + display: grid; + justify-items: center; + gap: var(--net-space-2); + border: 0.0625rem solid var(--net-blue-border); + border-radius: var(--radius-md); + background: var(--net-canvas); + padding: var(--net-space-12) var(--net-space-10); + text-align: center; + box-shadow: var(--e1); +} + +.hub svg { + width: 1.25rem; + height: 1.25rem; + color: var(--net-blue); +} + +.hub strong { + color: var(--net-blue); + font-size: var(--text-3xs); + font-weight: 700; + letter-spacing: 0.06em; +} + +.hub span { + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-tight); +} + +.hubMeta { + color: var(--net-heading); + font-weight: 700; +} + +/* --- legend ------------------------------------------------------------ */ + +.legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--net-space-12); +} + +.legendTitle { + color: var(--net-heading); + font-size: var(--text-3xs); + font-weight: 700; +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: var(--net-space-4); + color: var(--net-muted); + font-size: var(--text-3xs); +} + +.legendItem b { + color: var(--net-heading); +} + +.legendSwatch { + width: 0.75rem; + height: 0.75rem; + border: 0.0625rem solid var(--net-border-strong); + border-radius: var(--radius-xs); +} + +.legendSwatch[data-state="available"] { + border-color: var(--net-good); + background: var(--net-good-soft); +} + +.legendSwatch[data-state="held"] { + border-color: var(--net-warn); + background: var(--net-warn-soft); +} + +.legendSwatch[data-state="potential"] { + border-style: dashed; + border-color: var(--net-blue); +} + +.legendSwatch[data-state="blocked"] { + border-color: var(--net-danger); + background: var(--net-danger-soft); +} + +.legendRoute, +.legendDemand { + width: 1.25rem; + height: 0; +} + +.legendRoute { + border-top: 0.125rem solid var(--net-blue); +} + +.legendDemand { + border-top: 0.0625rem solid var(--net-border-strong); +} + +/* --- shortlist --------------------------------------------------------- */ + +.patientLine { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--net-space-4); + color: var(--net-heading); + font-size: var(--text-3xs); + font-weight: 700; +} + +.patientSubLine { + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-body); +} + +.tableScroll { + overflow-x: auto; +} + +.compareTable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-3xs); +} + +.compareTable th, +.compareTable td { + border: 0.0625rem solid var(--net-border); + padding: var(--net-space-6); + text-align: left; + vertical-align: top; +} + +.compareTable thead th { + background: var(--net-subtle); + color: var(--net-blue); + font-weight: 700; +} + +.compareTable tbody th { + color: var(--net-muted); + font-weight: 600; +} + +.compareTable td strong { + color: var(--net-heading); + font-size: var(--text-xs); +} + +.compareTable td[data-tone="good"] { + color: var(--net-good); + font-weight: 700; +} + +.compareTable td[data-tone="warning"] { + color: var(--net-warn); + font-weight: 700; +} + +.compareTable td[data-tone="danger"] { + color: var(--net-danger); + font-weight: 700; +} + +.tierNote { + display: flex; + align-items: flex-start; + gap: var(--net-space-4); + border-radius: var(--radius-md); + background: var(--net-subtle); + padding: var(--net-space-8); + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-body); +} + +.tierNote b { + color: var(--net-heading); +} + +.factorsToggle { + display: flex; + min-height: 3rem; + align-items: center; + justify-content: space-between; + gap: var(--net-space-8); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-md); + background: var(--net-canvas); + padding: 0 var(--net-space-10); + color: var(--net-heading); + font-size: var(--text-3xs); + font-weight: 700; +} + +.factorsToggle svg { + width: 0.875rem; + height: 0.875rem; +} + +.factorsToggle svg[data-open="true"] { + transform: rotate(180deg); +} + +.factorList { + display: grid; + gap: var(--net-space-4); + padding-left: var(--net-space-16); + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-body); +} + +.ownerBlock { + display: grid; + gap: var(--net-space-2); + border: 0.0625rem solid var(--net-border); + border-radius: var(--radius-md); + padding: var(--net-space-10); + font-size: var(--text-3xs); +} + +.ownerLabel { + color: var(--net-muted); +} + +.ownerBlock strong { + color: var(--net-heading); + font-size: var(--text-xs); +} + +.ownerBlock span { + color: var(--net-muted); + line-height: var(--net-leading-body); +} + +.primaryLink { + display: inline-flex; + min-height: 3rem; + align-items: center; + justify-content: center; + border-radius: var(--radius-md); + background: var(--net-blue); + padding: 0 var(--net-space-16); + color: var(--net-canvas); + font-size: var(--text-xs); + font-weight: 700; +} + +.assurance { + color: var(--net-muted); + font-size: var(--text-3xs); + text-align: center; +} + +.detailBlock { + display: grid; + gap: var(--net-space-6); + border: 0.0625rem solid var(--net-blue-border); + border-radius: var(--radius-md); + background: var(--net-blue-soft); + padding: var(--net-space-10); +} + +.detailBlock h3 { + color: var(--net-heading); + font-size: var(--text-xs); + font-weight: 700; +} + +.detailBlock p { + color: var(--net-muted); + font-size: var(--text-3xs); + line-height: var(--net-leading-body); +} + +.detailMeta { + color: var(--net-muted); +} + +@media (max-width: 80rem) { + .networkGrid { + grid-template-columns: minmax(0, 1fr); + } + + .canvas { + grid-template-columns: minmax(0, 1fr); + } + + .connectorLayer { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .networkPage * { + transition: none !important; + } +} diff --git a/src/components/ward-management/ward-management-network.tsx b/src/components/ward-management/ward-management-network.tsx new file mode 100644 index 0000000000..5beffa2c8b --- /dev/null +++ b/src/components/ward-management/ward-management-network.tsx @@ -0,0 +1,537 @@ +"use client"; + +import Link from "next/link"; +import { ChevronDown, ChevronLeft, ChevronRight, Info, Network, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; + +import { eligibility } from "@/components/ward-management/ward-eligibility"; +import { + candidateReason, + destinationUnit, + eligibleCandidates, + elapsedLabel, + isOpen, + movementHealthService, + movementStageSummary, + stageCopy, + transportStatusLabel, + unitCapacity, + wardServiceOrder, +} from "@/components/ward-management/ward-derivations"; +import { formatInstant } from "@/components/ward-management/ward-clock"; +import type { HealthService, Movement, Unit } from "@/components/ward-management/ward-model"; +import { wardMovements } from "@/components/ward-management/ward-movements"; +import { NOW_ANCHOR, allUnits, siteByCode, unitById } from "@/components/ward-management/ward-sites"; + +import styles from "./ward-management-network.module.css"; + +type BedStateKey = "available" | "held" | "potential" | "blocked"; + +const bedStateCopy: Record = { + available: { label: "Ready", detail: "Available now" }, + held: { label: "Held", detail: "Bed held" }, + potential: { label: "Potential", detail: "May become available" }, + blocked: { label: "Blocked", detail: "Not available" }, +}; + +/** Left column carries the WA country service; right column carries the three metro services. */ +const columnServices: { left: readonly HealthService[]; right: readonly HealthService[] } = { + left: ["North Metro", "WACHS"], + right: ["East Metro", "South Metro", "Private"], +}; + +type Connector = { id: string; path: string; kind: "demand" | "route" }; +type Candidate = { unit: Unit; rank: number; etaLabel: string; verdict: ReturnType }; + +function capabilityLabel(unit: Unit) { + const cohortLabel = unit.cohort === "Older adult" ? "Older" : unit.cohort; + return `${unit.security} · ${cohortLabel}`; +} + +function candidatesFor(patient: Movement): Candidate[] { + // Only the movement's actual recorded destination may show a real transport state — the + // other two candidates are computed shortlist entries the movement was never referred to, + // and must not inherit a transport job that belongs to a different unit (Task 6 Important 3). + const recordedDestinationId = destinationUnit(patient)?.id; + return eligibleCandidates(patient, NOW_ANCHOR, 3).map((candidate, index) => ({ + unit: candidate.unit, + verdict: candidate.verdict, + rank: index + 1, + etaLabel: candidate.unit.id === recordedDestinationId ? transportStatusLabel(patient.transport) : "Not yet booked", + })); +} + +/** + * Compares the candidate unit's health service against the *origin ED's* health service — + * this is NOT catchment. Catchment is where the patient lives, not where they presented, and + * `Movement` has no catchment field (see the doc comment on `movementHealthService` and the + * glossary's Catchment entry). Naming this `catchmentFit` previously collapsed exactly the + * distinction Accepted ADR 3 exists to keep separate. + */ +function originServiceFit(patient: Movement, unit: Unit) { + const unitService = siteByCode(unit.siteCode)?.service; + if (unitService && unitService === movementHealthService(patient)) return { label: "Best", tone: "good" as const }; + return { label: "Escalation", tone: "warning" as const }; +} + +function settingFit(patient: Movement, unit: Unit) { + const verdict = eligibility(patient, unit, NOW_ANCHOR); + const cohortOk = verdict.gates.find((gate) => gate.gate === "cohort")?.pass ?? false; + const securityOk = verdict.gates.find((gate) => gate.gate === "security")?.pass ?? false; + if (cohortOk && securityOk) return { label: "Exact match", tone: "good" as const }; + if (cohortOk || securityOk) return { label: "Partial match", tone: "warning" as const }; + return { label: "Not eligible", tone: "danger" as const }; +} + +function transportTone(etaLabel: string) { + return /requested|awaiting|not yet/i.test(etaLabel) ? "warning" : "good"; +} + +function BedStateChips({ unit, showTime }: { unit: Unit; showTime?: boolean }) { + const capacity = unitCapacity(unit); + return ( + + {(Object.keys(bedStateCopy) as BedStateKey[]).map((key) => ( + + {capacity[key]} + + ))} + {showTime ? {formatInstant(unit.allocatable.confirmedAt)} : null} + + ); +} + +function ServiceCard({ + unit, + routed, + selected, + onSelect, + registerRef, +}: { + unit: Unit; + routed: boolean; + selected: boolean; + onSelect: () => void; + registerRef: (id: string, node: HTMLButtonElement | null) => void; +}) { + const capacity = unitCapacity(unit); + return ( + + ); +} + +export function WardNetworkWorkspace() { + const [selectedPatientId, setSelectedPatientId] = useState(wardMovements[0].id); + const [selectedUnitId, setSelectedUnitId] = useState(null); + const [factorsOpen, setFactorsOpen] = useState(false); + const [shortlistOpen, setShortlistOpen] = useState(true); + + // `selectedPatientId` is only ever set from a real movement's own id (see the queue button + // below), so this can't miss today — but every hook after this one must still run + // unconditionally, so the guard lives in the JSX at the bottom, not as an early return here + // (Task 6 Critical 3). + const patient = useMemo( + () => wardMovements.find((candidate) => candidate.id === selectedPatientId), + [selectedPatientId], + ); + const candidates = useMemo(() => (patient ? candidatesFor(patient) : []), [patient]); + const routedIds = useMemo(() => new Set(candidates.map((candidate) => candidate.unit.id)), [candidates]); + + const canvasRef = useRef(null); + const hubRef = useRef(null); + const clusterRefs = useRef(new Map()); + const cardRefs = useRef(new Map()); + const [connectors, setConnectors] = useState([]); + + const registerCard = useCallback((id: string, node: HTMLButtonElement | null) => { + cardRefs.current.set(id, node); + }, []); + const registerCluster = useCallback((service: string, node: HTMLElement | null) => { + clusterRefs.current.set(service, node); + }, []); + + const measure = useCallback(() => { + const canvas = canvasRef.current; + const hub = hubRef.current; + if (!canvas || !hub) return; + const base = canvas.getBoundingClientRect(); + const hubBox = hub.getBoundingClientRect(); + const hubLeft = { x: hubBox.left - base.left, y: hubBox.top - base.top + hubBox.height / 2 }; + const hubRight = { x: hubBox.right - base.left, y: hubLeft.y }; + const next: Connector[] = []; + + /** Elbow: leave the source edge, run along a mid trunk, then enter the target edge. */ + const elbow = (from: { x: number; y: number }, to: { x: number; y: number }) => { + const trunk = from.x + (to.x - from.x) / 2; + return `M ${from.x} ${from.y} H ${trunk} V ${to.y} H ${to.x}`; + }; + + for (const service of wardServiceOrder) { + const node = clusterRefs.current.get(service); + if (!node) continue; + const box = node.getBoundingClientRect(); + const onLeft = (columnServices.left as readonly string[]).includes(service); + const from = { x: (onLeft ? box.right : box.left) - base.left, y: box.top - base.top + box.height / 2 }; + next.push({ id: `demand-${service}`, path: elbow(from, onLeft ? hubLeft : hubRight), kind: "demand" }); + } + + for (const candidate of candidates) { + const node = cardRefs.current.get(candidate.unit.id); + if (!node) continue; + const box = node.getBoundingClientRect(); + const service = siteByCode(candidate.unit.siteCode)?.service; + const onLeft = service ? (columnServices.left as readonly string[]).includes(service) : true; + const to = { x: (onLeft ? box.right : box.left) - base.left, y: box.top - base.top + box.height / 2 }; + next.push({ + id: `route-${candidate.unit.id}`, + path: elbow(onLeft ? hubLeft : hubRight, to), + kind: "route", + }); + } + + setConnectors(next); + }, [candidates]); + + useLayoutEffect(() => { + measure(); + }, [measure]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => measure()); + observer.observe(canvas); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [measure]); + + const detail = selectedUnitId ? unitById(selectedUnitId) : null; + // Arrived and self-discharged movements have left the pathway (spec §7), so this must not + // be the raw stage-count sum — that includes them and overstates live demand. + const openMovements = wardMovements.filter(isOpen).length; + const primary = candidates[0]; + + if (!patient) { + return ( +
+

No synthetic movement matches the current selection.

+
+ ); + } + + return ( +
+
+ {movementStageSummary.map((stage, index) => ( + + + {index + 1} {stage.label} + + {stage.count} + + ))} +
+ +
+
+
+

Priority queue

+ {wardMovements.length} +
+
+ {wardMovements.map((candidate) => ( + + ))} +
+
+ +
+
+

+

+ + + + + +
+ +
+ + + {(["left", "right"] as const).map((side) => ( +
+ {columnServices[side].map((service) => ( +
registerCluster(service, node)} + aria-labelledby={`ward-network-${service}`} + > +
+ {service.toUpperCase()} + + {allUnits() + .filter((unit) => siteByCode(unit.siteCode)?.service === service) + .reduce((sum, unit) => sum + unit.allocatable.value, 0)}{" "} + ready + +
+
+ {allUnits() + .filter((unit) => siteByCode(unit.siteCode)?.service === service) + .map((unit) => ( + setSelectedUnitId(detail?.id === unit.id ? null : unit.id)} + registerRef={registerCard} + /> + ))} +
+
+ ))} +
+ ))} + +
+
+
+ +
+ Legend + {(Object.keys(bedStateCopy) as BedStateKey[]).map((key) => ( + +