Files
vigilcare-clinical/vigilcare-dashboard/src/views/PatientDetail.vue
T

338 lines
11 KiB
Vue

<script setup>
import { ref, watch, computed, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePolling } from '@/composables/usePolling'
import { useReplayControls } from '@/composables/useReplayControls'
import { useRoleAccess } from '@/composables/roleAccess'
import { useAlertStore } from '@/stores/alerts'
import { useScoringStore } from '@/stores/scoring'
import * as encountersApi from '@/api/encounters'
import * as clinicalApi from '@/api/clinical'
import * as alertsApi from '@/api/alerts'
import PatientBanner from '@/components/patient/PatientBanner.vue'
import VitalsPanel from '@/components/patient/VitalsPanel.vue'
import ScoresPanel from '@/components/patient/ScoresPanel.vue'
import SofaScorePanel from '@/components/patient/SofaScorePanel.vue'
import AlertsList from '@/components/patient/AlertsList.vue'
import OrdersPanel from '@/components/patient/OrdersPanel.vue'
import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue'
import TrendsGrid from '@/components/charts/TrendsGrid.vue'
import News2History from '@/components/charts/News2History.vue'
import GcsHistory from '@/components/charts/GcsHistory.vue'
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
import SofaHistory from '@/components/charts/SofaHistory.vue'
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
import ReplayControls from '@/components/replay/ReplayControls.vue'
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
import CollapsibleSection from '@/components/ui/CollapsibleSection.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
const route = useRoute()
const { nursePatientLayout, physicianPatientLayout } = useRoleAccess()
const alertStore = useAlertStore()
const scoringStore = useScoringStore()
const { alerts } = storeToRefs(alertStore)
const { qsofa, sofa } = storeToRefs(scoringStore)
const {
isPaused,
speed,
progress,
formattedTime,
currentMs,
scenarioEndMs,
scenarioStartMs,
pause,
resume,
setSpeed,
jumpToTimestamp,
isAtOrBefore,
setScenarioBounds,
syncToEnd,
stopPlayback,
} = useReplayControls()
const encounter = ref(null)
const loading = ref(true)
const observations = ref([])
const news2History = ref([])
const gcsHistory = ref([])
const qsofaHistory = ref([])
const sofaHistory = ref([])
const medications = ref([])
const sepsisBundle = ref(null)
const orders = ref([])
const timelineEvents = ref([])
const selectedAlert = ref(null)
let nextAlertIndex = 0
const openAlerts = computed(() =>
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
)
const isDischarged = computed(() => encounter.value?.status === 'Discharged')
const replayObservations = computed(() =>
observations.value.filter(o => isAtOrBefore(o.recordedAt)),
)
const replayMedications = computed(() =>
medications.value.filter(m => isAtOrBefore(m.administeredAt)),
)
const replayNews2History = computed(() =>
news2History.value.filter(h => isAtOrBefore(h.calculatedAt)),
)
const replayGcsHistory = computed(() =>
gcsHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
)
const replayQsofaHistory = computed(() =>
qsofaHistory.value.filter(h => isAtOrBefore(h.evaluatedAt)),
)
const replaySofaHistory = computed(() =>
sofaHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
)
const replayTimelineEvents = computed(() =>
timelineEvents.value.filter(e => isAtOrBefore(e.timestamp)),
)
function collectScenarioTimes() {
return [
...observations.value.map(o => new Date(o.recordedAt).getTime()),
...news2History.value.map(h => new Date(h.calculatedAt).getTime()),
...gcsHistory.value.map(h => new Date(h.calculatedAt).getTime()),
...qsofaHistory.value.map(h => new Date(h.evaluatedAt).getTime()),
...sofaHistory.value.map(h => new Date(h.calculatedAt).getTime()),
...timelineEvents.value.map(e => new Date(e.timestamp).getTime()),
...alerts.value.map(a => new Date(a.triggeredAt).getTime()),
].filter(Number.isFinite)
}
function syncReplayBounds() {
const times = collectScenarioTimes()
if (!times.length) return
const atEnd = currentMs.value >= scenarioEndMs.value - 1_000
setScenarioBounds(Math.min(...times), Math.max(...times))
if (atEnd || scenarioEndMs.value === scenarioStartMs.value) {
syncToEnd()
}
}
async function loadAll() {
const id = route.params.encounterId
loading.value = true
try {
const [enc, obs, history, gcsHist, qsofaHist, sofaHist, meds, bundle, ord, timeline] = await Promise.all([
encountersApi.fetchEncounter(id),
encountersApi.fetchObservations(id),
clinicalApi.fetchNews2History(id).catch(() => []),
clinicalApi.fetchGcsHistory(id).catch(() => []),
clinicalApi.fetchQsofaHistory(id).catch(() => []),
clinicalApi.fetchSofaHistory(id).catch(() => []),
clinicalApi.fetchMedications(id).catch(() => []),
clinicalApi.fetchSepsisBundle(id),
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
encountersApi.fetchTimeline(id).catch(() => ({ events: [] })),
])
await alertStore.loadAlerts(id)
encounter.value = enc
observations.value = obs
news2History.value = history
gcsHistory.value = gcsHist
qsofaHistory.value = qsofaHist
sofaHistory.value = sofaHist
medications.value = meds
sepsisBundle.value = bundle
orders.value = ord.items ?? ord
timelineEvents.value = timeline.events ?? []
scoringStore.startPolling(id)
syncReplayBounds()
} finally {
loading.value = false
}
}
async function onSelectAlert(alert) {
try {
selectedAlert.value = await alertsApi.fetchAlertById(alert.id)
} catch {
selectedAlert.value = alert
}
jumpToTimestamp(alert.triggeredAt)
}
function jumpToNextAlert() {
const sorted = [...openAlerts.value].sort(
(a, b) => new Date(a.triggeredAt) - new Date(b.triggeredAt),
)
if (!sorted.length) return
const alert = sorted[nextAlertIndex % sorted.length]
selectedAlert.value = alert
nextAlertIndex++
jumpToTimestamp(alert.triggeredAt)
document.getElementById(`alert-row-${alert.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
document.getElementById('clinical-review')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
usePolling(loadAll, 5_000)
watch(() => route.params.encounterId, () => {
selectedAlert.value = null
nextAlertIndex = 0
pause()
scoringStore.stopPolling()
loadAll()
})
watch(openAlerts, (list) => {
nextAlertIndex = 0
if (selectedAlert.value && !list.some(a => a.id === selectedAlert.value.id)) {
selectedAlert.value = null
}
})
watch([observations, news2History, gcsHistory, qsofaHistory, sofaHistory, timelineEvents, alerts], syncReplayBounds, { deep: true })
onBeforeUnmount(() => {
stopPlayback()
scoringStore.stopPolling()
})
</script>
<template>
<Skeleton v-if="loading && !encounter" :rows="6" />
<div v-else-if="encounter" class="w-full min-w-0 space-y-6 lg:space-y-8">
<div class="flex flex-wrap items-center gap-4">
<RouterLink
to="/ward"
class="inline-flex min-h-11 items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Ward
</RouterLink>
</div>
<PatientBanner :encounter="encounter" />
<DischargeSummaryPanel
:encounter-id="route.params.encounterId"
:discharged="isDischarged"
/>
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-3">
<div
class="order-1 space-y-4"
:class="{
'lg:order-3': nursePatientLayout,
'lg:order-1': physicianPatientLayout || (!nursePatientLayout && !physicianPatientLayout),
}"
>
<ScoresPanel />
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
</div>
<VitalsPanel
class="order-2 min-w-0 lg:order-2"
:encounter-id="route.params.encounterId"
:observations="replayObservations"
@recorded="loadAll"
/>
<AlertsList
class="order-3 min-w-0 lg:order-3"
:class="{ 'lg:order-1': nursePatientLayout }"
:encounter-id="route.params.encounterId"
:selected-id="selectedAlert?.id"
@select="onSelectAlert"
/>
</div>
<AlertReasoning
v-if="selectedAlert"
:alert="selectedAlert"
:medications="medications"
/>
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-2">
<div class="order-2 space-y-4 lg:order-1">
<SofaScorePanel :encounter-id="route.params.encounterId" />
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
</div>
<div class="order-1 lg:order-2">
<OrdersPanel :orders="orders" />
</div>
</div>
<SepsisBundlePanel
:bundle="sepsisBundle"
:qsofa="qsofa"
:sofa="sofa"
/>
<div class="hidden lg:block">
<EncounterTimeline :events="replayTimelineEvents" />
</div>
<CollapsibleSection
class="lg:hidden"
section-id="encounter-timeline"
title="Encounter timeline"
:default-open="false"
>
<EncounterTimeline :events="replayTimelineEvents" />
</CollapsibleSection>
<div id="clinical-review" class="hidden w-full min-w-0 space-y-6 lg:block lg:space-y-8">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
<ReplayControls
:alerts="openAlerts"
:is-paused="isPaused"
:progress="progress"
:formatted-time="formattedTime"
:speed="speed"
@pause="pause()"
@resume="resume()"
@set-speed="setSpeed"
@jump-to-alert="jumpToNextAlert"
/>
</div>
<CollapsibleSection
class="lg:hidden"
section-id="clinical-review"
title="Vital trends & clinical review"
:default-open="false"
>
<div class="space-y-6">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
</div>
</CollapsibleSection>
<CollapsibleSection
class="lg:hidden"
section-id="replay-controls"
title="Scenario replay"
:default-open="false"
>
<ReplayControls
:alerts="openAlerts"
:is-paused="isPaused"
:progress="progress"
:formatted-time="formattedTime"
:speed="speed"
@pause="pause()"
@resume="resume()"
@set-speed="setSpeed"
@jump-to-alert="jumpToNextAlert"
/>
</CollapsibleSection>
</div>
</template>