256 lines
9.0 KiB
Vue
256 lines
9.0 KiB
Vue
<script setup>
|
|
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'
|
|
import Button from '@/components/ui/Button.vue'
|
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
|
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
|
|
|
|
const store = useAlertQualityStore()
|
|
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 },
|
|
{ label: '14 days', value: 14 },
|
|
{ label: '30 days', value: 30 },
|
|
]
|
|
|
|
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>
|
|
<div class="w-full min-w-0 space-y-8">
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 class="text-xl font-bold dark:text-white">Alert Quality Analytics</h1>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Clinician feedback and acknowledgement metrics from the last {{ periodDays }} days.
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Button
|
|
v-for="opt in periodOptions"
|
|
:key="opt.value"
|
|
size="sm"
|
|
:variant="periodDays === opt.value ? 'primary' : 'secondary'"
|
|
@click="changePeriod(opt.value)"
|
|
>
|
|
{{ opt.label }}
|
|
</Button>
|
|
<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 -->
|
|
<div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Skeleton v-for="n in 4" :key="n" class="h-24" />
|
|
</div>
|
|
<div v-else class="grid w-full min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold dark:text-white">{{ summaryStats.totalAlerts }}</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">Total Alerts</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold text-green-600">{{ summaryStats.usefulRate }}%</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">Useful Rate</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold text-red-600">{{ summaryStats.falsePositiveRate }}%</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">False Positive Rate</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold text-blue-600">{{ summaryStats.acknowledgementRate }}%</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">Acknowledgement Rate</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Secondary KPIs -->
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.totalFeedback }}</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">Feedback Submissions</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.avgAckMinutes }} min</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Time to Acknowledge</div>
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<div class="text-center">
|
|
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.avgResolveMinutes }} min</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Time to Resolve</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<AlertQualityChart
|
|
:snapshots="snapshots"
|
|
metric="usefulRate"
|
|
title="Useful Rate by Alert Type"
|
|
/>
|
|
<AlertQualityChart
|
|
:snapshots="snapshots"
|
|
metric="falsePositiveRate"
|
|
title="False Positive Rate by Alert Type"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Per alert type table -->
|
|
<Card>
|
|
<h2 class="mb-4 text-lg font-semibold dark:text-white">By Alert Type</h2>
|
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
<div
|
|
v-for="(rows, alertType) in byAlertType"
|
|
:key="alertType"
|
|
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">{{ alertTypeLabel(alertType) }}</span>
|
|
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
|
({{ rows.reduce((s, r) => s + r.totalAlerts, 0) }} alerts)
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<Badge variant="success" size="xs">
|
|
{{ Math.round(rows.at(-1)?.usefulRate * 100 ?? 0) }}% useful
|
|
</Badge>
|
|
<Badge variant="critical" size="xs">
|
|
{{ Math.round(rows.at(-1)?.falsePositiveRate * 100 ?? 0) }}% FP
|
|
</Badge>
|
|
<Badge variant="info" size="xs">
|
|
{{ Math.round(rows.at(-1)?.acknowledgementRate * 100 ?? 0) }}% ack
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<p v-if="!Object.keys(byAlertType).length" class="py-4 text-sm text-gray-400">
|
|
No aggregated metrics yet. Metrics appear after the hourly aggregation cycle runs.
|
|
</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>
|