338 lines
11 KiB
Vue
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"
|
|
>
|
|
← 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> |