feature: add handoff report

This commit is contained in:
voltsrage
2026-06-23 20:32:33 +08:00
parent 79b6d9d85e
commit c6b98b5f7c
14 changed files with 906 additions and 7 deletions
@@ -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 = ''
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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)
})
})
@@ -45,3 +45,9 @@ 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,
})
}
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
import { usePolling } from '@/composables/usePolling'
import Card from '@/components/ui/Card.vue'
@@ -0,0 +1,105 @@
<script setup>
import { reactive, computed } from 'vue'
import {
AVPU_OPTIONS,
VITAL_FIELD_DEFS,
emptyVitalsForm,
validateVitalsForm,
} from '@/composables/vitalsForm'
defineProps({
submitting: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['submit'])
const values = reactive(emptyVitalsForm())
const touched = reactive(emptyVitalsForm())
const validation = computed(() => validateVitalsForm(values))
const fieldErrors = computed(() => validation.value.errors)
const numericFields = computed(() =>
VITAL_FIELD_DEFS.filter(field => field.type !== 'select'),
)
function onBlur(key) {
touched[key] = true
}
function showError(key) {
return Boolean(touched[key] && fieldErrors.value[key])
}
function submit() {
for (const field of VITAL_FIELD_DEFS) {
touched[field.key] = true
}
if (!validation.value.valid) return
emit('submit', validation.value.observations)
}
</script>
<template>
<div class="space-y-4">
<h3 class="text-sm font-semibold dark:text-white">Record Vital Signs</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
Enter one or more measurements. Values are validated against plausible clinical ranges before submission.
</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div v-for="field in numericFields" :key="field.key">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">
{{ field.label }}
<span class="text-gray-400">({{ field.unit }})</span>
</label>
<input
v-model="values[field.key]"
type="number"
:step="field.step"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError(field.key) ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur(field.key)"
>
<p v-if="showError(field.key)" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors[field.key] }}
</p>
</div>
<div>
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">AVPU (score)</label>
<select
v-model="values.avpu"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('avpu') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('avpu')"
>
<option value="">Not recorded</option>
<option v-for="option in AVPU_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<p v-if="showError('avpu')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.avpu }}
</p>
</div>
</div>
<p v-if="fieldErrors._form" class="text-sm text-red-600 dark:text-red-400">
{{ fieldErrors._form }}
</p>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex justify-end">
<button
type="button"
class="w-full rounded bg-blue-500 px-6 py-2 text-sm font-medium text-white hover:bg-blue-600 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:opacity-50 sm:w-auto"
:disabled="submitting"
@click="submit"
>
{{ submitting ? 'Saving…' : 'Record Vitals' }}
</button>
</div>
</div>
</template>
@@ -1,12 +1,21 @@
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import Card from '@/components/ui/Card.vue'
import VitalsEntryForm from '@/components/patient/VitalsEntryForm.vue'
import { observationCodeLabel } from '@/api/normalize'
import { submitVitalsObservations } from '@/api/encounters'
const props = defineProps({
encounterId: { type: String, required: true },
observations: { type: Array, default: () => [] },
})
const emit = defineEmits(['recorded'])
const showVitalsForm = ref(false)
const submitting = ref(false)
const submitError = ref('')
const latestByCode = computed(() => {
const map = new Map()
for (const obs of props.observations) {
@@ -19,16 +28,49 @@ const latestByCode = computed(() => {
observationCodeLabel(a.observationCode).localeCompare(observationCodeLabel(b.observationCode)),
)
})
async function onSubmit(observations) {
submitError.value = ''
submitting.value = true
try {
await submitVitalsObservations(props.encounterId, observations)
showVitalsForm.value = false
emit('recorded')
} catch (error) {
submitError.value = error.message
} finally {
submitting.value = false
}
}
</script>
<template>
<Card>
<template #header>
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Latest Vitals
</h2>
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Latest Vitals
</h2>
<button
type="button"
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
@click="showVitalsForm = !showVitalsForm"
>
{{ showVitalsForm ? 'Cancel' : 'Record Vitals' }}
</button>
</div>
</template>
<Transition name="fade">
<VitalsEntryForm
v-if="showVitalsForm"
class="mb-6"
:submitting="submitting"
:error="submitError"
@submit="onSubmit"
/>
</Transition>
<div v-if="latestByCode.length" class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div
v-for="obs in latestByCode"
@@ -47,3 +89,14 @@ const latestByCode = computed(() => {
<p v-else class="text-sm text-gray-500 dark:text-gray-400">No observations recorded</p>
</Card>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,199 @@
<script setup>
import { ref, onMounted } from 'vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import { buildHandoffReport, formatReportTimestamp } from '@/composables/handoffReport'
const props = defineProps({
encounters: { type: Array, required: true },
department: { type: String, default: null },
generatedBy: { type: String, default: 'Clinical user' },
})
const emit = defineEmits(['close'])
const loading = ref(true)
const error = ref('')
const report = ref(null)
onMounted(async () => {
try {
report.value = await buildHandoffReport(
props.encounters,
props.department,
props.generatedBy,
)
} catch (err) {
error.value = err.message ?? 'Failed to generate handoff report'
} finally {
loading.value = false
}
})
function printReport() {
window.print()
}
</script>
<template>
<Teleport to="body">
<div class="handoff-root fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 print:relative print:inset-auto print:block print:overflow-visible print:bg-white print:p-0">
<div class="handoff-panel my-4 w-full max-w-5xl rounded-lg border border-gray-200 bg-white shadow-xl print:my-0 print:max-w-none print:rounded-none print:border-0 print:shadow-none dark:border-gray-700 dark:bg-gray-900 print:dark:bg-white">
<div class="handoff-controls flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shift Handoff Report</h2>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="secondary" @click="printReport">
Print / Save PDF
</Button>
<Button size="sm" variant="ghost" @click="emit('close')">
Close
</Button>
</div>
</div>
<div class="handoff-body p-4 text-gray-900 print:p-8 dark:text-gray-100 print:dark:text-gray-900">
<Skeleton v-if="loading" :rows="8" />
<p v-else-if="error" class="text-sm text-red-600">{{ error }}</p>
<div v-else-if="report" class="handoff-print-area space-y-6">
<header class="border-b border-gray-300 pb-4">
<h1 class="text-2xl font-bold">Shift Handoff Report</h1>
<p class="mt-1 text-sm text-gray-600">
{{ report.summary.department }} · Generated {{ formatReportTimestamp(report.generatedAt) }}
</p>
<p v-if="report.generatedBy" class="text-sm text-gray-600">
Prepared by {{ report.generatedBy }}
</p>
</header>
<section>
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-600">Ward Summary</h2>
<div class="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
<div class="rounded border border-gray-200 p-3 print:border-gray-400">
<div class="text-xs uppercase text-gray-500">Patients</div>
<div class="text-xl font-semibold">{{ report.summary.patientCount }}</div>
</div>
<div class="rounded border border-gray-200 p-3 print:border-gray-400">
<div class="text-xs uppercase text-gray-500">Critical (NEWS2 7)</div>
<div class="text-xl font-semibold">{{ report.summary.criticalCount }}</div>
</div>
<div class="rounded border border-gray-200 p-3 print:border-gray-400">
<div class="text-xs uppercase text-gray-500">Open Alerts</div>
<div class="text-xl font-semibold">{{ report.summary.alertCount }}</div>
</div>
<div class="rounded border border-gray-200 p-3 print:border-gray-400">
<div class="text-xs uppercase text-gray-500">Active Bundles</div>
<div class="text-xl font-semibold">{{ report.summary.activeBundles }}</div>
</div>
</div>
</section>
<section
v-for="patient in report.patients"
:key="patient.encounterId"
class="break-inside-avoid border-t border-gray-300 pt-4"
>
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h3 class="text-lg font-semibold">{{ patient.name }}</h3>
<span class="text-sm text-gray-600">
MRN {{ patient.mrn }} · Room {{ patient.room }} · {{ patient.department }}
</span>
</div>
<table class="mb-4 w-full border-collapse text-sm">
<thead>
<tr class="border-b border-gray-300 text-left text-xs uppercase text-gray-500">
<th class="py-1 pr-3">NEWS2</th>
<th class="py-1 pr-3">SOFA</th>
<th class="py-1 pr-3">GCS</th>
<th class="py-1 pr-3">qSOFA</th>
<th class="py-1 pr-3">Attending</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-1 pr-3 font-medium">{{ patient.news2 ?? '—' }}</td>
<td class="py-1 pr-3 font-medium">
{{ patient.sofa ?? '—' }}
<span v-if="patient.sofaDelta"> (Δ+{{ patient.sofaDelta }})</span>
</td>
<td class="py-1 pr-3 font-medium">{{ patient.gcs ?? '—' }}</td>
<td class="py-1 pr-3 font-medium">{{ patient.qsofa ?? '—' }}</td>
<td class="py-1 pr-3">{{ patient.attending }}</td>
</tr>
</tbody>
</table>
<dl class="mb-4 grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs font-medium uppercase text-gray-500">Key Vitals</dt>
<dd>{{ patient.vitalsSummary }}</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase text-gray-500">Active Alerts</dt>
<dd>{{ patient.alertsSummary }}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-xs font-medium uppercase text-gray-500">Pending Orders</dt>
<dd>{{ patient.pendingOrders }}</dd>
</div>
</dl>
<div class="rounded border border-gray-200 p-3 text-sm print:border-gray-400">
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">SBAR</h4>
<dl class="space-y-2">
<div>
<dt class="font-medium">Situation</dt>
<dd class="text-gray-700">{{ patient.sbar.situation }}</dd>
</div>
<div>
<dt class="font-medium">Background</dt>
<dd class="text-gray-700">{{ patient.sbar.background }}</dd>
</div>
<div>
<dt class="font-medium">Assessment</dt>
<dd class="text-gray-700">{{ patient.sbar.assessment }}</dd>
</div>
<div>
<dt class="font-medium">Recommendation</dt>
<dd class="text-gray-700">{{ patient.sbar.recommendation }}</dd>
</div>
</dl>
</div>
</section>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style>
@media print {
body > *:not(.handoff-root) {
display: none !important;
}
.handoff-root {
position: static !important;
overflow: visible !important;
background: white !important;
padding: 0 !important;
}
.handoff-controls {
display: none !important;
}
.handoff-panel {
box-shadow: none !important;
border: none !important;
max-width: none !important;
margin: 0 !important;
}
.handoff-body {
padding: 0 !important;
}
}
</style>
@@ -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
</Button>
<Button
size="sm"
variant="secondary"
:disabled="encounters.length === 0"
@click="emit('export-handoff')"
>
Export Handoff Report
</Button>
</div>
</div>
</template>
@@ -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',
})
}
@@ -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, '']))
}
@@ -212,7 +212,11 @@ onBeforeUnmount(() => {
<ScoresPanel />
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
</div>
<VitalsPanel :observations="replayObservations" />
<VitalsPanel
:encounter-id="route.params.encounterId"
:observations="replayObservations"
@recorded="loadAll"
/>
<AlertsList
:encounter-id="route.params.encounterId"
:selected-id="selectedAlert?.id"
@@ -1,12 +1,15 @@
<script setup>
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) {
</select>
</label>
<WardToolbar />
<WardToolbar @export-handoff="showHandoff = true" />
<label class="mb-4 block md:hidden">
<span class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -103,5 +108,13 @@ function onMobileSortChange(event) {
:sort-direction="wardSortDirection"
@sort="wardStore.setSort"
/>
<HandoffReport
v-if="showHandoff"
:encounters="encounters"
:department="department"
:generated-by="authStore.displayName"
@close="showHandoff = false"
/>
</div>
</template>