Self-Service Workspace Composition
Design for user-composable clinical workspaces from a widget palette.
Status: Draft for review — design only, nothing implemented.
Date: 2026-05-29
Author: design pass (Claude)
Related: miniapp-modal-override.md, department-command-center-tabs.md, web/AGENTS.md, web/public/docs/admin-config/ADMIN_WORKLIST_CONFIG_WORKFLOW_SOURCE.md
1. What this is
The ask, in the user’s words:
Can we extend admin-worklist config to support other workflow-store flows? Like IPD discharge. Also can we use it to dynamically allow for a new page / new route, save to database, show up as buttons / navigation — so a nurse can fully dynamically set up everything themselves (e.g. this screen for this laptop)?
Three distinct capabilities are bundled here:
- Flow coverage — make the existing worklist-config admin work for any workflow-store flow (discharge, and the rest), not just OPD/consult nodes.
- DB-driven pages + navigation — let an authorized user define a “page” and a nav button at runtime, persisted to the DB, with no code deploy.
- Per-workstation binding — “this screen for this laptop”: a given physical device boots into a chosen landing page and shows a chosen nav set.
All three are achievable. The whole design hinges on one reframe (Section 2). The rest of the doc is the data model, the runtime resolution, the phased rollout, and the guardrails.
2. The one decision that governs everything: compose, don’t author
There are two fundamentally different meanings of “let a nurse make a new page”:
| Compose (recommended) | Author (avoid for v1) | |
|---|---|---|
| What the user does | Arranges existing, already-built modules into a named page, pins it to a route + nav button + workstation | Defines brand-new UI / fields / behavior that doesn’t exist in the bundle |
| Mechanism | DB row → catch-all route → DynamicContentRenderer (already resolves 268 modules by string key) |
Runtime code-gen, a generic form/page-schema interpreter, or import() of arbitrary paths |
| Build/deploy needed? | No | Yes, or a large new interpreter |
| Risk | Low — only wires together vetted, tested components | High — this is the class of change that caused the “8,122 files destroyed” incident in web/CLAUDE.md; arbitrary FE-defined behavior is unauditable and can write anywhere |
This doc designs the compose model only. A nurse can build “everything” out of the 268 existing modules and the existing workflow flows — which covers ~90% of “set it up themselves” — without ever introducing un-vetted code. Authoring genuinely new modules/fields stays a developer + deploy activity (and is explicitly out of scope, Section 11).
Concrete consequence of the mechanism:
DynamicContentRendereris a hardcodedswitchovertabName(onecaseperDynamicCoreAppkey), not a registry or glob. So “compose” = pick from keys that already have acase. Adding a new module still means adding acasein code. That switch boundary is the compose/author line, enforced by the architecture itself.
3. What exists today (verified)
3.1 Worklist-config (the admin surface to extend)
- Route
/admin/worklist-config→worklist-editor. - Two config tables, two write paths:
- Current path:
workflow-action-config.service.ts→ Supabase RPCssave_workflow_config/get_active_workflow_config→ tableworkflow_action_configurations, keyed(department_id, subdepartment_id, workflow_id, node_id), JSONBconfiguration(actions,miniapps,menuActions,queueConfig), versioned +workflow_action_configuration_history. - Legacy path: writes
rowActionsstraight intoworkflow_templates.workflow_data.nodes[]. - Plus
role_worklistskeyed(org, dept, role, node)for per-role node visibility + table config.
- Current path:
- Key fact: config already keys on
workflow_id+node_id. It is not hardwired to OPD. Any workflow whose nodes are real worklist nodes can be targeted today.
3.2 Workflow-store (where flows like discharge live)
/workflow-storewith phase tabs (registration, opd, ipd, discharge, cashier-pharmacy, coder).- Discharge is a real
workflow_templatesrow, materialized + self-healed byDischargeFlowSection.tsxviadischargeFlowTemplate.ts(9 nodes:discharge → payments → account-close → medical-coding → claim-submission-by-scheme → wait-for-rep → claim-invoice-issue), now editable inline via the embedded ReactFlow editor. - Gap: those discharge nodes currently exist as canvas/visualization nodes. To be configurable worklist tabs they need to be runtime worklist nodes (Section 6).
3.3 Module rendering (the compose engine)
DynamicContentRenderer: mounts any of 268DynamicCoreAppmodules from a string key, injectingpatientRef/encounterRef/patientContext. Already used bySplitLayoutModalfor the miniapp-override system.- This is the existing, working seam for composed pages — no new renderer needed.
3.4 Routing & navigation (mostly static)
| Concern | Today | Implication |
|---|---|---|
| Routes | Static JSX in routes.tsx |
New routes need a catch-all, not per-page ``s |
| Nav menu | 100% hardcoded menuData.tsx (horizontalMenuItems: IMenuItem[]) |
Need a DB merge layer over the static menu |
| “Not built yet” convention | /no-page?pageAt=X placeholders already in the menu |
A DB nav entry can replace these by pointing at a composed page |
| Dormant prototype | page-registry.ts + dynamic-routes.tsx + hospital-config.ts, never wired in |
⚠️ Uses import(/* @vite-ignore */ runtimePath) — fragile, won’t resolve reliably in a prod bundle. Treat as a cautionary reference, not the mechanism. Reuse its idea (registry → routes, kit-gating) but render via DynamicContentRenderer, not arbitrary dynamic import. |
3.5 Per-device today
Only localStorage (NavigationSettingsProvider, hospital_config, theme/locale). No workstation identity, no backend sync. Net-new (Section 7) but small.
4. Architecture overview
Four layers. Layer A is Phase 1 (extend the existing admin). Layers B–D are the new self-service substrate. All four are config tables, distinct from the read models (encounter_journey_cache, department_queues) which the frontend must never write.
┌─────────────────────────────────────────────┐
│ SCOPE PRECEDENCE (most specific wins) │
│ workstation > role > department > org > global│
└─────────────────────────────────────────────┘
Layer A Flow coverage worklist-config now targets ANY workflow-store flow
(Phase 1) (discharge nodes become real worklist nodes)
│ keyed (dept, subdept, workflow_id, node_id) — already supported
Layer B Page composition custom_pages: a named arrangement of existing modules
(Phase 2) rendered at /w/:slug → CustomPageHost → DynamicContentRenderer
Layer C Navigation nav_entries: DB nav merged OVER static menuData.tsx
(Phase 2) target = route:/path OR page:slug
Layer D Scope & device workstations + workstation_layouts:
(Phase 3) "this laptop → this landing page → this nav set"
5. Data model (proposed)
All new tables are config tables (frontend may read/write via service+RPC, exactly like workflow_action_configurations). All carry a *_history companion + a save_* RPC for atomic versioned writes, mirroring the existing pattern. All scope columns are nullable; resolution precedence in Section 8.
5.1 custom_pages (Layer B)
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | |
slug |
text unique | URL segment → /w/:slug |
title |
text | display name |
title_i18n |
jsonb | { en, th, ... } — bilingual required (CLAUDE.md rule 7) |
composition |
jsonb | layout spec, see below |
organization_id / department_id / role |
text / uuid / text, nullable | scope |
requires_patient |
bool | true if any slot module is patient-scoped (Section 8.3) |
enabled |
bool | |
created_by / timestamps |
composition JSONB:
{
"layout": "single | tabs | grid", // v1: keep to these three
"slots": [
{
"moduleKey": "modules.LabResultsWidget", // MUST be a DynamicCoreApp key with a DCR case
"title": "Lab Results",
"title_i18n": { "en": "Lab Results", "th": "ผลแลป" },
"span": 6, // grid only (12-col)
"props": {} // static props merged into the module
}
]
}
5.2 nav_entries (Layer C)
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | |
parent_id |
uuid nullable | nest under a section, or top-level |
title / title_i18n |
text / jsonb | bilingual |
icon |
text | MUI icon name or Iconify key (matches menuData convention) |
target |
text | route:/cashier?tab=ipd or page:my-ward-board (custom_pages slug) |
sort_order |
int | |
organization_id / department_id / role / workstation_id |
nullable | scope |
enabled |
bool |
5.3 workstations (Layer D)
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | |
workstation_key |
text unique | minted into localStorage on first boot (uuid) |
friendly_name |
text | “Triage Desk 2”, “Ward 4 Nurse Station” |
organization_id / department_id |
nullable | where the device lives |
last_seen_at |
timestamptz | heartbeat on boot |
enabled |
bool |
5.4 workstation_layouts (Layer D)
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | |
workstation_id |
uuid FK → workstations | |
default_landing |
text | route:/... or page:slug — where this laptop boots |
nav_entry_ids |
uuid[] | which nav entries show (or null = inherit dept/role) |
locale / theme |
text nullable | optional per-device overrides |
| timestamps |
Distinction to keep loud:
custom_pages,nav_entries,workstations,workstation_layoutsare configuration, not clinical read models. Writing to them from the frontend is allowed (same as worklist-config). They never hold patient/encounter state.
6. Phase 1 — extend worklist-config to discharge (and any flow)
This is the small, low-risk win and reuses everything. The config layer already keys on workflow_id + node_id; the work is making discharge nodes into real worklist nodes so they show up and render.
Per the verified node-render contract, a workflow node becomes a runtime worklist tab (table + row actions) only when it has all of:
- An entry in
MASTER_WORKFLOW_NODES(workflowState.ts) withfhir,queue.status,transitions, and anapiType(forworkflowActionRouter.tsendpoint routing). - Presence in the workflow JSON’s
navigation.mainTabs[](the worklist manifest) so the picker and tabs surface it. queueConfig.rowActions(default in JSON; overridable by worklist-config DB).
Steps:
- Register discharge nodes (
payments,account-close,medical-coding,claim-submission-by-scheme,wait-for-rep,claim-invoice-issue) inMASTER_WORKFLOW_NODESwith theirapiType(financial/RCM endpoints) +queue.status. (Check first — some may already exist under RCM naming.) - Add a discharge worklist manifest (or extend the discharge-pipeline workflow JSON) listing those nodes in
navigation.mainTabs[]with baseline columns +rowActions. - Worklist-config then works unchanged: select dept → select the discharge
workflow_id→ pick a node → configure actions / miniapp bindings, saved viasave_workflow_config. - Generalize the workflow picker in worklist-editor so it lists all workflow-store templates for the dept (it already reads
workflow_templates), making every phase’s flow configurable by the same UI.
Deliverable: the discharge pipeline (and any other workflow-store flow) is configurable in the existing admin with no new infrastructure.
⚠️
web/AGENTS.mdgate: this is workflow-sensitive. Before implementing, read the required docs (WORKFLOW_MANIFEST_WIRING_GUIDE.md+workflowState.tsSSOT — note the two master docs named in AGENTS.md don’t exist at the listed paths; use these). Backend stays write-truth; do not project transitions from the frontend.
7. Phase 2 — DB-driven pages + navigation
7.1 Pages: one catch-all route, render via DCR
- Add one route to
routes.tsx:/w/:slug→CustomPageHost(insideProtectedLayout). CustomPageHostloadscustom_pagesby slug, readscomposition, renders:single→ one module full-page,tabs→ a tab strip of modules (reuse existing tab UI),grid→ a 12-colBentoGrid-style layout.
- Each slot mounts through
DynamicContentRendererwithtabName = slot.moduleKey, passing patient/encounter context only whenrequires_patient(Section 8.3). - No
import(/* @vite-ignore */). Everything resolves through the statically-bundled DCR switch — that’s what makes it safe and reliable.
7.2 Navigation: merge DB over static
- New hook
useNavigationMenu()returnsmergeNav(horizontalMenuItems, dbNavEntries)wheredbNavEntriesarenav_entriesfiltered by current scope (org/dept/role/workstation). This mirrors the existingworkflow-config-merger.tsbase-over-DB pattern. - A nav entry’s
target:route:/existing/path→ normal navigation (can finally replace/no-page?pageAt=…stubs),page:slug→/w/slug(a composed page).
- Authoring UI: a new admin surface (
/admin/workspace-composer) — page builder (pick modules → arrange → name → save) + nav editor (label, icon, target, scope) + history. Reuse Bento + existing form patterns.
8. Runtime resolution & precedence
8.1 Boot sequence (per device)
App boot
→ read workstation_key from localStorage (mint uuid if absent)
→ upsert workstations (friendly_name optional, last_seen_at=now)
→ load workstation_layouts for this workstation
→ if default_landing set → navigate there
→ build nav = mergeNav(static menuData, nav_entries scoped to {org,dept,role,workstation})
→ render
8.2 Scope precedence (most specific wins)
workstation > role > department > organization > global default
Same spirit as worklist-config’s base < dept < role merge — workstation is simply the new, most-specific layer. A nav entry / page / layout row with a workstation_id overrides the role-scoped one, which overrides dept, etc.
8.3 Patient-context constraint (important)
Many DynamicCoreApp modules expect a patient/encounter (they live inside the patient profile). A top-level page at /w/:slug has no patient unless one is chosen. Therefore:
- The page builder tags each module as standalone-capable (worklists, dashboards, command centers) vs patient-scoped (MAR, nursing assessment, lab widget).
- A page mixing patient-scoped modules must declare
requires_patient = trueandCustomPageHostrenders a patient picker first (reuse the/sandbox/workspacepatient-picker pattern). - v1 recommendation: top-level composed pages offer standalone-capable modules only; patient-scoped composition stays inside the patient profile (which already has its tab system).
9. Permissions model
Separate authoring from binding, so “a nurse sets up her own laptop” doesn’t require giving her global page-authoring rights:
| Capability | Suggested permission | Who |
|---|---|---|
Create/edit custom_pages + global nav_entries |
admin.workspace-composer |
admin / super-admin |
| Bind a layout to this workstation (pick landing + nav from already-allowed pages) | workstation.self-config |
ward lead / charge nurse |
| View | inherited | all authenticated |
Backend permission truth is unchanged: a composed page that mounts a module still hits the same backend APIs, which still enforce 403s. Composition cannot grant data access the user didn’t already have.
10. Guardrails & invariants
- Compose, never author — only
DynamicCoreAppkeys that already have a DCRcaseare selectable. No runtime code, noeval, no arbitraryimport(). - Config tables ≠ read models — never write
encounter_journey_cache/department_queuesfrom these features. - Backend stays write-truth for all workflow transitions (Phase 1). No frontend projection writes.
- One catch-all route, not N dynamic routes; the dormant
import(@vite-ignore)prototype is not used. - Bilingual labels on every page title + nav entry (
title_i18n). - Versioned + audited — every config table gets a
*_historycompanion +save_*RPC (reuseworkflow_action_configuration_historyshape). - Permission is backend-enforced — composition never widens data access.
- Scope precedence is fixed — workstation > role > dept > org > global, everywhere.
- Patient-scoped modules only render where a patient context exists.
- Workstation identity is a soft id (localStorage uuid) — losing it = device re-registers, no data loss; it is not an auth boundary.
11. Out of scope (explicitly)
- Authoring brand-new modules, fields, or forms at runtime (needs deploy or a separate page-schema-interpreter project).
- A general drag-resize dashboarding engine — v1 layouts are
single | tabs | gridonly. - Per-workstation auth / kiosk lockdown beyond the existing public kiosk routes.
- Multi-tenancy changes (cloud tenancy is a separate cross-cutting migration, per
menu-price-ledger.mdD4).
12. Phased rollout
| Phase | Scope | New migrations | Risk |
|---|---|---|---|
| P1 | Register discharge (+ other flow) nodes; generalize workflow picker in worklist-config | none (data + JSON only) | low — reuses existing admin |
| P2a | custom_pages + CustomPageHost + /w/:slug route |
NNNN_custom_pages.sql (+ history + save_custom_page RPC) |
medium |
| P2b | nav_entries + useNavigationMenu merge + /admin/workspace-composer |
NNNN_nav_entries.sql (+ history) |
medium |
| P3 | workstations + workstation_layouts + boot sequence + workstation.self-config |
NNNN_workstations.sql |
low-medium |
Each phase is independently shippable and demo-able. P1 alone satisfies the literal “extend worklist-config to IPD discharge” request.
13. Open questions for review
- Route namespace:
/w/:slugacceptable, or prefer/workspace/:slug//page/:slug? (Must not collide with the ~150 existing top-level paths.) - Nurse self-config scope: should
workstation.self-configlet a nurse pick from any page she can view, or only from a dept-curated allow-list? - Layout richness for v1: is
single | tabs | gridenough, or is drag-resize needed day one? (Strongly recommend deferring drag-resize.) - Workstation registration: silent auto-register on first boot, or require an admin to name/approve a device before it can hold a layout?
- Phase 1 node naming: do RCM/discharge nodes already exist in
MASTER_WORKFLOW_NODESunder other names (avoid duplicates)? Needs a grep pass before P1. - Authoring surface: extend the existing
/admin/worklist-configUI, or a new sibling/admin/workspace-composer? (Leaning new sibling to keep concerns clean.)