medOS ultra

IPD Nursing Assessment

One assessment modal across 3 surfaces, age-routed forms, write-through vitals to CDS + EWS + realtime fan-out.

8 min read diagramsUpdated 2026-05-25docs/architecture/ipd-nursing-assessment.md

How the IPD nursing assessment miniapp surfaces from three entry points (worklist, command center, workflow JSON menu), how it wires into the unified observation pipeline (CDS + EWS), and how the form-completion envelope tracks acknowledgement state across surfaces.

Three Surfaces, One Modal

Every nursing assessment entry point now converges on the same IpdNursingAssessment modal — registered once in modalRegistry, rendered by GlobalModalRenderer, dispatched via Redux actionModalResolver/openModal.

┌────────────────────────────┐  ┌────────────────────────────┐  ┌────────────────────────────┐
│ IPD Worklist Table         │  │ IPD Command Center Step 2  │  │ Workflow JSON menu item    │
│ (AllPatientTable row btn)  │  │ (Step2AssessmentPanel)     │  │ (open-nursing-assessment)  │
└──────────────┬─────────────┘  └──────────────┬─────────────┘  └──────────────┬─────────────┘
               │                               │                               │
               └───────────────┬───────────────┴───────────────┬───────────────┘
                               ▼                               ▼
                  dispatch({ type: 'actionModalResolver/openModal',
                             payload: { modalId: 'IpdNursingAssessment', data: {...} } })
                               │
                               ▼
                  ┌──────────────────────────────┐
                  │ Redux actionModalResolver    │
                  │ activeModals[]               │
                  └──────────────┬───────────────┘
                                 ▼
                  ┌──────────────────────────────┐
                  │ GlobalModalRenderer          │
                  │   prop mapping for           │
                  │   IpdNursingAssessment       │
                  └──────────────┬───────────────┘
                                 ▼
                  ┌──────────────────────────────┐
                  │ <IpdNursingAssessment        │
                  │   patientData={...}          │
                  │   selectData={...}           │
                  │   isModal />                 │
                  └──────────────────────────────┘

Component Anatomy

IpdNursingAssessment (packages/miniapps/ipd-nursing-assessment/)
├── EWS + CDS header strip          ← lazy-loaded, graceful null fallback
├── Tab 1: แรกรับ (Initial)         ← InitialIntakeForm
│   └── getForm(birthDate)          ← age-routed form (4 brackets)
│       ├── newborn-1mo
│       ├── 1-12y
│       ├── 12-60y
│       └── 60+
└── Tab 2: ประจำวัน (Daily)         ← DailyAssessmentForm
    ├── ShiftSelector (เช้า/บ่าย/ดึก)
    ├── Vitals grid (BP, HR, RR, Temp, SpO2, Pain, GCS)
    ├── Inline EWS + CDS badges
    ├── Free-text notes
    └── AssessmentTimeline           ← previous daily entries

Age-Based Form Routing

InitialIntakeForm calls getForm(birthDate, formType) and getFormTitle(birthDate) from formData.ts. The patient’s birthDate flows in from GlobalModalRenderer:

Age bracket Title
< 1 month แบบประเมินทางการพยาบาลแรกรับเข้าหอผู้ป่วยสำหรับอายุ แรกเกิด - 1 เดือน
1 – 12 years แบบประเมินทางการพยาบาลแรกรับเข้าหอผู้ป่วยสำหรับอายุ > 1 - 12 ปี
12 – 60 years แบบประเมินทางการพยาบาลแรกรับเข้าหอผู้ป่วยสำหรับอายุ > 12 ปี - 60 ปี
> 60 years แบบประเมินทางการพยาบาลแรกรับเข้าหอผู้ป่วยสำหรับผู้สูงอายุ > 60 ปี

Resolution happens automatically — no per-bracket entry point or routing config.

Unified Observation Pipeline

Every vital sign captured in the assessment flows through the canonical observation pipeline so CDS, EWS, the graphic sheet, and every realtime surface stay in sync.

DailyAssessmentForm.handleSubmit
  └─▶ writeVitalsToObservationPipeline(patientId, encounterId, vitals, shift)
        └─▶ writeThroughVitals({ sourceKind: 'ipd-nursing-assessment', components: [...] })
              └─▶ recordObservation()
                    └─▶ POST /v2/diagnostic/observations
                          └─▶ Backend stores in observations table
                                ├─▶ NATS emit VITALS_DOCUMENTED
                                │    └─▶ encounter-orchestrator
                                │          ├─▶ refreshPatientEws()    ← NEWS2/MEWS recompute
                                │          └─▶ dispatchCdsForObservation() ← fire CDS rules
                                └─▶ Supabase realtime broadcast
                                      └─▶ All open VitalsTrendCard / graphic sheet / EWS badge re-render

What Gets Captured

DailyAssessmentForm maps form vitals → ObservationComponentInput[] with LOINC codes:

Form field LOINC Unit
bloodPressureSystolic/Diastolic 85354-9 mm[Hg]
pulse 8867-4 /min
temperature 8310-5 Cel
respiratoryRate 9279-1 /min
oxygenSaturation 59408-5 %
painScore 38208-5 {score}
consciousness (GCS) 9269-2 {score}

InitialIntakeForm scans formPatientInfo + formHealthAssessment for known vital-sign field keys (pulse, temp, rr, spo2, weight, height, bp, etc.) and pushes the same way.

Source Kind Registration

Added ipd-nursing-assessment (and the auxiliary vital-signs-summary) to ObservationSourceKind in web/src/services/observation-core/types.ts so the backend audit log distinguishes nursing assessments from screening, graphic sheet, partograph, lab feeds, HL7v2, etc.

Form Completion Envelope

FormCompletionContext (in @medical-kit/form-completion-envelope) wraps the assessment in a status-aware shell:

state.status: 'draft' → 'complete' → 'ack_requested' → 'acknowledged' | 'declined'
  • Header: progress bar + status chip
  • Footer: ack request panel + decline reason capture
  • Children call useFormCompletion() and registerSection / markSectionComplete / markComplete to drive the bar
  • Supabase realtime subscription on acknowledgement_requests filtered by ackRequestId auto-locks the form when the reviewer acknowledges, unlocks + shows decline reason when declined — no manual refresh needed across surfaces

Configurable Worklist Column

Any worklist can show an assessment status chip + click-to-open without code changes:

AssessmentColumnCellConnected (Redux-connected)
  ├─▶ derives status from row.nursing_assessment metadata
  ├─▶ icon chip: ✓ complete / ⌛ in-progress / ○ not started
  └─▶ onClick → dispatch openModal('IpdNursingAssessment', { preData: row })

Drop it into any EnhancedActionConfig column or row-action menu. The cell handles dual data shapes (Supabase patient_id / encounter_id and MongoDB _id / encounterRef._id) via resolveRowIdentifiers().

Data Flow at Runtime

┌─ Entry surface ────────────────────────────────────────────────────┐
│  (1) Worklist row btn        (2) Command Center Step 2  (3) Menu  │
└──────────────────┬──────────────────┬────────────────────┬─────────┘
                   │                  │                    │
                   ▼                  ▼                    ▼
        workflowActionHandler   Step2AssessmentPanel   dialogDynamicCore
        handleModalAction()     openNursingAssessment()  menu handler
                   │                  │                    │
                   └────────┬─────────┴───────┬────────────┘
                            ▼                 ▼
              dispatch({ actionModalResolver/openModal,
                         payload: { modalId: 'IpdNursingAssessment',
                                    data: { patientId, encounterId,
                                            patientData, preData, ... } } })
                            │
                            ▼
              ┌─────────────────────────────────┐
              │ GlobalModalRenderer             │
              │   - resolves IDs (Supabase or   │
              │     MongoDB shape)              │
              │   - resolves birthDate          │
              │   - builds patientDataForCmp +  │
              │     selectDataForCmp            │
              └────────────┬────────────────────┘
                           ▼
              ┌─────────────────────────────────┐
              │ <IpdNursingAssessment isModal/> │
              │  ┌─ EWS + CDS strip ──────────┐ │
              │  ├─ Tab 1: InitialIntakeForm  │ │
              │  │  (age-routed by birthDate) │ │
              │  └─ Tab 2: DailyAssessmentForm│ │
              │     ├─ Inline EWS/CDS badges  │ │
              │     └─ on save:               │ │
              │        writeThroughVitals → ↓ │ │
              └────────────┬────────────────────┘
                           ▼
              ┌─────────────────────────────────┐
              │ Observation Pipeline            │
              │  → observations table           │
              │  → VITALS_DOCUMENTED            │
              │  → encounter-orchestrator       │
              │     ├─ refresh EWS              │
              │     └─ dispatch CDS rules       │
              │  → Supabase realtime fan-out    │
              └─────────────────────────────────┘

Key Files

File Role
web/packages/miniapps/ipd-nursing-assessment/IpdNursingAssessment.tsx Root component — EWS/CDS header strip + 2 tabs
web/packages/miniapps/ipd-nursing-assessment/components/InitialIntakeForm.tsx Initial-intake form, calls getForm(birthDate) for age routing, pushes vitals to observation pipeline
web/packages/miniapps/ipd-nursing-assessment/components/DailyAssessmentForm.tsx Per-shift daily form, inline EWS+CDS badges, writeVitalsToObservationPipeline on save
web/packages/miniapps/ipd-nursing-assessment/components/AssessmentTimeline.tsx Previous daily entries list
web/packages/miniapps/ipd-nursing-assessment/components/AssessmentStatusBadge.tsx Status chip used in tab header
web/packages/miniapps/ipd-nursing-assessment/components/AssessmentColumnCell.tsx Reusable worklist column cell (presentational)
web/packages/miniapps/ipd-nursing-assessment/components/AssessmentColumnCellConnected.tsx Redux-connected variant — dispatches modal on click
web/packages/miniapps/ipd-nursing-assessment/components/ShiftSelector.tsx เช้า/บ่าย/ดึก shift picker
web/src/common/components/medical/builder/modal/GlobalModalRenderer.tsx Prop mapping block for IpdNursingAssessment (resolves patient/encounter/birthDate from both Supabase and MongoDB shapes)
web/src/common/components/medical/builder/modal/registry/modalRegistry.ts Registers IpdNursingAssessment in CORE_MODAL_COMPONENTS
web/src/setup/dynamic/central-table/handlers/workflowActionHandler.ts IpdNursingAssessment dispatch branch — uses resolveRowIdentifiers()
web/packages/medical-kit/src/medical-worklist/defaults/nurse-ipd-workflow.json Workflow JSON — ipd-nursing-assessment node + shared open-nursing-assessment menu item on every node
web/packages/medical-kit/src/ipd-system/command-center/Step2AssessmentPanel.tsx Command Center Step 2 — Redux dispatch (replaces old DIALOG_NURSING_ASSESSMENT + NursingAssessmentDialog)
web/packages/medical-kit/src/ipd-system/command-center/useCommandCenter.ts Adds patientId to EncounterCache (extracted from encounter_journey_cache.patient_id)
web/packages/medical-kit/src/form-completion-envelope/FormCompletionContext.tsx Supabase realtime subscription on acknowledgement_requests for ack lifecycle
web/src/utils/observation-write-through.ts writeThroughVitals() — INVARIANT: never throws
web/src/services/observation-core/types.ts Adds ipd-nursing-assessment to ObservationSourceKind
web/src/services/observation-core/codes.ts DISPLAY_TO_LOINC mapping (Pulse/Temperature/SpO2/etc.)
web/src/common/components/medical/observation/EarlyWarningScoreBadge.tsx EWS badge component (lazy-loaded by the assessment)
web/src/common/components/medical/surface/cds-alert/InlineCdsAlertBadge.tsx Inline CDS alert badge (lazy-loaded by the assessment)
web/packages/medical-kit/src/ipd-system/ipd-management/components/dashboard/dialog-assessment-form-v2/formData.ts getForm(birthDate, formType) + getFormTitle(birthDate) — age routing source

GlobalModalRenderer Prop Mapping

if (modalId === 'IpdNursingAssessment') {
  const sourceData = enhancedData.patientData || enhancedData.preData || enhancedData.patient || enhancedData.data;
  const raw = sourceData?._raw || sourceData || {};
  const patientId = enhancedData.patientId
    || raw.patient_id
    || sourceData?.patientId
    || sourceData?.patient_id
    || sourceData?.patientRef?._id
    || sourceData?._id;
  const encounterId = enhancedData.encounterId
    || raw.encounter_id
    || sourceData?.encounterId
    || sourceData?.encounter_id
    || sourceData?.encounterRef?._id
    || sourceData?.lastEncounter?._id;
  const birthDate = raw.patient_birthdate
    || sourceData?.birthDate
    || sourceData?.dateOfBirth
    || sourceData?.patientRef?.birthDate
    || '';
  // ... builds patientDataForComponent + selectDataForComponent
  return (
    <ModalErrorBoundary key={modalId} modalId={modalId} onClose={() => dispatch(removeModalResolver(modalId))}>
      <ModalComponent {...mappedProps} />
    </ModalErrorBoundary>
  );
}

Flow-validation bypass list (around line 945) explicitly includes 'IpdNursingAssessment' so the modal opens directly without going through the workflow transition gate.

Extension Points

Adding the assessment to a new worklist column

import { AssessmentColumnCellConnected } from '@miniapps/ipd-nursing-assessment';

// In a column config:
{
  field: 'assessmentStatus',
  headerName: 'ประเมิน',
  renderCell: (params) => (
    <AssessmentColumnCellConnected
      row={params.row}
      assessmentType="initial"   // or "daily" or undefined for both
      compact
    />
  ),
}

Adding the menu item to another workflow node

Add open-nursing-assessment to the node’s data.menus[]:

{
  "id": "open-nursing-assessment",
  "type": "dialogDynamicCore",
  "title": "แบบประเมินทางการพยาบาล",
  "label": "ประเมิน",
  "label_en": "Assessment",
  "icon": "assignment",
  "modalConfig": { "modalId": "IpdNursingAssessment" }
}

Capturing a new vital sign

  1. Add the field to DailyAssessmentForm (or another sub-form)
  2. Map it in writeVitalsToObservationPipeline with the right LOINC code (look up in web/src/services/observation-core/codes.ts)
  3. CDS rules referencing that LOINC will auto-fire — no other wiring

Adding a new age bracket

  1. Add a new entry in formData.ts FORM_BRACKETS
  2. Update getForm() and getFormTitle() age range check
  3. No frontend wiring change — InitialIntakeForm picks it up automatically

Invariants

  1. One modal, all surfaces — there is no separate dialog for command center vs. worklist vs. menu. If you find a surface still using DIALOG_NURSING_ASSESSMENT or rendering NursingAssessmentDialog directly, migrate it to dispatch IpdNursingAssessment.
  2. Vitals always flow through writeThroughVitals — never write to observation tables directly from the form. The pipeline is responsible for CDS + EWS + realtime fan-out.
  3. writeThroughVitals never throws — fire-and-forget at the call site (.catch(() => {})); form submission must not block on observation write.
  4. Age routing is implicit — never branch on patient.age in calling code. Pass birthDate through and let getForm() route.
  5. GlobalModalRenderer is the prop translation layer — components downstream receive the canonical patientData / selectData shape regardless of whether the caller is Supabase- or MongoDB-sourced. Don’t push dual-shape handling into the component itself.
Ask Anything