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