diff --git a/vigilcare-dashboard/src/__tests__/HandoffReport.test.js b/vigilcare-dashboard/src/__tests__/HandoffReport.test.js new file mode 100644 index 0000000..08f3a6e --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/HandoffReport.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import HandoffReport from '@/components/ward/HandoffReport.vue' + +const mockReport = { + generatedAt: '2026-06-23T08:00:00Z', + generatedBy: 'Test Nurse', + summary: { + department: 'ICU', + patientCount: 1, + criticalCount: 1, + alertCount: 2, + activeBundles: 1, + }, + patients: [ + { + encounterId: 'enc-1', + name: 'Jane Doe', + mrn: 'MRN001', + room: 'ICU-3', + department: 'ICU', + attending: 'Dr Smith', + news2: 8, + sofa: 6, + sofaDelta: 2, + gcs: 14, + qsofa: 2, + vitalsSummary: 'HR 110 bpm', + alertsSummary: 'NEWS2 Emergency', + pendingOrders: 'Blood cultures', + sbar: { + situation: 'Sepsis workup', + background: 'Allergies: Penicillin', + assessment: 'NEWS2 8', + recommendation: 'Blood cultures', + }, + }, + ], +} + +vi.mock('@/composables/handoffReport', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + buildHandoffReport: vi.fn(() => Promise.resolve(mockReport)), + } +}) + +describe('HandoffReport', () => { + it('rendersWardSummaryAndPatientRows', async () => { + const wrapper = mount(HandoffReport, { + props: { + encounters: [{ encounterId: 'enc-1' }], + department: 'ICU', + generatedBy: 'Test Nurse', + }, + attachTo: document.body, + }) + + await vi.waitFor(() => expect(document.body.textContent).toContain('Ward Summary')) + expect(document.body.textContent).toContain('Jane Doe') + expect(document.body.textContent).toContain('SBAR') + expect(document.body.textContent).toContain('Sepsis workup') + wrapper.unmount() + document.body.innerHTML = '' + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/VitalsEntryForm.test.js b/vigilcare-dashboard/src/__tests__/VitalsEntryForm.test.js new file mode 100644 index 0000000..bc00dc1 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/VitalsEntryForm.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import VitalsEntryForm from '@/components/patient/VitalsEntryForm.vue' + +describe('VitalsEntryForm', () => { + it('rendersVitalSignFields', () => { + const wrapper = mount(VitalsEntryForm) + expect(wrapper.text()).toContain('Heart Rate') + expect(wrapper.text()).toContain('SpO₂') + expect(wrapper.text()).toContain('AVPU') + }) + + it('emitsBatchObservationsOnSubmit', async () => { + const wrapper = mount(VitalsEntryForm) + await wrapper.findAll('input[type="number"]')[0].setValue('90') + await wrapper.find('select').setValue('0') + await wrapper.find('button').trigger('click') + + const payload = wrapper.emitted('submit')?.[0]?.[0] + expect(payload).toHaveLength(2) + expect(payload).toEqual(expect.arrayContaining([ + expect.objectContaining({ observationCode: 'HEART_RATE', value: 90 }), + expect.objectContaining({ observationCode: 'AVPU', value: 0 }), + ])) + }) + + it('showsPlausibilityError', async () => { + const wrapper = mount(VitalsEntryForm) + await wrapper.findAll('input[type="number"]')[0].setValue('999') + await wrapper.find('button').trigger('click') + expect(wrapper.emitted('submit')).toBeUndefined() + expect(wrapper.text()).toContain('Value must be between 1 and 300') + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/handoffReport.test.js b/vigilcare-dashboard/src/__tests__/handoffReport.test.js new file mode 100644 index 0000000..4e48813 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/handoffReport.test.js @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { + buildPatientReport, + buildSbar, + buildWardSummary, + extractLatestVitals, + formatAlertsSummary, + formatVitalsSummary, +} from '@/composables/handoffReport' + +const wardPatient = { + encounterId: 'enc-1', + firstName: 'Jane', + lastName: 'Doe', + mrn: 'MRN001', + roomBed: 'ICU-3', + department: 'ICU', + attendingPhysician: 'Dr Smith', + news2Score: 8, + sofaScore: 6, + sofaDelta: 2, + gcsScore: 14, + qsofaScore: 2, + openAlertCount: 1, +} + +const enrichment = { + encounter: { + admissionReason: 'Sepsis workup', + patient: { allergies: 'Penicillin' }, + }, + openAlerts: [{ alertType: 'News2Emergency' }], + pendingOrders: [{ description: 'Blood cultures', status: 'Pending' }], + vitals: extractLatestVitals([ + { observationCode: 'HEART_RATE', value: 110, unit: 'bpm', recordedAt: '2026-06-23T10:00:00Z' }, + { observationCode: 'SPO2', value: 94, unit: '%', recordedAt: '2026-06-23T10:00:00Z' }, + ]), + bundle: null, +} + +describe('handoffReport', () => { + it('buildsWardSummary', () => { + const summary = buildWardSummary( + [ + wardPatient, + { news2Score: 3, openAlertCount: 0, sepsisActive: true }, + ], + 'ICU', + ) + expect(summary).toMatchObject({ + department: 'ICU', + patientCount: 2, + criticalCount: 1, + alertCount: 1, + activeBundles: 1, + }) + }) + + it('formatsLatestVitals', () => { + expect(formatVitalsSummary(enrichment.vitals)).toContain('HR 110 bpm') + expect(formatVitalsSummary(enrichment.vitals)).toContain('SpO₂ 94%') + }) + + it('formatsAlertSummary', () => { + expect(formatAlertsSummary(enrichment.openAlerts)).toContain('NEWS2 Emergency') + }) + + it('buildsPatientReportWithSbar', () => { + const report = buildPatientReport(wardPatient, enrichment) + expect(report.name).toBe('Jane Doe') + expect(report.pendingOrders).toContain('Blood cultures') + expect(report.sbar.situation).toBe('Sepsis workup') + expect(report.sbar.background).toContain('Penicillin') + expect(report.sbar.assessment).toContain('NEWS2 8') + expect(report.sbar.recommendation).toContain('Blood cultures') + }) + + it('includesBundleInRecommendation', () => { + const sbar = buildSbar(wardPatient, enrichment.encounter, { + ...enrichment, + pendingOrders: [], + bundle: { + complianceStatus: 'IN_PROGRESS', + elements: [ + { elementType: 'BloodCultures', status: 'Pending' }, + { elementType: 'SerumLactate', status: 'Completed' }, + ], + }, + }) + expect(sbar.recommendation).toContain('Sepsis bundle') + expect(sbar.recommendation).toContain('Blood cultures') + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/vitalsForm.test.js b/vigilcare-dashboard/src/__tests__/vitalsForm.test.js new file mode 100644 index 0000000..73f9694 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/vitalsForm.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { + buildVitalsObservations, + isPlausibleValue, + validateVitalsForm, +} from '@/composables/vitalsForm' + +describe('vitalsForm', () => { + it('validatesPlausibleRanges', () => { + expect(isPlausibleValue('HEART_RATE', 78)).toBe(true) + expect(isPlausibleValue('HEART_RATE', 350)).toBe(false) + expect(isPlausibleValue('TEMP_C', 37.2)).toBe(true) + expect(isPlausibleValue('TEMP_C', 10)).toBe(false) + }) + + it('requiresAtLeastOneValue', () => { + const result = validateVitalsForm({ + heartRate: '', + respRate: '', + systolicBp: '', + diastolicBp: '', + spo2: '', + tempC: '', + avpu: '', + }) + expect(result.valid).toBe(false) + expect(result.errors._form).toBeTruthy() + }) + + it('buildsBatchObservations', () => { + const observations = buildVitalsObservations({ + heartRate: '88', + respRate: '18', + systolicBp: '120', + diastolicBp: '80', + spo2: '97', + tempC: '37.1', + avpu: '0', + }, '2026-06-23T12:00:00Z') + + expect(observations).toHaveLength(7) + expect(observations[0]).toMatchObject({ + observationCode: 'HEART_RATE', + value: 88, + unit: 'bpm', + source: 'Manual', + recordedAt: '2026-06-23T12:00:00Z', + }) + expect(observations.find(obs => obs.observationCode === 'AVPU')?.value).toBe(0) + }) +}) diff --git a/vigilcare-dashboard/src/api/encounters.js b/vigilcare-dashboard/src/api/encounters.js index 1e6d83b..4a42441 100644 --- a/vigilcare-dashboard/src/api/encounters.js +++ b/vigilcare-dashboard/src/api/encounters.js @@ -44,4 +44,10 @@ export async function fetchObservations(encounterId, { limit = 50 } = {}) { export function fetchTimeline(encounterId) { return api.get(`/api/v1/encounters/${encounterId}/timeline`) +} + +export function submitVitalsObservations(encounterId, observations) { + return api.post(`/api/v1/encounters/${encounterId}/observations`, { + observations, + }) } \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/patient/AlertsList.vue b/vigilcare-dashboard/src/components/patient/AlertsList.vue index 41c6a9e..c8176f3 100644 --- a/vigilcare-dashboard/src/components/patient/AlertsList.vue +++ b/vigilcare-dashboard/src/components/patient/AlertsList.vue @@ -1,5 +1,6 @@ + + diff --git a/vigilcare-dashboard/src/components/patient/VitalsPanel.vue b/vigilcare-dashboard/src/components/patient/VitalsPanel.vue index 58cce65..acbd53f 100644 --- a/vigilcare-dashboard/src/components/patient/VitalsPanel.vue +++ b/vigilcare-dashboard/src/components/patient/VitalsPanel.vue @@ -1,12 +1,21 @@ + + diff --git a/vigilcare-dashboard/src/components/ward/HandoffReport.vue b/vigilcare-dashboard/src/components/ward/HandoffReport.vue new file mode 100644 index 0000000..e7835e8 --- /dev/null +++ b/vigilcare-dashboard/src/components/ward/HandoffReport.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/vigilcare-dashboard/src/components/ward/WardToolbar.vue b/vigilcare-dashboard/src/components/ward/WardToolbar.vue index 7ca72f7..07d6b08 100644 --- a/vigilcare-dashboard/src/components/ward/WardToolbar.vue +++ b/vigilcare-dashboard/src/components/ward/WardToolbar.vue @@ -3,8 +3,10 @@ import { storeToRefs } from 'pinia' import { useWardStore } from '@/stores/ward' import Button from '@/components/ui/Button.vue' +const emit = defineEmits(['export-handoff']) + const wardStore = useWardStore() -const { searchInput, filters, hasActiveFilters } = storeToRefs(wardStore) +const { searchInput, filters, hasActiveFilters, encounters } = storeToRefs(wardStore) const filterOptions = [ { key: 'hasAlerts', label: 'Has alerts' }, @@ -45,6 +47,15 @@ const filterOptions = [ > Clear filters + + diff --git a/vigilcare-dashboard/src/composables/handoffReport.js b/vigilcare-dashboard/src/composables/handoffReport.js new file mode 100644 index 0000000..88c20b0 --- /dev/null +++ b/vigilcare-dashboard/src/composables/handoffReport.js @@ -0,0 +1,174 @@ +import { fetchEncounter, fetchObservations } from '@/api/encounters' +import { fetchAlerts } from '@/api/alerts' +import { fetchOrders, fetchSepsisBundle } from '@/api/clinical' +import { alertTypeLabel, bundleElementLabel } from '@/api/normalize' +import { formatAllergiesDisplay } from '@/composables/patientFormat' +import { formatDepartment, complianceStatusLabel, outstandingElements } from '@/composables/sepsisFormat' + +const VITAL_CODES = ['HEART_RATE', 'RESP_RATE', 'SYSTOLIC_BP', 'DIASTOLIC_BP', 'SPO2', 'TEMP_C'] + +export function extractLatestVitals(observations) { + const map = {} + for (const obs of observations ?? []) { + if (!VITAL_CODES.includes(obs.observationCode)) continue + const existing = map[obs.observationCode] + if (!existing || new Date(obs.recordedAt) > new Date(existing.recordedAt)) { + map[obs.observationCode] = obs + } + } + return map +} + +export function formatVitalsSummary(vitals) { + const parts = [] + if (vitals.HEART_RATE) parts.push(`HR ${vitals.HEART_RATE.value} bpm`) + if (vitals.RESP_RATE) parts.push(`RR ${vitals.RESP_RATE.value}/min`) + if (vitals.SYSTOLIC_BP && vitals.DIASTOLIC_BP) { + parts.push(`BP ${vitals.SYSTOLIC_BP.value}/${vitals.DIASTOLIC_BP.value} mmHg`) + } else if (vitals.SYSTOLIC_BP) { + parts.push(`BP ${vitals.SYSTOLIC_BP.value} mmHg`) + } + if (vitals.SPO2) parts.push(`SpO₂ ${vitals.SPO2.value}%`) + if (vitals.TEMP_C) parts.push(`Temp ${vitals.TEMP_C.value}°C`) + return parts.join(' · ') || 'No recent vitals' +} + +export function formatAlertsSummary(alerts) { + if (!alerts?.length) return 'No open alerts' + return alerts.map(alert => alertTypeLabel(alert.alertType)).join('; ') +} + +export function formatPendingOrdersSummary(orders) { + if (!orders?.length) return '' + return orders.map(order => order.description).join('; ') +} + +export function buildWardSummary(encounters, department) { + const activeBundles = encounters.filter( + patient => + patient.sepsisActive + || patient.sepsisBundleStatus === 'IN_PROGRESS' + || patient.sepsisBundleStatus === 'InProgress', + ).length + + return { + department: department ? formatDepartment(department) : 'All departments', + patientCount: encounters.length, + criticalCount: encounters.filter(patient => (patient.news2Score ?? 0) >= 7).length, + alertCount: encounters.reduce((sum, patient) => sum + (patient.openAlertCount ?? 0), 0), + activeBundles, + } +} + +export function buildSbar(wardPatient, encounter, enrichment) { + const patient = encounter?.patient ?? {} + const scores = [ + wardPatient.news2Score != null ? `NEWS2 ${wardPatient.news2Score}` : null, + wardPatient.sofaScore != null + ? `SOFA ${wardPatient.sofaScore}${wardPatient.sofaDelta ? ` (Δ+${wardPatient.sofaDelta})` : ''}` + : null, + wardPatient.gcsScore != null ? `GCS ${wardPatient.gcsScore}` : null, + `qSOFA ${wardPatient.qsofaScore ?? 0}`, + ].filter(Boolean).join(', ') + + const assessmentParts = [scores] + const vitals = formatVitalsSummary(enrichment.vitals) + if (vitals !== 'No recent vitals') assessmentParts.push(vitals) + const alerts = formatAlertsSummary(enrichment.openAlerts) + if (alerts !== 'No open alerts') assessmentParts.push(alerts) + + let recommendation = formatPendingOrdersSummary(enrichment.pendingOrders) + const bundle = enrichment.bundle + if (bundle) { + const outstanding = outstandingElements(bundle) + .map(element => bundleElementLabel(element.elementType)) + .join(', ') + const bundleNote = `Sepsis bundle: ${complianceStatusLabel(bundle.complianceStatus)}${ + outstanding ? ` — outstanding: ${outstanding}` : '' + }` + recommendation = recommendation ? `${recommendation}; ${bundleNote}` : bundleNote + } + if (enrichment.openAlerts?.length) { + const alertNote = `${enrichment.openAlerts.length} open alert(s) require attention` + recommendation = recommendation ? `${recommendation}; ${alertNote}` : alertNote + } + + return { + situation: encounter?.admissionReason ?? 'Admission reason not documented', + background: `Allergies: ${formatAllergiesDisplay(patient.allergies)}`, + assessment: assessmentParts.join('. '), + recommendation: recommendation || 'No pending actions documented', + } +} + +export function buildPatientReport(wardPatient, enrichment) { + return { + encounterId: wardPatient.encounterId, + name: `${wardPatient.firstName} ${wardPatient.lastName}`.trim(), + mrn: wardPatient.mrn, + room: wardPatient.roomBed ?? '—', + department: formatDepartment(wardPatient.department), + attending: wardPatient.attendingPhysician ?? '—', + news2: wardPatient.news2Score, + sofa: wardPatient.sofaScore, + sofaDelta: wardPatient.sofaDelta, + gcs: wardPatient.gcsScore, + qsofa: wardPatient.qsofaScore, + alertsSummary: formatAlertsSummary(enrichment.openAlerts), + pendingOrders: formatPendingOrdersSummary(enrichment.pendingOrders) || 'None', + vitalsSummary: formatVitalsSummary(enrichment.vitals), + sbar: buildSbar(wardPatient, enrichment.encounter, enrichment), + } +} + +export async function loadHandoffEnrichment(encounters) { + const entries = await Promise.all( + encounters.map(async wardPatient => { + const id = wardPatient.encounterId + const [enc, alertsData, ordersData, observations, bundle] = await Promise.all([ + fetchEncounter(id).catch(() => null), + fetchAlerts(id, 'OPEN').catch(() => ({ items: [] })), + fetchOrders(id).catch(() => ({ items: [] })), + fetchObservations(id, { limit: 50 }).catch(() => []), + fetchSepsisBundle(id).catch(() => null), + ]) + const orders = ordersData.items ?? ordersData ?? [] + return [ + id, + { + encounter: enc, + openAlerts: alertsData.items ?? [], + pendingOrders: orders.filter( + order => order.status === 'Pending' || order.status === 'InProgress', + ), + vitals: extractLatestVitals(observations), + bundle, + }, + ] + }), + ) + return Object.fromEntries(entries) +} + +export async function buildHandoffReport(encounters, department, generatedBy) { + const enrichment = await loadHandoffEnrichment(encounters) + return { + generatedAt: new Date().toISOString(), + generatedBy, + summary: buildWardSummary(encounters, department), + patients: encounters.map(patient => + buildPatientReport(patient, enrichment[patient.encounterId]), + ), + } +} + +export function formatReportTimestamp(iso) { + if (!iso) return '' + return new Date(iso).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} diff --git a/vigilcare-dashboard/src/composables/vitalsForm.js b/vigilcare-dashboard/src/composables/vitalsForm.js new file mode 100644 index 0000000..96f1a5c --- /dev/null +++ b/vigilcare-dashboard/src/composables/vitalsForm.js @@ -0,0 +1,88 @@ +export const PLAUSIBILITY_RANGES = { + HEART_RATE: { min: 1, max: 300 }, + TEMP_C: { min: 15, max: 50 }, + SPO2: { min: 50, max: 100 }, + RESP_RATE: { min: 1, max: 80 }, + SYSTOLIC_BP: { min: 40, max: 300 }, + DIASTOLIC_BP: { min: 20, max: 200 }, + AVPU: { min: 0, max: 3 }, +} + +export const AVPU_OPTIONS = [ + { value: 0, label: '0 — Alert' }, + { value: 1, label: '1 — Responds to voice' }, + { value: 2, label: '2 — Responds to pain' }, + { value: 3, label: '3 — Unresponsive' }, +] + +export const VITAL_FIELD_DEFS = [ + { key: 'heartRate', code: 'HEART_RATE', label: 'Heart Rate', unit: 'bpm', step: '1' }, + { key: 'respRate', code: 'RESP_RATE', label: 'Respiratory Rate', unit: 'breaths/min', step: '1' }, + { key: 'systolicBp', code: 'SYSTOLIC_BP', label: 'Systolic BP', unit: 'mmHg', step: '1' }, + { key: 'diastolicBp', code: 'DIASTOLIC_BP', label: 'Diastolic BP', unit: 'mmHg', step: '1' }, + { key: 'spo2', code: 'SPO2', label: 'SpO₂', unit: '%', step: '1' }, + { key: 'tempC', code: 'TEMP_C', label: 'Temperature', unit: '°C', step: '0.1' }, + { key: 'avpu', code: 'AVPU', label: 'AVPU', unit: 'score', type: 'select' }, +] + +export function isPlausibleValue(code, value) { + const range = PLAUSIBILITY_RANGES[code] + if (!range) return true + const numeric = Number(value) + if (Number.isNaN(numeric)) return false + return numeric >= range.min && numeric <= range.max +} + +export function plausibilityMessage(code, value) { + const range = PLAUSIBILITY_RANGES[code] + if (!range) return null + if (isPlausibleValue(code, value)) return null + return `Value must be between ${range.min} and ${range.max}` +} + +export function buildVitalsObservations(values, recordedAt = new Date().toISOString()) { + const observations = [] + + for (const field of VITAL_FIELD_DEFS) { + const raw = values[field.key] + if (raw === '' || raw == null) continue + + observations.push({ + observationCode: field.code, + value: Number(raw), + unit: field.unit, + source: 'Manual', + recordedAt, + }) + } + + return observations +} + +export function validateVitalsForm(values) { + const errors = {} + let hasValue = false + + for (const field of VITAL_FIELD_DEFS) { + const raw = values[field.key] + if (raw === '' || raw == null) continue + + hasValue = true + const message = plausibilityMessage(field.code, raw) + if (message) errors[field.key] = message + } + + if (!hasValue) { + errors._form = 'Enter at least one vital sign.' + } + + return { + valid: Object.keys(errors).length === 0, + errors, + observations: buildVitalsObservations(values), + } +} + +export function emptyVitalsForm() { + return Object.fromEntries(VITAL_FIELD_DEFS.map(field => [field.key, ''])) +} diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index 10d509e..d95fcea 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -212,7 +212,11 @@ onBeforeUnmount(() => { - + +import { ref } from 'vue' import { storeToRefs } from 'pinia' import { useRoute } from 'vue-router' import { useWardStore } from '@/stores/ward' import { useSettingsStore } from '@/stores/settings' +import { useAuthStore } from '@/stores/auth' import { usePolling } from '@/composables/usePolling' import { WARD_SORT_FIELDS } from '@/composables/wardSort' import WardToolbar from '@/components/ward/WardToolbar.vue' import WardTable from '@/components/ward/WardTable.vue' +import HandoffReport from '@/components/ward/HandoffReport.vue' import Skeleton from '@/components/ui/Skeleton.vue' import EmptyState from '@/components/ui/EmptyState.vue' import Badge from '@/components/ui/Badge.vue' @@ -14,6 +17,8 @@ import Badge from '@/components/ui/Badge.vue' const route = useRoute() const wardStore = useWardStore() const settingsStore = useSettingsStore() +const authStore = useAuthStore() +const showHandoff = ref(false) const { encounters, displayEncounters, @@ -70,7 +75,7 @@ function onMobileSortChange(event) { - +