feature: Sepsis Engine Refactor: Remove SIRS, Rewire qSOFA + Bundle

Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor
This commit is contained in:
voltsrage
2026-06-21 03:56:27 +08:00
parent 93ea473d2b
commit bf46e6554a
48 changed files with 2686 additions and 714 deletions
@@ -1,16 +1,27 @@
<script setup>
import { computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import { alertTypeLabel } from '@/api/normalize'
defineProps({
const props = defineProps({
alert: { type: Object, required: true },
})
const emit = defineEmits(['acknowledge', 'resolve'])
const actionHint = computed(() => {
const hints = {
QsofaScreen: 'Recommend SOFA labs',
SofaSepsis: 'Review organ breakdown · Initiate bundle',
GcsCritical: 'Urgent neuro assessment',
GcsWarning: 'Monitor consciousness',
}
return hints[props.alert.alertType] ?? null
})
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
@@ -35,11 +46,12 @@ function formatTime(iso) {
<template>
<Card>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge>
<Badge variant="info" size="xs">{{ alert.status }}</Badge>
<Badge v-if="actionHint" variant="info" size="xs">{{ actionHint }}</Badge>
</div>
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
{{ alertTypeLabel(alert.alertType) }}
@@ -80,4 +92,4 @@ function formatTime(iso) {
/>
</div>
</Card>
</template>
</template>
@@ -12,14 +12,60 @@ const props = defineProps({
const CORRELATION_WINDOW_MS = 90 * 60 * 1000
const reasoningMap = {
WarningHeartRate: { label: 'Heart Rate Warning', explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91110 bpm or 4150 bpm).` },
WarningSystolicBp: { label: 'Systolic BP Warning', explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101110 mmHg).` },
WarningTempC: { label: 'Temperature Warning', explain: (a) => `Temperature ${extractValue(a)} is in the warning range.` },
SepsisWarning: { label: 'SIRS / Sepsis Alert', explain: () => '≥2 of 4 SIRS criteria met: temperature, heart rate, respiratory rate, WBC.' },
QsofaWarning: { label: 'qSOFA Alert', explain: () => '≥2 of 3 qSOFA criteria met: RR ≥22, SBP ≤100, altered mentation (AVPU ≥1).' },
News2Warning: { label: 'NEWS2 Medium Risk', explain: () => 'NEWS2 composite score 56 indicating medium clinical risk.' },
News2Emergency: { label: 'NEWS2 High Risk', explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.' },
RapidDeterioration: { label: 'Rapid Deterioration', explain: () => 'Vital sign trajectory shows rapid change within the sliding window.' },
WarningHeartRate: {
label: 'Heart Rate Warning',
explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91110 bpm or 4150 bpm).`,
},
WarningSystolicBp: {
label: 'Systolic BP Warning',
explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101110 mmHg).`,
},
WarningTempC: {
label: 'Temperature Warning',
explain: (a) => `Temperature ${extractValue(a)} is in the warning range.`,
},
SepsisWarning: {
label: 'SIRS / Sepsis Alert (Legacy)',
explain: () =>
'Historical alert: ≥2 of 4 SIRS criteria met. New sepsis detection uses SOFA delta ≥ 2.',
},
QsofaWarning: {
label: 'qSOFA Alert (Legacy)',
explain: () => 'Historical alert: ≥2 of 3 qSOFA criteria met.',
},
QsofaScreen: {
label: 'qSOFA Screen',
explain: () =>
'Bedside screen positive (≥2/3). Recommend ordering SOFA labs to evaluate organ dysfunction.',
},
SofaSepsis: {
label: 'Sepsis Alert (SOFA)',
explain: (a) => explainSofaAlert(a, true),
},
SofaWarning: {
label: 'SOFA Warning',
explain: (a) => explainSofaAlert(a, false),
},
GcsCritical: {
label: 'GCS Critical',
explain: (a) => explainGcsAlert(a),
},
GcsWarning: {
label: 'GCS Warning',
explain: (a) => explainGcsAlert(a),
},
News2Warning: {
label: 'NEWS2 Medium Risk',
explain: () => 'NEWS2 composite score 56 indicating medium clinical risk.',
},
News2Emergency: {
label: 'NEWS2 High Risk',
explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.',
},
RapidDeterioration: {
label: 'Rapid Deterioration',
explain: () => 'Vital sign trajectory shows rapid change within the sliding window.',
},
}
const recentMedications = computed(() => {
@@ -32,11 +78,45 @@ const recentMedications = computed(() => {
})
})
const actionHint = computed(() => {
const hints = {
QsofaScreen: 'Recommend ordering SOFA labs (PaO₂, platelets, bilirubin, creatinine).',
SofaSepsis: 'Review organ dysfunction and confirm sepsis bundle initiation.',
GcsCritical: 'Urgent neurological assessment — GCS ≤ 8.',
GcsWarning: 'Monitor consciousness closely — GCS 912.',
}
return hints[props.alert.alertType] ?? null
})
function extractValue(alert) {
const match = alert.details?.match(/(\d+\.?\d*)/)
return match ? match[1] : '—'
}
function explainGcsAlert(alert) {
const e = alert.details?.match(/E[=:]?\s*(\d)/i)?.[1]
const v = alert.details?.match(/V[=:]?\s*(\d)/i)?.[1]
const m = alert.details?.match(/M[=:]?\s*(\d)/i)?.[1]
if (e && v && m) return `GCS components: Eye ${e}, Verbal ${v}, Motor ${m}.`
return alert.details ?? 'GCS threshold crossed.'
}
function explainSofaAlert(alert, isSepsis) {
const delta = alert.details?.match(/delta\s*[+:]?\s*(\d+)/i)?.[1]
const baseline = alert.details?.match(/baseline\s*(\d+)/i)?.[1]
const current = alert.details?.match(/current\s*(\d+)/i)?.[1]
const parts = []
if (baseline && current && delta) {
parts.push(`SOFA score increased from ${baseline} to ${current} (delta +${delta}).`)
} else if (delta) {
parts.push(`SOFA delta +${delta} from baseline.`)
}
const organs = alert.details?.match(/organs?:\s*([^.]+)/i)?.[1]
if (organs) parts.push(`Organ dysfunction: ${organs.trim()}.`)
if (isSepsis) parts.push('Meets sepsis criteria: suspected infection + SOFA delta ≥ 2.')
return parts.length ? parts.join(' ') : (alert.details ?? 'SOFA threshold crossed.')
}
function formatMed(med) {
return `${med.drugName} ${med.dose}${med.doseUnit} (${med.route})`
}
@@ -58,6 +138,13 @@ function formatMed(med) {
{{ reasoningMap[alert.alertType]?.explain(alert) ?? alert.details }}
</p>
<p
v-if="actionHint"
class="rounded bg-blue-50 px-4 py-2 text-sm text-blue-800 dark:bg-blue-950/40 dark:text-blue-200"
>
{{ actionHint }}
</p>
<div
v-if="recentMedications.length"
class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
@@ -70,7 +157,10 @@ function formatMed(med) {
</ul>
</div>
<div v-if="alert.details" class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<div
v-if="alert.details"
class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ alert.details }}
</div>
@@ -87,4 +177,4 @@ function formatMed(med) {
</div>
</div>
</Card>
</template>
</template>
@@ -0,0 +1,107 @@
<script setup>
import { ref, computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
defineProps({
submitting: { type: Boolean, default: false },
})
const emit = defineEmits(['submit'])
const eye = ref(4)
const verbal = ref(5)
const motor = ref(6)
const total = computed(() => eye.value + verbal.value + motor.value)
const classification = computed(() => {
if (total.value <= 8) return { label: 'Severe', variant: 'critical' }
if (total.value <= 12) return { label: 'Moderate', variant: 'warning' }
return { label: 'Mild', variant: 'success' }
})
const eyeOptions = [
{ value: 1, label: '1 — No opening' },
{ value: 2, label: '2 — To pressure' },
{ value: 3, label: '3 — To voice' },
{ value: 4, label: '4 — Spontaneous' },
]
const verbalOptions = [
{ value: 1, label: '1 — None' },
{ value: 2, label: '2 — Incomprehensible' },
{ value: 3, label: '3 — Inappropriate' },
{ value: 4, label: '4 — Confused' },
{ value: 5, label: '5 — Oriented' },
]
const motorOptions = [
{ value: 1, label: '1 — None' },
{ value: 2, label: '2 — Extension' },
{ value: 3, label: '3 — Abnormal flexion' },
{ value: 4, label: '4 — Withdrawal' },
{ value: 5, label: '5 — Localizing' },
{ value: 6, label: '6 — Obeys commands' },
]
function submit() {
emit('submit', { eye: eye.value, verbal: verbal.value, motor: motor.value })
}
</script>
<template>
<div class="space-y-4">
<h3 class="text-sm font-semibold dark:text-white">Record Glasgow Coma Scale</h3>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="min-w-0">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">Eye (E)</label>
<select
v-model.number="eye"
class="w-full rounded border border-gray-300 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"
>
<option v-for="opt in eyeOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="min-w-0">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">Verbal (V)</label>
<select
v-model.number="verbal"
class="w-full rounded border border-gray-300 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"
>
<option v-for="opt in verbalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="min-w-0">
<label class="mb-2 block text-xs text-gray-500 dark:text-gray-400">Motor (M)</label>
<select
v-model.number="motor"
class="w-full rounded border border-gray-300 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"
>
<option v-for="opt in motorOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="text-lg font-bold dark:text-white">GCS {{ total }}/15</span>
<Badge :variant="classification.variant" size="xs">{{ classification.label }}</Badge>
<span class="text-xs text-gray-500 dark:text-gray-400">E{{ eye }} V{{ verbal }} M{{ motor }}</span>
</div>
<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 GCS' }}
</button>
</div>
</div>
</template>
@@ -1,40 +1,38 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import * as clinicalApi from '@/api/clinical'
import GcsEntryForm from '@/components/patient/GcsEntryForm.vue'
import { useScoringStore } from '@/stores/scoring'
const props = defineProps({
news2: { type: Object, default: null },
encounter: { type: Object, required: true },
})
const scoring = useScoringStore()
const {
news2,
qsofa,
gcsTotal,
gcsComponents,
gcsClassificationDisplay,
sofaTotal,
sofaDelta,
sofaOrgans,
news2Variant,
qsofaVariant,
submittingGcs,
} = storeToRefs(scoring)
const qsofa = ref(null)
const showGcsForm = ref(false)
async function loadQsofa() {
if (!props.encounter?.id) return
try {
qsofa.value = await clinicalApi.fetchCurrentQsofa(props.encounter.id)
} catch {
qsofa.value = null
}
function sofaOrganVariant(score) {
if (score === 0) return 'success'
if (score <= 2) return 'warning'
return 'critical'
}
watch(() => props.encounter?.id, loadQsofa, { immediate: true })
const news2Variant = computed(() => {
const score = props.news2?.totalScore ?? 0
if (score >= 7 || props.news2?.hasSingleParamThree) return 'critical'
if (score >= 5) return 'warning'
return 'success'
})
const qsofaVariant = computed(() => {
const count = qsofa.value?.activeCriteria ?? 0
if (count >= 2) return 'critical'
if (count === 1) return 'warning'
return 'success'
})
async function onGcsSubmit({ eye, verbal, motor }) {
await scoring.submitGcs(eye, verbal, motor)
showGcsForm.value = false
}
</script>
<template>
@@ -45,23 +43,102 @@ const qsofaVariant = computed(() => {
</h2>
</template>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">NEWS2</div>
<div class="mt-2 flex items-center gap-2">
<div class="space-y-6">
<!-- NEWS2 -->
<section class="space-y-2">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">NEWS2</div>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="news2Variant">{{ news2?.totalScore ?? '—' }}</Badge>
<span v-if="news2?.riskLevel" class="text-xs text-gray-500 dark:text-gray-400">
{{ news2.riskLevel }}
</span>
</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">qSOFA</div>
<div class="mt-2">
<Badge :variant="qsofaVariant">{{ qsofa?.activeCriteria ?? '—' }}</Badge>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">/ 3 criteria</span>
</section>
<!-- GCS -->
<section class="space-y-2 border-t border-gray-100 pt-4 dark:border-gray-700">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">GCS</div>
<button
type="button"
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
@click="showGcsForm = !showGcsForm"
>
{{ showGcsForm ? 'Cancel' : 'Record GCS' }}
</button>
</div>
</div>
<div v-if="gcsTotal != null" class="flex flex-wrap items-center gap-2">
<Badge :variant="gcsClassificationDisplay?.variant ?? 'info'">
{{ gcsTotal }}/15
</Badge>
<span v-if="gcsClassificationDisplay" class="text-xs text-gray-500 dark:text-gray-400">
{{ gcsClassificationDisplay.label }}
</span>
<span v-if="gcsComponents" class="text-xs text-gray-500 dark:text-gray-400">
E{{ gcsComponents.eye }} V{{ gcsComponents.verbal }} M{{ gcsComponents.motor }}
</span>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">No GCS recorded</div>
<Transition name="fade">
<GcsEntryForm
v-if="showGcsForm"
:submitting="submittingGcs"
@submit="onGcsSubmit"
/>
</Transition>
</section>
<!-- SOFA (compact) -->
<section class="space-y-2 border-t border-gray-100 pt-4 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">SOFA</div>
<div v-if="sofaTotal != null" class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="sofaDelta != null && sofaDelta >= 2 ? 'critical' : 'info'">
{{ sofaTotal }}/24
</Badge>
<Badge v-if="sofaDelta != null && sofaDelta >= 2" variant="critical" size="xs">
Δ +{{ sofaDelta }}
</Badge>
<Badge v-else-if="sofaDelta === 1" variant="warning" size="xs">Δ +1</Badge>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
<Badge
v-for="organ in sofaOrgans"
:key="organ.name"
:variant="sofaOrganVariant(organ.score)"
size="xs"
>
{{ organ.name }} {{ organ.score }}
</Badge>
</div>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">Labs pending</div>
</section>
<!-- qSOFA Screen -->
<section class="space-y-2 border-t border-gray-100 pt-4 dark:border-gray-700">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">qSOFA Screen</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Bedside screening qSOFA 2 suggests ordering SOFA labs
</p>
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="qsofaVariant">
{{ qsofa?.activeCriteria ?? '—' }}
</Badge>
<span class="text-xs text-gray-500 dark:text-gray-400">/ 3 criteria</span>
</div>
</section>
</div>
</Card>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -2,10 +2,12 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import { bundleElementLabel } from '@/api/normalize'
import { bundleElementLabel, bundleTriggerLabel } from '@/api/normalize'
const props = defineProps({
bundle: { type: Object, required: true },
bundle: { type: Object, default: null },
qsofa: { type: Object, default: null },
sofa: { type: Object, default: null },
})
const now = ref(Date.now())
@@ -21,7 +23,17 @@ onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const showQsofaScreenMessage = computed(() => {
if (props.bundle) return false
return (props.qsofa?.activeCriteria ?? 0) >= 2
})
const triggerLabel = computed(() =>
bundleTriggerLabel(props.bundle?.triggeringAlertType),
)
const remainingMs = computed(() => {
if (!props.bundle?.deadlineAt) return 0
const deadline = new Date(props.bundle.deadlineAt).getTime()
return Math.max(0, deadline - now.value)
})
@@ -34,7 +46,7 @@ const countdown = computed(() => {
})
const complianceVariant = computed(() => {
const status = props.bundle.complianceStatus
const status = props.bundle?.complianceStatus
if (status === 'Compliant') return 'success'
if (status === 'NonCompliant') return 'critical'
return 'warning'
@@ -52,43 +64,72 @@ function elementComplete(element) {
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sepsis Bundle
</h2>
<Badge :variant="complianceVariant">{{ bundle.complianceStatus }}</Badge>
<Badge v-if="bundle" :variant="complianceVariant">{{ bundle.complianceStatus }}</Badge>
</div>
</template>
<div class="mb-4 flex items-center justify-between rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<span class="text-sm text-gray-600 dark:text-gray-300">Time to deadline</span>
<span
class="font-mono text-lg font-semibold"
:class="remainingMs === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
>
{{ countdown }}
</span>
<div
v-if="showQsofaScreenMessage"
class="mb-4 rounded-lg bg-amber-50 px-4 py-2 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
qSOFA screen positive order SOFA labs to evaluate organ dysfunction.
</div>
<ul class="space-y-2">
<li
v-for="element in bundle.elements"
:key="element.id"
class="flex items-center gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
<div v-if="sofa?.totalScore != null" class="mb-4 flex flex-wrap items-center gap-2 text-sm dark:text-gray-300">
<span>Current SOFA:</span>
<Badge variant="info" size="xs">{{ sofa.totalScore }}/24</Badge>
<Badge
v-if="sofa.deltaFromBaseline != null && sofa.deltaFromBaseline >= 2"
variant="critical"
size="xs"
>
Δ +{{ sofa.deltaFromBaseline }}
</Badge>
</div>
<template v-if="bundle">
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
Triggered by {{ triggerLabel }}
</p>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<span class="text-sm text-gray-600 dark:text-gray-300">Time to deadline</span>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs"
:class="elementComplete(element)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'"
class="font-mono text-lg font-semibold"
:class="remainingMs === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
>
{{ elementComplete(element) ? '✓' : '○' }}
{{ countdown }}
</span>
<span
class="text-sm"
:class="elementComplete(element)
? 'text-gray-500 line-through dark:text-gray-400'
: 'text-gray-900 dark:text-white'"
</div>
<ul class="space-y-2">
<li
v-for="element in bundle.elements"
:key="element.id"
class="flex flex-wrap items-center gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
>
{{ bundleElementLabel(element.elementType) }}
</span>
</li>
</ul>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs"
:class="elementComplete(element)
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'"
>
{{ elementComplete(element) ? '✓' : '○' }}
</span>
<span
class="min-w-0 flex-1 text-sm"
:class="elementComplete(element)
? 'text-gray-500 line-through dark:text-gray-400'
: 'text-gray-900 dark:text-white'"
>
{{ bundleElementLabel(element.elementType) }}
</span>
</li>
</ul>
</template>
<p v-else class="text-sm text-gray-500 dark:text-gray-400">
No active sepsis bundle for this encounter.
</p>
</Card>
</template>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue'
import { useSofa } from '@/composables/useSofa'
import Badge from '@/components/ui/Badge.vue'
import Card from '@/components/ui/Card.vue'
const props = defineProps({
encounterId: { type: String, required: true },
})
const encounterIdRef = computed(() => props.encounterId)
const { total, delta, organs, hasStaleData, staleness, loading, fetch } = useSofa(encounterIdRef)
function organVariant(score) {
if (score === 0) return 'success'
if (score <= 2) return 'warning'
return 'critical'
}
</script>
<template>
<Card>
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<h3 class="text-sm font-semibold dark:text-white">SOFA Score</h3>
<div v-if="loading" class="text-sm text-gray-400 dark:text-gray-500">Loading</div>
<div v-else-if="total !== null" class="flex flex-wrap items-center gap-2">
<span class="text-2xl font-bold dark:text-white">{{ total }}/24</span>
<Badge v-if="delta !== null && delta >= 2" variant="critical" size="xs">
+{{ delta }} from baseline
</Badge>
<Badge v-else-if="delta !== null && delta === 1" variant="warning" size="xs">
+{{ delta }} from baseline
</Badge>
</div>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">Labs pending</span>
</div>
<div v-if="organs.length" class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div
v-for="organ in organs"
:key="organ.name"
class="rounded-lg bg-gray-50 p-4 text-center dark:bg-gray-800"
>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ organ.name }}</div>
<div class="mt-2">
<Badge :variant="organVariant(organ.score)" size="sm">
{{ organ.score }}/{{ organ.max }}
</Badge>
</div>
</div>
</div>
<div
v-if="hasStaleData"
class="rounded bg-amber-50 px-4 py-2 text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
<span v-if="staleness?.missingComponents?.length">
Missing: {{ staleness.missingComponents.join(', ') }}.
</span>
<span v-if="staleness?.staleComponents?.length">
Stale: {{ staleness.staleComponents.join(', ') }}.
</span>
<span v-if="staleness?.usedSpO2Fallback">
Using SpO/FiO proxy (no arterial blood gas).
</span>
</div>
<button
type="button"
class="text-xs text-blue-600 hover:underline dark:text-blue-400"
@click="fetch"
>
Refresh SOFA
</button>
</div>
</Card>
</template>