IPD Nursing Assessment
One assessment modal across 3 surfaces, age-routed forms, write-through vitals to CDS + EWS + realtime fan-out.
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()andregisterSection/markSectionComplete/markCompleteto drive the bar - Supabase realtime subscription on
acknowledgement_requestsfiltered byackRequestIdauto-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
- Add the field to
DailyAssessmentForm(or another sub-form) - Map it in
writeVitalsToObservationPipelinewith the right LOINC code (look up inweb/src/services/observation-core/codes.ts) - CDS rules referencing that LOINC will auto-fire — no other wiring
Adding a new age bracket
- Add a new entry in
formData.tsFORM_BRACKETS - Update
getForm()andgetFormTitle()age range check - No frontend wiring change —
InitialIntakeFormpicks it up automatically
Invariants
- 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_ASSESSMENTor renderingNursingAssessmentDialogdirectly, migrate it to dispatchIpdNursingAssessment. - Vitals always flow through
writeThroughVitals— never write to observation tables directly from the form. The pipeline is responsible for CDS + EWS + realtime fan-out. writeThroughVitalsnever throws — fire-and-forget at the call site (.catch(() => {})); form submission must not block on observation write.- Age routing is implicit — never branch on
patient.agein calling code. PassbirthDatethrough and letgetForm()route. - GlobalModalRenderer is the prop translation layer — components downstream receive the canonical
patientData/selectDatashape regardless of whether the caller is Supabase- or MongoDB-sourced. Don’t push dual-shape handling into the component itself.