feature: Improve dashboard functionality
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const alert = {
|
||||
id: 'alert-1',
|
||||
alertType: 'SofaSepsis',
|
||||
severity: 'Critical',
|
||||
status: 'Open',
|
||||
details: 'SOFA delta >= 2',
|
||||
}
|
||||
|
||||
describe('AcknowledgeModal', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.$patch({
|
||||
user: {
|
||||
userId: '11111111-1111-1111-1111-111111111111',
|
||||
username: 'nurse.demo',
|
||||
displayName: 'Demo Nurse',
|
||||
role: 'NURSE',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('showsAuthenticatedUserAndRoleContext', () => {
|
||||
const wrapper = mount(AcknowledgeModal, {
|
||||
props: { open: true, alert },
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: {
|
||||
props: ['open', 'title'],
|
||||
template: '<div v-if="open"><slot /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Demo Nurse')
|
||||
expect(wrapper.text()).toContain('Nurse')
|
||||
expect(wrapper.text()).toContain('documenting awareness')
|
||||
expect(wrapper.text()).toContain('[NURSE] Acknowledged by Demo Nurse.')
|
||||
})
|
||||
|
||||
it('emitsOptionalNoteOnConfirm', async () => {
|
||||
const wrapper = mount(AcknowledgeModal, {
|
||||
props: { open: true, alert },
|
||||
global: {
|
||||
stubs: {
|
||||
Modal: {
|
||||
props: ['open', 'title'],
|
||||
template: '<div v-if="open"><slot /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('textarea').setValue('Will reassess in 30 minutes')
|
||||
const ackButton = wrapper.findAll('button').find(button => button.text() === 'Acknowledge')
|
||||
await ackButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('confirm')).toEqual([['Will reassess in 30 minutes']])
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,20 @@ describe('AlertCard', () => {
|
||||
expect(wrapper.emitted('acknowledge')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('showsAcknowledgedByWhenPresent', () => {
|
||||
const wrapper = mount(AlertCard, {
|
||||
props: {
|
||||
alert: {
|
||||
...openAlert,
|
||||
status: 'Acknowledged',
|
||||
acknowledgedBy: 'Demo Nurse (NURSE)',
|
||||
acknowledgedAt: '2026-06-19T12:30:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Acknowledged by Demo Nurse (Nurse)')
|
||||
})
|
||||
|
||||
it('resolvedAlertHidesActions', () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: resolvedAlert } })
|
||||
const actionButtons = wrapper.findAll('button').filter(b => ['Acknowledge', 'Resolve'].includes(b.text()))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
|
||||
const { mockPush } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
describe('CriticalAlertBanner', () => {
|
||||
it('rendersCriticalAlertsAndDismisses', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const alertStore = useAlertStore()
|
||||
alertStore.bannerAlerts = [{
|
||||
id: 'alert-1',
|
||||
alertType: 'SofaSepsis',
|
||||
severity: 'Critical',
|
||||
status: 'Open',
|
||||
details: 'SOFA delta >= 2',
|
||||
}]
|
||||
|
||||
const wrapper = mount(CriticalAlertBanner)
|
||||
expect(wrapper.text()).toContain('Sepsis Alert (SOFA)')
|
||||
expect(wrapper.text()).toContain('SOFA delta >= 2')
|
||||
|
||||
const dismissButton = wrapper.findAll('button').find(button => button.text() === 'Dismiss')
|
||||
await dismissButton.trigger('click')
|
||||
expect(alertStore.bannerAlerts).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import DepartmentOverviewView from '@/views/DepartmentOverviewView.vue'
|
||||
|
||||
const { mockPush } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePolling', () => ({
|
||||
usePolling: (fn) => {
|
||||
fn()
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/api/encounters', () => ({
|
||||
fetchAllActiveEncounters: vi.fn(() => Promise.resolve([
|
||||
{
|
||||
department: 'ICU',
|
||||
news2Score: 8,
|
||||
openAlertCount: 2,
|
||||
sepsisActive: true,
|
||||
sepsisBundleStatus: 'IN_PROGRESS',
|
||||
},
|
||||
{
|
||||
department: 'GENERAL_MEDICINE',
|
||||
news2Score: 3,
|
||||
openAlertCount: 0,
|
||||
sepsisActive: false,
|
||||
},
|
||||
])),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/analytics', () => ({
|
||||
fetchAlertSummary: vi.fn(() => Promise.resolve({
|
||||
summary: [{ department: 'ICU', total: 5 }],
|
||||
})),
|
||||
}))
|
||||
|
||||
describe('DepartmentOverviewView', () => {
|
||||
it('rendersDepartmentCardsAndTotals', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = mount(DepartmentOverviewView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('1 active patient'))
|
||||
|
||||
expect(wrapper.text()).toContain('Department Overview')
|
||||
expect(wrapper.text()).toContain('Critical (NEWS2 ≥ 7)')
|
||||
expect(wrapper.text()).toContain('Alert volume')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SepsisBoardView from '@/views/SepsisBoardView.vue'
|
||||
|
||||
const { mockPush } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePolling', () => ({
|
||||
usePolling: () => {},
|
||||
}))
|
||||
|
||||
const bundles = [
|
||||
{
|
||||
id: 'b1',
|
||||
encounterId: 'enc-1',
|
||||
firstName: 'Alice',
|
||||
lastName: 'A',
|
||||
mrn: 'M1',
|
||||
department: 'Icu',
|
||||
roomBed: '101',
|
||||
recognizedAt: '2026-06-23T12:00:00Z',
|
||||
deadlineAt: '2026-06-23T15:00:00Z',
|
||||
complianceStatus: 'IN_PROGRESS',
|
||||
elements: [
|
||||
{ id: 'e1', elementType: 'BloodCultures', status: 'Completed' },
|
||||
{ id: 'e2', elementType: 'SerumLactate', status: 'Pending' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@/api/sepsis', () => ({
|
||||
fetchSepsisBundles: vi.fn(() => Promise.resolve({ items: bundles, totalCount: 1 })),
|
||||
}))
|
||||
|
||||
describe('SepsisBoardView', () => {
|
||||
it('rendersBundleBoardWithSummary', async () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = mount(SepsisBoardView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Alice A'))
|
||||
expect(wrapper.text()).toContain('Sepsis Bundle Board')
|
||||
expect(wrapper.text()).toContain('Serum lactate')
|
||||
})
|
||||
})
|
||||
@@ -68,14 +68,19 @@ const patients = [
|
||||
]
|
||||
|
||||
describe('WardTable', () => {
|
||||
const sortProps = {
|
||||
sortField: 'news2Score',
|
||||
sortDirection: 'desc',
|
||||
}
|
||||
|
||||
it('rendersAllPatientRows', () => {
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('emitsClickWithEncounterId', async () => {
|
||||
mockPush.mockClear()
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
|
||||
await wrapper.findAll('tbody tr')[2].trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
name: 'PatientDetail',
|
||||
@@ -84,18 +89,26 @@ describe('WardTable', () => {
|
||||
})
|
||||
|
||||
it('showsCriticalBadgeForHighNews2', () => {
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
|
||||
const highRiskRow = wrapper.findAll('tbody tr')[2]
|
||||
expect(highRiskRow.html()).toContain('text-severity-critical')
|
||||
})
|
||||
|
||||
it('showsExtendedClinicalColumns', () => {
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
|
||||
const header = wrapper.find('thead').text()
|
||||
expect(header).toContain('SOFA')
|
||||
expect(header).toContain('GCS')
|
||||
expect(header).toContain('Department')
|
||||
expect(header).toContain('Last vitals')
|
||||
expect(wrapper.text()).toContain('Dr. C')
|
||||
expect(wrapper.text()).toContain('Δ+2')
|
||||
})
|
||||
|
||||
it('emitsSortWhenHeaderClicked', async () => {
|
||||
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
|
||||
const roomHeader = wrapper.findAll('thead button').find(button => button.text().includes('Room'))
|
||||
await roomHeader.trigger('click')
|
||||
expect(wrapper.emitted('sort')).toEqual([['roomBed']])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatAcknowledgedByDisplay,
|
||||
previewAcknowledgmentNote,
|
||||
roleAcknowledgmentMessage,
|
||||
} from '@/composables/alertAcknowledge'
|
||||
|
||||
describe('alertAcknowledge', () => {
|
||||
it('buildsRoleAwareNotePreview', () => {
|
||||
expect(previewAcknowledgmentNote('NURSE', 'Demo Nurse', 'Escalating to physician'))
|
||||
.toBe('[NURSE] Acknowledged by Demo Nurse. Escalating to physician')
|
||||
})
|
||||
|
||||
it('usesRoleSpecificAcknowledgmentMessage', () => {
|
||||
expect(roleAcknowledgmentMessage('PHYSICIAN')).toContain('physician')
|
||||
})
|
||||
|
||||
it('formatsAcknowledgedByDisplay', () => {
|
||||
expect(formatAcknowledgedByDisplay('Demo Nurse (NURSE)')).toBe('Demo Nurse (Nurse)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { detectNewCriticalAlerts, isNotifiableCriticalAlert } from '@/composables/criticalAlertDetect'
|
||||
|
||||
const criticalOpen = {
|
||||
id: 'a1',
|
||||
severity: 'Critical',
|
||||
status: 'Open',
|
||||
alertType: 'SofaSepsis',
|
||||
}
|
||||
|
||||
const criticalAcknowledged = {
|
||||
id: 'a2',
|
||||
severity: 'Critical',
|
||||
status: 'Acknowledged',
|
||||
alertType: 'News2Emergency',
|
||||
}
|
||||
|
||||
const warningOpen = {
|
||||
id: 'a3',
|
||||
severity: 'Warning',
|
||||
status: 'Open',
|
||||
alertType: 'News2Warning',
|
||||
}
|
||||
|
||||
describe('criticalAlertDetect', () => {
|
||||
it('identifiesNotifiableCriticalAlerts', () => {
|
||||
expect(isNotifiableCriticalAlert(criticalOpen)).toBe(true)
|
||||
expect(isNotifiableCriticalAlert(criticalAcknowledged)).toBe(false)
|
||||
expect(isNotifiableCriticalAlert(warningOpen)).toBe(false)
|
||||
})
|
||||
|
||||
it('seedsSeenIdsOnFirstPoll', () => {
|
||||
const result = detectNewCriticalAlerts([criticalOpen, warningOpen], [], false)
|
||||
expect(result.newAlerts).toEqual([])
|
||||
expect(result.nextSeenIds).toEqual(['a1'])
|
||||
expect(result.seeded).toBe(true)
|
||||
})
|
||||
|
||||
it('detectsNewCriticalAlertsAfterSeed', () => {
|
||||
const seeded = detectNewCriticalAlerts([criticalOpen], [], false)
|
||||
const next = detectNewCriticalAlerts(
|
||||
[criticalOpen, { ...criticalOpen, id: 'a4', alertType: 'GcsCritical' }],
|
||||
seeded.nextSeenIds,
|
||||
seeded.seeded,
|
||||
)
|
||||
expect(next.newAlerts.map(alert => alert.id)).toEqual(['a4'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
aggregateByDepartment,
|
||||
news2AcuityTier,
|
||||
normalizeDepartmentKey,
|
||||
summarizeDepartments,
|
||||
} from '@/composables/departmentFormat'
|
||||
|
||||
const encounters = [
|
||||
{
|
||||
department: 'ICU',
|
||||
news2Score: 8,
|
||||
openAlertCount: 2,
|
||||
sepsisActive: true,
|
||||
sepsisBundleStatus: 'IN_PROGRESS',
|
||||
},
|
||||
{
|
||||
department: 'Icu',
|
||||
news2Score: 5,
|
||||
openAlertCount: 1,
|
||||
sepsisActive: false,
|
||||
sepsisBundleStatus: null,
|
||||
},
|
||||
{
|
||||
department: 'GENERAL_MEDICINE',
|
||||
news2Score: 2,
|
||||
openAlertCount: 0,
|
||||
sepsisActive: false,
|
||||
sepsisBundleStatus: null,
|
||||
},
|
||||
{
|
||||
department: 'Surgery',
|
||||
news2Score: 6,
|
||||
openAlertCount: 3,
|
||||
sepsisActive: true,
|
||||
sepsisBundleStatus: 'IN_PROGRESS',
|
||||
},
|
||||
]
|
||||
|
||||
describe('departmentFormat', () => {
|
||||
it('normalizesDepartmentKeys', () => {
|
||||
expect(normalizeDepartmentKey('Icu')).toBe('ICU')
|
||||
expect(normalizeDepartmentKey('GeneralMedicine')).toBe('GENERAL_MEDICINE')
|
||||
})
|
||||
|
||||
it('classifiesNews2Acuity', () => {
|
||||
expect(news2AcuityTier(2)).toBe('low')
|
||||
expect(news2AcuityTier(5)).toBe('medium')
|
||||
expect(news2AcuityTier(7)).toBe('high')
|
||||
})
|
||||
|
||||
it('aggregatesByDepartment', () => {
|
||||
const departments = aggregateByDepartment(encounters, [
|
||||
{ department: 'ICU', total: 10 },
|
||||
{ department: 'SURGERY', total: 4 },
|
||||
])
|
||||
|
||||
const icu = departments.find(d => d.key === 'ICU')
|
||||
const surgery = departments.find(d => d.key === 'SURGERY')
|
||||
const general = departments.find(d => d.key === 'GENERAL_MEDICINE')
|
||||
|
||||
expect(icu.patientCount).toBe(2)
|
||||
expect(icu.acuity.high).toBe(1)
|
||||
expect(icu.acuity.medium).toBe(1)
|
||||
expect(icu.activeBundleCount).toBe(1)
|
||||
expect(icu.openAlertCount).toBe(3)
|
||||
expect(icu.averageNews2).toBe(6.5)
|
||||
expect(icu.alertVolume).toBe(10)
|
||||
|
||||
expect(surgery.patientCount).toBe(1)
|
||||
expect(surgery.activeBundleCount).toBe(1)
|
||||
expect(surgery.alertVolume).toBe(4)
|
||||
|
||||
expect(general.patientCount).toBe(1)
|
||||
expect(general.acuity.low).toBe(1)
|
||||
})
|
||||
|
||||
it('summarizesTotals', () => {
|
||||
const departments = aggregateByDepartment(encounters)
|
||||
expect(summarizeDepartments(departments)).toEqual({
|
||||
patientCount: 4,
|
||||
criticalCount: 1,
|
||||
activeBundleCount: 2,
|
||||
openAlertCount: 6,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
bundleUrgency,
|
||||
formatCountdown,
|
||||
sortBundlesByUrgency,
|
||||
summarizeBundles,
|
||||
} from '@/composables/sepsisFormat'
|
||||
|
||||
const baseBundle = {
|
||||
complianceStatus: 'IN_PROGRESS',
|
||||
deadlineAt: '2026-06-23T15:00:00Z',
|
||||
}
|
||||
|
||||
describe('sepsisFormat', () => {
|
||||
const now = new Date('2026-06-23T14:00:00Z').getTime()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('classifiesUrgency', () => {
|
||||
expect(bundleUrgency({ ...baseBundle, deadlineAt: '2026-06-23T15:00:00Z' }, now)).toBe('on_track')
|
||||
expect(bundleUrgency({ ...baseBundle, deadlineAt: '2026-06-23T14:20:00Z' }, now)).toBe('at_risk')
|
||||
expect(bundleUrgency({ ...baseBundle, deadlineAt: '2026-06-23T13:00:00Z' }, now)).toBe('overdue')
|
||||
expect(bundleUrgency({ complianceStatus: 'NON_COMPLIANT', deadlineAt: '2026-06-23T15:00:00Z' }, now)).toBe('overdue')
|
||||
})
|
||||
|
||||
it('formatsCountdown', () => {
|
||||
expect(formatCountdown('2026-06-23T14:45:00Z', now)).toBe('45:00')
|
||||
expect(formatCountdown('2026-06-23T13:00:00Z', now)).toBe('0:00')
|
||||
})
|
||||
|
||||
it('sortsByUrgency', () => {
|
||||
const bundles = [
|
||||
{ id: 'a', complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T15:00:00Z' },
|
||||
{ id: 'b', complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T13:00:00Z' },
|
||||
{ id: 'c', complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T14:20:00Z' },
|
||||
]
|
||||
const sorted = sortBundlesByUrgency(bundles, now)
|
||||
expect(sorted.map(b => b.id)).toEqual(['b', 'c', 'a'])
|
||||
})
|
||||
|
||||
it('summarizesBundles', () => {
|
||||
const bundles = [
|
||||
{ complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T15:00:00Z' },
|
||||
{ complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T14:20:00Z' },
|
||||
{ complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T13:00:00Z' },
|
||||
]
|
||||
expect(summarizeBundles(bundles, now)).toEqual({ on_track: 1, at_risk: 1, overdue: 1 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
|
||||
describe('alert store notifications', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('queuesBannerAlertsForNewCriticalItems', () => {
|
||||
const store = useAlertStore()
|
||||
const critical = {
|
||||
id: 'alert-1',
|
||||
severity: 'Critical',
|
||||
status: 'Open',
|
||||
alertType: 'SofaSepsis',
|
||||
}
|
||||
|
||||
expect(store.applyPollResults([critical])).toEqual([])
|
||||
expect(store.bannerAlerts).toEqual([])
|
||||
|
||||
const next = {
|
||||
id: 'alert-2',
|
||||
severity: 'Critical',
|
||||
status: 'Open',
|
||||
alertType: 'GcsCritical',
|
||||
}
|
||||
const newAlerts = store.applyPollResults([critical, next])
|
||||
expect(newAlerts.map(alert => alert.id)).toEqual(['alert-2'])
|
||||
expect(store.bannerAlerts.map(alert => alert.id)).toEqual(['alert-2'])
|
||||
})
|
||||
|
||||
it('dismissesBannerAlert', () => {
|
||||
const store = useAlertStore()
|
||||
store.bannerAlerts = [{ id: 'alert-1' }]
|
||||
store.dismissBannerAlert('alert-1')
|
||||
expect(store.bannerAlerts).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
|
||||
describe('ward store sort', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('persistsSortPreference', () => {
|
||||
const settings = useSettingsStore()
|
||||
const ward = useWardStore()
|
||||
|
||||
ward.setSort('name')
|
||||
expect(settings.wardSortField).toBe('name')
|
||||
expect(settings.wardSortDirection).toBe('asc')
|
||||
expect(localStorage.getItem('wardSortField')).toBe('name')
|
||||
|
||||
ward.setSort('name')
|
||||
expect(settings.wardSortDirection).toBe('desc')
|
||||
expect(localStorage.getItem('wardSortDirection')).toBe('desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ward store filters', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('filtersEncountersForDisplay', () => {
|
||||
const ward = useWardStore()
|
||||
ward.encounters = [
|
||||
{ encounterId: '1', firstName: 'Alice', lastName: 'A', mrn: 'M1', news2Score: 8, openAlertCount: 1 },
|
||||
{ encounterId: '2', firstName: 'Bob', lastName: 'B', mrn: 'M2', news2Score: 3, openAlertCount: 0 },
|
||||
]
|
||||
|
||||
ward.toggleFilter('critical')
|
||||
expect(ward.displayEncounters.map(e => e.encounterId)).toEqual(['1'])
|
||||
|
||||
ward.toggleFilter('hasAlerts')
|
||||
expect(ward.displayEncounters.map(e => e.encounterId)).toEqual(['1'])
|
||||
|
||||
ward.clearFilters()
|
||||
expect(ward.displayEncounters.map(e => e.encounterId)).toEqual(['1', '2'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { filterEncounters, matchesPatientSearch } from '@/composables/wardFilter'
|
||||
|
||||
const patients = [
|
||||
{
|
||||
encounterId: '1',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Anderson',
|
||||
mrn: 'MRN-100',
|
||||
news2Score: 8,
|
||||
openAlertCount: 2,
|
||||
sepsisActive: true,
|
||||
sepsisBundleStatus: 'IN_PROGRESS',
|
||||
},
|
||||
{
|
||||
encounterId: '2',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Baker',
|
||||
mrn: 'MRN-200',
|
||||
news2Score: 4,
|
||||
openAlertCount: 0,
|
||||
sepsisActive: false,
|
||||
},
|
||||
{
|
||||
encounterId: '3',
|
||||
firstName: 'Carol',
|
||||
lastName: 'Clark',
|
||||
mrn: 'MRN-300',
|
||||
news2Score: 7,
|
||||
openAlertCount: 1,
|
||||
sepsisActive: false,
|
||||
sepsisBundleStatus: 'IN_PROGRESS',
|
||||
},
|
||||
]
|
||||
|
||||
describe('wardFilter', () => {
|
||||
it('matchesNameOrMrn', () => {
|
||||
expect(matchesPatientSearch(patients[0], 'alice')).toBe(true)
|
||||
expect(matchesPatientSearch(patients[0], 'MRN-100')).toBe(true)
|
||||
expect(matchesPatientSearch(patients[0], 'anderson')).toBe(true)
|
||||
expect(matchesPatientSearch(patients[0], 'xyz')).toBe(false)
|
||||
})
|
||||
|
||||
it('filtersBySearch', () => {
|
||||
const result = filterEncounters(patients, { search: 'baker' })
|
||||
expect(result.map(p => p.encounterId)).toEqual(['2'])
|
||||
})
|
||||
|
||||
it('filtersByHasAlerts', () => {
|
||||
const result = filterEncounters(patients, { hasAlerts: true })
|
||||
expect(result.map(p => p.encounterId)).toEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it('filtersBySepsisActive', () => {
|
||||
const result = filterEncounters(patients, { sepsisActive: true })
|
||||
expect(result.map(p => p.encounterId)).toEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it('filtersByCriticalNews2', () => {
|
||||
const result = filterEncounters(patients, { critical: true })
|
||||
expect(result.map(p => p.encounterId)).toEqual(['1', '3'])
|
||||
})
|
||||
|
||||
it('combinesSearchAndFilters', () => {
|
||||
const result = filterEncounters(patients, {
|
||||
search: 'carol',
|
||||
hasAlerts: true,
|
||||
critical: true,
|
||||
})
|
||||
expect(result.map(p => p.encounterId)).toEqual(['3'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
defaultSortDirection,
|
||||
sortEncounters,
|
||||
} from '@/composables/wardSort'
|
||||
|
||||
const patients = [
|
||||
{
|
||||
encounterId: '1',
|
||||
firstName: 'Bob',
|
||||
lastName: 'B',
|
||||
roomBed: '202',
|
||||
department: 'SURGERY',
|
||||
news2Score: 5,
|
||||
qsofaScore: 1,
|
||||
sepsisActive: false,
|
||||
openAlertCount: 1,
|
||||
},
|
||||
{
|
||||
encounterId: '2',
|
||||
firstName: 'Alice',
|
||||
lastName: 'A',
|
||||
roomBed: '101',
|
||||
department: 'ICU',
|
||||
news2Score: 8,
|
||||
qsofaScore: 2,
|
||||
sepsisActive: true,
|
||||
sepsisBundleStatus: 'IN_PROGRESS',
|
||||
openAlertCount: 3,
|
||||
},
|
||||
{
|
||||
encounterId: '3',
|
||||
firstName: 'Carol',
|
||||
lastName: 'C',
|
||||
roomBed: '103',
|
||||
department: 'GENERAL_MEDICINE',
|
||||
news2Score: 2,
|
||||
qsofaScore: 0,
|
||||
sepsisActive: false,
|
||||
openAlertCount: 0,
|
||||
},
|
||||
]
|
||||
|
||||
describe('wardSort', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('defaultsTextFieldsToAscending', () => {
|
||||
expect(defaultSortDirection('name')).toBe('asc')
|
||||
expect(defaultSortDirection('news2Score')).toBe('desc')
|
||||
})
|
||||
|
||||
it('sortsByNews2DescendingByDefault', () => {
|
||||
const sorted = sortEncounters(patients, 'news2Score', 'desc')
|
||||
expect(sorted.map(p => p.encounterId)).toEqual(['2', '1', '3'])
|
||||
})
|
||||
|
||||
it('sortsByPatientNameAscending', () => {
|
||||
const sorted = sortEncounters(patients, 'name', 'asc')
|
||||
expect(sorted.map(p => p.firstName)).toEqual(['Alice', 'Bob', 'Carol'])
|
||||
})
|
||||
|
||||
it('sortsByRoomBedAscending', () => {
|
||||
const sorted = sortEncounters(patients, 'roomBed', 'asc')
|
||||
expect(sorted.map(p => p.roomBed)).toEqual(['101', '103', '202'])
|
||||
})
|
||||
|
||||
it('sortsByOpenAlertCountDescending', () => {
|
||||
const sorted = sortEncounters(patients, 'openAlertCount', 'desc')
|
||||
expect(sorted.map(p => p.encounterId)).toEqual(['2', '1', '3'])
|
||||
})
|
||||
|
||||
it('sortsBySepsisStatusDescending', () => {
|
||||
const sorted = sortEncounters(patients, 'sepsis', 'desc')
|
||||
expect(sorted[0].encounterId).toBe('2')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,26 @@ export function fetchAllAlerts(status) {
|
||||
return api.get(`/api/v1/alerts?${params}`)
|
||||
}
|
||||
|
||||
export async function fetchAllOpenAlerts() {
|
||||
const items = []
|
||||
let page = 1
|
||||
let totalCount = 0
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
status: 'OPEN',
|
||||
page: String(page),
|
||||
pageSize: '100',
|
||||
})
|
||||
const data = await api.get(`/api/v1/alerts?${params}`)
|
||||
items.push(...(data.items ?? []))
|
||||
totalCount = data.totalCount ?? items.length
|
||||
page += 1
|
||||
} while (items.length < totalCount)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function acknowledgeAlert(alertId, note) {
|
||||
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { note })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchAlertSummary({ severity, department, from, to } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (severity) params.set('severity', severity)
|
||||
if (department) params.set('department', department)
|
||||
if (from) params.set('from', from)
|
||||
if (to) params.set('to', to)
|
||||
const qs = params.toString()
|
||||
return api.get(`/api/v1/analytics/alerts/summary${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchActiveEncounters(department) {
|
||||
const params = new URLSearchParams({ status: 'ACTIVE' })
|
||||
export function fetchActiveEncounters(department, { page = 1, pageSize = 20 } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
status: 'ACTIVE',
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
})
|
||||
if (department) params.set('department', department)
|
||||
return api.get(`/api/v1/encounters?${params}`)
|
||||
}
|
||||
|
||||
export async function fetchAllActiveEncounters(department) {
|
||||
const items = []
|
||||
let page = 1
|
||||
let totalCount = 0
|
||||
|
||||
do {
|
||||
const data = await fetchActiveEncounters(department, { page, pageSize: 100 })
|
||||
items.push(...(data.items ?? []))
|
||||
totalCount = data.totalCount ?? items.length
|
||||
page += 1
|
||||
} while (items.length < totalCount)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function fetchEncounter(id) {
|
||||
return api.get(`/api/v1/encounters/${id}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchSepsisBundles({ status = 'IN_PROGRESS', page = 1, pageSize = 100 } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
})
|
||||
if (status) params.set('status', status)
|
||||
return api.get(`/api/v1/sepsis-bundles?${params}`)
|
||||
}
|
||||
@@ -1,19 +1,45 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import {
|
||||
formatRoleLabel,
|
||||
previewAcknowledgmentNote,
|
||||
roleAcknowledgmentMessage,
|
||||
} from '@/composables/alertAcknowledge'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
alert: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'close'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { displayName, role } = storeToRefs(authStore)
|
||||
const note = ref('')
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) note.value = ''
|
||||
})
|
||||
|
||||
const roleLabel = computed(() => formatRoleLabel(role.value))
|
||||
const roleMessage = computed(() => roleAcknowledgmentMessage(role.value))
|
||||
const notePreview = computed(() =>
|
||||
previewAcknowledgmentNote(role.value, displayName.value, note.value),
|
||||
)
|
||||
|
||||
function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
emit('confirm', note.value.trim())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,12 +54,36 @@ function severityVariant(severity) {
|
||||
<p v-if="alert.details" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ alert.details }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Confirm that you have reviewed this alert.
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
<span class="text-gray-500 dark:text-gray-400">· {{ roleLabel }}</span>
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ roleMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Optional note
|
||||
</span>
|
||||
<textarea
|
||||
v-model="note"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Add clinical context (optional)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Audit record: {{ notePreview }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="emit('close')">Cancel</Button>
|
||||
<Button variant="primary" @click="emit('confirm')">Acknowledge</Button>
|
||||
<Button variant="primary" @click="onConfirm">Acknowledge</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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'
|
||||
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
|
||||
|
||||
const props = defineProps({
|
||||
alert: { type: Object, required: true },
|
||||
@@ -62,6 +63,13 @@ function formatTime(iso) {
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{{ formatTime(alert.triggeredAt) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="alert.acknowledgedBy"
|
||||
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
|
||||
<span v-if="alert.acknowledgedAt">at {{ formatTime(alert.acknowledgedAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions(alert.status)" class="flex w-full shrink-0 gap-2 sm:w-auto">
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import {
|
||||
formatAlertPatientLabel,
|
||||
requestNotificationPermission,
|
||||
stopCriticalTitleFlash,
|
||||
} from '@/composables/useAlertNotification'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const alertStore = useAlertStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { bannerAlerts } = storeToRefs(alertStore)
|
||||
const { alertSoundMuted } = storeToRefs(settingsStore)
|
||||
|
||||
const showNotificationPrompt = computed(() => {
|
||||
if (!('Notification' in window)) return false
|
||||
return Notification.permission === 'default'
|
||||
})
|
||||
|
||||
function dismissAll() {
|
||||
alertStore.dismissAllBannerAlerts()
|
||||
stopCriticalTitleFlash()
|
||||
}
|
||||
|
||||
function dismissOne(alertId) {
|
||||
alertStore.dismissBannerAlert(alertId)
|
||||
if (bannerAlerts.value.length === 0) {
|
||||
stopCriticalTitleFlash()
|
||||
}
|
||||
}
|
||||
|
||||
function openAlertCenter() {
|
||||
router.push({ name: 'AlertCenter' })
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
await requestNotificationPermission()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="bannerAlerts.length > 0"
|
||||
class="border-b border-red-700 bg-red-600 text-white"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 lg:px-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-wide">Critical alert</p>
|
||||
<p class="text-sm text-red-100">
|
||||
{{ bannerAlerts.length }} new critical alert{{ bannerAlerts.length === 1 ? '' : 's' }} require attention
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-700" @click="settingsStore.toggleAlertSoundMute()">
|
||||
{{ alertSoundMuted ? 'Unmute sound' : 'Mute sound' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showNotificationPrompt"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="!text-white hover:!bg-red-700"
|
||||
@click="enableNotifications"
|
||||
>
|
||||
Enable notifications
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" @click="openAlertCenter">
|
||||
Open Alert Center
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" @click="dismissAll">
|
||||
Dismiss all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="alert in bannerAlerts"
|
||||
:key="alert.id"
|
||||
class="flex flex-wrap items-start justify-between gap-4 rounded-lg bg-red-700/60 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">{{ alertTypeLabel(alert.alertType) }}</p>
|
||||
<p class="text-sm text-red-100">{{ formatAlertPatientLabel(alert) }}</p>
|
||||
<p v-if="alert.details" class="mt-1 text-sm text-red-50">{{ alert.details }}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-800" @click="dismissOne(alert.id)">
|
||||
Dismiss
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
acuity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const segments = computed(() => {
|
||||
const total = props.acuity.low + props.acuity.medium + props.acuity.high
|
||||
if (total === 0) {
|
||||
return [
|
||||
{ key: 'empty', label: 'No patients', pct: 100, className: 'bg-gray-200 dark:bg-gray-700' },
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: 'low', label: 'Low', count: props.acuity.low, pct: (props.acuity.low / total) * 100, className: 'bg-green-500' },
|
||||
{ key: 'medium', label: 'Medium', count: props.acuity.medium, pct: (props.acuity.medium / total) * 100, className: 'bg-amber-500' },
|
||||
{ key: 'high', label: 'High', count: props.acuity.high, pct: (props.acuity.high / total) * 100, className: 'bg-red-500' },
|
||||
].filter(segment => segment.count > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
v-for="segment in segments"
|
||||
:key="segment.key"
|
||||
class="h-full transition-all"
|
||||
:class="segment.className"
|
||||
:style="{ width: `${segment.pct}%` }"
|
||||
:title="segment.label"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-for="segment in segments" :key="`${segment.key}-legend`">
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full" :class="segment.className" />
|
||||
{{ segment.label }}: {{ segment.count ?? 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import AcuityBar from '@/components/departments/AcuityBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
department: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const hasPatients = computed(() => props.department.patientCount > 0)
|
||||
|
||||
function onSelect() {
|
||||
if (!hasPatients.value) return
|
||||
emit('select', props.department.filterValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
:class="hasPatients ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default opacity-80'"
|
||||
:disabled="!hasPatients"
|
||||
@click="onSelect"
|
||||
>
|
||||
<Card padding="lg" class="h-full">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.label }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ department.patientCount }} active patient{{ department.patientCount === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge v-if="department.acuity.high > 0" variant="critical">
|
||||
{{ department.acuity.high }} critical
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
NEWS2 acuity
|
||||
</p>
|
||||
<AcuityBar :acuity="department.acuity" />
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Avg NEWS2</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.averageNews2 ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Open alerts</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.openAlertCount }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Sepsis bundles</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.activeBundleCount }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Alert volume</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.alertVolume }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p v-if="hasPatients" class="mt-4 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
View patients in Virtual Ward
|
||||
</p>
|
||||
</Card>
|
||||
</button>
|
||||
</template>
|
||||
@@ -3,11 +3,14 @@ import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
|
||||
const route = useRoute()
|
||||
const wardStore = useWardStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { department } = storeToRefs(wardStore)
|
||||
const { alertSoundMuted } = storeToRefs(settingsStore)
|
||||
const { darkMode, toggle } = useDarkMode()
|
||||
|
||||
const pageTitle = computed(() => route.meta.title ?? 'VigilCare')
|
||||
@@ -51,6 +54,50 @@ function onDepartmentChange(event) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
:aria-label="alertSoundMuted ? 'Unmute critical alert sound' : 'Mute critical alert sound'"
|
||||
@click="settingsStore.toggleAlertSoundMute()"
|
||||
>
|
||||
<svg
|
||||
v-if="alertSoundMuted"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import MobileNav from './MobileNav.vue'
|
||||
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
useCriticalAlertPolling(settingsStore.pollInterval)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,6 +15,7 @@ import MobileNav from './MobileNav.vue'
|
||||
<AppSidebar />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<AppHeader />
|
||||
<CriticalAlertBanner />
|
||||
<main class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
<slot />
|
||||
|
||||
@@ -5,6 +5,8 @@ const route = useRoute()
|
||||
|
||||
const links = [
|
||||
{ to: '/ward', label: 'Virtual Ward', icon: 'ward' },
|
||||
{ to: '/departments', label: 'Departments', icon: 'departments' },
|
||||
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis' },
|
||||
{ to: '/alerts', label: 'Alert Center', icon: 'alerts' },
|
||||
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback' },
|
||||
]
|
||||
@@ -60,6 +62,36 @@ function linkClasses(path) {
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'sepsis'"
|
||||
class="h-6 w-6 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'departments'"
|
||||
class="h-6 w-6 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-6 w-6 shrink-0"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
|
||||
|
||||
const props = defineProps({
|
||||
encounterId: { type: String, required: true },
|
||||
@@ -18,6 +19,7 @@ const emit = defineEmits(['select'])
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
const confirmingAlert = ref(null)
|
||||
|
||||
function loadEncounterAlerts() {
|
||||
return alertStore.loadAlerts(props.encounterId)
|
||||
@@ -33,8 +35,10 @@ function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
|
||||
async function acknowledge(alertId) {
|
||||
await alertStore.acknowledge(alertId)
|
||||
async function handleAcknowledge(note) {
|
||||
if (!confirmingAlert.value) return
|
||||
await alertStore.acknowledge(confirmingAlert.value.id, note)
|
||||
confirmingAlert.value = null
|
||||
await loadEncounterAlerts()
|
||||
}
|
||||
|
||||
@@ -72,13 +76,19 @@ async function resolve(alertId) {
|
||||
<p v-if="alert.details" class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ alert.details }}
|
||||
</p>
|
||||
<p
|
||||
v-if="alert.acknowledgedBy"
|
||||
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click.stop="acknowledge(alert.id)"
|
||||
@click.stop="confirmingAlert = alert"
|
||||
>
|
||||
Ack
|
||||
</Button>
|
||||
@@ -93,5 +103,12 @@ async function resolve(alertId) {
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<AcknowledgeModal
|
||||
:open="!!confirmingAlert"
|
||||
:alert="confirmingAlert"
|
||||
@confirm="handleAcknowledge"
|
||||
@close="confirmingAlert = null"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { bundleElementLabel } from '@/api/normalize'
|
||||
import {
|
||||
bundleUrgency,
|
||||
formatCountdown,
|
||||
formatDepartment,
|
||||
outstandingElements,
|
||||
urgencyLabel,
|
||||
urgencyVariant,
|
||||
} from '@/composables/sepsisFormat'
|
||||
|
||||
const props = defineProps({
|
||||
bundle: { type: Object, required: true },
|
||||
now: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const urgency = computed(() => bundleUrgency(props.bundle, props.now))
|
||||
const outstanding = computed(() => outstandingElements(props.bundle))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition duration-200 hover:border-gray-300 hover:shadow-md active:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:active:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ bundle.firstName }} {{ bundle.lastName }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.mrn }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDepartment(bundle.department) }}
|
||||
<span v-if="bundle.roomBed"> · {{ bundle.roomBed }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Badge :variant="urgencyVariant(urgency)">{{ urgencyLabel(urgency) }}</Badge>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||||
<div>
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Time remaining</dt>
|
||||
<dd
|
||||
class="mt-2 font-mono text-sm font-semibold"
|
||||
:class="urgency === 'overdue' ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ formatCountdown(bundle.deadlineAt, now) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Outstanding</dt>
|
||||
<dd class="mt-2 text-sm text-gray-900 dark:text-white">
|
||||
<span v-if="outstanding.length === 0">None</span>
|
||||
<ul v-else class="space-y-1">
|
||||
<li v-for="element in outstanding" :key="element.id" class="text-xs">
|
||||
{{ bundleElementLabel(element.elementType) }}
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { bundleElementLabel } from '@/api/normalize'
|
||||
import {
|
||||
bundleUrgency,
|
||||
complianceStatusLabel,
|
||||
formatCountdown,
|
||||
formatDepartment,
|
||||
outstandingElements,
|
||||
urgencyLabel,
|
||||
urgencyVariant,
|
||||
} from '@/composables/sepsisFormat'
|
||||
|
||||
const props = defineProps({
|
||||
bundle: { type: Object, required: true },
|
||||
now: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const urgency = computed(() => bundleUrgency(props.bundle, props.now))
|
||||
const outstanding = computed(() => outstandingElements(props.bundle))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4 py-4">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ bundle.firstName }} {{ bundle.lastName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.mrn }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDepartment(bundle.department) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ bundle.roomBed ?? '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ new Date(bundle.recognizedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ new Date(bundle.deadlineAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-right">
|
||||
<span
|
||||
class="font-mono text-sm font-semibold"
|
||||
:class="urgency === 'overdue' ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ formatCountdown(bundle.deadlineAt, now) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4">
|
||||
<Badge :variant="urgencyVariant(urgency)">{{ urgencyLabel(urgency) }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ complianceStatusLabel(bundle.complianceStatus) }}</span>
|
||||
<ul v-if="outstanding.length" class="mt-2 space-y-1">
|
||||
<li v-for="element in outstanding" :key="element.id" class="text-xs text-amber-700 dark:text-amber-300">
|
||||
{{ bundleElementLabel(element.elementType) }}
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else class="mt-1 block text-xs text-green-700 dark:text-green-300">All elements complete</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import SepsisBundleRow from './SepsisBundleRow.vue'
|
||||
import SepsisBundleCard from './SepsisBundleCard.vue'
|
||||
|
||||
defineProps({
|
||||
bundles: { type: Array, required: true },
|
||||
now: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goToPatient(encounterId) {
|
||||
router.push({ name: 'PatientDetail', params: { encounterId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 md:hidden">
|
||||
<SepsisBundleCard
|
||||
v-for="bundle in bundles"
|
||||
:key="bundle.id"
|
||||
:bundle="bundle"
|
||||
:now="now"
|
||||
@click="goToPatient(bundle.encounterId)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Patient</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Department</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Room</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Deadline</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Remaining</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Elements</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<SepsisBundleRow
|
||||
v-for="bundle in bundles"
|
||||
:key="bundle.id"
|
||||
:bundle="bundle"
|
||||
:now="now"
|
||||
class="cursor-pointer transition duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
@click="goToPatient(bundle.encounterId)"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
patientRoom,
|
||||
stalenessClass,
|
||||
} from '@/composables/wardFormat'
|
||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||
|
||||
const props = defineProps({ patient: { type: Object, required: true } })
|
||||
|
||||
@@ -50,6 +51,9 @@ const vitalsStaleness = computed(() =>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDepartment(patient.department) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-right">
|
||||
<Badge :variant="riskVariant">{{ patient.news2Score ?? '—' }}</Badge>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
field: { type: String, required: true },
|
||||
activeField: { type: String, required: true },
|
||||
direction: { type: String, required: true },
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
validator: value => ['left', 'right'].includes(value),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sort'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
class="px-4 py-4 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
:class="align === 'right' ? 'text-right' : 'text-left'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 transition duration-200 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:text-gray-200"
|
||||
:class="[
|
||||
align === 'right' ? 'ml-auto' : '',
|
||||
activeField === field ? 'text-gray-900 dark:text-white' : '',
|
||||
]"
|
||||
@click="emit('sort', field)"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<svg
|
||||
v-if="activeField === field"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-if="direction === 'asc'"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</th>
|
||||
</template>
|
||||
@@ -2,8 +2,15 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import PatientRow from './PatientRow.vue'
|
||||
import PatientCard from './PatientCard.vue'
|
||||
import SortableHeader from './SortableHeader.vue'
|
||||
|
||||
defineProps({ patients: { type: Array, required: true } })
|
||||
defineProps({
|
||||
patients: { type: Array, required: true },
|
||||
sortField: { type: String, required: true },
|
||||
sortDirection: { type: String, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sort'])
|
||||
const router = useRouter()
|
||||
|
||||
function goToPatient(encounterId) {
|
||||
@@ -27,17 +34,72 @@ function goToPatient(encounterId) {
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Room</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Patient</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">NEWS2</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">SOFA</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">GCS</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">qSOFA</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Attending</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">LOS</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Last vitals</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Sepsis</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Alerts</th>
|
||||
<SortableHeader
|
||||
label="Room"
|
||||
field="roomBed"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="Patient"
|
||||
field="name"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="Department"
|
||||
field="department"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="NEWS2"
|
||||
field="news2Score"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
align="right"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
SOFA
|
||||
</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
GCS
|
||||
</th>
|
||||
<SortableHeader
|
||||
label="qSOFA"
|
||||
field="qsofaScore"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
align="right"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Attending
|
||||
</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
LOS
|
||||
</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Last vitals
|
||||
</th>
|
||||
<SortableHeader
|
||||
label="Sepsis"
|
||||
field="sepsis"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="Alerts"
|
||||
field="openAlertCount"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const wardStore = useWardStore()
|
||||
const { searchInput, filters, hasActiveFilters } = storeToRefs(wardStore)
|
||||
|
||||
const filterOptions = [
|
||||
{ key: 'hasAlerts', label: 'Has alerts' },
|
||||
{ key: 'sepsisActive', label: 'Sepsis active' },
|
||||
{ key: 'critical', label: 'Critical (NEWS2 ≥ 7)' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<label class="block">
|
||||
<span class="sr-only">Search patients</span>
|
||||
<input
|
||||
:value="searchInput"
|
||||
type="search"
|
||||
placeholder="Search by name or MRN…"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
@input="wardStore.setSearchInput($event.target.value)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.key"
|
||||
size="sm"
|
||||
:variant="filters[option.key] ? 'primary' : 'secondary'"
|
||||
@click="wardStore.toggleFilter(option.key)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="wardStore.clearFilters()"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
export function formatRoleLabel(role) {
|
||||
const map = {
|
||||
NURSE: 'Nurse',
|
||||
PHYSICIAN: 'Physician',
|
||||
ADMIN: 'Administrator',
|
||||
INTEGRATION: 'Integration',
|
||||
}
|
||||
return map[role] ?? role?.replace(/_/g, ' ') ?? 'Clinician'
|
||||
}
|
||||
|
||||
export function roleAcknowledgmentMessage(role) {
|
||||
const messages = {
|
||||
NURSE: 'You are acknowledging as a nurse — documenting awareness of this alert.',
|
||||
PHYSICIAN: 'You are acknowledging as a physician — confirming clinical assessment.',
|
||||
ADMIN: 'You are acknowledging as an administrator — documenting review of this alert.',
|
||||
INTEGRATION: 'You are acknowledging as an integration account.',
|
||||
}
|
||||
return messages[role] ?? 'You are acknowledging this alert in your current role.'
|
||||
}
|
||||
|
||||
export function previewAcknowledgmentNote(role, displayName, userNote = '') {
|
||||
const roleLabel = role ?? 'UNKNOWN'
|
||||
const prefix = `[${roleLabel}] Acknowledged by ${displayName}.`
|
||||
const trimmed = userNote.trim()
|
||||
return trimmed ? `${prefix} ${trimmed}` : prefix
|
||||
}
|
||||
|
||||
export function parseAcknowledgedBy(acknowledgedBy) {
|
||||
if (!acknowledgedBy) return { name: null, role: null }
|
||||
|
||||
const match = acknowledgedBy.match(/^(.+)\s+\(([A-Z_]+)\)$/)
|
||||
if (match) {
|
||||
return {
|
||||
name: match[1].trim(),
|
||||
role: match[2],
|
||||
}
|
||||
}
|
||||
|
||||
return { name: acknowledgedBy, role: null }
|
||||
}
|
||||
|
||||
export function formatAcknowledgedByDisplay(acknowledgedBy) {
|
||||
const { name, role } = parseAcknowledgedBy(acknowledgedBy)
|
||||
if (!name) return '—'
|
||||
if (!role) return name
|
||||
return `${name} (${formatRoleLabel(role)})`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function isNotifiableCriticalAlert(alert) {
|
||||
return (
|
||||
alert.severity === 'Critical'
|
||||
&& (alert.status === 'Open' || alert.status === 'Escalated')
|
||||
)
|
||||
}
|
||||
|
||||
export function detectNewCriticalAlerts(alerts, lastSeenIds, seeded = false) {
|
||||
const criticalOpen = alerts.filter(isNotifiableCriticalAlert)
|
||||
const criticalIds = criticalOpen.map(alert => alert.id)
|
||||
|
||||
if (!seeded) {
|
||||
return { newAlerts: [], nextSeenIds: criticalIds, seeded: true }
|
||||
}
|
||||
|
||||
const seen = new Set(lastSeenIds)
|
||||
const newAlerts = criticalOpen.filter(alert => !seen.has(alert.id))
|
||||
return { newAlerts, nextSeenIds: criticalIds, seeded: true }
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||
|
||||
export const KNOWN_DEPARTMENTS = ['ICU', 'GENERAL_MEDICINE', 'SURGERY']
|
||||
|
||||
const DEPARTMENT_KEY_MAP = {
|
||||
ICU: 'ICU',
|
||||
Icu: 'ICU',
|
||||
GENERAL_MEDICINE: 'GENERAL_MEDICINE',
|
||||
GeneralMedicine: 'GENERAL_MEDICINE',
|
||||
SURGERY: 'SURGERY',
|
||||
Surgery: 'SURGERY',
|
||||
EMERGENCY: 'EMERGENCY',
|
||||
Emergency: 'EMERGENCY',
|
||||
CARDIOLOGY: 'CARDIOLOGY',
|
||||
Cardiology: 'CARDIOLOGY',
|
||||
PEDIATRICS: 'PEDIATRICS',
|
||||
Pediatrics: 'PEDIATRICS',
|
||||
}
|
||||
|
||||
export function normalizeDepartmentKey(department) {
|
||||
if (!department) return 'UNKNOWN'
|
||||
return DEPARTMENT_KEY_MAP[department] ?? department
|
||||
}
|
||||
|
||||
export function departmentFilterValue(key) {
|
||||
if (KNOWN_DEPARTMENTS.includes(key)) return key
|
||||
return key
|
||||
}
|
||||
|
||||
export function news2AcuityTier(score) {
|
||||
const value = score ?? 0
|
||||
if (value >= 7) return 'high'
|
||||
if (value >= 5) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function emptyBucket(key) {
|
||||
return {
|
||||
key,
|
||||
label: formatDepartment(key),
|
||||
filterValue: departmentFilterValue(key),
|
||||
patientCount: 0,
|
||||
acuity: { low: 0, medium: 0, high: 0 },
|
||||
activeBundleCount: 0,
|
||||
openAlertCount: 0,
|
||||
news2Sum: 0,
|
||||
news2Count: 0,
|
||||
alertVolume: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveBundle(encounter) {
|
||||
return Boolean(encounter.sepsisActive || encounter.sepsisBundleStatus === 'IN_PROGRESS')
|
||||
}
|
||||
|
||||
export function aggregateByDepartment(encounters, alertSummaryRows = []) {
|
||||
const alertByDept = new Map()
|
||||
for (const row of alertSummaryRows) {
|
||||
const key = normalizeDepartmentKey(row.department)
|
||||
alertByDept.set(key, (alertByDept.get(key) ?? 0) + Number(row.total ?? 0))
|
||||
}
|
||||
|
||||
const buckets = new Map()
|
||||
for (const key of KNOWN_DEPARTMENTS) {
|
||||
buckets.set(key, emptyBucket(key))
|
||||
}
|
||||
|
||||
function ensure(key) {
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, emptyBucket(key))
|
||||
}
|
||||
const bucket = buckets.get(key)
|
||||
bucket.alertVolume = alertByDept.get(key) ?? bucket.alertVolume
|
||||
return bucket
|
||||
}
|
||||
|
||||
for (const encounter of encounters) {
|
||||
const key = normalizeDepartmentKey(encounter.department)
|
||||
const bucket = ensure(key)
|
||||
bucket.patientCount += 1
|
||||
|
||||
const tier = news2AcuityTier(encounter.news2Score)
|
||||
bucket.acuity[tier] += 1
|
||||
|
||||
if (isActiveBundle(encounter)) bucket.activeBundleCount += 1
|
||||
bucket.openAlertCount += encounter.openAlertCount ?? 0
|
||||
|
||||
if (encounter.news2Score != null) {
|
||||
bucket.news2Sum += encounter.news2Score
|
||||
bucket.news2Count += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, total] of alertByDept) {
|
||||
ensure(key).alertVolume = total
|
||||
}
|
||||
|
||||
return [...buckets.values()]
|
||||
.map(bucket => ({
|
||||
...bucket,
|
||||
averageNews2:
|
||||
bucket.news2Count > 0
|
||||
? Math.round((bucket.news2Sum / bucket.news2Count) * 10) / 10
|
||||
: null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const knownA = KNOWN_DEPARTMENTS.indexOf(a.key)
|
||||
const knownB = KNOWN_DEPARTMENTS.indexOf(b.key)
|
||||
if (knownA !== -1 || knownB !== -1) {
|
||||
if (knownA === -1) return 1
|
||||
if (knownB === -1) return -1
|
||||
return knownA - knownB
|
||||
}
|
||||
return b.patientCount - a.patientCount
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeDepartments(departments) {
|
||||
return departments.reduce(
|
||||
(totals, dept) => ({
|
||||
patientCount: totals.patientCount + dept.patientCount,
|
||||
criticalCount: totals.criticalCount + dept.acuity.high,
|
||||
activeBundleCount: totals.activeBundleCount + dept.activeBundleCount,
|
||||
openAlertCount: totals.openAlertCount + dept.openAlertCount,
|
||||
}),
|
||||
{ patientCount: 0, criticalCount: 0, activeBundleCount: 0, openAlertCount: 0 },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
const AT_RISK_MS = 30 * 60 * 1000
|
||||
|
||||
export function remainingMs(deadlineAt, now = Date.now()) {
|
||||
if (!deadlineAt) return 0
|
||||
return new Date(deadlineAt).getTime() - now
|
||||
}
|
||||
|
||||
export function formatCountdown(deadlineAt, now = Date.now()) {
|
||||
const ms = Math.max(0, remainingMs(deadlineAt, now))
|
||||
const mins = Math.floor(ms / 60_000)
|
||||
const secs = Math.floor((ms % 60_000) / 1000)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function bundleUrgency(bundle, now = Date.now()) {
|
||||
const status = bundle.complianceStatus
|
||||
if (status === 'NON_COMPLIANT') return 'overdue'
|
||||
if (status === 'COMPLIANT') return 'compliant'
|
||||
if (remainingMs(bundle.deadlineAt, now) <= 0) return 'overdue'
|
||||
if (remainingMs(bundle.deadlineAt, now) <= AT_RISK_MS) return 'at_risk'
|
||||
return 'on_track'
|
||||
}
|
||||
|
||||
export function urgencyVariant(urgency) {
|
||||
if (urgency === 'overdue') return 'critical'
|
||||
if (urgency === 'at_risk') return 'warning'
|
||||
if (urgency === 'compliant') return 'success'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
export function urgencyLabel(urgency) {
|
||||
const map = {
|
||||
on_track: 'On track',
|
||||
at_risk: 'At risk',
|
||||
overdue: 'Overdue',
|
||||
compliant: 'Compliant',
|
||||
}
|
||||
return map[urgency] ?? urgency
|
||||
}
|
||||
|
||||
export function sortBundlesByUrgency(bundles, now = Date.now()) {
|
||||
const order = { overdue: 0, at_risk: 1, on_track: 2, compliant: 3 }
|
||||
return [...bundles].sort((a, b) => {
|
||||
const ua = bundleUrgency(a, now)
|
||||
const ub = bundleUrgency(b, now)
|
||||
if (order[ua] !== order[ub]) return order[ua] - order[ub]
|
||||
return remainingMs(a.deadlineAt, now) - remainingMs(b.deadlineAt, now)
|
||||
})
|
||||
}
|
||||
|
||||
export function completedElements(bundle) {
|
||||
return (bundle.elements ?? []).filter(e => e.status === 'Completed')
|
||||
}
|
||||
|
||||
export function outstandingElements(bundle) {
|
||||
return (bundle.elements ?? []).filter(e => e.status !== 'Completed')
|
||||
}
|
||||
|
||||
export function formatDepartment(department) {
|
||||
const map = {
|
||||
ICU: 'ICU',
|
||||
Icu: 'ICU',
|
||||
GENERAL_MEDICINE: 'General Medicine',
|
||||
GeneralMedicine: 'General Medicine',
|
||||
SURGERY: 'Surgery',
|
||||
Surgery: 'Surgery',
|
||||
}
|
||||
return map[department] ?? department?.replace(/_/g, ' ') ?? '—'
|
||||
}
|
||||
|
||||
export function complianceStatusLabel(status) {
|
||||
const map = {
|
||||
IN_PROGRESS: 'In progress',
|
||||
COMPLIANT: 'Compliant',
|
||||
NON_COMPLIANT: 'Non-compliant',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
export function summarizeBundles(bundles, now = Date.now()) {
|
||||
const counts = { on_track: 0, at_risk: 0, overdue: 0 }
|
||||
for (const bundle of bundles) {
|
||||
const urgency = bundleUrgency(bundle, now)
|
||||
if (urgency in counts) counts[urgency]++
|
||||
}
|
||||
return counts
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
const DEFAULT_TITLE = 'VigilCare'
|
||||
let titleFlashTimer = null
|
||||
let titleFlashOriginal = DEFAULT_TITLE
|
||||
|
||||
export function playCriticalTone() {
|
||||
try {
|
||||
const ctx = new AudioContext()
|
||||
const playBeep = (startTime) => {
|
||||
const oscillator = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
oscillator.type = 'square'
|
||||
oscillator.frequency.value = 880
|
||||
gain.gain.setValueAtTime(0.12, startTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.35)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
oscillator.start(startTime)
|
||||
oscillator.stop(startTime + 0.35)
|
||||
}
|
||||
playBeep(ctx.currentTime)
|
||||
playBeep(ctx.currentTime + 0.45)
|
||||
window.setTimeout(() => ctx.close(), 1000)
|
||||
} catch {
|
||||
// Autoplay may be blocked until user interaction.
|
||||
}
|
||||
}
|
||||
|
||||
export function flashCriticalTitle() {
|
||||
if (titleFlashTimer) return
|
||||
|
||||
titleFlashOriginal = document.title || DEFAULT_TITLE
|
||||
let showAlert = true
|
||||
titleFlashTimer = window.setInterval(() => {
|
||||
document.title = showAlert ? '⚠ CRITICAL ALERT — VigilCare' : titleFlashOriginal
|
||||
showAlert = !showAlert
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
export function stopCriticalTitleFlash() {
|
||||
if (titleFlashTimer) {
|
||||
clearInterval(titleFlashTimer)
|
||||
titleFlashTimer = null
|
||||
}
|
||||
if (document.title.includes('CRITICAL ALERT')) {
|
||||
document.title = titleFlashOriginal || DEFAULT_TITLE
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestNotificationPermission() {
|
||||
if (!('Notification' in window)) return 'unsupported'
|
||||
if (Notification.permission === 'granted') return 'granted'
|
||||
if (Notification.permission === 'denied') return 'denied'
|
||||
return Notification.requestPermission()
|
||||
}
|
||||
|
||||
export function formatAlertPatientLabel(alert) {
|
||||
const wardStore = useWardStore()
|
||||
const encounter = wardStore.encounters.find(
|
||||
item => item.encounterId === alert.encounterId,
|
||||
)
|
||||
if (encounter) {
|
||||
return `${encounter.firstName} ${encounter.lastName} (${encounter.mrn})`
|
||||
}
|
||||
|
||||
const patient = alert.encounter?.patient
|
||||
if (patient?.firstName || patient?.lastName) {
|
||||
const mrn = patient.mrn ? ` (${patient.mrn})` : ''
|
||||
return `${patient.firstName ?? ''} ${patient.lastName ?? ''}${mrn}`.trim()
|
||||
}
|
||||
|
||||
return alert.details ?? 'Critical patient alert'
|
||||
}
|
||||
|
||||
export function showBrowserNotification(alert) {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||
|
||||
const patientLabel = formatAlertPatientLabel(alert)
|
||||
const body = alert.details
|
||||
? `${patientLabel} — ${alert.details}`
|
||||
: patientLabel
|
||||
|
||||
new Notification(`Critical: ${alertTypeLabel(alert.alertType)}`, {
|
||||
body,
|
||||
tag: alert.id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAlertNotification() {
|
||||
const settings = useSettingsStore()
|
||||
|
||||
function notifyNewCriticalAlerts(alerts) {
|
||||
if (alerts.length === 0) return
|
||||
|
||||
if (!settings.alertSoundMuted) {
|
||||
playCriticalTone()
|
||||
}
|
||||
flashCriticalTitle()
|
||||
|
||||
for (const alert of alerts) {
|
||||
showBrowserNotification(alert)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopCriticalTitleFlash)
|
||||
|
||||
return {
|
||||
notifyNewCriticalAlerts,
|
||||
requestNotificationPermission,
|
||||
stopCriticalTitleFlash,
|
||||
playCriticalTone,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { useAlertNotification } from '@/composables/useAlertNotification'
|
||||
|
||||
export function useCriticalAlertPolling(intervalMs = 10_000) {
|
||||
const authStore = useAuthStore()
|
||||
const alertStore = useAlertStore()
|
||||
const { notifyNewCriticalAlerts } = useAlertNotification()
|
||||
|
||||
async function poll() {
|
||||
if (!authStore.isAuthenticated) {
|
||||
alertStore.dismissAllBannerAlerts()
|
||||
return
|
||||
}
|
||||
const newAlerts = await alertStore.pollOpenAlerts()
|
||||
notifyNewCriticalAlerts(newAlerts)
|
||||
}
|
||||
|
||||
usePolling(poll, intervalMs)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export function matchesPatientSearch(encounter, query) {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return true
|
||||
|
||||
const needle = trimmed.toLowerCase()
|
||||
const mrn = (encounter.mrn ?? '').toLowerCase()
|
||||
const firstName = (encounter.firstName ?? '').toLowerCase()
|
||||
const lastName = (encounter.lastName ?? '').toLowerCase()
|
||||
const fullName = `${firstName} ${lastName}`.trim()
|
||||
|
||||
return (
|
||||
mrn.includes(needle)
|
||||
|| firstName.includes(needle)
|
||||
|| lastName.includes(needle)
|
||||
|| fullName.includes(needle)
|
||||
)
|
||||
}
|
||||
|
||||
function isSepsisActive(encounter) {
|
||||
return Boolean(encounter.sepsisActive || encounter.sepsisBundleStatus === 'IN_PROGRESS')
|
||||
}
|
||||
|
||||
export function filterEncounters(
|
||||
encounters,
|
||||
{ search = '', hasAlerts = false, sepsisActive = false, critical = false } = {},
|
||||
) {
|
||||
return encounters.filter(encounter => {
|
||||
if (!matchesPatientSearch(encounter, search)) return false
|
||||
if (hasAlerts && !(encounter.openAlertCount > 0)) return false
|
||||
if (sepsisActive && !isSepsisActive(encounter)) return false
|
||||
if (critical && (encounter.news2Score ?? 0) < 7) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function hasWardFilters({ search = '', hasAlerts, sepsisActive, critical } = {}) {
|
||||
return Boolean(search.trim() || hasAlerts || sepsisActive || critical)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
export const DEFAULT_WARD_SORT_FIELD = 'news2Score'
|
||||
export const DEFAULT_WARD_SORT_DIRECTION = 'desc'
|
||||
|
||||
export const WARD_SORT_FIELDS = [
|
||||
{ key: 'roomBed', label: 'Room' },
|
||||
{ key: 'name', label: 'Patient' },
|
||||
{ key: 'department', label: 'Department' },
|
||||
{ key: 'news2Score', label: 'NEWS2' },
|
||||
{ key: 'qsofaScore', label: 'qSOFA' },
|
||||
{ key: 'sepsis', label: 'Sepsis' },
|
||||
{ key: 'openAlertCount', label: 'Alerts' },
|
||||
]
|
||||
|
||||
const SEPSIS_RANK = {
|
||||
IN_PROGRESS: 3,
|
||||
NON_COMPLIANT: 2,
|
||||
COMPLIANT: 1,
|
||||
}
|
||||
|
||||
export function defaultSortDirection(field) {
|
||||
if (field === 'name' || field === 'roomBed' || field === 'department') return 'asc'
|
||||
return 'desc'
|
||||
}
|
||||
|
||||
function getSortValue(encounter, field) {
|
||||
switch (field) {
|
||||
case 'name':
|
||||
return `${encounter.lastName ?? ''} ${encounter.firstName ?? ''}`.trim().toLowerCase()
|
||||
case 'roomBed':
|
||||
return (encounter.roomBed ?? encounter.room ?? '').toLowerCase()
|
||||
case 'department':
|
||||
return (encounter.department ?? '').toLowerCase()
|
||||
case 'news2Score':
|
||||
return encounter.news2Score ?? -1
|
||||
case 'qsofaScore':
|
||||
return encounter.qsofaScore ?? -1
|
||||
case 'openAlertCount':
|
||||
return encounter.openAlertCount ?? 0
|
||||
case 'sepsis':
|
||||
if (encounter.sepsisActive) return 4
|
||||
return SEPSIS_RANK[encounter.sepsisBundleStatus] ?? 0
|
||||
default:
|
||||
return encounter.news2Score ?? -1
|
||||
}
|
||||
}
|
||||
|
||||
function compareSortValues(a, b) {
|
||||
if (typeof a === 'string' && typeof b === 'string') {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
if (a < b) return -1
|
||||
if (a > b) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
export function sortEncounters(encounters, field, direction) {
|
||||
const multiplier = direction === 'asc' ? 1 : -1
|
||||
|
||||
return [...encounters].sort((left, right) => {
|
||||
const primary = compareSortValues(getSortValue(left, field), getSortValue(right, field)) * multiplier
|
||||
if (primary !== 0) return primary
|
||||
return (right.news2Score ?? 0) - (left.news2Score ?? 0)
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,12 @@ const routes = [
|
||||
component: () => import('@/views/WardDashboard.vue'),
|
||||
meta: { title: 'Virtual Ward', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/departments',
|
||||
name: 'DepartmentOverview',
|
||||
component: () => import('@/views/DepartmentOverviewView.vue'),
|
||||
meta: { title: 'Department Overview', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/patients/:encounterId',
|
||||
name: 'PatientDetail',
|
||||
@@ -30,6 +36,12 @@ const routes = [
|
||||
component: () => import('@/views/AlertCenter.vue'),
|
||||
meta: { title: 'Alert Center', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/sepsis',
|
||||
name: 'SepsisBoard',
|
||||
component: () => import('@/views/SepsisBoardView.vue'),
|
||||
meta: { title: 'Sepsis Bundle Board', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
name: 'FeedbackSummary',
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as alertsApi from '@/api/alerts'
|
||||
import { detectNewCriticalAlerts } from '@/composables/criticalAlertDetect'
|
||||
|
||||
export const useAlertStore = defineStore('alerts', () => {
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref(null)
|
||||
const lastSeenAlertIds = ref([])
|
||||
const bannerAlerts = ref([])
|
||||
const pollSeeded = ref(false)
|
||||
|
||||
const openAlerts = computed(() => alerts.value.filter(a => a.status === 'Open'))
|
||||
const criticalAlerts = computed(() => alerts.value.filter(a => a.severity === 'Critical'))
|
||||
|
||||
function mergeBannerAlerts(newAlerts) {
|
||||
const existingIds = new Set(bannerAlerts.value.map(alert => alert.id))
|
||||
const merged = [...bannerAlerts.value]
|
||||
for (const alert of newAlerts) {
|
||||
if (!existingIds.has(alert.id)) merged.push(alert)
|
||||
}
|
||||
bannerAlerts.value = merged
|
||||
}
|
||||
|
||||
function applyPollResults(items) {
|
||||
const { newAlerts, nextSeenIds, seeded } = detectNewCriticalAlerts(
|
||||
items,
|
||||
lastSeenAlertIds.value,
|
||||
pollSeeded.value,
|
||||
)
|
||||
lastSeenAlertIds.value = nextSeenIds
|
||||
pollSeeded.value = seeded
|
||||
if (newAlerts.length > 0) {
|
||||
mergeBannerAlerts(newAlerts)
|
||||
}
|
||||
return newAlerts
|
||||
}
|
||||
|
||||
async function loadAlerts(encounterId, status) {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -35,17 +62,60 @@ export const useAlertStore = defineStore('alerts', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOpenAlerts() {
|
||||
try {
|
||||
const items = await alertsApi.fetchAllOpenAlerts()
|
||||
alerts.value = items
|
||||
return applyPollResults(items)
|
||||
} catch (e) {
|
||||
console.error('Failed to poll alerts', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function dismissBannerAlert(alertId) {
|
||||
bannerAlerts.value = bannerAlerts.value.filter(alert => alert.id !== alertId)
|
||||
}
|
||||
|
||||
function dismissAllBannerAlerts() {
|
||||
bannerAlerts.value = []
|
||||
}
|
||||
|
||||
async function acknowledge(alertId, note) {
|
||||
await alertsApi.acknowledgeAlert(alertId, note)
|
||||
const updated = await alertsApi.acknowledgeAlert(alertId, note || undefined)
|
||||
const alert = alerts.value.find(a => a.id === alertId)
|
||||
if (alert) alert.status = 'Acknowledged'
|
||||
if (alert) {
|
||||
alert.status = updated.status ?? 'Acknowledged'
|
||||
alert.acknowledgedBy = updated.acknowledgedBy ?? alert.acknowledgedBy
|
||||
alert.acknowledgedAt = updated.acknowledgedAt ?? alert.acknowledgedAt
|
||||
}
|
||||
dismissBannerAlert(alertId)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function resolve(alertId) {
|
||||
await alertsApi.resolveAlert(alertId)
|
||||
const alert = alerts.value.find(a => a.id === alertId)
|
||||
if (alert) alert.status = 'Resolved'
|
||||
dismissBannerAlert(alertId)
|
||||
}
|
||||
|
||||
return { alerts, loading, statusFilter, openAlerts, criticalAlerts, loadAlerts, loadGlobalAlerts, acknowledge, resolve }
|
||||
})
|
||||
return {
|
||||
alerts,
|
||||
loading,
|
||||
statusFilter,
|
||||
lastSeenAlertIds,
|
||||
bannerAlerts,
|
||||
pollSeeded,
|
||||
openAlerts,
|
||||
criticalAlerts,
|
||||
loadAlerts,
|
||||
loadGlobalAlerts,
|
||||
pollOpenAlerts,
|
||||
dismissBannerAlert,
|
||||
dismissAllBannerAlerts,
|
||||
acknowledge,
|
||||
resolve,
|
||||
applyPollResults,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
role: (state) => state.user?.role ?? null,
|
||||
displayName: (state) =>
|
||||
state.user?.displayName ?? state.user?.username ?? 'Unknown user',
|
||||
userId: (state) => state.user?.userId ?? null,
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchAllActiveEncounters } from '@/api/encounters'
|
||||
import { fetchAlertSummary } from '@/api/analytics'
|
||||
import { aggregateByDepartment, summarizeDepartments } from '@/composables/departmentFormat'
|
||||
|
||||
export const useDepartmentsStore = defineStore('departments', () => {
|
||||
const encounters = ref([])
|
||||
const alertSummary = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const departments = computed(() =>
|
||||
aggregateByDepartment(encounters.value, alertSummary.value),
|
||||
)
|
||||
|
||||
const totals = computed(() => summarizeDepartments(departments.value))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [encounterItems, alertData] = await Promise.all([
|
||||
fetchAllActiveEncounters(),
|
||||
fetchAlertSummary(),
|
||||
])
|
||||
encounters.value = encounterItems
|
||||
alertSummary.value = alertData.summary ?? []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { encounters, alertSummary, departments, totals, loading, error, load }
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSepsisBundles } from '@/api/sepsis'
|
||||
import { sortBundlesByUrgency, summarizeBundles } from '@/composables/sepsisFormat'
|
||||
|
||||
export const useSepsisStore = defineStore('sepsis', () => {
|
||||
const bundles = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const now = ref(Date.now())
|
||||
|
||||
const sortedBundles = computed(() => sortBundlesByUrgency(bundles.value, now.value))
|
||||
|
||||
const summary = computed(() => summarizeBundles(bundles.value, now.value))
|
||||
|
||||
async function loadBundles(status = 'IN_PROGRESS') {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await fetchSepsisBundles({ status, pageSize: 100 })
|
||||
bundles.value = data.items ?? []
|
||||
now.value = Date.now()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
now.value = Date.now()
|
||||
}
|
||||
|
||||
return { bundles, loading, error, sortedBundles, summary, now, loadBundles, tick }
|
||||
})
|
||||
@@ -1,10 +1,17 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
DEFAULT_WARD_SORT_DIRECTION,
|
||||
DEFAULT_WARD_SORT_FIELD,
|
||||
defaultSortDirection,
|
||||
} from '@/composables/wardSort'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const darkMode = ref(localStorage.getItem('darkMode') === 'true')
|
||||
const pollInterval = ref(10_000)
|
||||
const clinicianId = ref(localStorage.getItem('clinicianId') ?? 'DR-DEMO')
|
||||
const wardSortField = ref(localStorage.getItem('wardSortField') ?? DEFAULT_WARD_SORT_FIELD)
|
||||
const wardSortDirection = ref(localStorage.getItem('wardSortDirection') ?? DEFAULT_WARD_SORT_DIRECTION)
|
||||
const alertSoundMuted = ref(localStorage.getItem('alertSoundMuted') === 'true')
|
||||
|
||||
watch(darkMode, (val) => {
|
||||
localStorage.setItem('darkMode', val)
|
||||
@@ -15,5 +22,30 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
darkMode.value = !darkMode.value
|
||||
}
|
||||
|
||||
return { darkMode, pollInterval, clinicianId, toggleDarkMode }
|
||||
})
|
||||
function toggleAlertSoundMute() {
|
||||
alertSoundMuted.value = !alertSoundMuted.value
|
||||
localStorage.setItem('alertSoundMuted', String(alertSoundMuted.value))
|
||||
}
|
||||
|
||||
function setWardSort(field) {
|
||||
if (wardSortField.value === field) {
|
||||
wardSortDirection.value = wardSortDirection.value === 'desc' ? 'asc' : 'desc'
|
||||
} else {
|
||||
wardSortField.value = field
|
||||
wardSortDirection.value = defaultSortDirection(field)
|
||||
}
|
||||
localStorage.setItem('wardSortField', wardSortField.value)
|
||||
localStorage.setItem('wardSortDirection', wardSortDirection.value)
|
||||
}
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
pollInterval,
|
||||
wardSortField,
|
||||
wardSortDirection,
|
||||
alertSoundMuted,
|
||||
toggleDarkMode,
|
||||
toggleAlertSoundMute,
|
||||
setWardSort,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { refDebounced } from '@vueuse/core'
|
||||
import { fetchActiveEncounters } from '@/api/encounters'
|
||||
import { fetchCurrentNews2 } from '@/api/clinical'
|
||||
import { filterEncounters, hasWardFilters } from '@/composables/wardFilter'
|
||||
import { sortEncounters } from '@/composables/wardSort'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
export const useWardStore = defineStore('ward', () => {
|
||||
const encounters = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const department = ref(null)
|
||||
const searchInput = ref('')
|
||||
const debouncedSearch = refDebounced(searchInput, 300)
|
||||
const filters = ref({
|
||||
hasAlerts: false,
|
||||
sepsisActive: false,
|
||||
critical: false,
|
||||
})
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const sortedByRisk = computed(() =>
|
||||
[...encounters.value].sort((a, b) => (b.news2Score ?? 0) - (a.news2Score ?? 0))
|
||||
const filteredEncounters = computed(() =>
|
||||
filterEncounters(encounters.value, {
|
||||
search: debouncedSearch.value,
|
||||
...filters.value,
|
||||
}),
|
||||
)
|
||||
|
||||
const displayEncounters = computed(() =>
|
||||
sortEncounters(
|
||||
filteredEncounters.value,
|
||||
settings.wardSortField,
|
||||
settings.wardSortDirection,
|
||||
),
|
||||
)
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
hasWardFilters({
|
||||
search: debouncedSearch.value,
|
||||
...filters.value,
|
||||
}),
|
||||
)
|
||||
|
||||
const criticalCount = computed(() =>
|
||||
encounters.value.filter(e => (e.news2Score ?? 0) >= 7).length
|
||||
encounters.value.filter(e => (e.news2Score ?? 0) >= 7).length,
|
||||
)
|
||||
|
||||
async function loadEncounters() {
|
||||
@@ -35,5 +64,43 @@ export const useWardStore = defineStore('ward', () => {
|
||||
loadEncounters()
|
||||
}
|
||||
|
||||
return { encounters, loading, error, department, sortedByRisk, criticalCount, loadEncounters, setDepartment }
|
||||
})
|
||||
function setSort(field) {
|
||||
settings.setWardSort(field)
|
||||
}
|
||||
|
||||
function setSearchInput(value) {
|
||||
searchInput.value = value
|
||||
}
|
||||
|
||||
function toggleFilter(key) {
|
||||
filters.value[key] = !filters.value[key]
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchInput.value = ''
|
||||
filters.value = {
|
||||
hasAlerts: false,
|
||||
sepsisActive: false,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
encounters,
|
||||
loading,
|
||||
error,
|
||||
department,
|
||||
searchInput,
|
||||
filters,
|
||||
filteredEncounters,
|
||||
displayEncounters,
|
||||
hasActiveFilters,
|
||||
criticalCount,
|
||||
loadEncounters,
|
||||
setDepartment,
|
||||
setSort,
|
||||
setSearchInput,
|
||||
toggleFilter,
|
||||
clearFilters,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,9 +19,9 @@ watch(activeFilter, (status) => {
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(status))
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleAcknowledge() {
|
||||
async function handleAcknowledge(note) {
|
||||
if (!confirmingAlert.value) return
|
||||
await alertStore.acknowledge(confirmingAlert.value.id)
|
||||
await alertStore.acknowledge(confirmingAlert.value.id, note)
|
||||
confirmingAlert.value = null
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import DepartmentCard from '@/components/departments/DepartmentCard.vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
const { departments, totals, loading, error } = storeToRefs(departmentsStore)
|
||||
|
||||
usePolling(() => departmentsStore.load(), 10_000)
|
||||
|
||||
function onSelectDepartment(filterValue) {
|
||||
router.push({ name: 'WardDashboard', query: { department: filterValue } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold dark:text-white">Department Overview</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Unit-level snapshot across active patients, acuity, alerts, and sepsis bundles.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-if="totals.criticalCount > 0" variant="critical">
|
||||
{{ totals.criticalCount }} critical
|
||||
</Badge>
|
||||
<Badge v-if="totals.openAlertCount > 0" variant="warning">
|
||||
{{ totals.openAlertCount }} open alerts
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold dark:text-white">{{ totals.patientCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active patients</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-red-600">{{ totals.criticalCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Critical (NEWS2 ≥ 7)</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-amber-600">{{ totals.openAlertCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Open alerts</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ totals.activeBundleCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active sepsis bundles</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="loading && departments.length === 0" :rows="3" />
|
||||
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<DepartmentCard
|
||||
v-for="department in departments"
|
||||
:key="department.key"
|
||||
:department="department"
|
||||
@select="onSelectDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSepsisStore } from '@/stores/sepsis'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import SepsisBundleTable from '@/components/sepsis/SepsisBundleTable.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const sepsisStore = useSepsisStore()
|
||||
const { sortedBundles, loading, error, summary, now } = storeToRefs(sepsisStore)
|
||||
|
||||
usePolling(() => sepsisStore.loadBundles(), 10_000)
|
||||
|
||||
let countdownTimer = null
|
||||
onMounted(() => {
|
||||
sepsisStore.loadBundles()
|
||||
countdownTimer = setInterval(() => sepsisStore.tick(), 1000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
|
||||
const summaryLine = computed(() => {
|
||||
const total = sortedBundles.value.length
|
||||
if (total === 0) return 'No active sepsis bundles'
|
||||
const { on_track: onTrack, at_risk: atRisk, overdue } = summary.value
|
||||
return `${total} active bundle${total === 1 ? '' : 's'} — ${onTrack} on track, ${atRisk} at risk, ${overdue} overdue`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold dark:text-white">Sepsis Bundle Board</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ summaryLine }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-if="summary.overdue > 0" variant="critical">{{ summary.overdue }} overdue</Badge>
|
||||
<Badge v-if="summary.at_risk > 0" variant="warning">{{ summary.at_risk }} at risk</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<Skeleton v-if="loading && sortedBundles.length === 0" :rows="4" />
|
||||
<EmptyState v-else-if="sortedBundles.length === 0" message="No active sepsis bundles" />
|
||||
<SepsisBundleTable
|
||||
v-else
|
||||
:bundles="sortedBundles"
|
||||
:now="now"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,32 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { WARD_SORT_FIELDS } from '@/composables/wardSort'
|
||||
import WardToolbar from '@/components/ward/WardToolbar.vue'
|
||||
import WardTable from '@/components/ward/WardTable.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const wardStore = useWardStore()
|
||||
const { sortedByRisk, loading, error, criticalCount, department } = storeToRefs(wardStore)
|
||||
const settingsStore = useSettingsStore()
|
||||
const {
|
||||
encounters,
|
||||
displayEncounters,
|
||||
loading,
|
||||
error,
|
||||
criticalCount,
|
||||
department,
|
||||
} = storeToRefs(wardStore)
|
||||
const { wardSortField, wardSortDirection } = storeToRefs(settingsStore)
|
||||
|
||||
if (route.query.department) {
|
||||
wardStore.setDepartment(String(route.query.department))
|
||||
}
|
||||
|
||||
usePolling(() => wardStore.loadEncounters(), 10_000)
|
||||
|
||||
@@ -22,6 +40,10 @@ const departments = [
|
||||
function onDepartmentChange(event) {
|
||||
wardStore.setDepartment(event.target.value || null)
|
||||
}
|
||||
|
||||
function onMobileSortChange(event) {
|
||||
wardStore.setSort(event.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,8 +70,38 @@ function onDepartmentChange(event) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<Skeleton v-if="loading && sortedByRisk.length === 0" :rows="5" />
|
||||
<EmptyState v-else-if="sortedByRisk.length === 0" message="No active patients" />
|
||||
<WardTable v-else :patients="sortedByRisk" />
|
||||
<WardToolbar />
|
||||
|
||||
<label class="mb-4 block md:hidden">
|
||||
<span class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Sort by
|
||||
</span>
|
||||
<select
|
||||
:value="wardSortField"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
@change="onMobileSortChange"
|
||||
>
|
||||
<option v-for="option in WARD_SORT_FIELDS" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<Skeleton v-if="loading && encounters.length === 0" :rows="5" />
|
||||
<EmptyState
|
||||
v-else-if="encounters.length === 0"
|
||||
message="No active patients"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="displayEncounters.length === 0"
|
||||
message="No patients match your search or filters"
|
||||
/>
|
||||
<WardTable
|
||||
v-else
|
||||
:patients="displayEncounters"
|
||||
:sort-field="wardSortField"
|
||||
:sort-direction="wardSortDirection"
|
||||
@sort="wardStore.setSort"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user