feature: Self-Service Clinical Testing Sessions
This commit is contained in:
@@ -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