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
@@ -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>