fix: finish all patient related upgrades
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user