feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ResetWardPanel from '@/components/simulation/ResetWardPanel.vue'
|
||||
|
||||
const summary = {
|
||||
simulatedPatients: 12,
|
||||
encounters: 12,
|
||||
observations: 340,
|
||||
alerts: 28,
|
||||
activeRuns: 0,
|
||||
}
|
||||
|
||||
describe('ResetWardPanel', () => {
|
||||
it('displaysCounts', () => {
|
||||
const wrapper = mount(ResetWardPanel, { props: { summary } })
|
||||
expect(wrapper.text()).toContain('12 simulated patients')
|
||||
expect(wrapper.text()).toContain('340 observations')
|
||||
expect(wrapper.text()).toContain('28 alerts')
|
||||
})
|
||||
|
||||
it('requiresTypedConfirmation', async () => {
|
||||
const wrapper = mount(ResetWardPanel, {
|
||||
props: { summary },
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await wrapper.findAll('button').find(b => b.text() === 'Reset ward').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(document.body.textContent).toContain('Type')
|
||||
expect(document.body.textContent).toContain('RESET')
|
||||
|
||||
const input = document.body.querySelector('input')
|
||||
expect(input).toBeTruthy()
|
||||
input.value = 'RESET'
|
||||
input.dispatchEvent(new Event('input'))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Confirm button is the danger button inside the modal (last Reset ward).
|
||||
const buttons = [...document.body.querySelectorAll('button')]
|
||||
.filter(b => b.textContent.trim() === 'Reset ward')
|
||||
const confirmBtn = buttons.at(-1)
|
||||
expect(confirmBtn.disabled).toBe(false)
|
||||
|
||||
confirmBtn.click()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('purge')).toHaveLength(1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('blocksWhileRunsActive', () => {
|
||||
const wrapper = mount(ResetWardPanel, {
|
||||
props: {
|
||||
summary: { ...summary, activeRuns: 2 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Reset is blocked')
|
||||
expect(wrapper.text()).toContain('Stop all runs')
|
||||
|
||||
const resetBtn = wrapper.findAll('button').find(b => b.text() === 'Reset ward')
|
||||
expect(resetBtn.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SessionCard from '@/components/simulation/SessionCard.vue'
|
||||
|
||||
const baseSession = {
|
||||
id: 'session-b-alert-quality',
|
||||
name: 'Session B — Alert quality deep dive',
|
||||
goal: 'Compare alert types across contrasting scenarios.',
|
||||
estimatedMinutes: 50,
|
||||
defaultSpeed: 120,
|
||||
scenarios: [
|
||||
{ id: 'medication-false-alarm-01', name: 'Medication False Alarm' },
|
||||
{ id: 'uti-sepsis-elderly-01', name: 'UTI Sepsis Elderly' },
|
||||
{ id: 'respiratory-failure-asthma-01', name: 'Respiratory Failure' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('SessionCard', () => {
|
||||
it('rendersPresetSummary', () => {
|
||||
const wrapper = mount(SessionCard, {
|
||||
props: { session: baseSession },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain(baseSession.name)
|
||||
expect(wrapper.text()).toContain(baseSession.goal)
|
||||
expect(wrapper.text()).toContain('~50 min')
|
||||
expect(wrapper.text()).toContain('3 patients')
|
||||
expect(wrapper.text()).toContain('Medication False Alarm')
|
||||
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
|
||||
})
|
||||
|
||||
it('disablesStartWhenInsufficientCapacity', async () => {
|
||||
const wrapper = mount(SessionCard, {
|
||||
props: {
|
||||
session: baseSession,
|
||||
activeRunCount: 0,
|
||||
maxConcurrentRuns: 2,
|
||||
},
|
||||
})
|
||||
|
||||
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start session')
|
||||
expect(startBtn.attributes('disabled')).toBeDefined()
|
||||
expect(startBtn.attributes('title')).toMatch(/Needs 3 run slots/)
|
||||
|
||||
await startBtn.trigger('click')
|
||||
expect(wrapper.emitted('start')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('emitsStartWhenCapacityAvailable', async () => {
|
||||
const wrapper = mount(SessionCard, {
|
||||
props: {
|
||||
session: baseSession,
|
||||
activeRunCount: 0,
|
||||
maxConcurrentRuns: 8,
|
||||
},
|
||||
})
|
||||
|
||||
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start session')
|
||||
await startBtn.trigger('click')
|
||||
expect(wrapper.emitted('start')).toEqual([['session-b-alert-quality']])
|
||||
})
|
||||
})
|
||||
@@ -5,24 +5,36 @@ import SimulationControlView from '@/views/SimulationControlView.vue'
|
||||
|
||||
const {
|
||||
fetchScenarios,
|
||||
fetchSessions,
|
||||
fetchRuns,
|
||||
fetchSimulationConfig,
|
||||
fetchDataSummary,
|
||||
startRun,
|
||||
startSession,
|
||||
stopRun,
|
||||
purgeData,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchScenarios: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchRuns: vi.fn(),
|
||||
fetchSimulationConfig: vi.fn(),
|
||||
fetchDataSummary: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
startSession: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
purgeData: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/simulation', () => ({
|
||||
fetchScenarios,
|
||||
fetchSessions,
|
||||
fetchRuns,
|
||||
fetchSimulationConfig,
|
||||
fetchDataSummary,
|
||||
startRun,
|
||||
startSession,
|
||||
stopRun,
|
||||
purgeData,
|
||||
fetchRun: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -30,6 +42,10 @@ vi.mock('@/stores/ward', () => ({
|
||||
useWardStore: () => ({ loadEncounters: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/alerts', () => ({
|
||||
useAlertStore: () => ({ loadGlobalAlerts: vi.fn() }),
|
||||
}))
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
id: 'uti-sepsis-elderly-01',
|
||||
@@ -51,12 +67,34 @@ const scenarios = [
|
||||
},
|
||||
]
|
||||
|
||||
const sessions = [
|
||||
{
|
||||
id: 'session-a-orientation',
|
||||
name: 'Session A — Quick orientation',
|
||||
goal: 'Learn the interface.',
|
||||
estimatedMinutes: 25,
|
||||
defaultSpeed: 60,
|
||||
scenarios: [
|
||||
{ id: 'stable-baseline-01', name: 'Stable Baseline' },
|
||||
{ id: 'uti-sepsis-elderly-01', name: 'UTI Sepsis Elderly' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
describe('SimulationControlView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
fetchScenarios.mockResolvedValue(scenarios)
|
||||
fetchSessions.mockResolvedValue(sessions)
|
||||
fetchRuns.mockResolvedValue([])
|
||||
fetchDataSummary.mockResolvedValue({
|
||||
simulatedPatients: 0,
|
||||
encounters: 0,
|
||||
observations: 0,
|
||||
alerts: 0,
|
||||
activeRuns: 0,
|
||||
})
|
||||
fetchSimulationConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
maxSpeed: 600,
|
||||
@@ -64,8 +102,30 @@ describe('SimulationControlView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defaultsToSessionsTab', async () => {
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A — Quick orientation'))
|
||||
expect(wrapper.text()).toContain('Testing sessions')
|
||||
expect(wrapper.text()).not.toContain('Scenario catalogue')
|
||||
})
|
||||
|
||||
it('switchesToScenariosTab', async () => {
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||
|
||||
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||
await scenariosTab.trigger('click')
|
||||
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Scenario catalogue'))
|
||||
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
|
||||
})
|
||||
|
||||
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||
|
||||
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||
await scenariosTab.trigger('click')
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
|
||||
|
||||
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
|
||||
@@ -78,9 +138,12 @@ describe('SimulationControlView', () => {
|
||||
expect(instant.text()).toMatch(/1 minute for selected scenario/)
|
||||
})
|
||||
|
||||
|
||||
it('filtersCatalogueByTag', async () => {
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||
|
||||
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||
await scenariosTab.trigger('click')
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
|
||||
|
||||
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
|
||||
@@ -93,6 +156,11 @@ describe('SimulationControlView', () => {
|
||||
it('showsEmptyStateWhenCatalogueMissing', async () => {
|
||||
fetchScenarios.mockResolvedValue([])
|
||||
const wrapper = mount(SimulationControlView)
|
||||
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
|
||||
|
||||
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
|
||||
await scenariosTab.trigger('click')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAlertQualityStore } from '@/stores/alertQuality'
|
||||
|
||||
vi.mock('@/api/alertQuality', () => ({
|
||||
const {
|
||||
submitAlertFeedback,
|
||||
fetchQualityMetricsSummary,
|
||||
fetchQualityMetrics,
|
||||
fetchQualityFeedback,
|
||||
} = vi.hoisted(() => ({
|
||||
submitAlertFeedback: vi.fn().mockResolvedValue({ id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }),
|
||||
fetchQualityMetricsSummary: vi.fn().mockResolvedValue({
|
||||
totalAlerts: 10, totalFeedback: 4,
|
||||
@@ -13,12 +18,54 @@ vi.mock('@/api/alertQuality', () => ({
|
||||
fetchQualityMetrics: vi.fn().mockResolvedValue({
|
||||
items: [{ alertType: 'News2Warning', usefulRate: 0.75, falsePositiveRate: 0.1, acknowledgementRate: 0.8, totalAlerts: 5 }],
|
||||
}),
|
||||
fetchQualityFeedback: vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
alertId: 'a1',
|
||||
alertType: 'NEWS2_WARNING',
|
||||
feedbackType: 'USEFUL',
|
||||
comment: null,
|
||||
createdAt: '2026-06-23T00:00:00Z',
|
||||
scenarioId: 'uti-sepsis-elderly-01',
|
||||
sessionId: 'session-b-alert-quality',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
alertId: 'a2',
|
||||
alertType: 'NEWS2_WARNING',
|
||||
feedbackType: 'FALSE_POSITIVE',
|
||||
comment: null,
|
||||
createdAt: '2026-06-23T01:00:00Z',
|
||||
scenarioId: 'medication-false-alarm-01',
|
||||
sessionId: 'session-b-alert-quality',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
alertId: 'a3',
|
||||
alertType: 'NEWS2_WARNING',
|
||||
feedbackType: 'FALSE_POSITIVE',
|
||||
comment: null,
|
||||
createdAt: '2026-06-23T02:00:00Z',
|
||||
scenarioId: 'medication-false-alarm-01',
|
||||
sessionId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/alertQuality', () => ({
|
||||
submitAlertFeedback,
|
||||
fetchQualityMetricsSummary,
|
||||
fetchQualityMetrics,
|
||||
fetchQualityFeedback,
|
||||
FEEDBACK_TYPE_MAP: { useful: 'Useful' },
|
||||
}))
|
||||
|
||||
describe('alertQuality store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('loads dashboard summary and snapshots', async () => {
|
||||
@@ -34,4 +81,39 @@ describe('alertQuality store', () => {
|
||||
await store.submitFeedback('alert-1', 'useful', 'test note')
|
||||
expect(store.getFeedback('alert-1').rating).toBe('useful')
|
||||
})
|
||||
})
|
||||
|
||||
it('groupsFeedbackByScenario', async () => {
|
||||
const store = useAlertQualityStore()
|
||||
await store.loadDashboard()
|
||||
|
||||
expect(store.scenarioOptions).toEqual([
|
||||
'medication-false-alarm-01',
|
||||
'uti-sepsis-elderly-01',
|
||||
])
|
||||
|
||||
const med = store.byScenario.find(s => s.scenarioId === 'medication-false-alarm-01')
|
||||
expect(med.totalFeedback).toBe(2)
|
||||
expect(med.falsePositiveRate).toBe(1)
|
||||
expect(med.usefulRate).toBe(0)
|
||||
})
|
||||
|
||||
it('passesScenarioFilterToFeedbackApi', async () => {
|
||||
const store = useAlertQualityStore()
|
||||
store.setScenarioFilter('uti-sepsis-elderly-01')
|
||||
await store.loadDashboard()
|
||||
|
||||
expect(fetchQualityFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ scenarioId: 'uti-sepsis-elderly-01' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('csvIncludesScenarioColumns', async () => {
|
||||
const store = useAlertQualityStore()
|
||||
await store.loadDashboard()
|
||||
const csv = store.buildCsv()
|
||||
expect(csv.split('\n')[0]).toContain('scenarioId')
|
||||
expect(csv.split('\n')[0]).toContain('sessionId')
|
||||
expect(csv).toContain('uti-sepsis-elderly-01')
|
||||
expect(csv).toContain('session-b-alert-quality')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,25 +6,43 @@ import { useSimulationStore } from '@/stores/simulation'
|
||||
const {
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios,
|
||||
fetchSessions,
|
||||
fetchRuns,
|
||||
startRun,
|
||||
startSession,
|
||||
stopRun,
|
||||
fetchDataSummary,
|
||||
purgeData,
|
||||
loadEncounters,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchSimulationConfig: vi.fn(),
|
||||
fetchScenarios: vi.fn(),
|
||||
fetchSessions: vi.fn(),
|
||||
fetchRuns: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
startSession: vi.fn(),
|
||||
stopRun: vi.fn(),
|
||||
fetchDataSummary: vi.fn().mockResolvedValue({
|
||||
simulatedPatients: 0,
|
||||
encounters: 0,
|
||||
observations: 0,
|
||||
alerts: 0,
|
||||
activeRuns: 0,
|
||||
}),
|
||||
purgeData: vi.fn(),
|
||||
loadEncounters: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/simulation', () => ({
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios,
|
||||
fetchSessions,
|
||||
fetchRuns,
|
||||
startRun,
|
||||
startSession,
|
||||
stopRun,
|
||||
fetchDataSummary,
|
||||
purgeData,
|
||||
fetchRun: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -32,6 +50,10 @@ vi.mock('@/stores/ward', () => ({
|
||||
useWardStore: () => ({ loadEncounters }),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/alerts', () => ({
|
||||
useAlertStore: () => ({ loadGlobalAlerts: vi.fn() }),
|
||||
}))
|
||||
|
||||
function runningRun(overrides = {}) {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
|
||||
@@ -34,4 +34,13 @@ export function fetchQualityMetrics({ alertType, from, to } = {}) {
|
||||
if (to) params.set('to', to)
|
||||
const qs = params.toString()
|
||||
return api.get(`/api/v1/alerts/quality-metrics${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export function fetchQualityFeedback({ scenarioId, from, to } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (scenarioId) params.set('scenarioId', scenarioId)
|
||||
if (from) params.set('from', from)
|
||||
if (to) params.set('to', to)
|
||||
const qs = params.toString()
|
||||
return api.get(`/api/v1/alerts/quality-metrics/feedback${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { api } from './client'
|
||||
|
||||
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
|
||||
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
|
||||
export const fetchSessions = () => api.get('/api/v1/simulation/sessions')
|
||||
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 startSession = (sessionId, body = {}) =>
|
||||
api.post(`/api/v1/simulation/sessions/${sessionId}/start`, body)
|
||||
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
|
||||
export const fetchDataSummary = () => api.get('/api/v1/simulation/data/summary')
|
||||
export const purgeData = () => api.delete('/api/v1/simulation/data')
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
summary: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
simulatedPatients: 0,
|
||||
encounters: 0,
|
||||
observations: 0,
|
||||
alerts: 0,
|
||||
activeRuns: 0,
|
||||
}),
|
||||
},
|
||||
purging: { type: Boolean, default: false },
|
||||
stoppingAll: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['purge', 'stop-all'])
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const confirmText = ref('')
|
||||
|
||||
const hasActiveRuns = computed(() => (props.summary?.activeRuns ?? 0) > 0)
|
||||
const patientCount = computed(() => props.summary?.simulatedPatients ?? 0)
|
||||
const observationCount = computed(() => props.summary?.observations ?? 0)
|
||||
const alertCount = computed(() => props.summary?.alerts ?? 0)
|
||||
|
||||
const countsSentence = computed(() => {
|
||||
const patients = patientCount.value
|
||||
const observations = observationCount.value
|
||||
const alerts = alertCount.value
|
||||
return [
|
||||
`${patients} simulated patient${patients === 1 ? '' : 's'}`,
|
||||
`${observations} observation${observations === 1 ? '' : 's'}`,
|
||||
`${alerts} alert${alerts === 1 ? '' : 's'}`,
|
||||
].join(', ')
|
||||
})
|
||||
|
||||
const confirmEnabled = computed(() =>
|
||||
confirmText.value.trim().toUpperCase() === 'RESET' && !props.purging,
|
||||
)
|
||||
|
||||
watch(confirmOpen, (open) => {
|
||||
if (open) confirmText.value = ''
|
||||
})
|
||||
|
||||
function openConfirm() {
|
||||
if (hasActiveRuns.value) return
|
||||
confirmOpen.value = true
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
confirmOpen.value = false
|
||||
confirmText.value = ''
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
if (!confirmEnabled.value) return
|
||||
emit('purge')
|
||||
}
|
||||
|
||||
function onStopAll() {
|
||||
emit('stop-all')
|
||||
}
|
||||
|
||||
defineExpose({ closeConfirm })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card padding="md">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Reset ward
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<template v-if="patientCount > 0">
|
||||
{{ patientCount }} simulated patient{{ patientCount === 1 ? '' : 's' }}
|
||||
currently on the ward
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
({{ observationCount }} observations, {{ alertCount }} alerts).
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
No simulated patients on the ward.
|
||||
</template>
|
||||
</p>
|
||||
<p
|
||||
v-if="hasActiveRuns"
|
||||
class="mt-2 text-sm text-amber-800 dark:text-amber-200"
|
||||
role="status"
|
||||
>
|
||||
Reset is blocked while {{ summary.activeRuns }} run{{ summary.activeRuns === 1 ? '' : 's' }}
|
||||
{{ summary.activeRuns === 1 ? 'is' : 'are' }} still active. Stop them first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="hasActiveRuns"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="stoppingAll"
|
||||
@click="onStopAll"
|
||||
>
|
||||
{{ stoppingAll ? 'Stopping…' : 'Stop all runs' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:disabled="hasActiveRuns || purging || patientCount === 0"
|
||||
:title="hasActiveRuns
|
||||
? 'Stop all active runs before resetting the ward.'
|
||||
: patientCount === 0
|
||||
? 'Nothing to reset.'
|
||||
: undefined"
|
||||
@click="openConfirm"
|
||||
>
|
||||
Reset ward
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
:open="confirmOpen"
|
||||
title="Reset simulated ward"
|
||||
title-id="reset-ward-title"
|
||||
@close="closeConfirm"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
This will permanently delete
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ countsSentence }}</span>.
|
||||
</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Alert feedback for those patients is deleted with them. Non-simulated patients are not affected.
|
||||
</p>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type <kbd class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-700">RESET</kbd>
|
||||
to confirm
|
||||
</span>
|
||||
<input
|
||||
v-model="confirmText"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
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="RESET"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" :disabled="purging" @click="closeConfirm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:disabled="!confirmEnabled"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ purging ? 'Resetting…' : 'Reset ward' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const props = defineProps({
|
||||
session: { type: Object, required: true },
|
||||
activeRunCount: { type: Number, default: 0 },
|
||||
maxConcurrentRuns: { type: Number, default: 0 },
|
||||
starting: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['start'])
|
||||
|
||||
const scenarioCount = computed(() => props.session.scenarios?.length ?? 0)
|
||||
|
||||
const summaryLabel = computed(() => {
|
||||
const minutes = props.session.estimatedMinutes
|
||||
const patients = scenarioCount.value
|
||||
const timePart = minutes != null ? `~${minutes} min` : null
|
||||
const patientPart = `${patients} patient${patients === 1 ? '' : 's'}`
|
||||
return [timePart, patientPart].filter(Boolean).join(' · ')
|
||||
})
|
||||
|
||||
const scenarioNames = computed(() =>
|
||||
(props.session.scenarios ?? []).map(s => s.name || s.id),
|
||||
)
|
||||
|
||||
const insufficientCapacity = computed(() => {
|
||||
const max = props.maxConcurrentRuns
|
||||
if (!max) return false
|
||||
return props.activeRunCount + scenarioCount.value > max
|
||||
})
|
||||
|
||||
const startDisabled = computed(() =>
|
||||
props.starting || insufficientCapacity.value || scenarioCount.value === 0,
|
||||
)
|
||||
|
||||
const disabledReason = computed(() => {
|
||||
if (insufficientCapacity.value) {
|
||||
const need = scenarioCount.value
|
||||
const free = Math.max(0, props.maxConcurrentRuns - props.activeRunCount)
|
||||
return `Needs ${need} run slot${need === 1 ? '' : 's'} but only ${free} available (max ${props.maxConcurrentRuns}). Stop active runs or reset the ward first.`
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function onStart() {
|
||||
if (startDisabled.value) return
|
||||
emit('start', props.session.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card padding="md" class="flex h-full flex-col transition hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ session.name }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="session.goal"
|
||||
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{{ session.goal }}
|
||||
</p>
|
||||
|
||||
<p class="mt-3 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ summaryLabel }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="scenarioNames.length"
|
||||
class="mt-3 list-inside list-disc space-y-1 text-sm text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<li v-for="name in scenarioNames" :key="name">{{ name }}</li>
|
||||
</ul>
|
||||
|
||||
<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 session
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -4,13 +4,24 @@ import {
|
||||
submitAlertFeedback,
|
||||
fetchQualityMetricsSummary,
|
||||
fetchQualityMetrics,
|
||||
fetchQualityFeedback,
|
||||
} from '@/api/alertQuality'
|
||||
|
||||
function csvEscape(value) {
|
||||
if (value == null) return ''
|
||||
const s = String(value)
|
||||
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`
|
||||
return s
|
||||
}
|
||||
|
||||
export const useAlertQualityStore = defineStore('alertQuality', () => {
|
||||
const summary = ref(null)
|
||||
const snapshots = ref([])
|
||||
const feedbackRows = ref([])
|
||||
const availableScenarioIds = ref([])
|
||||
const periodDays = ref(7)
|
||||
const selectedAlertType = ref(null)
|
||||
const selectedScenarioId = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
@@ -52,6 +63,35 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
|
||||
return map
|
||||
})
|
||||
|
||||
const scenarioOptions = computed(() => availableScenarioIds.value)
|
||||
|
||||
const byScenario = computed(() => {
|
||||
const map = {}
|
||||
for (const row of feedbackRows.value) {
|
||||
const key = row.scenarioId || '(non-simulated)'
|
||||
if (!map[key]) {
|
||||
map[key] = {
|
||||
scenarioId: row.scenarioId || null,
|
||||
totalFeedback: 0,
|
||||
useful: 0,
|
||||
falsePositive: 0,
|
||||
wouldAct: 0,
|
||||
}
|
||||
}
|
||||
const bucket = map[key]
|
||||
bucket.totalFeedback += 1
|
||||
if (row.feedbackType === 'USEFUL') bucket.useful += 1
|
||||
if (row.feedbackType === 'FALSE_POSITIVE') bucket.falsePositive += 1
|
||||
if (row.feedbackType === 'WOULD_ACT') bucket.wouldAct += 1
|
||||
}
|
||||
return Object.values(map).map((b) => ({
|
||||
...b,
|
||||
usefulRate: b.totalFeedback ? b.useful / b.totalFeedback : 0,
|
||||
falsePositiveRate: b.totalFeedback ? b.falsePositive / b.totalFeedback : 0,
|
||||
wouldActRate: b.totalFeedback ? b.wouldAct / b.totalFeedback : 0,
|
||||
})).sort((a, b) => (a.scenarioId || '').localeCompare(b.scenarioId || ''))
|
||||
})
|
||||
|
||||
function periodRange() {
|
||||
const to = new Date().toISOString()
|
||||
const from = new Date(Date.now() - periodDays.value * 86_400_000).toISOString()
|
||||
@@ -63,16 +103,27 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
|
||||
error.value = null
|
||||
try {
|
||||
const { from, to } = periodRange()
|
||||
const [sum, list] = await Promise.all([
|
||||
const [sum, list, feedback] = await Promise.all([
|
||||
fetchQualityMetricsSummary(from, to),
|
||||
fetchQualityMetrics({
|
||||
alertType: selectedAlertType.value,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
fetchQualityFeedback({
|
||||
scenarioId: selectedScenarioId.value,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
])
|
||||
summary.value = sum
|
||||
snapshots.value = list.items ?? []
|
||||
feedbackRows.value = feedback.items ?? []
|
||||
if (!selectedScenarioId.value) {
|
||||
availableScenarioIds.value = [...new Set(
|
||||
feedbackRows.value.map(r => r.scenarioId).filter(Boolean),
|
||||
)].sort()
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
@@ -103,19 +154,75 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
|
||||
selectedAlertType.value = type
|
||||
}
|
||||
|
||||
function setScenarioFilter(scenarioId) {
|
||||
selectedScenarioId.value = scenarioId || null
|
||||
}
|
||||
|
||||
function buildCsv() {
|
||||
const includeScenario = feedbackRows.value.some(
|
||||
r => Object.prototype.hasOwnProperty.call(r, 'scenarioId'),
|
||||
)
|
||||
const headers = [
|
||||
'feedbackId',
|
||||
'alertId',
|
||||
'alertType',
|
||||
'feedbackType',
|
||||
'comment',
|
||||
'createdAt',
|
||||
]
|
||||
if (includeScenario) {
|
||||
headers.push('scenarioId', 'sessionId')
|
||||
}
|
||||
|
||||
const lines = [headers.join(',')]
|
||||
for (const row of feedbackRows.value) {
|
||||
const cols = [
|
||||
row.id,
|
||||
row.alertId,
|
||||
row.alertType,
|
||||
row.feedbackType,
|
||||
row.comment,
|
||||
row.createdAt,
|
||||
]
|
||||
if (includeScenario) {
|
||||
cols.push(row.scenarioId ?? '', row.sessionId ?? '')
|
||||
}
|
||||
lines.push(cols.map(csvEscape).join(','))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const csv = buildCsv()
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `alert-quality-feedback-${periodDays.value}d.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
snapshots,
|
||||
feedbackRows,
|
||||
periodDays,
|
||||
selectedAlertType,
|
||||
selectedScenarioId,
|
||||
loading,
|
||||
error,
|
||||
summaryStats,
|
||||
byAlertType,
|
||||
byScenario,
|
||||
scenarioOptions,
|
||||
loadDashboard,
|
||||
submitFeedback,
|
||||
getFeedback,
|
||||
setPeriodDays,
|
||||
setAlertTypeFilter,
|
||||
setScenarioFilter,
|
||||
buildCsv,
|
||||
exportCsv,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,11 +3,16 @@ import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
fetchSimulationConfig,
|
||||
fetchScenarios,
|
||||
fetchSessions,
|
||||
fetchRuns,
|
||||
startRun,
|
||||
startSession,
|
||||
stopRun,
|
||||
fetchDataSummary,
|
||||
purgeData,
|
||||
} from '@/api/simulation'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
|
||||
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
@@ -21,10 +26,20 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
const maxSpeed = ref(null)
|
||||
const maxConcurrentRuns = ref(0)
|
||||
const scenarios = ref([])
|
||||
const sessions = ref([])
|
||||
const runs = ref([])
|
||||
const dataSummary = ref({
|
||||
simulatedPatients: 0,
|
||||
encounters: 0,
|
||||
observations: 0,
|
||||
alerts: 0,
|
||||
activeRuns: 0,
|
||||
})
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const successMessage = ref(null)
|
||||
const starting = ref(false)
|
||||
const purging = ref(false)
|
||||
|
||||
let pollTimer = null
|
||||
let stopActiveWatch = null
|
||||
@@ -62,6 +77,16 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
function clearSuccess() {
|
||||
successMessage.value = null
|
||||
}
|
||||
|
||||
function sessionFitsCapacity(session) {
|
||||
const need = session?.scenarios?.length ?? 0
|
||||
if (!need || !maxConcurrentRuns.value) return false
|
||||
return activeRuns.value.length + need <= maxConcurrentRuns.value
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const data = await fetchSimulationConfig()
|
||||
@@ -90,6 +115,32 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
error.value = null
|
||||
try {
|
||||
const data = await fetchSessions()
|
||||
sessions.value = Array.isArray(data) ? data : []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
sessions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDataSummary() {
|
||||
try {
|
||||
const data = await fetchDataSummary()
|
||||
dataSummary.value = {
|
||||
simulatedPatients: data?.simulatedPatients ?? 0,
|
||||
encounters: data?.encounters ?? 0,
|
||||
observations: data?.observations ?? 0,
|
||||
alerts: data?.alerts ?? 0,
|
||||
activeRuns: data?.activeRuns ?? activeRuns.value.length,
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function detectNewlyRunning(previousById, nextRuns) {
|
||||
return nextRuns.some((run) => {
|
||||
if (run.status !== 'RUNNING') return false
|
||||
@@ -112,6 +163,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
|
||||
if (detectNewlyRunning(previousById, next)) {
|
||||
useWardStore().loadEncounters()
|
||||
loadDataSummary()
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
@@ -121,6 +173,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
async function start(scenarioId, speed) {
|
||||
starting.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
const scenario = scenarios.value.find(s => s.id === scenarioId)
|
||||
const placeholderId = `optimistic-${Date.now()}`
|
||||
@@ -132,6 +185,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
speed,
|
||||
patientId: null,
|
||||
encounterId: null,
|
||||
sessionId: null,
|
||||
patientDisplayName: '',
|
||||
startedAt: new Date().toISOString(),
|
||||
elapsedRealSeconds: 0,
|
||||
@@ -155,6 +209,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
if (run.status === 'RUNNING') {
|
||||
useWardStore().loadEncounters()
|
||||
}
|
||||
await loadDataSummary()
|
||||
return run
|
||||
} catch (e) {
|
||||
runs.value = runs.value.filter(r => r.runId !== placeholderId)
|
||||
@@ -165,6 +220,24 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function startSessionRun(sessionId, speed) {
|
||||
starting.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
try {
|
||||
const session = await startSession(sessionId, { speed })
|
||||
await refreshRuns()
|
||||
useWardStore().loadEncounters()
|
||||
await loadDataSummary()
|
||||
return session
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(runId) {
|
||||
error.value = null
|
||||
try {
|
||||
@@ -175,6 +248,7 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
} else {
|
||||
runs.value = [run, ...runs.value]
|
||||
}
|
||||
await loadDataSummary()
|
||||
return run
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
@@ -182,6 +256,46 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAll() {
|
||||
error.value = null
|
||||
const ids = activeRuns.value.map(r => r.runId)
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await stop(id)
|
||||
} catch {
|
||||
// continue stopping others; last error stays on store
|
||||
}
|
||||
}
|
||||
await refreshRuns()
|
||||
await loadDataSummary()
|
||||
}
|
||||
|
||||
async function purge() {
|
||||
purging.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
try {
|
||||
const result = await purgeData()
|
||||
runs.value = []
|
||||
await Promise.all([
|
||||
loadDataSummary(),
|
||||
useWardStore().loadEncounters(),
|
||||
useAlertStore().loadGlobalAlerts(),
|
||||
])
|
||||
const patients = result?.patientsDeleted ?? 0
|
||||
const alerts = result?.alertsDeleted ?? 0
|
||||
successMessage.value =
|
||||
`Ward reset — removed ${patients} simulated patient${patients === 1 ? '' : 's'}` +
|
||||
` and ${alerts} alert${alerts === 1 ? '' : 's'}.`
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
purging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncPollTimer(active) {
|
||||
if (active) {
|
||||
if (!pollTimer) {
|
||||
@@ -216,21 +330,32 @@ export const useSimulationStore = defineStore('simulation', () => {
|
||||
maxSpeed,
|
||||
maxConcurrentRuns,
|
||||
scenarios,
|
||||
sessions,
|
||||
runs,
|
||||
dataSummary,
|
||||
loading,
|
||||
error,
|
||||
successMessage,
|
||||
starting,
|
||||
purging,
|
||||
activeRuns,
|
||||
recentRuns,
|
||||
hasActiveRun,
|
||||
atConcurrencyLimit,
|
||||
scenariosByTag,
|
||||
clearError,
|
||||
clearSuccess,
|
||||
sessionFitsCapacity,
|
||||
loadConfig,
|
||||
loadScenarios,
|
||||
loadSessions,
|
||||
loadDataSummary,
|
||||
refreshRuns,
|
||||
start,
|
||||
startSessionRun,
|
||||
stop,
|
||||
stopAll,
|
||||
purge,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertQualityStore } from '@/stores/alertQuality'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
@@ -10,7 +11,20 @@ import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
|
||||
|
||||
const store = useAlertQualityStore()
|
||||
const { summaryStats, byAlertType, snapshots, loading, error, periodDays } = storeToRefs(store)
|
||||
const simulationStore = useSimulationStore()
|
||||
const {
|
||||
summaryStats,
|
||||
byAlertType,
|
||||
byScenario,
|
||||
scenarioOptions,
|
||||
snapshots,
|
||||
feedbackRows,
|
||||
loading,
|
||||
error,
|
||||
periodDays,
|
||||
selectedScenarioId,
|
||||
} = storeToRefs(store)
|
||||
const { enabled: simulationEnabled } = storeToRefs(simulationStore)
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '7 days', value: 7 },
|
||||
@@ -18,12 +32,32 @@ const periodOptions = [
|
||||
{ label: '30 days', value: 30 },
|
||||
]
|
||||
|
||||
onMounted(() => store.loadDashboard())
|
||||
const showScenarioAttribution = computed(() =>
|
||||
simulationEnabled.value
|
||||
|| feedbackRows.value.some(r => Object.prototype.hasOwnProperty.call(r, 'scenarioId')),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!simulationStore.enabled) {
|
||||
await simulationStore.loadConfig()
|
||||
}
|
||||
await store.loadDashboard()
|
||||
})
|
||||
|
||||
function changePeriod(days) {
|
||||
store.setPeriodDays(days)
|
||||
store.loadDashboard()
|
||||
}
|
||||
|
||||
function changeScenario(event) {
|
||||
const value = event.target.value || null
|
||||
store.setScenarioFilter(value)
|
||||
store.loadDashboard()
|
||||
}
|
||||
|
||||
function pct(rate) {
|
||||
return Math.round((rate ?? 0) * 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,9 +82,45 @@ function changePeriod(days) {
|
||||
<Button variant="secondary" size="sm" :disabled="loading" @click="store.loadDashboard()">
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading || feedbackRows.length === 0"
|
||||
@click="store.exportCsv()"
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showScenarioAttribution"
|
||||
class="flex flex-wrap items-end gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<label class="block min-w-[12rem] flex-1">
|
||||
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Scenario
|
||||
</span>
|
||||
<select
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-3 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-800 dark:text-gray-100"
|
||||
:value="selectedScenarioId ?? ''"
|
||||
@change="changeScenario"
|
||||
>
|
||||
<option value="">All scenarios</option>
|
||||
<option
|
||||
v-for="id in scenarioOptions"
|
||||
:key="id"
|
||||
:value="id"
|
||||
>
|
||||
{{ id }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Filter feedback by the simulation scenario that produced the alert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<!-- KPI cards -->
|
||||
@@ -152,5 +222,34 @@ function changePeriod(days) {
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- By scenario (simulation attribution) -->
|
||||
<Card v-if="showScenarioAttribution">
|
||||
<h2 class="mb-4 text-lg font-semibold dark:text-white">By Scenario</h2>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="row in byScenario"
|
||||
:key="row.scenarioId ?? 'non-simulated'"
|
||||
class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm font-medium dark:text-white">
|
||||
{{ row.scenarioId ?? 'Non-simulated' }}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ row.totalFeedback }} rating{{ row.totalFeedback === 1 ? '' : 's' }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge variant="success" size="xs">{{ pct(row.usefulRate) }}% useful</Badge>
|
||||
<Badge variant="critical" size="xs">{{ pct(row.falsePositiveRate) }}% FP</Badge>
|
||||
<Badge variant="info" size="xs">{{ pct(row.wouldActRate) }}% would-act</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!byScenario.length" class="py-4 text-sm text-gray-400">
|
||||
No feedback in this period. Rate alerts in Alert Center to populate scenario attribution.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSimulationStore } from '@/stores/simulation'
|
||||
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
|
||||
import SessionCard from '@/components/simulation/SessionCard.vue'
|
||||
import ResetWardPanel from '@/components/simulation/ResetWardPanel.vue'
|
||||
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
@@ -19,21 +21,28 @@ const SPEED_OPTIONS = [
|
||||
const simulationStore = useSimulationStore()
|
||||
const {
|
||||
scenarios,
|
||||
sessions,
|
||||
activeRuns,
|
||||
recentRuns,
|
||||
dataSummary,
|
||||
loading,
|
||||
error,
|
||||
successMessage,
|
||||
starting,
|
||||
purging,
|
||||
atConcurrencyLimit,
|
||||
maxConcurrentRuns,
|
||||
maxSpeed,
|
||||
} = storeToRefs(simulationStore)
|
||||
|
||||
const activeTab = ref('sessions')
|
||||
const selectedScenarioId = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const activeTag = ref(null)
|
||||
const speed = ref(60)
|
||||
const stoppingId = ref(null)
|
||||
const stoppingAll = ref(false)
|
||||
const resetPanelRef = ref(null)
|
||||
|
||||
const availableSpeeds = computed(() => {
|
||||
const cap = maxSpeed.value
|
||||
@@ -76,6 +85,11 @@ const filteredScenarios = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const summaryWithActive = computed(() => ({
|
||||
...dataSummary.value,
|
||||
activeRuns: activeRuns.value.length,
|
||||
}))
|
||||
|
||||
function wallClockFor(multiplier) {
|
||||
if (!selectedScenario.value?.durationMinutes) return null
|
||||
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
|
||||
@@ -100,6 +114,14 @@ async function onStart(scenarioId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onStartSession(sessionId) {
|
||||
try {
|
||||
await simulationStore.startSessionRun(sessionId, speed.value)
|
||||
} catch (e) {
|
||||
simulationStore.error = concurrencyErrorMessage(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function onStop(runId) {
|
||||
stoppingId.value = runId
|
||||
try {
|
||||
@@ -111,6 +133,24 @@ async function onStop(runId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onStopAll() {
|
||||
stoppingAll.value = true
|
||||
try {
|
||||
await simulationStore.stopAll()
|
||||
} finally {
|
||||
stoppingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onPurge() {
|
||||
try {
|
||||
await simulationStore.purge()
|
||||
resetPanelRef.value?.closeConfirm()
|
||||
} catch {
|
||||
// error already on store
|
||||
}
|
||||
}
|
||||
|
||||
function selectScenario(id) {
|
||||
selectedScenarioId.value = id
|
||||
}
|
||||
@@ -119,10 +159,16 @@ function toggleTag(tag) {
|
||||
activeTag.value = activeTag.value === tag ? null : tag
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
simulationStore.loadSessions(),
|
||||
simulationStore.loadScenarios(),
|
||||
simulationStore.refreshRuns(),
|
||||
simulationStore.loadDataSummary(),
|
||||
])
|
||||
simulationStore.startPolling()
|
||||
if (!selectedScenarioId.value && scenarios.value[0]) {
|
||||
@@ -146,7 +192,7 @@ onBeforeUnmount(() => {
|
||||
<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.
|
||||
Start a testing session or replay an individual scenario. All patients created here are simulated.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -165,6 +211,30 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="successMessage"
|
||||
role="status"
|
||||
class="flex items-start justify-between gap-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
>
|
||||
<p>{{ successMessage }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 font-medium underline"
|
||||
@click="simulationStore.clearSuccess()"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ResetWardPanel
|
||||
ref="resetPanelRef"
|
||||
:summary="summaryWithActive"
|
||||
:purging="purging"
|
||||
:stopping-all="stoppingAll"
|
||||
@purge="onPurge"
|
||||
@stop-all="onStopAll"
|
||||
/>
|
||||
|
||||
<SimulationRunPanel
|
||||
v-if="activeRuns.length || recentRuns.length"
|
||||
:active-runs="activeRuns"
|
||||
@@ -201,7 +271,7 @@ onBeforeUnmount(() => {
|
||||
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)">
|
||||
<template v-if="activeTab === 'scenarios' && wallClockFor(opt.multiplier)">
|
||||
{{ wallClockFor(opt.multiplier) }} for selected scenario
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -210,14 +280,81 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<p
|
||||
v-if="activeTab === 'scenarios' && 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>
|
||||
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Session starts use this speed (overriding each preset’s default).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="sim-catalogue-heading">
|
||||
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="border-b-2 px-4 py-2 text-sm font-medium transition"
|
||||
:class="activeTab === 'sessions'
|
||||
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
:aria-selected="activeTab === 'sessions'"
|
||||
@click="switchTab('sessions')"
|
||||
>
|
||||
Sessions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="border-b-2 px-4 py-2 text-sm font-medium transition"
|
||||
:class="activeTab === 'scenarios'
|
||||
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
|
||||
:aria-selected="activeTab === 'scenarios'"
|
||||
@click="switchTab('scenarios')"
|
||||
>
|
||||
Scenarios
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="activeTab === 'sessions'" aria-labelledby="sim-sessions-heading">
|
||||
<h2
|
||||
id="sim-sessions-heading"
|
||||
class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Testing sessions
|
||||
</h2>
|
||||
|
||||
<Skeleton v-if="loading && sessions.length === 0 && scenarios.length === 0" :rows="3" />
|
||||
|
||||
<EmptyState
|
||||
v-else-if="sessions.length === 0"
|
||||
message="No session presets found. Check that sessions.json is present beside the scenario directory."
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
|
||||
role="list"
|
||||
>
|
||||
<div
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
role="listitem"
|
||||
>
|
||||
<SessionCard
|
||||
:session="session"
|
||||
:active-run-count="activeRuns.length"
|
||||
:max-concurrent-runs="maxConcurrentRuns"
|
||||
:starting="starting"
|
||||
@start="onStartSession"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else aria-labelledby="sim-catalogue-heading">
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
|
||||
<h2
|
||||
id="sim-catalogue-heading"
|
||||
|
||||
Reference in New Issue
Block a user