fix: finish all patient related upgrades

This commit is contained in:
voltsrage
2026-06-23 18:17:13 +08:00
parent 7751d6df06
commit 5d46200941
20 changed files with 985 additions and 19 deletions
@@ -1,7 +1,10 @@
<script setup>
import VitalTrendChart from './VitalTrendChart.vue'
defineProps({ observations: { type: Array, required: true } })
defineProps({
observations: { type: Array, required: true },
medications: { type: Array, default: () => [] },
})
const charts = [
{ code: 'HEART_RATE', title: 'Heart Rate (bpm)', yMin: 30, yMax: 180 },
@@ -18,6 +21,7 @@ const charts = [
v-for="chart in charts"
:key="chart.code"
:observations="observations"
:medications="medications"
:code="chart.code"
:title="chart.title"
:y-min="chart.yMin"
@@ -1,23 +1,36 @@
<script setup>
import { computed, toValue, shallowRef, markRaw } from 'vue'
import { computed, toValue } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart, registerables } from 'chart.js'
import {
filterMedicationsForWindow,
formatMedicationTooltip,
getMedicationsNearTimestamp,
} from '@/composables/chartMedications'
import { medicationMarkerPlugin } from '@/plugins/medicationMarkerPlugin'
Chart.register(...registerables)
Chart.register(...registerables, medicationMarkerPlugin)
const props = defineProps({
chartData: { type: Object, required: true },
title: { type: String, required: true },
yMin: { type: Number, default: undefined },
yMax: { type: Number, default: undefined },
observations: { type: Array, default: () => [] },
observationCode: { type: String, default: '' },
medications: { type: Array, default: () => [] },
})
const lineData = computed(() => {
const data = toValue(props.chartData)
return data?.datasets ? data : { labels: [], datasets: [] }
return data?.datasets ? data : { labels: [], timestamps: [], datasets: [] }
})
const chartOptions = shallowRef(markRaw({
const chartMedications = computed(() =>
filterMedicationsForWindow(props.observations, props.observationCode, props.medications),
)
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: true,
animation: {
@@ -29,6 +42,23 @@ const chartOptions = shallowRef(markRaw({
},
plugins: {
legend: { display: false },
medicationMarkers: {
medications: chartMedications.value,
timestamps: lineData.value.timestamps ?? [],
},
tooltip: {
callbacks: {
afterBody(items) {
if (!items.length) return []
const timestamps = lineData.value.timestamps ?? []
const idx = items[0].dataIndex
const targetMs = timestamps[idx] ? new Date(timestamps[idx]).getTime() : null
if (targetMs == null) return []
return getMedicationsNearTimestamp(chartMedications.value, targetMs)
.map(formatMedicationTooltip)
},
},
},
},
}))
</script>
@@ -40,4 +70,4 @@ const chartOptions = shallowRef(markRaw({
<Line :data="lineData" :options="chartOptions" />
</div>
</div>
</template>
</template>
@@ -4,6 +4,7 @@ import VitalChart from './VitalChart.vue'
const props = defineProps({
observations: { type: Array, required: true },
medications: { type: Array, default: () => [] },
code: { type: String, required: true },
title: { type: String, required: true },
yMin: { type: Number, default: undefined },
@@ -19,5 +20,8 @@ const { chartData } = useChartData(() => props.observations, props.code)
:title="title"
:y-min="yMin"
:y-max="yMax"
:observations="observations"
:observation-code="code"
:medications="medications"
/>
</template>
@@ -0,0 +1,142 @@
<script setup>
import { ref, computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
TIMELINE_EVENT_TYPES,
formatTimelineEvent,
timelineEventStyles,
timelineTypeLabel,
} from '@/composables/timelineFormat'
const props = defineProps({
events: { type: Array, default: () => [] },
})
const expanded = ref(true)
const sinceHours = ref('')
const enabledTypes = ref(new Set(TIMELINE_EVENT_TYPES.map(t => t.id)))
const hourOptions = [
{ value: '', label: 'All time' },
{ value: '4', label: 'Last 4 hours' },
{ value: '8', label: 'Last 8 hours' },
{ value: '24', label: 'Last 24 hours' },
]
const filteredEvents = computed(() => {
let list = props.events.filter(e => enabledTypes.value.has(e.type))
const hours = Number(sinceHours.value)
if (hours > 0) {
const cutoff = Date.now() - hours * 60 * 60 * 1000
list = list.filter(e => new Date(e.timestamp).getTime() >= cutoff)
}
return [...list].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
})
function toggleType(typeId) {
const next = new Set(enabledTypes.value)
if (next.has(typeId)) next.delete(typeId)
else next.add(typeId)
enabledTypes.value = next
}
function formatTime(iso) {
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function alertBadgeVariant(event) {
if (event.type !== 'alert') return timelineEventStyles(event.type).badge
return event.severity === 'Critical' ? 'critical' : 'warning'
}
</script>
<template>
<Card>
<template #header>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
@click="expanded = !expanded"
>
<span class="text-xs" aria-hidden="true">{{ expanded ? '▼' : '▶' }}</span>
Encounter Timeline
</button>
<span v-if="events.length" class="text-xs text-gray-500 dark:text-gray-400">
{{ filteredEvents.length }} of {{ events.length }} events
</span>
</div>
</template>
<div v-if="expanded" class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<label class="text-xs text-gray-500 dark:text-gray-400">
Range
<select
v-model="sinceHours"
class="ml-2 rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
>
<option v-for="opt in hourOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="t in TIMELINE_EVENT_TYPES"
:key="t.id"
type="button"
class="rounded-full border px-2 py-0.5 text-xs transition-colors"
:class="enabledTypes.has(t.id)
? 'border-gray-800 bg-gray-800 text-white dark:border-gray-200 dark:bg-gray-200 dark:text-gray-900'
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400'"
@click="toggleType(t.id)"
>
{{ t.label }}
</button>
</div>
</div>
<EmptyState v-if="!filteredEvents.length" message="No timeline events match the current filters" />
<ol v-else class="relative space-y-0 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
<li
v-for="(event, index) in filteredEvents"
:key="`${event.type}-${event.timestamp}-${index}`"
class="relative pb-6 last:pb-0"
>
<span
class="absolute -left-[1.3rem] top-1 h-3 w-3 rounded-full ring-2 ring-white dark:ring-gray-900"
:class="timelineEventStyles(event.type).dot"
/>
<div
class="rounded-lg border-l-4 bg-gray-50 p-3 dark:bg-gray-800/50"
:class="timelineEventStyles(event.type).border"
>
<div class="mb-1 flex flex-wrap items-center gap-2">
<Badge :variant="alertBadgeVariant(event)" size="xs">
{{ timelineTypeLabel(event.type) }}
</Badge>
<time class="text-xs text-gray-500 dark:text-gray-400">
{{ formatTime(event.timestamp) }}
</time>
</div>
<p class="text-sm text-gray-900 dark:text-gray-100">
{{ formatTimelineEvent(event) }}
</p>
</div>
</li>
</ol>
</div>
</Card>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { computed } from 'vue'
import {
formatAllergiesDisplay,
formatBloodType,
formatDepartment,
formatDobWithAge,
formatGender,
hasKnownAllergies,
} from '@/composables/patientFormat'
const props = defineProps({
encounter: { type: Object, required: true },
})
const patient = computed(() => props.encounter.patient ?? {})
const fullName = computed(() =>
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
)
const allergyText = computed(() => formatAllergiesDisplay(patient.value.allergies))
const allergiesKnown = computed(() => hasKnownAllergies(patient.value.allergies))
const emergencyContact = computed(() => {
const name = patient.value.emergencyContactName
const phone = patient.value.emergencyContactPhone
if (name && phone) return `${name} · ${phone}`
return name || phone || null
})
</script>
<template>
<header class="w-full min-w-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<div
class="px-4 py-2 text-sm font-medium"
:class="allergiesKnown
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'"
role="status"
:aria-label="allergiesKnown ? `Known allergies: ${allergyText}` : 'No known drug allergies'"
>
<span class="font-semibold">Allergies:</span>
{{ allergyText }}
</div>
<div class="space-y-3 bg-white p-4 dark:bg-gray-900">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
{{ fullName }}
</h1>
<span class="text-sm text-gray-600 dark:text-gray-400">
MRN {{ patient.mrn ?? '—' }}
</span>
</div>
<dl class="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">DOB / Age</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatDobWithAge(patient.dateOfBirth) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Gender</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatGender(patient.gender) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Blood type</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatBloodType(patient.bloodType) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Room / Bed</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ encounter.roomBed ?? '—' }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Department</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatDepartment(encounter.department) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Attending</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ encounter.attendingPhysician ?? '—' }}
</dd>
</div>
<div class="sm:col-span-2 lg:col-span-3">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Admission reason</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ encounter.admissionReason ?? '—' }}
</dd>
</div>
<div v-if="emergencyContact" class="sm:col-span-2 lg:col-span-3">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Emergency contact</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ emergencyContact }}
</dd>
</div>
</dl>
</div>
</header>
</template>