feature: Simulation Control Center (Dashboard)
This commit is contained in:
@@ -40,17 +40,15 @@ describe('PatientBanner', () => {
|
||||
expect(strip.classes().join(' ')).toMatch(/bg-red-600/)
|
||||
})
|
||||
|
||||
it('showsNkdaWhenNoAllergies', () => {
|
||||
it('showsSimChipWhenPatientIsSimulated', () => {
|
||||
const wrapper = mount(PatientBanner, {
|
||||
props: {
|
||||
encounter: {
|
||||
...encounter,
|
||||
patient: { ...encounter.patient, allergies: null },
|
||||
patient: { ...encounter.patient, isSimulated: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('NKDA')
|
||||
const strip = wrapper.find('[role="status"]')
|
||||
expect(strip.classes().join(' ')).not.toMatch(/bg-red-600/)
|
||||
expect(wrapper.text()).toContain('SIM')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
||||
|
||||
const baseScenario = {
|
||||
id: 'stable-baseline-01',
|
||||
name: 'Stable Baseline — Routine Inpatient Monitoring',
|
||||
description: '52-year-old female admitted for elective cholecystectomy. Completely uneventful 8-hour post-op monitoring period with all vitals remaining normal throughout. This is a CONTROL scenario that validates the system does NOT generate false alerts on a stable patient.',
|
||||
durationMinutes: 480,
|
||||
tags: ['stable', 'baseline', 'control', 'surgery'],
|
||||
department: 'Surgery',
|
||||
eventCount: 312,
|
||||
}
|
||||
|
||||
describe('ScenarioCard', () => {
|
||||
it('rendersMetadata', () => {
|
||||
const wrapper = mount(ScenarioCard, {
|
||||
props: { scenario: baseScenario },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(baseScenario.name)
|
||||
expect(wrapper.text()).toContain('8 hours of monitoring')
|
||||
expect(wrapper.text()).toContain('Surgery')
|
||||
expect(wrapper.text()).toContain('312 events')
|
||||
expect(wrapper.text()).toContain('control')
|
||||
})
|
||||
|
||||
it('labelsControlScenario', () => {
|
||||
const wrapper = mount(ScenarioCard, {
|
||||
props: { scenario: baseScenario },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Control scenario — should stay quiet')
|
||||
})
|
||||
|
||||
it('labelsRealDataScenario', () => {
|
||||
const wrapper = mount(ScenarioCard, {
|
||||
props: {
|
||||
scenario: {
|
||||
...baseScenario,
|
||||
id: 'mimic-case-01',
|
||||
tags: ['mimic-iv', 'real-data', 'sepsis'],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Real de-identified ICU record')
|
||||
})
|
||||
|
||||
it('disablesStartAtConcurrencyLimit', async () => {
|
||||
const wrapper = mount(ScenarioCard, {
|
||||
props: {
|
||||
scenario: baseScenario,
|
||||
atConcurrencyLimit: true,
|
||||
maxConcurrentRuns: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
|
||||
expect(startBtn.attributes('disabled')).toBeDefined()
|
||||
expect(startBtn.attributes('title')).toMatch(/Concurrency limit reached/)
|
||||
|
||||
await startBtn.trigger('click')
|
||||
expect(wrapper.emitted('start')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emitsStartWithScenarioId', async () => {
|
||||
const wrapper = mount(ScenarioCard, {
|
||||
props: { scenario: baseScenario },
|
||||
})
|
||||
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
|
||||
await startBtn.trigger('click')
|
||||
expect(wrapper.emitted('start')).toEqual([['stable-baseline-01']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SimulationControlView from '@/views/SimulationControlView.vue'
|
||||
|
||||
const {
|
||||
fetchScenarios,
|
||||
fetchRuns,
|
||||
fetchSimulationConfig,
|
||||
startRun,
|
||||
stopRun,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchScenarios: vi.fn(),
|
||||
fetchRuns: vi.fn(),
|
||||
fetchSimulationConfig: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/simulation', () => ({
|
||||
fetchScenarios,
|
||||
fetchRuns,
|
||||
fetchSimulationConfig,
|
||||
startRun,
|
||||
stopRun,
|
||||
fetchRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/ward', () => ({
|
||||
useWardStore: () => ({ loadEncounters: vi.fn() }),
|
||||
}))
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'uti-sepsis-elderly-01',
|
||||
name: 'UTI Sepsis Elderly',
|
||||
description: 'Elderly patient with UTI progressing to sepsis.',
|
||||
durationMinutes: 480,
|
||||
tags: ['sepsis', 'uti'],
|
||||
department: 'GeneralMedicine',
|
||||
eventCount: 200,
|
||||
},
|
||||
{
|
||||
id: 'stable-baseline-01',
|
||||
name: 'Stable Baseline',
|
||||
description: 'Control scenario.',
|
||||
durationMinutes: 480,
|
||||
tags: ['control', 'stable'],
|
||||
department: 'Surgery',
|
||||
eventCount: 312,
|
||||
},
|
||||
]
|
||||
|
||||
describe('SimulationControlView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
fetchScenarios.mockResolvedValue(scenarios)
|
||||
fetchRuns.mockResolvedValue([])
|
||||
fetchSimulationConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
maxSpeed: 600,
|
||||
maxConcurrentRuns: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
|
||||
|
||||
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
|
||||
expect(wrapper.text()).toMatch(/8 minutes for selected scenario/)
|
||||
|
||||
const realTime = wrapper.findAll('button').find(b => b.text().includes('Real time'))
|
||||
expect(realTime.text()).toMatch(/8 hours for selected scenario/)
|
||||
|
||||
const instant = wrapper.findAll('button').find(b => b.text().includes('Instant'))
|
||||
expect(instant.text()).toMatch(/1 minute for selected scenario/)
|
||||
})
|
||||
|
||||
|
||||
it('filtersCatalogueByTag', async () => {
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
|
||||
|
||||
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
|
||||
await sepsisChip.trigger('click')
|
||||
|
||||
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
|
||||
expect(wrapper.text()).not.toContain('Stable Baseline')
|
||||
})
|
||||
|
||||
it('showsEmptyStateWhenCatalogueMissing', async () => {
|
||||
fetchScenarios.mockResolvedValue([])
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() =>
|
||||
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
describe('SimulationModeBanner', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('rendersWhenEnabled', () => {
|
||||
useSimulationStore().enabled = true
|
||||
const wrapper = mount(SimulationModeBanner)
|
||||
expect(wrapper.text()).toContain('SIMULATION MODE')
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('isAbsentWhenDisabled', () => {
|
||||
useSimulationStore().enabled = false
|
||||
const wrapper = mount(SimulationModeBanner)
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a :href="typeof to === \'string\' ? to : \'\'"><slot /></a>',
|
||||
}
|
||||
|
||||
const activeRun = {
|
||||
runId: 'run-1',
|
||||
scenarioId: 'uti-sepsis-elderly-01',
|
||||
scenarioName: 'UTI Sepsis Elderly',
|
||||
status: 'RUNNING',
|
||||
speed: 60,
|
||||
encounterId: 'enc-99',
|
||||
patientDisplayName: 'Mary Chen',
|
||||
startedAt: '2026-08-05T10:00:00Z',
|
||||
elapsedRealSeconds: 125,
|
||||
lastOffsetMinutes: 200,
|
||||
totalOffsetMinutes: 480,
|
||||
progressPercent: 41.6,
|
||||
observationsSent: 40,
|
||||
medicationsSent: 2,
|
||||
ordersPlaced: 1,
|
||||
failureReason: null,
|
||||
}
|
||||
|
||||
function mountPanel(props) {
|
||||
return mount(SimulationRunPanel, {
|
||||
props,
|
||||
global: { stubs: { RouterLink: RouterLinkStub } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('SimulationRunPanel', () => {
|
||||
it('showsProgressAndSimClock', () => {
|
||||
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
|
||||
expect(wrapper.text()).toContain('03:20 of 08:00 elapsed')
|
||||
expect(wrapper.text()).toContain('42%')
|
||||
expect(wrapper.text()).toContain('60×')
|
||||
expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
|
||||
})
|
||||
|
||||
it('linksToPatientEncounter', () => {
|
||||
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
|
||||
const link = wrapper.find('a[href="/patients/enc-99"]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.text()).toContain('Mary Chen')
|
||||
})
|
||||
|
||||
it('requiresStopConfirmBeforeEmitting', async () => {
|
||||
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
|
||||
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Stop').trigger('click')
|
||||
expect(wrapper.emitted('stop')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Stopping leaves the patient on the ward')
|
||||
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Confirm stop').trigger('click')
|
||||
expect(wrapper.emitted('stop')).toEqual([['run-1']])
|
||||
})
|
||||
|
||||
it('showsFailureReasonForFailedRecentRun', () => {
|
||||
const wrapper = mountPanel({
|
||||
activeRuns: [],
|
||||
recentRuns: [{
|
||||
...activeRun,
|
||||
status: 'FAILED',
|
||||
failureReason: 'Loopback authentication failed',
|
||||
}],
|
||||
})
|
||||
expect(wrapper.text()).toContain('Failed')
|
||||
expect(wrapper.text()).toContain('Loopback authentication failed')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN_NAV_LINKS } from '@/composables/roleAccess'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import {
|
||||
canAccessOps,
|
||||
filterNavLinks,
|
||||
isDashboardRole,
|
||||
roleCanAccessRoute,
|
||||
MAIN_NAV_LINKS,
|
||||
useRoleAccess,
|
||||
} from '@/composables/roleAccess'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
describe('roleAccess', () => {
|
||||
it('filtersNavLinksByRole', () => {
|
||||
@@ -29,3 +39,50 @@ describe('roleAccess', () => {
|
||||
expect(canAccessOps('INTEGRATION')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRoleAccess simulation nav', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'tok'
|
||||
auth.user = {
|
||||
userId: '1',
|
||||
username: 'nurse',
|
||||
displayName: 'Nurse',
|
||||
role: 'NURSE',
|
||||
}
|
||||
})
|
||||
|
||||
it('includesSimulationLinkOnlyWhenEnabled', () => {
|
||||
const sim = useSimulationStore()
|
||||
const { mainNavLinks } = useRoleAccess()
|
||||
|
||||
sim.enabled = false
|
||||
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
|
||||
|
||||
sim.enabled = true
|
||||
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
|
||||
})
|
||||
|
||||
it('omitsSimulationLinkWhenDisabledRegardlessOfRole', () => {
|
||||
const auth = useAuthStore()
|
||||
const sim = useSimulationStore()
|
||||
sim.enabled = false
|
||||
|
||||
for (const role of ['NURSE', 'PHYSICIAN', 'ADMIN']) {
|
||||
auth.user = { ...auth.user, role }
|
||||
const { mainNavLinks } = useRoleAccess()
|
||||
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('movesQualityToOverflowWhenSimulationEnabledOnMobile', () => {
|
||||
const sim = useSimulationStore()
|
||||
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
|
||||
|
||||
sim.enabled = true
|
||||
expect(mobileNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
|
||||
expect(mobileNavLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(false)
|
||||
expect(mobileOverflowLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
const { fetchSimulationConfig } = vi.hoisted(() => ({
|
||||
fetchSimulationConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/simulation', () => ({
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios: vi.fn(),
|
||||
fetchRuns: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
fetchRun: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('simulation router guard', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'test-token'
|
||||
auth.user = {
|
||||
userId: 'u1',
|
||||
username: 'nurse',
|
||||
displayName: 'Test Nurse',
|
||||
role: 'NURSE',
|
||||
}
|
||||
|
||||
fetchSimulationConfig.mockResolvedValue({ enabled: false })
|
||||
|
||||
const { default: router } = await import('@/router')
|
||||
// Reset to a known route; ignore failures from first load
|
||||
await router.push('/ward').catch(() => {})
|
||||
await router.isReady()
|
||||
})
|
||||
|
||||
it('redirectsDirectSimulationNavigationWhenDisabled', async () => {
|
||||
const sim = useSimulationStore()
|
||||
sim.enabled = false
|
||||
|
||||
const { default: router } = await import('@/router')
|
||||
await router.push('/simulation')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/ward')
|
||||
expect(fetchSimulationConfig).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allowsSimulationWhenEnabled', async () => {
|
||||
const sim = useSimulationStore()
|
||||
sim.enabled = true
|
||||
|
||||
const { default: router } = await import('@/router')
|
||||
await router.push('/simulation')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/simulation')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
const {
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios,
|
||||
fetchRuns,
|
||||
startRun,
|
||||
stopRun,
|
||||
loadEncounters,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchSimulationConfig: vi.fn(),
|
||||
fetchScenarios: vi.fn(),
|
||||
fetchRuns: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
loadEncounters: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/simulation', () => ({
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios,
|
||||
fetchRuns,
|
||||
startRun,
|
||||
stopRun,
|
||||
fetchRun: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/ward', () => ({
|
||||
useWardStore: () => ({ loadEncounters }),
|
||||
}))
|
||||
|
||||
function runningRun(overrides = {}) {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
scenarioId: 'uti-sepsis-elderly-01',
|
||||
scenarioName: 'UTI Sepsis',
|
||||
status: 'RUNNING',
|
||||
speed: 60,
|
||||
patientId: 'p1',
|
||||
encounterId: 'e1',
|
||||
patientDisplayName: 'Test Patient',
|
||||
startedAt: '2026-08-05T10:00:00Z',
|
||||
elapsedRealSeconds: 30,
|
||||
lastOffsetMinutes: 20,
|
||||
totalOffsetMinutes: 480,
|
||||
progressPercent: 4,
|
||||
observationsSent: 3,
|
||||
medicationsSent: 0,
|
||||
ordersPlaced: 0,
|
||||
failureReason: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSimulationStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
setActivePinia(createPinia())
|
||||
fetchRuns.mockResolvedValue([])
|
||||
fetchScenarios.mockResolvedValue([])
|
||||
fetchSimulationConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
maxSpeed: 600,
|
||||
maxConcurrentRuns: 2,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const store = useSimulationStore()
|
||||
store.stopPolling()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('activeRuns_and_atConcurrencyLimit', () => {
|
||||
const store = useSimulationStore()
|
||||
store.maxConcurrentRuns = 2
|
||||
store.runs = [
|
||||
runningRun({ runId: 'a', status: 'RUNNING' }),
|
||||
runningRun({ runId: 'b', status: 'PENDING' }),
|
||||
runningRun({ runId: 'c', status: 'COMPLETED' }),
|
||||
]
|
||||
|
||||
expect(store.activeRuns.map(r => r.runId)).toEqual(['a', 'b'])
|
||||
expect(store.hasActiveRun).toBe(true)
|
||||
expect(store.atConcurrencyLimit).toBe(true)
|
||||
expect(store.recentRuns.map(r => r.runId)).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('start_pushesOptimisticPlaceholderThenReconciles', async () => {
|
||||
const store = useSimulationStore()
|
||||
store.scenarios = [{ id: 's1', name: 'Scenario One', durationMinutes: 480 }]
|
||||
|
||||
let resolveStart
|
||||
startRun.mockReturnValue(new Promise((resolve) => {
|
||||
resolveStart = resolve
|
||||
}))
|
||||
|
||||
const startPromise = store.start('s1', 60)
|
||||
await nextTick()
|
||||
|
||||
expect(store.activeRuns).toHaveLength(1)
|
||||
expect(store.activeRuns[0].status).toBe('PENDING')
|
||||
expect(store.activeRuns[0]._optimistic).toBe(true)
|
||||
expect(store.starting).toBe(true)
|
||||
|
||||
resolveStart(runningRun({ runId: 'real-1', scenarioId: 's1', scenarioName: 'Scenario One' }))
|
||||
await startPromise
|
||||
|
||||
expect(store.runs).toHaveLength(1)
|
||||
expect(store.runs[0].runId).toBe('real-1')
|
||||
expect(store.runs[0]._optimistic).toBeUndefined()
|
||||
expect(store.starting).toBe(false)
|
||||
expect(loadEncounters).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('start_rollsBackPlaceholderOnFailure', async () => {
|
||||
const store = useSimulationStore()
|
||||
startRun.mockRejectedValue(new Error('Maximum concurrent simulation runs (2) reached.'))
|
||||
|
||||
await expect(store.start('s1', 60)).rejects.toThrow(/concurrent/)
|
||||
expect(store.runs).toEqual([])
|
||||
expect(store.error).toMatch(/concurrent/)
|
||||
expect(store.starting).toBe(false)
|
||||
})
|
||||
|
||||
it('stop_updatesRunFromApi', async () => {
|
||||
const store = useSimulationStore()
|
||||
store.runs = [runningRun()]
|
||||
stopRun.mockResolvedValue(runningRun({ status: 'CANCELLED' }))
|
||||
|
||||
await store.stop('run-1')
|
||||
expect(stopRun).toHaveBeenCalledWith('run-1')
|
||||
expect(store.runs[0].status).toBe('CANCELLED')
|
||||
})
|
||||
|
||||
it('polling_runsOnlyWhileActiveRunExists', async () => {
|
||||
const store = useSimulationStore()
|
||||
store.startPolling()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
expect(fetchRuns).not.toHaveBeenCalled()
|
||||
|
||||
store.runs = [runningRun()]
|
||||
await nextTick()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(fetchRuns).toHaveBeenCalledTimes(1)
|
||||
|
||||
fetchRuns.mockResolvedValue([runningRun({ status: 'COMPLETED', progressPercent: 100 })])
|
||||
await store.refreshRuns()
|
||||
await nextTick()
|
||||
|
||||
fetchRuns.mockClear()
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
expect(fetchRuns).not.toHaveBeenCalled()
|
||||
|
||||
store.stopPolling()
|
||||
})
|
||||
|
||||
it('loadConfig_failsClosedOnError', async () => {
|
||||
const store = useSimulationStore()
|
||||
fetchSimulationConfig.mockRejectedValue(new Error('network'))
|
||||
await store.loadConfig()
|
||||
expect(store.enabled).toBe(false)
|
||||
expect(store.maxConcurrentRuns).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -88,4 +88,15 @@ const BUNDLE_ELEMENT_LABELS = {
|
||||
|
||||
export function bundleElementLabel(elementType) {
|
||||
return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the ward summary or encounter detail marks the patient as simulated.
|
||||
* Ward list: top-level `isSimulated`. Patient detail: `patient.isSimulated`.
|
||||
*/
|
||||
export function isSimulatedPatient(source) {
|
||||
if (!source) return false
|
||||
if (source.isSimulated === true) return true
|
||||
if (source.patient?.isSimulated === true) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { api } from './client'
|
||||
|
||||
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
|
||||
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
|
||||
export const fetchRuns = () => api.get('/api/v1/simulation/runs')
|
||||
export const fetchRun = (runId) => api.get(`/api/v1/simulation/runs/${runId}`)
|
||||
export const startRun = (body) => api.post('/api/v1/simulation/runs', body)
|
||||
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
|
||||
@@ -1,13 +1,31 @@
|
||||
<script setup>
|
||||
import { onMounted, watch } from 'vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import MobileNav from './MobileNav.vue'
|
||||
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
|
||||
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
|
||||
import { useSimulationAvailable } from '@/composables/useSimulationAvailable'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const simulationStore = useSimulationStore()
|
||||
const { detect: detectSimulation } = useSimulationAvailable()
|
||||
|
||||
useCriticalAlertPolling(settingsStore.pollInterval)
|
||||
|
||||
onMounted(() => {
|
||||
if (authStore.isAuthenticated) detectSimulation()
|
||||
})
|
||||
|
||||
watch(() => authStore.isAuthenticated, (ok) => {
|
||||
if (ok) detectSimulation()
|
||||
else simulationStore.enabled = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,6 +39,7 @@ useCriticalAlertPolling(settingsStore.pollInterval)
|
||||
<AppSidebar />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<AppHeader />
|
||||
<SimulationModeBanner />
|
||||
<CriticalAlertBanner />
|
||||
<main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
|
||||
@@ -133,6 +133,27 @@ function linkClasses(path) {
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'simulation'"
|
||||
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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-6 w-6 shrink-0"
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoleAccess } from '@/composables/roleAccess'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { mobileNavLinks } = useRoleAccess()
|
||||
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
|
||||
|
||||
const overflowOpen = ref(false)
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
|
||||
function linkActive(to) {
|
||||
return activePath.value.startsWith(to)
|
||||
}
|
||||
|
||||
function toggleOverflow() {
|
||||
overflowOpen.value = !overflowOpen.value
|
||||
}
|
||||
|
||||
function closeOverflow() {
|
||||
overflowOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -16,14 +30,32 @@ const activePath = computed(() => route.path)
|
||||
class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white lg:hidden dark:border-gray-800 dark:bg-gray-900"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
<div v-if="overflowOpen && mobileOverflowLinks.length" class="border-b border-gray-200 dark:border-gray-800">
|
||||
<ul class="flex flex-col py-2">
|
||||
<li v-for="link in mobileOverflowLinks" :key="link.to">
|
||||
<RouterLink
|
||||
:to="link.to"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||
:class="linkActive(link.to)
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300'"
|
||||
@click="closeOverflow"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul class="flex h-16 items-stretch">
|
||||
<li v-for="link in mobileNavLinks" :key="link.to" class="flex-1">
|
||||
<li v-for="link in mobileNavLinks" :key="link.to" class="min-w-0 flex-1">
|
||||
<RouterLink
|
||||
:to="link.to"
|
||||
class="flex h-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
|
||||
:class="activePath.startsWith(link.to)
|
||||
class="flex h-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
|
||||
:class="linkActive(link.to)
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
@click="closeOverflow"
|
||||
>
|
||||
<svg
|
||||
v-if="link.icon === 'ward'"
|
||||
@@ -70,6 +102,27 @@ const activePath = computed(() => route.path)
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'simulation'"
|
||||
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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-6 w-6"
|
||||
@@ -85,19 +138,38 @@ const activePath = computed(() => route.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>
|
||||
{{ link.label }}
|
||||
<span class="truncate">{{ link.label }}</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li class="flex-1">
|
||||
|
||||
<li v-if="mobileOverflowLinks.length" class="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
|
||||
:class="overflowOpen || mobileOverflowLinks.some(l => linkActive(l.to))
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
:aria-expanded="overflowOpen"
|
||||
aria-label="More navigation"
|
||||
@click="toggleOverflow"
|
||||
>
|
||||
<svg 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 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
<span class="truncate">More</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<li class="min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400 sm:gap-2 sm:px-4 sm:text-xs"
|
||||
@click="authStore.logout()"
|
||||
>
|
||||
<svg 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign out
|
||||
<span class="truncate">Sign out</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatGender,
|
||||
hasKnownAllergies,
|
||||
} from '@/composables/patientFormat'
|
||||
import { isSimulatedPatient } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({
|
||||
encounter: { type: Object, required: true },
|
||||
@@ -15,6 +16,8 @@ const props = defineProps({
|
||||
|
||||
const patient = computed(() => props.encounter.patient ?? {})
|
||||
|
||||
const simulated = computed(() => isSimulatedPatient(props.encounter))
|
||||
|
||||
const fullName = computed(() =>
|
||||
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
|
||||
)
|
||||
@@ -49,6 +52,13 @@ const emergencyContact = computed(() => {
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ fullName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="simulated"
|
||||
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
title="Simulated patient"
|
||||
>
|
||||
SIM
|
||||
</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
MRN {{ patient.mrn ?? '—' }}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||
import {
|
||||
formatMonitoringDuration,
|
||||
tagChipClass,
|
||||
isControlTag,
|
||||
isRealDataTag,
|
||||
} from '@/composables/simulationFormat'
|
||||
|
||||
const props = defineProps({
|
||||
scenario: { type: Object, required: true },
|
||||
atConcurrencyLimit: { type: Boolean, default: false },
|
||||
maxConcurrentRuns: { type: Number, default: 0 },
|
||||
selected: { type: Boolean, default: false },
|
||||
starting: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['start', 'select'])
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const description = computed(() => props.scenario.description?.trim() || '')
|
||||
const canExpand = computed(() => description.value.length > 140)
|
||||
|
||||
const durationLabel = computed(() =>
|
||||
formatMonitoringDuration(props.scenario.durationMinutes),
|
||||
)
|
||||
|
||||
const departmentLabel = computed(() =>
|
||||
formatDepartment(props.scenario.department),
|
||||
)
|
||||
|
||||
const eventLabel = computed(() => {
|
||||
const n = props.scenario.eventCount ?? 0
|
||||
return `${n} event${n === 1 ? '' : 's'}`
|
||||
})
|
||||
|
||||
const tags = computed(() => props.scenario.tags ?? [])
|
||||
|
||||
const showControlNote = computed(() => tags.value.some(isControlTag))
|
||||
const showRealDataNote = computed(() => tags.value.some(isRealDataTag))
|
||||
|
||||
const startDisabled = computed(() =>
|
||||
props.atConcurrencyLimit || props.starting,
|
||||
)
|
||||
|
||||
const disabledReason = computed(() => {
|
||||
if (props.atConcurrencyLimit) {
|
||||
const n = props.maxConcurrentRuns
|
||||
return n > 0
|
||||
? `Concurrency limit reached (${n} run${n === 1 ? '' : 's'} max). Stop an active run first.`
|
||||
: 'Concurrency limit reached. Stop an active run first.'
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function onStart() {
|
||||
if (startDisabled.value) return
|
||||
emit('start', props.scenario.id)
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
emit('select', props.scenario.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
padding="md"
|
||||
class="flex h-full flex-col transition"
|
||||
:class="selected
|
||||
? 'ring-2 ring-blue-500 dark:ring-blue-400'
|
||||
: 'hover:border-gray-300 dark:hover:border-gray-600'"
|
||||
>
|
||||
<div
|
||||
class="flex-1 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-pressed="selected"
|
||||
@click="onSelect"
|
||||
@keydown.enter.prevent="onSelect"
|
||||
@keydown.space.prevent="onSelect"
|
||||
>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ scenario.name }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
:class="expanded ? '' : 'line-clamp-3'"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canExpand"
|
||||
type="button"
|
||||
class="mt-1 self-start text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
{{ expanded ? 'Show less' : 'Show more' }}
|
||||
</button>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{{ durationLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{{ departmentLabel }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{{ eventLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tags.length" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
:class="tagChipClass(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="showControlNote"
|
||||
class="mt-3 text-xs font-medium text-emerald-700 dark:text-emerald-300"
|
||||
>
|
||||
Control scenario — should stay quiet
|
||||
</p>
|
||||
<p
|
||||
v-if="showRealDataNote"
|
||||
class="mt-1 text-xs font-medium text-violet-700 dark:text-violet-300"
|
||||
>
|
||||
Real de-identified ICU record
|
||||
</p>
|
||||
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||||
<Button
|
||||
class="w-full"
|
||||
size="sm"
|
||||
:disabled="startDisabled"
|
||||
:title="disabledReason"
|
||||
@click="onStart"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
const { enabled } = storeToRefs(useSimulationStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="enabled"
|
||||
role="status"
|
||||
class="border-b border-amber-400 bg-amber-100 px-4 py-2.5 text-center text-sm text-amber-950 dark:border-amber-600 dark:bg-amber-950/60 dark:text-amber-100"
|
||||
>
|
||||
<span class="font-bold tracking-wide">SIMULATION MODE</span>
|
||||
<span class="mx-1.5">—</span>
|
||||
patients shown here are simulated. Do not use for clinical decisions.
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import {
|
||||
formatSimClock,
|
||||
formatElapsedReal,
|
||||
formatSpeedBadge,
|
||||
runStatusLabel,
|
||||
runStatusVariant,
|
||||
} from '@/composables/simulationFormat'
|
||||
|
||||
defineProps({
|
||||
activeRuns: { type: Array, default: () => [] },
|
||||
recentRuns: { type: Array, default: () => [] },
|
||||
stoppingId: { type: [String, null], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['stop'])
|
||||
|
||||
const confirmStopId = ref(null)
|
||||
|
||||
function requestStop(runId) {
|
||||
confirmStopId.value = runId
|
||||
}
|
||||
|
||||
function cancelStop() {
|
||||
confirmStopId.value = null
|
||||
}
|
||||
|
||||
function confirmStop(runId) {
|
||||
confirmStopId.value = null
|
||||
emit('stop', runId)
|
||||
}
|
||||
|
||||
function patientLink(run) {
|
||||
return run.encounterId ? `/patients/${run.encounterId}` : null
|
||||
}
|
||||
|
||||
function progressWidth(run) {
|
||||
const pct = Math.min(100, Math.max(0, Number(run.progressPercent) || 0))
|
||||
return `${pct}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="activeRuns.length" class="space-y-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Active runs
|
||||
</h2>
|
||||
|
||||
<Card
|
||||
v-for="run in activeRuns"
|
||||
:key="run.runId"
|
||||
padding="md"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ run.scenarioName }}
|
||||
</h3>
|
||||
<Badge :variant="runStatusVariant(run.status)" size="xs">
|
||||
{{ runStatusLabel(run.status) }}
|
||||
</Badge>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
{{ formatSpeedBadge(run.speed) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
<template v-if="patientLink(run)">
|
||||
<RouterLink
|
||||
:to="patientLink(run)"
|
||||
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{{ run.patientDisplayName || 'Open patient' }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ run.patientDisplayName || 'Patient pending…' }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<template v-if="confirmStopId === run.runId">
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<p class="max-w-xs text-right text-xs text-gray-500 dark:text-gray-400">
|
||||
Stopping leaves the patient on the ward.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" variant="secondary" @click="cancelStop">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="stoppingId === run.runId"
|
||||
@click="confirmStop(run.runId)"
|
||||
>
|
||||
Confirm stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
variant="danger"
|
||||
:disabled="run._optimistic || stoppingId === run.runId"
|
||||
@click="requestStop(run.runId)"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{{ formatSimClock(run.lastOffsetMinutes, run.totalOffsetMinutes) }}</span>
|
||||
<span>{{ Math.round(run.progressPercent ?? 0) }}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
role="progressbar"
|
||||
:aria-valuenow="Math.round(run.progressPercent ?? 0)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-blue-600 transition-all dark:bg-blue-500"
|
||||
:style="{ width: progressWidth(run) }"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Wall clock {{ formatElapsedReal(run.elapsedRealSeconds) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Observations</dt>
|
||||
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
|
||||
{{ run.observationsSent ?? 0 }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Medications</dt>
|
||||
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
|
||||
{{ run.medicationsSent ?? 0 }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Orders</dt>
|
||||
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
|
||||
{{ run.ordersPlaced ?? 0 }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p
|
||||
v-if="run.status === 'FAILED' && run.failureReason"
|
||||
class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
|
||||
role="alert"
|
||||
>
|
||||
{{ run.failureReason }}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="recentRuns.length">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Recent runs
|
||||
</h2>
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Scenario</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Duration</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Counters</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Patient</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<tr
|
||||
v-for="run in recentRuns"
|
||||
:key="run.runId"
|
||||
:class="run.status === 'FAILED' ? 'bg-red-50/50 dark:bg-red-950/20' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white">
|
||||
{{ run.scenarioName }}
|
||||
<p
|
||||
v-if="run.status === 'FAILED' && run.failureReason"
|
||||
class="mt-1 text-xs text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ run.failureReason }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<Badge :variant="runStatusVariant(run.status)" size="xs">
|
||||
{{ runStatusLabel(run.status) }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{{ formatElapsedReal(run.elapsedRealSeconds) }}
|
||||
<span class="text-gray-400">·</span>
|
||||
{{ formatSpeedBadge(run.speed) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{{ run.observationsSent ?? 0 }} obs ·
|
||||
{{ run.medicationsSent ?? 0 }} med ·
|
||||
{{ run.ordersPlaced ?? 0 }} ord
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<RouterLink
|
||||
v-if="patientLink(run)"
|
||||
:to="patientLink(run)"
|
||||
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{{ run.patientDisplayName || 'Open' }}
|
||||
</RouterLink>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
patientRoom,
|
||||
stalenessClass,
|
||||
} from '@/composables/wardFormat'
|
||||
import { isSimulatedPatient } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({ patient: { type: Object, required: true } })
|
||||
|
||||
@@ -23,8 +24,10 @@ const vitalsStaleness = computed(() =>
|
||||
observationStaleness(props.patient.lastObservationAt, props.patient.status),
|
||||
)
|
||||
|
||||
const simulated = computed(() => isSimulatedPatient(props.patient))
|
||||
|
||||
const cardLabel = computed(() =>
|
||||
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
|
||||
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
|
||||
)
|
||||
|
||||
const emit = defineEmits(['activate'])
|
||||
@@ -53,6 +56,13 @@ function onKeydown(event) {
|
||||
Room {{ patientRoom(patient) }}
|
||||
</span>
|
||||
<Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge>
|
||||
<span
|
||||
v-if="simulated"
|
||||
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
title="Simulated patient"
|
||||
>
|
||||
SIM
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="mt-2 truncate text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ patient.firstName }} {{ patient.lastName }}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
stalenessClass,
|
||||
} from '@/composables/wardFormat'
|
||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||
import { isSimulatedPatient } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({
|
||||
patient: { type: Object, required: true },
|
||||
@@ -18,6 +19,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['activate'])
|
||||
|
||||
const simulated = computed(() => isSimulatedPatient(props.patient))
|
||||
|
||||
const riskVariant = computed(() => {
|
||||
const score = props.patient.news2Score ?? 0
|
||||
if (score >= 7) return 'critical'
|
||||
@@ -45,7 +48,7 @@ const vitalsStaleness = computed(() =>
|
||||
)
|
||||
|
||||
const rowLabel = computed(() =>
|
||||
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
|
||||
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
|
||||
)
|
||||
|
||||
function onKeydown(event) {
|
||||
@@ -68,8 +71,17 @@ function onKeydown(event) {
|
||||
{{ patientRoom(patient) }}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ patient.firstName }} {{ patient.lastName }}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ patient.firstName }} {{ patient.lastName }}
|
||||
</div>
|
||||
<span
|
||||
v-if="simulated"
|
||||
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
title="Simulated patient"
|
||||
>
|
||||
SIM
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
|
||||
</td>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
||||
|
||||
@@ -10,6 +11,19 @@ export const OPS_NAV_LINK = {
|
||||
icon: 'ops',
|
||||
}
|
||||
|
||||
export const SIMULATION_NAV_LINK = {
|
||||
to: '/simulation',
|
||||
label: 'Simulation',
|
||||
icon: 'simulation',
|
||||
roles: CLINICAL_ROLES,
|
||||
}
|
||||
|
||||
/** Shorter label for the bottom bar at ~360 px. */
|
||||
export const SIMULATION_MOBILE_NAV_LINK = {
|
||||
...SIMULATION_NAV_LINK,
|
||||
label: 'Sim',
|
||||
}
|
||||
|
||||
export const MAIN_NAV_LINKS = [
|
||||
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
|
||||
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
|
||||
@@ -37,6 +51,8 @@ export const MOBILE_NAV_LINKS = [
|
||||
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
|
||||
]
|
||||
|
||||
const QUALITY_MOBILE_PATH = '/admin/reconciliation'
|
||||
|
||||
export function isDashboardRole(role) {
|
||||
return CLINICAL_ROLES.includes(role)
|
||||
}
|
||||
@@ -60,6 +76,7 @@ export function filterNavLinks(links, role) {
|
||||
|
||||
export function useRoleAccess() {
|
||||
const auth = useAuthStore()
|
||||
const simulation = useSimulationStore()
|
||||
const { role } = storeToRefs(auth)
|
||||
|
||||
const isNurse = computed(() => role.value === 'NURSE')
|
||||
@@ -69,11 +86,30 @@ export function useRoleAccess() {
|
||||
|
||||
const mainNavLinks = computed(() => {
|
||||
const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
|
||||
if (simulation.enabled && CLINICAL_ROLES.includes(role.value)) {
|
||||
links.push(SIMULATION_NAV_LINK)
|
||||
}
|
||||
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
|
||||
return links
|
||||
})
|
||||
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
|
||||
const mobileNavLinks = computed(() => filterNavLinks(MOBILE_NAV_LINKS, role.value))
|
||||
|
||||
// Keep the bottom bar usable at ~360 px: when simulation is on, Quality moves to overflow.
|
||||
const mobileNavLinks = computed(() => {
|
||||
const links = filterNavLinks(MOBILE_NAV_LINKS, role.value)
|
||||
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return links
|
||||
return [
|
||||
...links.filter(l => l.to !== QUALITY_MOBILE_PATH),
|
||||
SIMULATION_MOBILE_NAV_LINK,
|
||||
]
|
||||
})
|
||||
|
||||
const mobileOverflowLinks = computed(() => {
|
||||
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return []
|
||||
return filterNavLinks(MOBILE_NAV_LINKS, role.value)
|
||||
.filter(l => l.to === QUALITY_MOBILE_PATH)
|
||||
})
|
||||
|
||||
const showAdminSection = computed(() => adminNavLinks.value.length > 0)
|
||||
|
||||
const nursePatientLayout = computed(() => isNurse.value)
|
||||
@@ -88,6 +124,7 @@ export function useRoleAccess() {
|
||||
mainNavLinks,
|
||||
adminNavLinks,
|
||||
mobileNavLinks,
|
||||
mobileOverflowLinks,
|
||||
showAdminSection,
|
||||
nursePatientLayout,
|
||||
physicianPatientLayout,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** Formats a minute count as a short clinician-facing duration. */
|
||||
export function formatDurationMinutes(minutes) {
|
||||
if (minutes == null || Number.isNaN(Number(minutes))) return '—'
|
||||
const total = Math.max(0, Number(minutes))
|
||||
if (total < 60) {
|
||||
const m = Math.round(total)
|
||||
return `${m} minute${m === 1 ? '' : 's'}`
|
||||
}
|
||||
const hoursExact = total / 60
|
||||
if (Math.abs(hoursExact - Math.round(hoursExact)) < 0.05) {
|
||||
const h = Math.round(hoursExact)
|
||||
return `${h} hour${h === 1 ? '' : 's'}`
|
||||
}
|
||||
const h = Math.floor(total / 60)
|
||||
const m = Math.round(total % 60)
|
||||
if (m === 0) return `${h} hour${h === 1 ? '' : 's'}`
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
|
||||
/** Scenario duration pill: "8 hours of monitoring". */
|
||||
export function formatMonitoringDuration(durationMinutes) {
|
||||
return `${formatDurationMinutes(durationMinutes)} of monitoring`
|
||||
}
|
||||
|
||||
/** Wall-clock time for a scenario at a given speed multiplier. */
|
||||
export function formatWallClockForSpeed(durationMinutes, speed) {
|
||||
if (durationMinutes == null || !speed) return '—'
|
||||
return formatDurationMinutes(Number(durationMinutes) / Number(speed))
|
||||
}
|
||||
|
||||
/** Simulated clock: "03:20 of 08:00 elapsed". */
|
||||
export function formatSimClock(lastOffsetMinutes, totalOffsetMinutes) {
|
||||
const fmt = (mins) => {
|
||||
const total = Math.max(0, Math.floor(Number(mins) || 0))
|
||||
const h = Math.floor(total / 60)
|
||||
const m = total % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
return `${fmt(lastOffsetMinutes)} of ${fmt(totalOffsetMinutes)} elapsed`
|
||||
}
|
||||
|
||||
/** Wall-clock elapsed from elapsedRealSeconds. */
|
||||
export function formatElapsedReal(seconds) {
|
||||
const s = Math.max(0, Math.floor(Number(seconds) || 0))
|
||||
const m = Math.floor(s / 60)
|
||||
const rem = s % 60
|
||||
if (m >= 60) {
|
||||
const h = Math.floor(m / 60)
|
||||
return `${h}h ${m % 60}m`
|
||||
}
|
||||
return `${m}:${String(rem).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatSpeedBadge(speed) {
|
||||
if (speed == null) return '—'
|
||||
return `${speed}×`
|
||||
}
|
||||
|
||||
export function runStatusLabel(status) {
|
||||
const map = {
|
||||
PENDING: 'Pending',
|
||||
RUNNING: 'Running',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
FAILED: 'Failed',
|
||||
}
|
||||
return map[status] ?? status ?? '—'
|
||||
}
|
||||
|
||||
export function runStatusVariant(status) {
|
||||
if (status === 'FAILED') return 'critical'
|
||||
if (status === 'CANCELLED') return 'warning'
|
||||
if (status === 'RUNNING') return 'success'
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const TAG_CHIP_CLASS = {
|
||||
sepsis: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
|
||||
control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
'mimic-iv': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
'real-data': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
|
||||
stable: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
|
||||
baseline: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
|
||||
}
|
||||
|
||||
export function tagChipClass(tag) {
|
||||
return TAG_CHIP_CLASS[tag]
|
||||
?? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||
}
|
||||
|
||||
export function isControlTag(tag) {
|
||||
return tag === 'control'
|
||||
}
|
||||
|
||||
export function isRealDataTag(tag) {
|
||||
return tag === 'real-data' || tag === 'mimic-iv'
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
|
||||
/**
|
||||
* Feature-detects in-app simulation via GET /api/v1/simulation/config.
|
||||
* Fail-closed: any error leaves simulationStore.enabled = false.
|
||||
*/
|
||||
export function useSimulationAvailable() {
|
||||
const auth = useAuthStore()
|
||||
const simulation = useSimulationStore()
|
||||
|
||||
const available = computed(() => simulation.enabled)
|
||||
|
||||
async function detect() {
|
||||
if (!auth.isAuthenticated) {
|
||||
simulation.enabled = false
|
||||
return false
|
||||
}
|
||||
await simulation.loadConfig()
|
||||
return simulation.enabled
|
||||
}
|
||||
|
||||
return { available, detect }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
|
||||
|
||||
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
|
||||
@@ -45,6 +46,12 @@ const routes = [
|
||||
component: () => import('@/views/SepsisBoardView.vue'),
|
||||
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
|
||||
},
|
||||
{
|
||||
path: '/simulation',
|
||||
name: 'SimulationControl',
|
||||
component: () => import('@/views/SimulationControlView.vue'),
|
||||
meta: { title: 'Simulation', layout: 'default', allowedRoles: CLINICAL, requiresSimulation: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/thresholds',
|
||||
name: 'ThresholdManagement',
|
||||
@@ -92,7 +99,7 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
|
||||
|
||||
const auth = useAuthStore()
|
||||
@@ -108,6 +115,15 @@ router.beforeEach((to) => {
|
||||
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
|
||||
return { path: defaultRouteForRole(auth.role) }
|
||||
}
|
||||
if (to.meta.requiresSimulation && auth.isAuthenticated) {
|
||||
const simulation = useSimulationStore()
|
||||
if (!simulation.enabled) {
|
||||
await simulation.loadConfig()
|
||||
}
|
||||
if (!simulation.enabled) {
|
||||
return { path: '/ward' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios,
|
||||
fetchRuns,
|
||||
startRun,
|
||||
stopRun,
|
||||
} from '@/api/simulation'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
|
||||
function isActiveStatus(status) {
|
||||
return ACTIVE_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export const useSimulationStore = defineStore('simulation', () => {
|
||||
const enabled = ref(false)
|
||||
const maxSpeed = ref(null)
|
||||
const maxConcurrentRuns = ref(0)
|
||||
const scenarios = ref([])
|
||||
const runs = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const starting = ref(false)
|
||||
|
||||
let pollTimer = null
|
||||
let stopActiveWatch = null
|
||||
|
||||
const activeRuns = computed(() =>
|
||||
runs.value.filter(r => isActiveStatus(r.status)),
|
||||
)
|
||||
|
||||
const recentRuns = computed(() =>
|
||||
runs.value
|
||||
.filter(r => !isActiveStatus(r.status))
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
|
||||
.slice(0, 10),
|
||||
)
|
||||
|
||||
const hasActiveRun = computed(() => activeRuns.value.length > 0)
|
||||
|
||||
const atConcurrencyLimit = computed(() =>
|
||||
activeRuns.value.length >= maxConcurrentRuns.value,
|
||||
)
|
||||
|
||||
const scenariosByTag = computed(() => {
|
||||
const groups = {}
|
||||
for (const scenario of scenarios.value) {
|
||||
for (const tag of scenario.tags ?? []) {
|
||||
if (!groups[tag]) groups[tag] = []
|
||||
groups[tag].push(scenario)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const data = await fetchSimulationConfig()
|
||||
enabled.value = !!data?.enabled
|
||||
maxSpeed.value = data?.maxSpeed ?? null
|
||||
maxConcurrentRuns.value = data?.maxConcurrentRuns ?? 0
|
||||
} catch {
|
||||
// Fail closed — never surface simulation controls on a broken probe.
|
||||
enabled.value = false
|
||||
maxSpeed.value = null
|
||||
maxConcurrentRuns.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function loadScenarios() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await fetchScenarios()
|
||||
scenarios.value = Array.isArray(data) ? data : []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
scenarios.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function detectNewlyRunning(previousById, nextRuns) {
|
||||
return nextRuns.some((run) => {
|
||||
if (run.status !== 'RUNNING') return false
|
||||
const prev = previousById.get(run.runId)
|
||||
return prev !== 'RUNNING'
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshRuns() {
|
||||
try {
|
||||
const previousById = new Map(runs.value.map(r => [r.runId, r.status]))
|
||||
const data = await fetchRuns()
|
||||
const next = Array.isArray(data) ? data : []
|
||||
|
||||
// Keep optimistic placeholders until the server echoes a real run.
|
||||
const optimistic = runs.value.filter(r => r._optimistic)
|
||||
const serverIds = new Set(next.map(r => r.runId))
|
||||
const stillPending = optimistic.filter(r => !serverIds.has(r.runId))
|
||||
runs.value = [...stillPending, ...next]
|
||||
|
||||
if (detectNewlyRunning(previousById, next)) {
|
||||
useWardStore().loadEncounters()
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async function start(scenarioId, speed) {
|
||||
starting.value = true
|
||||
error.value = null
|
||||
|
||||
const scenario = scenarios.value.find(s => s.id === scenarioId)
|
||||
const placeholderId = `optimistic-${Date.now()}`
|
||||
const placeholder = {
|
||||
runId: placeholderId,
|
||||
scenarioId,
|
||||
scenarioName: scenario?.name ?? scenarioId,
|
||||
status: 'PENDING',
|
||||
speed,
|
||||
patientId: null,
|
||||
encounterId: null,
|
||||
patientDisplayName: '',
|
||||
startedAt: new Date().toISOString(),
|
||||
elapsedRealSeconds: 0,
|
||||
lastOffsetMinutes: 0,
|
||||
totalOffsetMinutes: scenario?.durationMinutes ?? 0,
|
||||
progressPercent: 0,
|
||||
observationsSent: 0,
|
||||
medicationsSent: 0,
|
||||
ordersPlaced: 0,
|
||||
failureReason: null,
|
||||
_optimistic: true,
|
||||
}
|
||||
runs.value = [placeholder, ...runs.value]
|
||||
|
||||
try {
|
||||
const run = await startRun({ scenarioId, speed })
|
||||
runs.value = [
|
||||
run,
|
||||
...runs.value.filter(r => r.runId !== placeholderId && r.runId !== run.runId),
|
||||
]
|
||||
if (run.status === 'RUNNING') {
|
||||
useWardStore().loadEncounters()
|
||||
}
|
||||
return run
|
||||
} catch (e) {
|
||||
runs.value = runs.value.filter(r => r.runId !== placeholderId)
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(runId) {
|
||||
error.value = null
|
||||
try {
|
||||
const run = await stopRun(runId)
|
||||
const idx = runs.value.findIndex(r => r.runId === runId)
|
||||
if (idx >= 0) {
|
||||
runs.value[idx] = run
|
||||
} else {
|
||||
runs.value = [run, ...runs.value]
|
||||
}
|
||||
return run
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function syncPollTimer(active) {
|
||||
if (active) {
|
||||
if (!pollTimer) {
|
||||
pollTimer = setInterval(() => {
|
||||
refreshRuns()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
} else if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/** View-owned lifecycle: start watching active runs and poll while any are in flight. */
|
||||
function startPolling() {
|
||||
if (stopActiveWatch) return
|
||||
stopActiveWatch = watch(hasActiveRun, (active) => {
|
||||
syncPollTimer(active)
|
||||
}, { immediate: true })
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (stopActiveWatch) {
|
||||
stopActiveWatch()
|
||||
stopActiveWatch = null
|
||||
}
|
||||
syncPollTimer(false)
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
maxSpeed,
|
||||
maxConcurrentRuns,
|
||||
scenarios,
|
||||
runs,
|
||||
loading,
|
||||
error,
|
||||
starting,
|
||||
activeRuns,
|
||||
recentRuns,
|
||||
hasActiveRun,
|
||||
atConcurrencyLimit,
|
||||
scenariosByTag,
|
||||
clearError,
|
||||
loadConfig,
|
||||
loadScenarios,
|
||||
refreshRuns,
|
||||
start,
|
||||
stop,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
||||
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import { formatWallClockForSpeed } from '@/composables/simulationFormat'
|
||||
|
||||
const SPEED_OPTIONS = [
|
||||
{ label: 'Real time', multiplier: 1 },
|
||||
{ label: 'Fast', multiplier: 60 },
|
||||
{ label: 'Very fast', multiplier: 240 },
|
||||
{ label: 'Instant', multiplier: 600 },
|
||||
]
|
||||
|
||||
const simulationStore = useSimulationStore()
|
||||
const {
|
||||
scenarios,
|
||||
activeRuns,
|
||||
recentRuns,
|
||||
loading,
|
||||
error,
|
||||
starting,
|
||||
atConcurrencyLimit,
|
||||
maxConcurrentRuns,
|
||||
maxSpeed,
|
||||
} = storeToRefs(simulationStore)
|
||||
|
||||
const selectedScenarioId = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const activeTag = ref(null)
|
||||
const speed = ref(60)
|
||||
const stoppingId = ref(null)
|
||||
|
||||
const availableSpeeds = computed(() => {
|
||||
const cap = maxSpeed.value
|
||||
return SPEED_OPTIONS.map((opt) => {
|
||||
const multiplier = cap != null ? Math.min(opt.multiplier, cap) : opt.multiplier
|
||||
return { ...opt, multiplier }
|
||||
}).filter((opt, index, arr) =>
|
||||
// Drop Instant (or any option) if a lower option already uses the same capped value
|
||||
index === arr.findIndex(o => o.multiplier === opt.multiplier),
|
||||
)
|
||||
})
|
||||
|
||||
const selectedScenario = computed(() =>
|
||||
scenarios.value.find(s => s.id === selectedScenarioId.value)
|
||||
?? filteredScenarios.value[0]
|
||||
?? null,
|
||||
)
|
||||
|
||||
const allTags = computed(() => {
|
||||
const set = new Set()
|
||||
for (const s of scenarios.value) {
|
||||
for (const tag of s.tags ?? []) set.add(tag)
|
||||
}
|
||||
return [...set].sort()
|
||||
})
|
||||
|
||||
const filteredScenarios = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return scenarios.value.filter((s) => {
|
||||
if (activeTag.value && !(s.tags ?? []).includes(activeTag.value)) return false
|
||||
if (!q) return true
|
||||
const haystack = [
|
||||
s.name,
|
||||
s.description,
|
||||
s.id,
|
||||
s.department,
|
||||
...(s.tags ?? []),
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
function wallClockFor(multiplier) {
|
||||
if (!selectedScenario.value?.durationMinutes) return null
|
||||
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
|
||||
}
|
||||
|
||||
function concurrencyErrorMessage(raw) {
|
||||
const n = maxConcurrentRuns.value
|
||||
if (/concurrent|concurrency|409/i.test(raw ?? '')) {
|
||||
return n > 0
|
||||
? `Concurrency limit reached — at most ${n} simulation run${n === 1 ? '' : 's'} can run at once. Stop an active run first.`
|
||||
: 'Concurrency limit reached. Stop an active run first.'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
async function onStart(scenarioId) {
|
||||
selectedScenarioId.value = scenarioId
|
||||
try {
|
||||
await simulationStore.start(scenarioId, speed.value)
|
||||
} catch (e) {
|
||||
simulationStore.error = concurrencyErrorMessage(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function onStop(runId) {
|
||||
stoppingId.value = runId
|
||||
try {
|
||||
await simulationStore.stop(runId)
|
||||
} catch {
|
||||
// error already on store
|
||||
} finally {
|
||||
stoppingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectScenario(id) {
|
||||
selectedScenarioId.value = id
|
||||
}
|
||||
|
||||
function toggleTag(tag) {
|
||||
activeTag.value = activeTag.value === tag ? null : tag
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
simulationStore.loadScenarios(),
|
||||
simulationStore.refreshRuns(),
|
||||
])
|
||||
simulationStore.startPolling()
|
||||
if (!selectedScenarioId.value && scenarios.value[0]) {
|
||||
selectedScenarioId.value = scenarios.value[0].id
|
||||
}
|
||||
if (!availableSpeeds.value.some(o => o.multiplier === speed.value)) {
|
||||
const preferred = availableSpeeds.value.find(o => o.multiplier === 60)
|
||||
?? availableSpeeds.value[1]
|
||||
?? availableSpeeds.value[0]
|
||||
if (preferred) speed.value = preferred.multiplier
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
simulationStore.stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Replay a recorded clinical scenario into this ward. All patients created here are simulated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="flex items-start justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
|
||||
>
|
||||
<p>{{ error }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 font-medium underline"
|
||||
@click="simulationStore.clearError()"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SimulationRunPanel
|
||||
v-if="activeRuns.length || recentRuns.length"
|
||||
:active-runs="activeRuns"
|
||||
:recent-runs="recentRuns"
|
||||
:stopping-id="stoppingId"
|
||||
@stop="onStop"
|
||||
/>
|
||||
|
||||
<section aria-labelledby="sim-speed-heading">
|
||||
<h2
|
||||
id="sim-speed-heading"
|
||||
class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Replay speed
|
||||
</h2>
|
||||
<div
|
||||
class="inline-flex max-w-full flex-wrap rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-900"
|
||||
role="group"
|
||||
aria-label="Replay speed"
|
||||
>
|
||||
<button
|
||||
v-for="opt in availableSpeeds"
|
||||
:key="opt.label"
|
||||
type="button"
|
||||
class="min-h-11 rounded-md px-4 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
:class="speed === opt.multiplier
|
||||
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800'"
|
||||
:aria-pressed="speed === opt.multiplier"
|
||||
@click="speed = opt.multiplier"
|
||||
>
|
||||
<span class="font-medium">{{ opt.label }}</span>
|
||||
<span
|
||||
class="mt-0.5 block text-xs"
|
||||
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
|
||||
>
|
||||
<template v-if="wallClockFor(opt.multiplier)">
|
||||
{{ wallClockFor(opt.multiplier) }} for selected scenario
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ opt.multiplier }}×
|
||||
</template>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Durations above use
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
|
||||
({{ selectedScenario.durationMinutes }} simulated minutes).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="sim-catalogue-heading">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||
<h2
|
||||
id="sim-catalogue-heading"
|
||||
class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Scenario catalogue
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<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 scenarios</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Search scenarios by name, tag, or department…"
|
||||
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"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div v-if="allTags.length" class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
v-for="tag in allTags"
|
||||
:key="tag"
|
||||
size="sm"
|
||||
:variant="activeTag === tag ? 'primary' : 'secondary'"
|
||||
@click="toggleTag(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="activeTag"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="activeTag = null"
|
||||
>
|
||||
Clear filter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="loading && scenarios.length === 0" :rows="3" />
|
||||
|
||||
<EmptyState
|
||||
v-else-if="scenarios.length === 0"
|
||||
message="No scenarios found. Check that Simulation:ScenarioDirectory is configured and contains scenario JSON files."
|
||||
/>
|
||||
|
||||
<EmptyState
|
||||
v-else-if="filteredScenarios.length === 0"
|
||||
message="No scenarios match the current search or tag filter."
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
role="list"
|
||||
>
|
||||
<div
|
||||
v-for="scenario in filteredScenarios"
|
||||
:key="scenario.id"
|
||||
role="listitem"
|
||||
>
|
||||
<ScenarioCard
|
||||
:scenario="scenario"
|
||||
:selected="selectedScenarioId === scenario.id"
|
||||
:at-concurrency-limit="atConcurrencyLimit"
|
||||
:max-concurrent-runs="maxConcurrentRuns"
|
||||
:starting="starting"
|
||||
@select="selectScenario"
|
||||
@start="onStart"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user