feature: Sepsis Engine Refactor: Remove SIRS, Rewire qSOFA + Bundle
Frontend: GCS Entry, SOFA Display, Sepsis UI Refactor
This commit is contained in:
@@ -26,7 +26,7 @@ describe('AlertCard', () => {
|
||||
it('showsAlertTypeAndSeverity', () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: openAlert } })
|
||||
expect(wrapper.text()).toContain('Critical')
|
||||
expect(wrapper.text()).toContain('SEPSIS_WARNING')
|
||||
expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)')
|
||||
})
|
||||
|
||||
it('acknowledgeButtonEmitsEvent', async () => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { alertTypeLabel, bundleTriggerLabel } from '@/api/normalize'
|
||||
|
||||
describe('AlertLabels', () => {
|
||||
it('sofaSepsisLabel', () => {
|
||||
expect(alertTypeLabel('SofaSepsis')).toBe('Sepsis Alert (SOFA)')
|
||||
})
|
||||
|
||||
it('legacySepsisLabel', () => {
|
||||
expect(alertTypeLabel('SepsisWarning')).toBe('Sepsis Warning (SIRS — Legacy)')
|
||||
})
|
||||
|
||||
it('qsofaScreenLabel', () => {
|
||||
expect(alertTypeLabel('QsofaScreen')).toBe('qSOFA Screen')
|
||||
})
|
||||
|
||||
it('gcsCriticalLabel', () => {
|
||||
expect(alertTypeLabel('GcsCritical')).toBe('GCS Critical (≤ 8)')
|
||||
})
|
||||
|
||||
it('bundleTriggerSofa', () => {
|
||||
expect(bundleTriggerLabel('SOFA_SEPSIS')).toBe('SOFA delta ≥ 2')
|
||||
})
|
||||
|
||||
it('bundleTriggerLegacy', () => {
|
||||
expect(bundleTriggerLabel('SEPSIS_WARNING')).toBe('SIRS criteria (Legacy)')
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,11 @@ const sepsisAlert = {
|
||||
triggeredAt: '2026-06-19T12:00:00Z',
|
||||
}
|
||||
|
||||
const qsofaAlert = {
|
||||
const qsofaScreenAlert = {
|
||||
id: 'alert-2',
|
||||
alertType: 'QsofaWarning',
|
||||
alertType: 'QsofaScreen',
|
||||
severity: 'Warning',
|
||||
details: 'qSOFA score elevated',
|
||||
details: 'qSOFA screen positive',
|
||||
triggeredAt: '2026-06-19T12:30:00Z',
|
||||
}
|
||||
|
||||
@@ -24,16 +24,18 @@ describe('AlertReasoning', () => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('showsExplanationForSepsisWarning', () => {
|
||||
it('showsExplanationForLegacySepsisWarning', () => {
|
||||
const wrapper = mount(AlertReasoning, { props: { alert: sepsisAlert } })
|
||||
expect(wrapper.text()).toContain('SIRS / Sepsis Alert')
|
||||
expect(wrapper.text()).toContain('≥2 of 4 SIRS criteria met')
|
||||
expect(wrapper.text()).toContain('SIRS / Sepsis Alert (Legacy)')
|
||||
expect(wrapper.text()).toContain('Historical alert')
|
||||
expect(wrapper.text()).toContain('SOFA delta')
|
||||
})
|
||||
|
||||
it('showsExplanationForQsofa', () => {
|
||||
const wrapper = mount(AlertReasoning, { props: { alert: qsofaAlert } })
|
||||
expect(wrapper.text()).toContain('qSOFA Alert')
|
||||
expect(wrapper.text()).toContain('≥2 of 3 qSOFA criteria met')
|
||||
it('showsExplanationForQsofaScreen', () => {
|
||||
const wrapper = mount(AlertReasoning, { props: { alert: qsofaScreenAlert } })
|
||||
expect(wrapper.text()).toContain('qSOFA Screen')
|
||||
expect(wrapper.text()).toContain('Bedside screen positive')
|
||||
expect(wrapper.text()).toContain('Recommend ordering SOFA labs (PaO₂')
|
||||
})
|
||||
|
||||
it('showsRawDetailsForUnknownType', () => {
|
||||
@@ -48,4 +50,4 @@ describe('AlertReasoning', () => {
|
||||
expect(wrapper.text()).toContain('CustomUnknown')
|
||||
expect(wrapper.text()).toContain('Something unusual happened')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -28,8 +28,8 @@ describe('FeedbackSummary', () => {
|
||||
it('showsPerAlertTypeBreakdown', () => {
|
||||
const wrapper = mount(FeedbackSummary)
|
||||
|
||||
expect(wrapper.text()).toContain('SEPSIS_WARNING')
|
||||
expect(wrapper.text()).toContain('WARNING_HEART_RATE')
|
||||
expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)')
|
||||
expect(wrapper.text()).toContain('Warning Heart Rate')
|
||||
expect(wrapper.text()).toContain('2 ratings')
|
||||
expect(wrapper.text()).toContain('1 ratings')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import GcsEntryForm from '@/components/patient/GcsEntryForm.vue'
|
||||
|
||||
describe('GcsEntryForm', () => {
|
||||
it('rendersThreeDropdowns', () => {
|
||||
const wrapper = mount(GcsEntryForm)
|
||||
expect(wrapper.findAll('select')).toHaveLength(3)
|
||||
expect(wrapper.text()).toContain('Eye (E)')
|
||||
expect(wrapper.text()).toContain('Verbal (V)')
|
||||
expect(wrapper.text()).toContain('Motor (M)')
|
||||
})
|
||||
|
||||
it('computesTotalCorrectly', async () => {
|
||||
const wrapper = mount(GcsEntryForm)
|
||||
await wrapper.findAll('select')[0].setValue(2)
|
||||
await wrapper.findAll('select')[1].setValue(3)
|
||||
await wrapper.findAll('select')[2].setValue(4)
|
||||
expect(wrapper.text()).toContain('GCS 9/15')
|
||||
})
|
||||
|
||||
it('showsClassification', async () => {
|
||||
const wrapper = mount(GcsEntryForm)
|
||||
await wrapper.findAll('select')[0].setValue(2)
|
||||
await wrapper.findAll('select')[1].setValue(3)
|
||||
await wrapper.findAll('select')[2].setValue(3)
|
||||
expect(wrapper.text()).toContain('Severe')
|
||||
|
||||
await wrapper.findAll('select')[0].setValue(4)
|
||||
await wrapper.findAll('select')[1].setValue(4)
|
||||
await wrapper.findAll('select')[2].setValue(4)
|
||||
expect(wrapper.text()).toContain('Moderate')
|
||||
|
||||
await wrapper.findAll('select')[0].setValue(4)
|
||||
await wrapper.findAll('select')[1].setValue(5)
|
||||
await wrapper.findAll('select')[2].setValue(6)
|
||||
expect(wrapper.text()).toContain('Mild')
|
||||
})
|
||||
|
||||
it('emitsSubmitWithComponents', async () => {
|
||||
const wrapper = mount(GcsEntryForm)
|
||||
await wrapper.findAll('select')[0].setValue(3)
|
||||
await wrapper.findAll('select')[1].setValue(4)
|
||||
await wrapper.findAll('select')[2].setValue(5)
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('submit')?.[0]?.[0]).toEqual({ eye: 3, verbal: 4, motor: 5 })
|
||||
})
|
||||
|
||||
it('defaultsToNormalValues', () => {
|
||||
const wrapper = mount(GcsEntryForm)
|
||||
expect(wrapper.text()).toContain('GCS 15/15')
|
||||
expect(wrapper.text()).toContain('E4 V5 M6')
|
||||
})
|
||||
|
||||
it('usesResponsiveLayout', () => {
|
||||
const wrapper = mount(GcsEntryForm)
|
||||
const grid = wrapper.find('.grid')
|
||||
expect(grid.classes()).toContain('grid-cols-1')
|
||||
expect(grid.classes()).toContain('sm:grid-cols-3')
|
||||
expect(wrapper.find('button').classes()).toContain('w-full')
|
||||
expect(wrapper.find('button').classes()).toContain('sm:w-auto')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ScoresPanel from '@/components/patient/ScoresPanel.vue'
|
||||
import { useScoringStore } from '@/stores/scoring'
|
||||
|
||||
describe('ScoresPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const store = useScoringStore()
|
||||
store.$patch({
|
||||
news2: { totalScore: 5, riskLevel: 'Medium' },
|
||||
gcs: { eyeScore: 4, verbalScore: 5, motorScore: 6, totalScore: 15 },
|
||||
sofa: { totalScore: 4, deltaFromBaseline: 0, respiratoryScore: 1, coagulationScore: 0, liverScore: 0, cardiovascularScore: 1, cnsScore: 1, renalScore: 1 },
|
||||
qsofa: { activeCriteria: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('showsGcsSection', () => {
|
||||
const wrapper = mount(ScoresPanel)
|
||||
expect(wrapper.text()).toContain('GCS')
|
||||
expect(wrapper.text()).toContain('15/15')
|
||||
expect(wrapper.text()).toContain('E4 V5 M6')
|
||||
})
|
||||
|
||||
it('showsSofaSection', () => {
|
||||
const wrapper = mount(ScoresPanel)
|
||||
expect(wrapper.text()).toContain('SOFA')
|
||||
expect(wrapper.text()).toContain('4/24')
|
||||
})
|
||||
|
||||
it('noSirsReference', () => {
|
||||
const wrapper = mount(ScoresPanel)
|
||||
expect(wrapper.text().toUpperCase()).not.toContain('SIRS')
|
||||
})
|
||||
|
||||
it('qsofaLabeledAsScreen', () => {
|
||||
const wrapper = mount(ScoresPanel)
|
||||
expect(wrapper.text()).toContain('qSOFA Screen')
|
||||
expect(wrapper.text()).toContain('Bedside screening')
|
||||
})
|
||||
|
||||
it('sofaOrganBadgesUseResponsiveGrid', () => {
|
||||
const wrapper = mount(ScoresPanel)
|
||||
const grid = wrapper.find('.grid.grid-cols-2')
|
||||
expect(grid.exists()).toBe(true)
|
||||
expect(grid.classes()).toContain('sm:flex')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SofaScorePanel from '@/components/patient/SofaScorePanel.vue'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
const mockState = {
|
||||
total: null,
|
||||
delta: null,
|
||||
organs: [],
|
||||
hasStaleData: false,
|
||||
staleness: null,
|
||||
loading: false,
|
||||
fetch: mockFetch,
|
||||
}
|
||||
|
||||
vi.mock('@/composables/useSofa', () => ({
|
||||
useSofa: () => mockState,
|
||||
}))
|
||||
|
||||
describe('SofaScorePanel', () => {
|
||||
beforeEach(() => {
|
||||
mockState.total = null
|
||||
mockState.delta = null
|
||||
mockState.organs = []
|
||||
mockState.hasStaleData = false
|
||||
mockState.staleness = null
|
||||
mockState.loading = false
|
||||
mockFetch.mockClear()
|
||||
})
|
||||
|
||||
it('showsLabsPending_WhenNoScore', () => {
|
||||
const wrapper = mount(SofaScorePanel, {
|
||||
props: { encounterId: 'enc-1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Labs pending')
|
||||
})
|
||||
|
||||
it('showsOrganBreakdown', () => {
|
||||
mockState.total = 8
|
||||
mockState.organs = [
|
||||
{ name: 'Respiratory', score: 2, max: 4 },
|
||||
{ name: 'Coagulation', score: 1, max: 4 },
|
||||
{ name: 'Liver', score: 0, max: 4 },
|
||||
{ name: 'Cardiovascular', score: 3, max: 4 },
|
||||
{ name: 'CNS', score: 1, max: 4 },
|
||||
{ name: 'Renal', score: 1, max: 4 },
|
||||
]
|
||||
const wrapper = mount(SofaScorePanel, {
|
||||
props: { encounterId: 'enc-1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Respiratory')
|
||||
expect(wrapper.text()).toContain('Renal')
|
||||
expect(wrapper.text()).toContain('8/24')
|
||||
})
|
||||
|
||||
it('showsDeltaBadge_WhenDeltaGe2', () => {
|
||||
mockState.total = 10
|
||||
mockState.delta = 2
|
||||
mockState.organs = [{ name: 'CNS', score: 2, max: 4 }]
|
||||
const wrapper = mount(SofaScorePanel, {
|
||||
props: { encounterId: 'enc-1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('+2 from baseline')
|
||||
})
|
||||
|
||||
it('showsStalenessWarning', () => {
|
||||
mockState.total = 6
|
||||
mockState.hasStaleData = true
|
||||
mockState.staleness = {
|
||||
staleComponents: ['Platelets'],
|
||||
missingComponents: ['PaO2'],
|
||||
usedSpO2Fallback: true,
|
||||
}
|
||||
mockState.organs = [{ name: 'Coagulation', score: 1, max: 4 }]
|
||||
const wrapper = mount(SofaScorePanel, {
|
||||
props: { encounterId: 'enc-1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Missing: PaO2')
|
||||
expect(wrapper.text()).toContain('Stale: Platelets')
|
||||
expect(wrapper.text()).toContain('SpO₂/FiO₂ proxy')
|
||||
})
|
||||
|
||||
it('organScoreColors', () => {
|
||||
mockState.total = 3
|
||||
mockState.organs = [
|
||||
{ name: 'Liver', score: 0, max: 4 },
|
||||
{ name: 'CNS', score: 2, max: 4 },
|
||||
{ name: 'Renal', score: 4, max: 4 },
|
||||
]
|
||||
const wrapper = mount(SofaScorePanel, {
|
||||
props: { encounterId: 'enc-1' },
|
||||
})
|
||||
const badges = wrapper.findAll('span')
|
||||
expect(badges.some(b => b.text().includes('0/4'))).toBe(true)
|
||||
expect(badges.some(b => b.text().includes('2/4'))).toBe(true)
|
||||
expect(badges.some(b => b.text().includes('4/4'))).toBe(true)
|
||||
})
|
||||
|
||||
it('usesResponsiveOrganGrid', () => {
|
||||
mockState.total = 4
|
||||
mockState.organs = [{ name: 'CNS', score: 1, max: 4 }]
|
||||
const wrapper = mount(SofaScorePanel, {
|
||||
props: { encounterId: 'enc-1' },
|
||||
})
|
||||
const grid = wrapper.find('.grid')
|
||||
expect(grid.classes()).toContain('grid-cols-2')
|
||||
expect(grid.classes()).toContain('sm:grid-cols-3')
|
||||
expect(grid.classes()).toContain('lg:grid-cols-6')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,14 @@ export function fetchCurrentQsofa(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/qsofa/current`)
|
||||
}
|
||||
|
||||
export function fetchCurrentGcs(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/gcs`)
|
||||
}
|
||||
|
||||
export function fetchCurrentSofa(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/sofa`)
|
||||
}
|
||||
|
||||
export function fetchSepsisBundle(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/sepsis-bundle/current`)
|
||||
}
|
||||
@@ -16,6 +24,35 @@ export function fetchOrders(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/orders`)
|
||||
}
|
||||
|
||||
export async function submitGcsObservations(encounterId, eye, verbal, motor) {
|
||||
const recordedAt = new Date().toISOString()
|
||||
return api.post(`/api/v1/encounters/${encounterId}/observations`, {
|
||||
observations: [
|
||||
{
|
||||
observationCode: 'GCS_EYE',
|
||||
value: eye,
|
||||
unit: 'score',
|
||||
source: 'Manual',
|
||||
recordedAt,
|
||||
},
|
||||
{
|
||||
observationCode: 'GCS_VERBAL',
|
||||
value: verbal,
|
||||
unit: 'score',
|
||||
source: 'Manual',
|
||||
recordedAt,
|
||||
},
|
||||
{
|
||||
observationCode: 'GCS_MOTOR',
|
||||
value: motor,
|
||||
unit: 'score',
|
||||
source: 'Manual',
|
||||
recordedAt,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNews2History(encounterId, { limit = 100 } = {}) {
|
||||
const all = []
|
||||
let cursor = null
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
// Map API PascalCase alert types to display labels (e.g. WarningHeartRate → WARNING_HEART_RATE)
|
||||
const ALERT_TYPE_LABELS = {
|
||||
SepsisWarning: 'Sepsis Warning (SIRS — Legacy)',
|
||||
QsofaWarning: 'qSOFA Alert (Legacy)',
|
||||
QsofaScreen: 'qSOFA Screen',
|
||||
SofaSepsis: 'Sepsis Alert (SOFA)',
|
||||
SofaWarning: 'SOFA Warning',
|
||||
GcsCritical: 'GCS Critical (≤ 8)',
|
||||
GcsWarning: 'GCS Warning (9–12)',
|
||||
News2Warning: 'NEWS2 Warning',
|
||||
News2Emergency: 'NEWS2 Emergency',
|
||||
RapidDeterioration: 'Rapid Deterioration',
|
||||
CriticalHeartRate: 'Critical Heart Rate',
|
||||
CriticalTempC: 'Critical Temperature',
|
||||
CriticalPotassiumMeqL: 'Critical Potassium',
|
||||
CriticalSpo2: 'Critical SpO₂',
|
||||
CriticalRespRate: 'Critical Respiratory Rate',
|
||||
CriticalWbcKUl: 'Critical WBC',
|
||||
CriticalSystolicBp: 'Critical Systolic BP',
|
||||
CriticalDiastolicBp: 'Critical Diastolic BP',
|
||||
CriticalLactateMmolL: 'Critical Lactate',
|
||||
CriticalAvpu: 'Critical AVPU',
|
||||
CriticalGlucoseMgDl: 'Critical Glucose',
|
||||
WarningHeartRate: 'Warning Heart Rate',
|
||||
WarningTempC: 'Warning Temperature',
|
||||
WarningPotassiumMeqL: 'Warning Potassium',
|
||||
WarningSpo2: 'Warning SpO₂',
|
||||
WarningRespRate: 'Warning Respiratory Rate',
|
||||
WarningWbcKUl: 'Warning WBC',
|
||||
WarningSystolicBp: 'Warning Systolic BP',
|
||||
WarningDiastolicBp: 'Warning Diastolic BP',
|
||||
WarningLactateMmolL: 'Warning Lactate',
|
||||
WarningGlucoseMgDl: 'Warning Glucose',
|
||||
CriticalPao2MmHg: 'Critical PaO₂',
|
||||
WarningPao2MmHg: 'Warning PaO₂',
|
||||
CriticalPlateletKUl: 'Critical Platelets',
|
||||
WarningPlateletKUl: 'Warning Platelets',
|
||||
CriticalBilirubinMgDl: 'Critical Bilirubin',
|
||||
WarningBilirubinMgDl: 'Warning Bilirubin',
|
||||
CriticalCreatinineMgDl: 'Critical Creatinine',
|
||||
WarningCreatinineMgDl: 'Warning Creatinine',
|
||||
}
|
||||
|
||||
const BUNDLE_TRIGGER_LABELS = {
|
||||
SOFA_SEPSIS: 'SOFA delta ≥ 2',
|
||||
SEPSIS_WARNING: 'SIRS criteria (Legacy)',
|
||||
QSOFA_WARNING: 'qSOFA alert (Legacy)',
|
||||
}
|
||||
|
||||
export function alertTypeLabel(type) {
|
||||
if (!type) return ''
|
||||
return type.replace(/([A-Z])/g, '_$1').slice(1).toUpperCase()
|
||||
return ALERT_TYPE_LABELS[type]
|
||||
?? type.replace(/([A-Z])/g, '_$1').slice(1).toUpperCase()
|
||||
}
|
||||
|
||||
export function bundleTriggerLabel(triggerType) {
|
||||
if (!triggerType) return ''
|
||||
return BUNDLE_TRIGGER_LABELS[triggerType] ?? triggerType.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
export function alertStatusToApiFilter(status) {
|
||||
@@ -23,6 +76,9 @@ const OBSERVATION_LABELS = {
|
||||
AVPU: 'AVPU',
|
||||
SUPPLEMENTAL_O2: 'Supplemental O₂',
|
||||
WBC_K_UL: 'WBC',
|
||||
GCS_EYE: 'GCS Eye',
|
||||
GCS_VERBAL: 'GCS Verbal',
|
||||
GCS_MOTOR: 'GCS Motor',
|
||||
}
|
||||
|
||||
export function observationCodeLabel(code) {
|
||||
|
||||
@@ -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 (91–110 bpm or 41–50 bpm).` },
|
||||
WarningSystolicBp: { label: 'Systolic BP Warning', explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101–110 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 5–6 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 (91–110 bpm or 41–50 bpm).`,
|
||||
},
|
||||
WarningSystolicBp: {
|
||||
label: 'Systolic BP Warning',
|
||||
explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101–110 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 5–6 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 9–12.',
|
||||
}
|
||||
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>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ref, computed, unref, watch } from 'vue'
|
||||
import * as clinicalApi from '@/api/clinical'
|
||||
|
||||
function gcsClassification(total) {
|
||||
if (total <= 8) return { label: 'Severe', variant: 'critical' }
|
||||
if (total <= 12) return { label: 'Moderate', variant: 'warning' }
|
||||
return { label: 'Mild', variant: 'success' }
|
||||
}
|
||||
|
||||
export function useGcs(encounterId) {
|
||||
const gcs = ref(null)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetch() {
|
||||
const id = unref(encounterId)
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
gcs.value = await clinicalApi.fetchCurrentGcs(id)
|
||||
} catch (e) {
|
||||
gcs.value = null
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(eye, verbal, motor) {
|
||||
const id = unref(encounterId)
|
||||
if (!id) return
|
||||
|
||||
const previous = gcs.value
|
||||
const optimisticTotal = eye + verbal + motor
|
||||
gcs.value = {
|
||||
eyeScore: eye,
|
||||
verbalScore: verbal,
|
||||
motorScore: motor,
|
||||
totalScore: optimisticTotal,
|
||||
classification: gcsClassification(optimisticTotal).label.toUpperCase(),
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await clinicalApi.submitGcsObservations(id, eye, verbal, motor)
|
||||
await fetch()
|
||||
} catch (e) {
|
||||
gcs.value = previous
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const total = computed(() => gcs.value?.totalScore ?? null)
|
||||
const components = computed(() => {
|
||||
if (!gcs.value) return null
|
||||
return {
|
||||
eye: gcs.value.eyeScore,
|
||||
verbal: gcs.value.verbalScore,
|
||||
motor: gcs.value.motorScore,
|
||||
}
|
||||
})
|
||||
const classification = computed(() => {
|
||||
if (total.value == null) return null
|
||||
return gcsClassification(total.value)
|
||||
})
|
||||
|
||||
watch(() => unref(encounterId), fetch, { immediate: true })
|
||||
|
||||
return {
|
||||
gcs,
|
||||
loading,
|
||||
submitting,
|
||||
error,
|
||||
total,
|
||||
components,
|
||||
classification,
|
||||
fetch,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ref, computed, unref, watch } from 'vue'
|
||||
import * as clinicalApi from '@/api/clinical'
|
||||
|
||||
const ORGAN_FIELDS = [
|
||||
{ name: 'Respiratory', key: 'respiratoryScore' },
|
||||
{ name: 'Coagulation', key: 'coagulationScore' },
|
||||
{ name: 'Liver', key: 'liverScore' },
|
||||
{ name: 'Cardiovascular', key: 'cardiovascularScore' },
|
||||
{ name: 'CNS', key: 'cnsScore' },
|
||||
{ name: 'Renal', key: 'renalScore' },
|
||||
]
|
||||
|
||||
export function useSofa(encounterId) {
|
||||
const sofa = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetch() {
|
||||
const id = unref(encounterId)
|
||||
if (!id) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
sofa.value = await clinicalApi.fetchCurrentSofa(id)
|
||||
} catch (e) {
|
||||
sofa.value = null
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const total = computed(() => sofa.value?.totalScore ?? null)
|
||||
const delta = computed(() => sofa.value?.deltaFromBaseline ?? null)
|
||||
const isBaseline = computed(() => sofa.value?.isBaseline ?? false)
|
||||
const organs = computed(() => {
|
||||
if (!sofa.value) return []
|
||||
return ORGAN_FIELDS.map(({ name, key }) => ({
|
||||
name,
|
||||
score: sofa.value[key] ?? 0,
|
||||
max: 4,
|
||||
}))
|
||||
})
|
||||
const staleness = computed(() => sofa.value?.staleness ?? null)
|
||||
const hasStaleData = computed(() => {
|
||||
const s = staleness.value
|
||||
if (!s) return false
|
||||
return (s.staleComponents?.length ?? 0) > 0
|
||||
|| (s.missingComponents?.length ?? 0) > 0
|
||||
|| s.usedSpO2Fallback
|
||||
})
|
||||
|
||||
watch(() => unref(encounterId), fetch, { immediate: true })
|
||||
|
||||
return {
|
||||
sofa,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
delta,
|
||||
isBaseline,
|
||||
organs,
|
||||
staleness,
|
||||
hasStaleData,
|
||||
fetch,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as clinicalApi from '@/api/clinical'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
|
||||
function gcsClassification(total) {
|
||||
if (total <= 8) return { label: 'Severe', variant: 'critical' }
|
||||
if (total <= 12) return { label: 'Moderate', variant: 'warning' }
|
||||
return { label: 'Mild', variant: 'success' }
|
||||
}
|
||||
|
||||
export const useScoringStore = defineStore('scoring', () => {
|
||||
const encounterId = ref(null)
|
||||
const gcs = ref(null)
|
||||
const sofa = ref(null)
|
||||
const news2 = ref(null)
|
||||
const qsofa = ref(null)
|
||||
const loading = ref(false)
|
||||
const submittingGcs = ref(false)
|
||||
let timer = null
|
||||
|
||||
const gcsTotal = computed(() => gcs.value?.totalScore ?? null)
|
||||
const gcsComponents = computed(() => {
|
||||
if (!gcs.value) return null
|
||||
return {
|
||||
eye: gcs.value.eyeScore,
|
||||
verbal: gcs.value.verbalScore,
|
||||
motor: gcs.value.motorScore,
|
||||
}
|
||||
})
|
||||
const gcsClassificationDisplay = computed(() => {
|
||||
if (gcsTotal.value == null) return null
|
||||
return gcsClassification(gcsTotal.value)
|
||||
})
|
||||
|
||||
const sofaTotal = computed(() => sofa.value?.totalScore ?? null)
|
||||
const sofaDelta = computed(() => sofa.value?.deltaFromBaseline ?? null)
|
||||
const sofaOrgans = computed(() => {
|
||||
if (!sofa.value) return []
|
||||
return [
|
||||
{ name: 'Resp', score: sofa.value.respiratoryScore ?? 0 },
|
||||
{ name: 'Coag', score: sofa.value.coagulationScore ?? 0 },
|
||||
{ name: 'Liver', score: sofa.value.liverScore ?? 0 },
|
||||
{ name: 'CV', score: sofa.value.cardiovascularScore ?? 0 },
|
||||
{ name: 'CNS', score: sofa.value.cnsScore ?? 0 },
|
||||
{ name: 'Renal', score: sofa.value.renalScore ?? 0 },
|
||||
]
|
||||
})
|
||||
|
||||
const news2Variant = computed(() => {
|
||||
const score = news2.value?.totalScore ?? 0
|
||||
if (score >= 7 || news2.value?.hasSingleParamThree) return 'critical'
|
||||
if (score >= 5) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const qsofaVariant = computed(() => {
|
||||
const count = qsofa.value?.activeCriteria ?? 0
|
||||
if (count >= 2) return 'warning'
|
||||
if (count === 1) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
if (!encounterId.value) return
|
||||
loading.value = true
|
||||
const id = encounterId.value
|
||||
try {
|
||||
const [g, s, n, q] = await Promise.all([
|
||||
clinicalApi.fetchCurrentGcs(id).catch(() => null),
|
||||
clinicalApi.fetchCurrentSofa(id).catch(() => null),
|
||||
clinicalApi.fetchCurrentNews2(id).catch(() => null),
|
||||
clinicalApi.fetchCurrentQsofa(id).catch(() => null),
|
||||
])
|
||||
gcs.value = g
|
||||
sofa.value = s
|
||||
news2.value = n
|
||||
qsofa.value = q
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitGcs(eye, verbal, motor) {
|
||||
if (!encounterId.value) return
|
||||
submittingGcs.value = true
|
||||
try {
|
||||
await clinicalApi.submitGcsObservations(encounterId.value, eye, verbal, motor)
|
||||
await refresh()
|
||||
} finally {
|
||||
submittingGcs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(id) {
|
||||
stopPolling()
|
||||
encounterId.value = id
|
||||
refresh()
|
||||
timer = setInterval(refresh, POLL_MS)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
encounterId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
encounterId,
|
||||
gcs,
|
||||
sofa,
|
||||
news2,
|
||||
qsofa,
|
||||
loading,
|
||||
submittingGcs,
|
||||
gcsTotal,
|
||||
gcsComponents,
|
||||
gcsClassificationDisplay,
|
||||
sofaTotal,
|
||||
sofaDelta,
|
||||
sofaOrgans,
|
||||
news2Variant,
|
||||
qsofaVariant,
|
||||
refresh,
|
||||
submitGcs,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
})
|
||||
@@ -5,10 +5,12 @@ import { storeToRefs } from 'pinia'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { useReplayControls } from '@/composables/useReplayControls'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useScoringStore } from '@/stores/scoring'
|
||||
import * as encountersApi from '@/api/encounters'
|
||||
import * as clinicalApi from '@/api/clinical'
|
||||
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'
|
||||
@@ -20,7 +22,9 @@ import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const alertStore = useAlertStore()
|
||||
const scoringStore = useScoringStore()
|
||||
const { alerts } = storeToRefs(alertStore)
|
||||
const { qsofa, sofa } = storeToRefs(scoringStore)
|
||||
const {
|
||||
isPaused,
|
||||
speed,
|
||||
@@ -42,7 +46,6 @@ const {
|
||||
const encounter = ref(null)
|
||||
const loading = ref(true)
|
||||
const observations = ref([])
|
||||
const news2 = ref(null)
|
||||
const news2History = ref([])
|
||||
const medications = ref([])
|
||||
const sepsisBundle = ref(null)
|
||||
@@ -85,10 +88,9 @@ async function loadAll() {
|
||||
const id = route.params.encounterId
|
||||
loading.value = true
|
||||
try {
|
||||
const [enc, obs, n2, history, meds, bundle, ord] = await Promise.all([
|
||||
const [enc, obs, history, meds, bundle, ord] = await Promise.all([
|
||||
encountersApi.fetchEncounter(id),
|
||||
encountersApi.fetchObservations(id),
|
||||
clinicalApi.fetchCurrentNews2(id).catch(() => null),
|
||||
clinicalApi.fetchNews2History(id).catch(() => []),
|
||||
clinicalApi.fetchMedications(id).catch(() => []),
|
||||
clinicalApi.fetchSepsisBundle(id).catch(() => null),
|
||||
@@ -97,11 +99,11 @@ async function loadAll() {
|
||||
await alertStore.loadAlerts(id)
|
||||
encounter.value = enc
|
||||
observations.value = obs
|
||||
news2.value = n2
|
||||
news2History.value = history
|
||||
medications.value = meds
|
||||
sepsisBundle.value = bundle
|
||||
orders.value = ord.items ?? ord
|
||||
scoringStore.startPolling(id)
|
||||
syncReplayBounds()
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -134,6 +136,7 @@ watch(() => route.params.encounterId, () => {
|
||||
selectedAlert.value = null
|
||||
nextAlertIndex = 0
|
||||
pause()
|
||||
scoringStore.stopPolling()
|
||||
loadAll()
|
||||
})
|
||||
|
||||
@@ -146,7 +149,10 @@ watch(openAlerts, (list) => {
|
||||
|
||||
watch([observations, news2History, alerts], syncReplayBounds, { deep: true })
|
||||
|
||||
onBeforeUnmount(() => stopPlayback())
|
||||
onBeforeUnmount(() => {
|
||||
stopPlayback()
|
||||
scoringStore.stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,7 +168,7 @@ onBeforeUnmount(() => stopPlayback())
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ScoresPanel :news2="news2" :encounter="encounter" />
|
||||
<ScoresPanel />
|
||||
<VitalsPanel :observations="replayObservations" />
|
||||
<AlertsList
|
||||
:encounter-id="route.params.encounterId"
|
||||
@@ -178,10 +184,16 @@ onBeforeUnmount(() => stopPlayback())
|
||||
/>
|
||||
|
||||
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
||||
<OrdersPanel :orders="orders" />
|
||||
<SepsisBundlePanel v-if="sepsisBundle" :bundle="sepsisBundle" />
|
||||
</div>
|
||||
|
||||
<SepsisBundlePanel
|
||||
:bundle="sepsisBundle"
|
||||
:qsofa="qsofa"
|
||||
:sofa="sofa"
|
||||
/>
|
||||
|
||||
<div id="clinical-review" class="w-full min-w-0 space-y-8">
|
||||
<TrendsGrid :observations="replayObservations" />
|
||||
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
|
||||
@@ -198,4 +210,4 @@ onBeforeUnmount(() => stopPlayback())
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
Reference in New Issue
Block a user