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) {
|
export function fetchTimeline(encounterId) {
|
||||||
return api.get(`/api/v1/encounters/${encounterId}/timeline`)
|
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>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import { useAlertStore } from '@/stores/alerts'
|
import { useAlertStore } from '@/stores/alerts'
|
||||||
import { usePolling } from '@/composables/usePolling'
|
import { usePolling } from '@/composables/usePolling'
|
||||||
import Card from '@/components/ui/Card.vue'
|
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>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import Card from '@/components/ui/Card.vue'
|
import Card from '@/components/ui/Card.vue'
|
||||||
|
import VitalsEntryForm from '@/components/patient/VitalsEntryForm.vue'
|
||||||
import { observationCodeLabel } from '@/api/normalize'
|
import { observationCodeLabel } from '@/api/normalize'
|
||||||
|
import { submitVitalsObservations } from '@/api/encounters'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
encounterId: { type: String, required: true },
|
||||||
observations: { type: Array, default: () => [] },
|
observations: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['recorded'])
|
||||||
|
|
||||||
|
const showVitalsForm = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitError = ref('')
|
||||||
|
|
||||||
const latestByCode = computed(() => {
|
const latestByCode = computed(() => {
|
||||||
const map = new Map()
|
const map = new Map()
|
||||||
for (const obs of props.observations) {
|
for (const obs of props.observations) {
|
||||||
@@ -19,16 +28,49 @@ const latestByCode = computed(() => {
|
|||||||
observationCodeLabel(a.observationCode).localeCompare(observationCodeLabel(b.observationCode)),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Card>
|
<Card>
|
||||||
<template #header>
|
<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">
|
||||||
Latest Vitals
|
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
</h2>
|
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>
|
</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-if="latestByCode.length" class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="obs in latestByCode"
|
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>
|
<p v-else class="text-sm text-gray-500 dark:text-gray-400">No observations recorded</p>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</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 { useWardStore } from '@/stores/ward'
|
||||||
import Button from '@/components/ui/Button.vue'
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['export-handoff'])
|
||||||
|
|
||||||
const wardStore = useWardStore()
|
const wardStore = useWardStore()
|
||||||
const { searchInput, filters, hasActiveFilters } = storeToRefs(wardStore)
|
const { searchInput, filters, hasActiveFilters, encounters } = storeToRefs(wardStore)
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ key: 'hasAlerts', label: 'Has alerts' },
|
{ key: 'hasAlerts', label: 'Has alerts' },
|
||||||
@@ -45,6 +47,15 @@ const filterOptions = [
|
|||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
:disabled="encounters.length === 0"
|
||||||
|
@click="emit('export-handoff')"
|
||||||
|
>
|
||||||
|
Export Handoff Report
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 />
|
<ScoresPanel />
|
||||||
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
|
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
|
||||||
</div>
|
</div>
|
||||||
<VitalsPanel :observations="replayObservations" />
|
<VitalsPanel
|
||||||
|
:encounter-id="route.params.encounterId"
|
||||||
|
:observations="replayObservations"
|
||||||
|
@recorded="loadAll"
|
||||||
|
/>
|
||||||
<AlertsList
|
<AlertsList
|
||||||
:encounter-id="route.params.encounterId"
|
:encounter-id="route.params.encounterId"
|
||||||
:selected-id="selectedAlert?.id"
|
:selected-id="selectedAlert?.id"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useWardStore } from '@/stores/ward'
|
import { useWardStore } from '@/stores/ward'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { usePolling } from '@/composables/usePolling'
|
import { usePolling } from '@/composables/usePolling'
|
||||||
import { WARD_SORT_FIELDS } from '@/composables/wardSort'
|
import { WARD_SORT_FIELDS } from '@/composables/wardSort'
|
||||||
import WardToolbar from '@/components/ward/WardToolbar.vue'
|
import WardToolbar from '@/components/ward/WardToolbar.vue'
|
||||||
import WardTable from '@/components/ward/WardTable.vue'
|
import WardTable from '@/components/ward/WardTable.vue'
|
||||||
|
import HandoffReport from '@/components/ward/HandoffReport.vue'
|
||||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
@@ -14,6 +17,8 @@ import Badge from '@/components/ui/Badge.vue'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const wardStore = useWardStore()
|
const wardStore = useWardStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const showHandoff = ref(false)
|
||||||
const {
|
const {
|
||||||
encounters,
|
encounters,
|
||||||
displayEncounters,
|
displayEncounters,
|
||||||
@@ -70,7 +75,7 @@ function onMobileSortChange(event) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<WardToolbar />
|
<WardToolbar @export-handoff="showHandoff = true" />
|
||||||
|
|
||||||
<label class="mb-4 block md:hidden">
|
<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">
|
<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-direction="wardSortDirection"
|
||||||
@sort="wardStore.setSort"
|
@sort="wardStore.setSort"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<HandoffReport
|
||||||
|
v-if="showHandoff"
|
||||||
|
:encounters="encounters"
|
||||||
|
:department="department"
|
||||||
|
:generated-by="authStore.displayName"
|
||||||
|
@close="showHandoff = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user