feature: Doctor Feedback Mode
This commit is contained in:
@@ -1,5 +1,100 @@
|
||||
# Vue 3 + Vite
|
||||
# VigilCare Dashboard
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
Vue 3 frontend for the VigilCare Clinical API — virtual ward overview, patient clinical review, alert triage, vital sign charts, NEWS2 history, alert reasoning, and **clinician feedback** for product research.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
**Full user guide:** [docs/dashboard-guide.md](../docs/dashboard-guide.md) — technical overview for developers and facilitators.
|
||||
|
||||
**Clinician testing guide:** [docs/clinical-testing-guide.md](../docs/clinical-testing-guide.md) — for doctors and nurses evaluating alerts and submitting feedback.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# API must be running on http://localhost:5270 (see repo root README)
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173
|
||||
|
||||
Optional `.env`:
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:5270
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run dev` | Vite dev server (port 5173) |
|
||||
| `npm run build` | Production build |
|
||||
| `npm run preview` | Preview production build |
|
||||
| `npm test` | Vitest — 38 tests across 10 files |
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | View |
|
||||
|---|---|
|
||||
| `/ward` | Virtual Ward — active patients by NEWS2 risk |
|
||||
| `/patients/:encounterId` | Patient detail — vitals, alerts, charts, replay, reasoning |
|
||||
| `/alerts` | Alert Center — global alert inbox with feedback buttons |
|
||||
| `/feedback` | Feedback Summary — aggregate ratings + JSON/CSV export |
|
||||
|
||||
## Features
|
||||
|
||||
| Area | Description |
|
||||
|---|---|
|
||||
| Virtual Ward | NEWS2-sorted patient table; department filter; 10 s polling |
|
||||
| Patient Detail | Vitals, scores, alerts, orders, sepsis bundle; 5 vital charts + NEWS2 history |
|
||||
| Alert Center | Global alert inbox; acknowledge / resolve; six feedback ratings per alert |
|
||||
| Alert Reasoning | Plain-language “why it fired” + optional medication context (90 min window) |
|
||||
| Replay Controls | Local pause/resume/speed scrub through fetched data (not live simulator control) |
|
||||
| Clinician Feedback | Six ratings + optional notes on every alert; persisted in `localStorage` |
|
||||
| Feedback Summary | Aggregate stats by alert type; export JSON/CSV for study analysis |
|
||||
|
||||
Feedback is **client-side only** in this phase — ratings stay in the browser until exported. See Phase 19 plan: `docs/plans/phase-19-plan.md`.
|
||||
|
||||
## Stack
|
||||
|
||||
Vue 3 (`<script setup>`), Vue Router, Pinia, Tailwind CSS v4, Chart.js + vue-chartjs, Vitest.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # HTTP client, encounters, clinical, alerts, normalize
|
||||
├── components/
|
||||
│ ├── alerts/ # AlertCard, AlertReasoning, AlertFilters, AcknowledgeModal
|
||||
│ ├── charts/ # VitalChart, VitalTrendChart, TrendsGrid, News2History
|
||||
│ ├── feedback/ # FeedbackButtons (six ratings + notes)
|
||||
│ ├── layout/ # AppShell, AppSidebar, AppHeader, MobileNav
|
||||
│ ├── patient/ # VitalsPanel, ScoresPanel, AlertsList, OrdersPanel, SepsisBundlePanel
|
||||
│ ├── replay/ # ReplayControls
|
||||
│ ├── ui/ # Button, Badge, Card, Modal, EmptyState, Skeleton
|
||||
│ └── ward/ # WardTable, PatientRow, PatientCard
|
||||
├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, chartFormat
|
||||
├── stores/ # ward, alerts, settings, feedback (localStorage persistence)
|
||||
├── views/ # WardDashboard, PatientDetail, AlertCenter, FeedbackSummary
|
||||
└── __tests__/ # Vitest — store, composables, components, views
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
| File | Coverage |
|
||||
|---|---|
|
||||
| `useFeedbackStore.test.js` | Pinia store — CRUD, stats, grouping, export, localStorage |
|
||||
| `FeedbackButtons.test.js` | Six rating buttons, selection, notes, ARIA, keyboard |
|
||||
| `FeedbackSummary.test.js` | Aggregate stats, per-type breakdown, export buttons |
|
||||
| `AlertCard.test.js` | Severity display, acknowledge/resolve, feedback integration |
|
||||
| `AlertReasoning.test.js` | Reasoning text for sepsis, qSOFA, unknown types |
|
||||
| `useChartData.test.js` | Chart data transformation |
|
||||
| `useReplayControls.test.js` | Pause, speed, progress, jump |
|
||||
| `usePolling.test.js` | Polling interval composable |
|
||||
| `WardTable.test.js` | Ward table rendering |
|
||||
| `Badge.test.js` | Severity badge variants |
|
||||
|
||||
Implementation plans: `docs/plans/phase-17-plan.md` (ward shell), `docs/plans/phase-18-plan.md` (charts, replay, reasoning), `docs/plans/phase-19-plan.md` (clinician feedback).
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AlertCard from '@/components/alerts/AlertCard.vue'
|
||||
|
||||
const openAlert = {
|
||||
@@ -18,6 +19,10 @@ const resolvedAlert = {
|
||||
}
|
||||
|
||||
describe('AlertCard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('showsAlertTypeAndSeverity', () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: openAlert } })
|
||||
expect(wrapper.text()).toContain('Critical')
|
||||
@@ -26,12 +31,15 @@ describe('AlertCard', () => {
|
||||
|
||||
it('acknowledgeButtonEmitsEvent', async () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: openAlert } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
const ackButton = wrapper.findAll('button').find(b => b.text() === 'Acknowledge')
|
||||
await ackButton.trigger('click')
|
||||
expect(wrapper.emitted('acknowledge')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolvedAlertHidesActions', () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: resolvedAlert } })
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
const actionButtons = wrapper.findAll('button').filter(b => ['Acknowledge', 'Resolve'].includes(b.text()))
|
||||
expect(actionButtons).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('Useful')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
||||
|
||||
const sepsisAlert = {
|
||||
@@ -19,6 +20,10 @@ const qsofaAlert = {
|
||||
}
|
||||
|
||||
describe('AlertReasoning', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('showsExplanationForSepsisWarning', () => {
|
||||
const wrapper = mount(AlertReasoning, { props: { alert: sepsisAlert } })
|
||||
expect(wrapper.text()).toContain('SIRS / Sepsis Alert')
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
||||
|
||||
const defaultProps = {
|
||||
alertId: 'alert-1',
|
||||
alertType: 'SepsisWarning',
|
||||
severity: 'Critical',
|
||||
}
|
||||
|
||||
function ratingButtons(wrapper) {
|
||||
return wrapper.findAll('[role="radio"]')
|
||||
}
|
||||
|
||||
function ratingButton(wrapper, label) {
|
||||
return ratingButtons(wrapper).find(b => b.text() === label)
|
||||
}
|
||||
|
||||
describe('FeedbackButtons', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('rendersAllSixRatingButtons', () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
expect(ratingButtons(wrapper)).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('selectingRatingHighlightsButton', async () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
const usefulBtn = ratingButton(wrapper, 'Useful')
|
||||
|
||||
await usefulBtn.trigger('click')
|
||||
|
||||
expect(usefulBtn.classes()).toContain('ring-2')
|
||||
expect(usefulBtn.classes()).toContain('bg-green-100')
|
||||
})
|
||||
|
||||
it('showNotesFieldOnPlusNote', async () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
await ratingButton(wrapper, 'Useful').trigger('click')
|
||||
|
||||
const noteBtn = wrapper.findAll('button').find(b => b.text() === '+ Note')
|
||||
await noteBtn.trigger('click')
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('ariaCheckedOnSelectedRating', async () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
const usefulBtn = ratingButton(wrapper, 'Useful')
|
||||
const fpBtn = ratingButton(wrapper, 'False positive')
|
||||
|
||||
await usefulBtn.trigger('click')
|
||||
|
||||
expect(usefulBtn.attributes('aria-checked')).toBe('true')
|
||||
expect(fpBtn.attributes('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('keyboardNavigationWorks', async () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
const buttons = ratingButtons(wrapper)
|
||||
|
||||
expect(buttons.every(b => b.element.tagName === 'BUTTON')).toBe(true)
|
||||
|
||||
await ratingButton(wrapper, 'Useful').trigger('click')
|
||||
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('true')
|
||||
|
||||
await ratingButton(wrapper, 'Too early').trigger('click')
|
||||
expect(ratingButton(wrapper, 'Too early').attributes('aria-checked')).toBe('true')
|
||||
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('false')
|
||||
|
||||
const noteBtn = wrapper.findAll('button').find(b => b.text() === '+ Note')
|
||||
await noteBtn.trigger('click')
|
||||
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('Expected after metoprolol')
|
||||
await input.trigger('keydown.enter')
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import FeedbackSummary from '@/views/FeedbackSummary.vue'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
|
||||
describe('FeedbackSummary', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
store.addFeedback('a2', 'SepsisWarning', 'Critical', 'would-act')
|
||||
store.addFeedback('a3', 'WarningHeartRate', 'Warning', 'false-positive')
|
||||
})
|
||||
|
||||
it('showsAggregateStats', () => {
|
||||
const wrapper = mount(FeedbackSummary)
|
||||
|
||||
expect(wrapper.text()).toContain('Total Ratings')
|
||||
expect(wrapper.text()).toContain('Useful / Would Act')
|
||||
expect(wrapper.text()).toContain('False Positive')
|
||||
expect(wrapper.text()).toContain('67%')
|
||||
expect(wrapper.text()).toContain('33%')
|
||||
})
|
||||
|
||||
it('showsPerAlertTypeBreakdown', () => {
|
||||
const wrapper = mount(FeedbackSummary)
|
||||
|
||||
expect(wrapper.text()).toContain('SEPSIS_WARNING')
|
||||
expect(wrapper.text()).toContain('WARNING_HEART_RATE')
|
||||
expect(wrapper.text()).toContain('2 ratings')
|
||||
expect(wrapper.text()).toContain('1 ratings')
|
||||
})
|
||||
|
||||
it('exportButtonsExist', () => {
|
||||
const wrapper = mount(FeedbackSummary)
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons.some(b => b.text().includes('Export JSON'))).toBe(true)
|
||||
expect(buttons.some(b => b.text().includes('Export CSV'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
|
||||
describe('useFeedbackStore', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('addFeedback_createsEntry', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
|
||||
expect(store.entries).toHaveLength(1)
|
||||
expect(store.entries[0].alertId).toBe('a1')
|
||||
expect(store.entries[0].rating).toBe('useful')
|
||||
})
|
||||
|
||||
it('addFeedback_updatesExisting', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'false-positive')
|
||||
|
||||
expect(store.entries).toHaveLength(1)
|
||||
expect(store.entries[0].rating).toBe('false-positive')
|
||||
})
|
||||
|
||||
it('stats_computesCorrectly', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
store.addFeedback('a2', 'SepsisWarning', 'Critical', 'would-act')
|
||||
store.addFeedback('a3', 'WarningHeartRate', 'Warning', 'false-positive')
|
||||
store.addFeedback('a4', 'WarningHeartRate', 'Warning', 'too-early')
|
||||
|
||||
expect(store.stats.total).toBe(4)
|
||||
expect(store.stats.useful).toBe(2)
|
||||
expect(store.stats.falsePositive).toBe(1)
|
||||
expect(store.stats.usefulPct).toBe(50)
|
||||
expect(store.stats.fpPct).toBe(25)
|
||||
})
|
||||
|
||||
it('byAlertType_groupsCorrectly', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
store.addFeedback('a2', 'WarningHeartRate', 'Warning', 'false-positive')
|
||||
store.addFeedback('a3', 'SepsisWarning', 'Critical', 'too-early')
|
||||
|
||||
expect(Object.keys(store.byAlertType)).toHaveLength(2)
|
||||
expect(store.byAlertType.SepsisWarning).toHaveLength(2)
|
||||
expect(store.byAlertType.WarningHeartRate).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('exportAsJson_generatesValidJson', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
|
||||
let capturedBlob = null
|
||||
const click = vi.fn()
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
|
||||
URL.createObjectURL = vi.fn((blob) => {
|
||||
capturedBlob = blob
|
||||
return 'blob:url'
|
||||
})
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag) => {
|
||||
if (tag === 'a') return { href: '', download: '', click }
|
||||
return originalCreateElement(tag)
|
||||
})
|
||||
|
||||
expect(() => store.exportAsJson()).not.toThrow()
|
||||
expect(capturedBlob).toBeInstanceOf(Blob)
|
||||
expect(capturedBlob.type).toBe('application/json')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clearAll_emptiesEntries', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
store.clearAll()
|
||||
|
||||
expect(store.entries).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('persistsToLocalStorage', () => {
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
|
||||
const stored = JSON.parse(localStorage.getItem('vigilcare-feedback'))
|
||||
expect(stored).toHaveLength(1)
|
||||
expect(stored[0].rating).toBe('useful')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
defineProps({
|
||||
@@ -70,5 +71,13 @@ function formatTime(iso) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
<FeedbackButtons
|
||||
:alert-id="alert.id"
|
||||
:alert-type="alert.alertType"
|
||||
:severity="alert.severity"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
||||
|
||||
const props = defineProps({
|
||||
alert: { type: Object, required: true },
|
||||
@@ -76,6 +77,14 @@ function formatMed(med) {
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Triggered at {{ new Date(alert.triggeredAt).toLocaleString() }}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
<FeedbackButtons
|
||||
:alert-id="alert.id"
|
||||
:alert-type="alert.alertType"
|
||||
:severity="alert.severity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
|
||||
const props = defineProps({
|
||||
alertId: { type: String, required: true },
|
||||
alertType: { type: String, required: true },
|
||||
severity: { type: String, required: true },
|
||||
})
|
||||
|
||||
const feedbackStore = useFeedbackStore()
|
||||
const showNotes = ref(false)
|
||||
const notes = ref('')
|
||||
|
||||
const existing = computed(() => feedbackStore.getFeedback(props.alertId))
|
||||
const selectedRating = computed(() => existing.value?.rating ?? null)
|
||||
|
||||
const ratings = [
|
||||
{ value: 'useful', label: 'Useful', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
|
||||
{ value: 'too-early', label: 'Too early', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' },
|
||||
{ value: 'too-late', label: 'Too late', color: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300' },
|
||||
{ value: 'false-positive', label: 'False positive', color: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300' },
|
||||
{ value: 'missing-context', label: 'Missing context', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' },
|
||||
{ value: 'would-act', label: 'Would act', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
|
||||
]
|
||||
|
||||
function select(rating) {
|
||||
feedbackStore.addFeedback(props.alertId, props.alertType, props.severity, rating, notes.value)
|
||||
}
|
||||
|
||||
function submitNotes() {
|
||||
if (selectedRating.value) {
|
||||
feedbackStore.addFeedback(props.alertId, props.alertType, props.severity, selectedRating.value, notes.value)
|
||||
}
|
||||
showNotes.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Rate this alert">
|
||||
<button
|
||||
v-for="r in ratings"
|
||||
:key="r.value"
|
||||
role="radio"
|
||||
:aria-checked="selectedRating === r.value"
|
||||
class="rounded-full px-4 py-2 text-xs font-medium transition duration-150 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
:class="[
|
||||
selectedRating === r.value ? r.color : 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||
selectedRating === r.value ? 'ring-2 ring-current' : '',
|
||||
]"
|
||||
@click.stop="select(r.value)"
|
||||
>
|
||||
{{ r.label }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="selectedRating && !showNotes"
|
||||
class="rounded-full px-4 py-2 text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
@click.stop="showNotes = true"
|
||||
>
|
||||
+ Note
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Transition name="slide">
|
||||
<div v-if="showNotes" class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
v-model.trim="notes"
|
||||
type="text"
|
||||
placeholder="Optional note..."
|
||||
class="min-w-0 flex-1 rounded border border-gray-300 px-4 py-2 text-xs dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||
@keydown.enter="submitNotes"
|
||||
/>
|
||||
<button
|
||||
class="rounded bg-blue-500 px-4 py-2 text-xs text-white hover:bg-blue-600"
|
||||
@click.stop="submitNotes"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active { transition: all 0.15s ease; }
|
||||
.slide-enter-from,
|
||||
.slide-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ const route = useRoute()
|
||||
const links = [
|
||||
{ to: '/ward', label: 'Virtual Ward', icon: 'ward' },
|
||||
{ to: '/alerts', label: 'Alert Center', icon: 'alerts' },
|
||||
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback' },
|
||||
]
|
||||
|
||||
function linkClasses(path) {
|
||||
@@ -45,7 +46,7 @@ function linkClasses(path) {
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
v-else-if="link.icon === 'alerts'"
|
||||
class="h-6 w-6 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -59,6 +60,21 @@ function linkClasses(path) {
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
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="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { computed } from 'vue'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
|
||||
export function useFeedback(alertId) {
|
||||
const store = useFeedbackStore()
|
||||
|
||||
const feedback = computed(() => store.getFeedback(alertId))
|
||||
const hasRating = computed(() => !!feedback.value)
|
||||
const rating = computed(() => feedback.value?.rating ?? null)
|
||||
|
||||
function rate(alertType, severity, ratingValue, notes = '') {
|
||||
store.addFeedback(alertId, alertType, severity, ratingValue, notes)
|
||||
}
|
||||
|
||||
return { feedback, hasRating, rating, rate }
|
||||
}
|
||||
@@ -23,6 +23,12 @@ const routes = [
|
||||
component: () => import('@/views/AlertCenter.vue'),
|
||||
meta: { title: 'Alert Center', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
name: 'FeedbackSummary',
|
||||
component: () => import('@/views/FeedbackSummary.vue'),
|
||||
meta: { title: 'Feedback Summary', layout: 'default' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useFeedbackStore = defineStore('feedback', () => {
|
||||
const entries = ref(JSON.parse(localStorage.getItem('vigilcare-feedback') || '[]'))
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = entries.value.length
|
||||
if (total === 0) return { total: 0, useful: 0, falsePositive: 0, usefulPct: 0, fpPct: 0 }
|
||||
|
||||
const useful = entries.value.filter(e => e.rating === 'useful' || e.rating === 'would-act').length
|
||||
const fp = entries.value.filter(e => e.rating === 'false-positive').length
|
||||
|
||||
return {
|
||||
total,
|
||||
useful,
|
||||
falsePositive: fp,
|
||||
usefulPct: Math.round((useful / total) * 100),
|
||||
fpPct: Math.round((fp / total) * 100),
|
||||
}
|
||||
})
|
||||
|
||||
const byAlertType = computed(() => {
|
||||
const map = {}
|
||||
for (const entry of entries.value) {
|
||||
if (!map[entry.alertType]) map[entry.alertType] = []
|
||||
map[entry.alertType].push(entry)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function addFeedback(alertId, alertType, severity, rating, notes = '') {
|
||||
const existing = entries.value.findIndex(e => e.alertId === alertId)
|
||||
const entry = {
|
||||
alertId,
|
||||
alertType,
|
||||
severity,
|
||||
rating,
|
||||
notes: notes.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (existing >= 0) {
|
||||
entries.value[existing] = entry
|
||||
} else {
|
||||
entries.value.push(entry)
|
||||
}
|
||||
|
||||
persist()
|
||||
}
|
||||
|
||||
function getFeedback(alertId) {
|
||||
return entries.value.find(e => e.alertId === alertId) ?? null
|
||||
}
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem('vigilcare-feedback', JSON.stringify(entries.value))
|
||||
}
|
||||
|
||||
function exportAsJson() {
|
||||
const blob = new Blob([JSON.stringify(entries.value, null, 2)], { type: 'application/json' })
|
||||
downloadBlob(blob, 'vigilcare-feedback.json')
|
||||
}
|
||||
|
||||
function exportAsCsv() {
|
||||
const headers = ['alertId', 'alertType', 'severity', 'rating', 'notes', 'timestamp']
|
||||
const rows = entries.value.map(e => headers.map(h => `"${(e[h] ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||
const csv = [headers.join(','), ...rows].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
downloadBlob(blob, 'vigilcare-feedback.csv')
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
entries.value = []
|
||||
persist()
|
||||
}
|
||||
|
||||
return { entries, stats, byAlertType, addFeedback, getFeedback, exportAsJson, exportAsCsv, clearAll }
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
|
||||
const feedbackStore = useFeedbackStore()
|
||||
const { entries, stats, byAlertType } = storeToRefs(feedbackStore)
|
||||
</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">
|
||||
<h1 class="text-xl font-bold dark:text-white">Feedback Summary</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" @click="feedbackStore.exportAsJson()">
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" @click="feedbackStore.exportAsCsv()">
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aggregate stats -->
|
||||
<div 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">{{ stats.total }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Total Ratings</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ stats.usefulPct }}%</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Useful / Would Act</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-red-600">{{ stats.fpPct }}%</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">False Positive</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-amber-600">
|
||||
{{ stats.total - stats.useful - stats.falsePositive }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Timing Issues</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Per alert type breakdown -->
|
||||
<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="(feedbacks, 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">
|
||||
({{ feedbacks.length }} ratings)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-if="feedbacks.filter(f => f.rating === 'useful' || f.rating === 'would-act').length"
|
||||
variant="success" size="xs">
|
||||
{{ feedbacks.filter(f => f.rating === 'useful' || f.rating === 'would-act').length }} useful
|
||||
</Badge>
|
||||
<Badge v-if="feedbacks.filter(f => f.rating === 'false-positive').length"
|
||||
variant="critical" size="xs">
|
||||
{{ feedbacks.filter(f => f.rating === 'false-positive').length }} FP
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Recent feedback entries -->
|
||||
<Card>
|
||||
<h2 class="mb-4 text-lg font-semibold dark:text-white">Recent Feedback</h2>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div v-for="entry in [...entries].reverse().slice(0, 20)" :key="entry.alertId"
|
||||
class="py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="entry.rating === 'false-positive' ? 'critical' : entry.rating === 'useful' || entry.rating === 'would-act' ? 'success' : 'warning'" size="xs">
|
||||
{{ entry.rating }}
|
||||
</Badge>
|
||||
<span class="text-sm dark:text-white">{{ alertTypeLabel(entry.alertType) }}</span>
|
||||
<span class="text-xs text-gray-400">{{ new Date(entry.timestamp).toLocaleString() }}</span>
|
||||
</div>
|
||||
<p v-if="entry.notes" class="mt-2 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{{ entry.notes }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user