feature: add handoff report
This commit is contained in:
@@ -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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user