feature: Improve dashboard functionality

This commit is contained in:
voltsrage
2026-06-23 20:19:32 +08:00
parent dd0fd88731
commit 79b6d9d85e
60 changed files with 2857 additions and 164 deletions
@@ -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')
})
})
+20
View File
@@ -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 })
}
+11
View File
@@ -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}` : ''}`)
}
+21 -2
View File
@@ -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}`)
}
+10
View File
@@ -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)
})
}
+12
View File
@@ -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',
+74 -4
View File
@@ -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,
}
})
+3
View File
@@ -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 }
})
+35
View File
@@ -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 }
})
+35 -3
View File
@@ -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,
}
})
+73 -6
View File
@@ -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>