feature: Degraded Operations Visibility

This commit is contained in:
voltsrage
2026-06-23 23:18:31 +08:00
parent 940e27c0ac
commit 4399996448
81 changed files with 3949 additions and 323 deletions
+2
View File
@@ -2,12 +2,14 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppShell from '@/components/layout/AppShell.vue'
import DegradedModeBanner from '@/components/DegradedModeBanner.vue'
const route = useRoute()
const useShell = computed(() => !route.meta.public)
</script>
<template>
<DegradedModeBanner />
<AppShell v-if="useShell">
<RouterView v-slot="{ Component }">
<KeepAlive include="WardDashboard">
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
vi.mock('@/api/encounters', () => ({
fetchDischargeSummaryStatus: vi.fn(() => Promise.resolve({
status: 'Ready',
dischargedAt: '2026-06-23T10:00:00Z',
})),
fetchDischargeSummaryContent: vi.fn(() => Promise.resolve('DISCHARGE SUMMARY\nPatient: Jane Doe')),
}))
describe('DischargeSummaryPanel', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('rendersSummaryWhenDischarged', async () => {
const wrapper = mount(DischargeSummaryPanel, {
props: {
encounterId: 'enc-1',
discharged: true,
},
})
await vi.waitFor(() => expect(wrapper.text()).toContain('DISCHARGE SUMMARY'))
expect(wrapper.text()).toContain('Download')
})
it('hidesWhenNotDischarged', () => {
const wrapper = mount(DischargeSummaryPanel, {
props: {
encounterId: 'enc-1',
discharged: false,
},
})
expect(wrapper.text()).toBe('')
})
})
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import GatewayOperations from '@/views/GatewayOperations.vue'
vi.mock('@/api/operations', () => ({
fetchGatewayFleet: vi.fn(),
fetchGatewayDetail: vi.fn(),
}))
vi.mock('@/composables/usePolling', () => ({
usePolling: (fn) => { fn(); return {} },
}))
import { fetchGatewayFleet } from '@/api/operations'
describe('GatewayOperations', () => {
beforeEach(() => {
setActivePinia(createPinia())
fetchGatewayFleet.mockResolvedValue([
{
id: 'gw-1',
gatewayCode: 'GW-ICU-3B',
department: 'ICU',
siteName: 'Demo Hospital',
status: 'DEGRADED',
reportedBufferDepth: 847,
minutesSinceHeartbeat: 12,
lastSyncAt: null,
},
])
})
it('rendersFleetTableWithDegradedBadge', async () => {
const wrapper = mount(GatewayOperations)
await flushPromises()
expect(wrapper.text()).toContain('GW-ICU-3B')
expect(wrapper.text()).toContain('DEGRADED')
expect(wrapper.text()).toContain('847')
})
})
@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ThresholdManagementView from '@/views/ThresholdManagementView.vue'
vi.mock('@/api/thresholds', () => ({
fetchThresholds: vi.fn(() => Promise.resolve([
{
id: 'thr-1',
observationCode: 'HEART_RATE',
displayName: 'Heart Rate',
unit: 'bpm',
criticalLow: 30,
warningLow: 50,
warningHigh: 100,
criticalHigh: 150,
},
])),
createThreshold: vi.fn(),
updateThreshold: vi.fn(),
deleteThreshold: vi.fn(),
}))
describe('ThresholdManagementView', () => {
it('rendersThresholdTable', async () => {
setActivePinia(createPinia())
const wrapper = mount(ThresholdManagementView)
await vi.waitFor(() => expect(wrapper.text()).toContain('HEART_RATE'))
expect(wrapper.text()).toContain('Alert Thresholds')
expect(wrapper.text()).toContain('Create Threshold')
})
})
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN_NAV_LINKS } from '@/composables/roleAccess'
describe('roleAccess', () => {
it('filtersNavLinksByRole', () => {
const nurseLinks = filterNavLinks(MAIN_NAV_LINKS, 'NURSE')
expect(nurseLinks.some((l) => l.to === '/feedback')).toBe(false)
expect(nurseLinks.some((l) => l.to === '/alerts')).toBe(true)
const physicianLinks = filterNavLinks(MAIN_NAV_LINKS, 'PHYSICIAN')
expect(physicianLinks.some((l) => l.to === '/feedback')).toBe(true)
})
it('rejectsIntegrationDashboardRole', () => {
expect(isDashboardRole('INTEGRATION')).toBe(false)
expect(isDashboardRole('NURSE')).toBe(true)
})
it('guardsRoutesByAllowedRoles', () => {
expect(roleCanAccessRoute('NURSE', { allowedRoles: ['PHYSICIAN', 'ADMIN'] })).toBe(false)
expect(roleCanAccessRoute('ADMIN', { allowedRoles: ['ADMIN'] })).toBe(true)
expect(roleCanAccessRoute('INTEGRATION', { allowedRoles: ['NURSE'] })).toBe(false)
})
it('restrictsOpsAccessToAdminByDefault', () => {
expect(canAccessOps('ADMIN')).toBe(true)
expect(canAccessOps('NURSE')).toBe(false)
expect(canAccessOps('PHYSICIAN')).toBe(false)
expect(canAccessOps('INTEGRATION')).toBe(false)
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import {
emptyThresholdForm,
thresholdFormToPayload,
validateThresholdForm,
} from '@/composables/thresholdForm'
describe('thresholdForm', () => {
it('validatesRequiredFields', () => {
const result = validateThresholdForm(emptyThresholdForm())
expect(result.valid).toBe(false)
expect(result.errors.observationCode).toBeTruthy()
})
it('validatesThresholdOrdering', () => {
const result = validateThresholdForm({
observationCode: 'HEART_RATE',
displayName: 'Heart Rate',
unit: 'bpm',
criticalLow: '60',
warningLow: '50',
warningHigh: '100',
criticalHigh: '130',
})
expect(result.valid).toBe(false)
expect(result.errors.warningLow).toBeTruthy()
})
it('buildsPayloadWithNullBounds', () => {
const payload = thresholdFormToPayload({
observationCode: 'SPO2',
displayName: 'SpO₂',
unit: '%',
criticalLow: '',
warningLow: '92',
warningHigh: '',
criticalHigh: '88',
})
expect(payload).toEqual({
observationCode: 'SPO2',
displayName: 'SpO₂',
unit: '%',
criticalLow: null,
warningLow: 92,
warningHigh: null,
criticalHigh: 88,
})
})
})
+54
View File
@@ -0,0 +1,54 @@
import { api } from './client'
function toQuery(params) {
const q = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') {
q.set(key, String(value))
}
}
const s = q.toString()
return s ? `?${s}` : ''
}
export function fetchAuditLogs({
entityType,
entityId,
userId,
action,
from,
to,
page = 1,
pageSize = 50,
} = {}) {
return api.get(`/api/v1/audit-logs${toQuery({
entityType,
entityId,
userId,
action,
from,
to,
page,
pageSize,
})}`)
}
export function fetchPhiAccessLogs({
patientId,
userId,
accessType,
from,
to,
page = 1,
pageSize = 50,
} = {}) {
return api.get(`/api/v1/phi-access-logs${toQuery({
patientId,
userId,
accessType,
from,
to,
page,
pageSize,
})}`)
}
+26
View File
@@ -52,4 +52,30 @@ export const api = {
method: 'POST',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
put: (path, body) => request(path, {
method: 'PUT',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
patch: (path, body) => request(path, {
method: 'PATCH',
body: body !== undefined ? JSON.stringify(body) : undefined,
}),
delete: async (path) => {
const res = await fetch(`${BASE_URL}${path}`, {
method: 'DELETE',
headers: authHeaders(),
})
if (res.status === 401) {
throw new Error('Session expired — please log in again.')
}
if (res.status === 204) return null
const envelope = await res.json()
if (!res.ok || !envelope.success) {
const msg = envelope.error?.message ?? `API ${res.status}: ${path}`
throw new Error(msg)
}
return envelope.data ?? null
},
}
export { BASE_URL, authHeaders }
+26 -1
View File
@@ -1,4 +1,4 @@
import { api } from './client'
import { api, BASE_URL, authHeaders } from './client'
export function fetchActiveEncounters(department, { page = 1, pageSize = 20 } = {}) {
const params = new URLSearchParams({
@@ -50,4 +50,29 @@ export function submitVitalsObservations(encounterId, observations) {
return api.post(`/api/v1/encounters/${encounterId}/observations`, {
observations,
})
}
export function fetchDischargeSummaryStatus(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/discharge-summary`)
}
export async function fetchDischargeSummaryContent(encounterId) {
const res = await fetch(
`${BASE_URL}/api/v1/encounters/${encounterId}/discharge-summary/content`,
{ headers: authHeaders() },
)
if (res.status === 404) {
const envelope = await res.json().catch(() => null)
const code = envelope?.error?.code
if (code === 'DISCHARGE_SUMMARY_PENDING') return null
throw new Error(envelope?.error?.message ?? 'Discharge summary not found.')
}
if (res.status === 401) {
throw new Error('Session expired — please log in again.')
}
if (!res.ok) {
const envelope = await res.json().catch(() => null)
throw new Error(envelope?.error?.message ?? `Failed to download discharge summary (${res.status})`)
}
return res.text()
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from './client'
export function fetchGatewayFleet(status) {
const qs = status ? `?status=${encodeURIComponent(status)}` : ''
return api.get(`/api/v1/operations/gateways${qs}`)
}
export function fetchGatewayDetail(gatewayId) {
return api.get(`/api/v1/operations/gateways/${gatewayId}`)
}
@@ -0,0 +1,26 @@
import { api } from './client'
function toQuery(params) {
const q = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined && value !== '') {
q.set(key, String(value))
}
}
const s = q.toString()
return s ? `?${s}` : ''
}
export function fetchReconciliationAlerts({
checkType,
resolved,
page = 1,
pageSize = 50,
} = {}) {
return api.get(`/api/v1/reconciliation-alerts${toQuery({
checkType,
resolved,
page,
pageSize,
})}`)
}
+17
View File
@@ -0,0 +1,17 @@
import { api } from './client'
export function fetchThresholds() {
return api.get('/api/v1/alert-thresholds')
}
export function createThreshold(payload) {
return api.post('/api/v1/alert-thresholds', payload)
}
export function updateThreshold(id, payload) {
return api.put(`/api/v1/alert-thresholds/${id}`, payload)
}
export function deleteThreshold(id) {
return api.delete(`/api/v1/alert-thresholds/${id}`)
}
+13
View File
@@ -0,0 +1,13 @@
import { api } from './client'
export function fetchUsers() {
return api.get('/api/v1/users')
}
export function createUser(payload) {
return api.post('/api/v1/users', payload)
}
export function updateUser(id, payload) {
return api.patch(`/api/v1/users/${id}`, payload)
}
@@ -0,0 +1,15 @@
<script setup>
import { useApiMode } from '@/composables/useApiMode'
const { isGatewayProxy } = useApiMode()
</script>
<template>
<div
v-if="isGatewayProxy"
role="alert"
class="border-b border-amber-300 bg-amber-50 px-4 py-2 text-center text-sm text-amber-900 dark:border-amber-700 dark:bg-amber-950/50 dark:text-amber-200"
>
Central sync paused ward operating in local mode. Alerts and documentation on this ward remain active.
</div>
</template>
@@ -0,0 +1,92 @@
<script setup>
import { reactive, computed, watch } from 'vue'
import Modal from '@/components/ui/Modal.vue'
import Button from '@/components/ui/Button.vue'
import {
emptyThresholdForm,
validateThresholdForm,
} from '@/composables/thresholdForm'
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: 'Edit Threshold' },
initialValues: { type: Object, default: () => emptyThresholdForm() },
submitting: { type: Boolean, default: false },
error: { type: String, default: '' },
readOnlyCode: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'submit'])
const values = reactive(emptyThresholdForm())
const touched = reactive(emptyThresholdForm())
const validation = computed(() => validateThresholdForm(values))
const fieldErrors = computed(() => validation.value.errors)
const fields = [
{ key: 'observationCode', label: 'Observation code', type: 'text' },
{ key: 'displayName', label: 'Display name', type: 'text' },
{ key: 'unit', label: 'Unit', type: 'text' },
{ key: 'criticalLow', label: 'Critical low', type: 'number', step: '0.001' },
{ key: 'warningLow', label: 'Warning low', type: 'number', step: '0.001' },
{ key: 'warningHigh', label: 'Warning high', type: 'number', step: '0.001' },
{ key: 'criticalHigh', label: 'Critical high', type: 'number', step: '0.001' },
]
watch(() => props.open, (isOpen) => {
if (!isOpen) return
Object.assign(values, props.initialValues)
for (const field of fields) touched[field.key] = false
}, { immediate: true })
function onBlur(key) {
touched[key] = true
}
function showError(key) {
return Boolean(touched[key] && fieldErrors.value[key])
}
function submit() {
for (const field of fields) touched[field.key] = true
if (!validation.value.valid) return
emit('submit', validation.value.payload)
}
</script>
<template>
<Modal :open="open" :title="title" @close="emit('close')">
<form class="space-y-4" @submit.prevent="submit">
<div v-for="field in fields" :key="field.key">
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ field.label }}
</label>
<input
v-model="values[field.key]"
:type="field.type"
:step="field.step"
:readonly="readOnlyCode && field.key === 'observationCode'"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="[
showError(field.key) ? 'border-red-500' : 'border-gray-300',
readOnlyCode && field.key === 'observationCode' ? 'bg-gray-100 dark:bg-gray-700' : '',
]"
@blur="onBlur(field.key)"
>
<p v-if="showError(field.key)" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors[field.key] }}
</p>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" @click="emit('close')">Cancel</Button>
<Button type="submit" :disabled="submitting">
{{ submitting ? 'Saving…' : 'Save' }}
</Button>
</div>
</form>
</Modal>
</template>
@@ -0,0 +1,148 @@
<script setup>
import { reactive, computed, watch } from 'vue'
import Modal from '@/components/ui/Modal.vue'
import Button from '@/components/ui/Button.vue'
import {
emptyUserForm,
ROLE_OPTIONS,
validateUserForm,
} from '@/composables/userForm'
const props = defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: 'Edit User' },
mode: { type: String, default: 'edit', validator: (v) => ['create', 'edit'].includes(v) },
initialValues: { type: Object, default: () => emptyUserForm() },
submitting: { type: Boolean, default: false },
error: { type: String, default: '' },
})
const emit = defineEmits(['close', 'submit'])
const values = reactive(emptyUserForm())
const touched = reactive({
username: false,
password: false,
displayName: false,
role: false,
})
const validation = computed(() => validateUserForm(values, props.mode))
const fieldErrors = computed(() => validation.value.errors)
watch(() => props.open, (isOpen) => {
if (!isOpen) return
Object.assign(values, props.initialValues)
for (const key of Object.keys(touched)) touched[key] = false
}, { immediate: true })
function onBlur(key) {
touched[key] = true
}
function showError(key) {
return Boolean(touched[key] && fieldErrors.value[key])
}
function submit() {
for (const key of Object.keys(touched)) touched[key] = true
if (!validation.value.valid) return
emit('submit', validation.value.payload)
}
</script>
<template>
<Modal :open="open" :title="title" @close="emit('close')">
<form class="space-y-4" @submit.prevent="submit">
<div v-if="mode === 'create'">
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Username
</label>
<input
v-model="values.username"
type="text"
autocomplete="off"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('username') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('username')"
>
<p v-if="showError('username')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.username }}
</p>
</div>
<div v-if="mode === 'create'">
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Password
</label>
<input
v-model="values.password"
type="password"
autocomplete="new-password"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('password') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('password')"
>
<p v-if="showError('password')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.password }}
</p>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Display name
</label>
<input
v-model="values.displayName"
type="text"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('displayName') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('displayName')"
>
<p v-if="showError('displayName')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.displayName }}
</p>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Role
</label>
<select
v-model="values.role"
class="w-full rounded border px-4 py-2 text-sm focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
:class="showError('role') ? 'border-red-500' : 'border-gray-300'"
@blur="onBlur('role')"
>
<option v-for="opt in ROLE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<p v-if="showError('role')" class="mt-1 text-xs text-red-600 dark:text-red-400">
{{ fieldErrors.role }}
</p>
</div>
<div v-if="mode === 'edit'" class="flex items-center gap-2">
<input
id="user-active"
v-model="values.isActive"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
>
<label for="user-active" class="text-sm text-gray-700 dark:text-gray-300">
Account active
</label>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" @click="emit('close')">Cancel</Button>
<Button type="submit" :disabled="submitting">
{{ submitting ? 'Saving…' : 'Save' }}
</Button>
</div>
</form>
</Modal>
</template>
@@ -2,6 +2,7 @@
import { computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import SeverityBadge from '@/components/ui/SeverityBadge.vue'
import Button from '@/components/ui/Button.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import { alertTypeLabel } from '@/api/normalize'
@@ -23,10 +24,6 @@ const actionHint = computed(() => {
return hints[props.alert.alertType] ?? null
})
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
function showActions(status) {
return status !== 'Resolved'
}
@@ -50,7 +47,7 @@ function formatTime(iso) {
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge>
<SeverityBadge :severity="alert.severity" />
<Badge variant="info" size="xs">{{ alert.status }}</Badge>
<Badge v-if="actionHint" variant="info" size="xs">{{ actionHint }}</Badge>
</div>
@@ -77,6 +74,7 @@ function formatTime(iso) {
v-if="canAcknowledge(alert.status)"
size="sm"
variant="secondary"
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
@click="emit('acknowledge')"
>
Acknowledge
@@ -85,6 +83,7 @@ function formatTime(iso) {
v-if="canResolve(alert.status)"
size="sm"
variant="primary"
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
@click="emit('resolve')"
>
Resolve
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode, accentLine } = useChartTheme()
const COMPONENT_DATASETS = [
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
@@ -33,6 +35,7 @@ const sortedHistory = computed(() =>
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.calculatedAt))
const totalLineColor = accentLine.value
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
label,
@@ -52,7 +55,7 @@ const chartData = computed(() => {
{
label: 'GCS Total',
data: sorted.map(h => h.totalScore),
borderColor: '#111827',
borderColor: totalLineColor,
backgroundColor: sorted.map(h => {
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
@@ -71,37 +74,34 @@ const chartData = computed(() => {
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
interaction: { mode: 'index', intersect: false },
scales: {
y: {
min: 3,
max: 15,
title: { display: true, text: 'GCS Score' },
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
interaction: { mode: 'index', intersect: false },
scales: {
y: {
min: 3,
max: 15,
title: { display: true, text: 'GCS Score' },
},
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'GCS Total')
if (!total) return ''
return gcsSeverityLabel(total.parsed.y)
plugins: {
legend: {
display: true,
position: 'bottom',
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'GCS Total')
if (!total) return ''
return gcsSeverityLabel(total.parsed.y)
},
},
},
},
},
}))
}).value
})
</script>
<template>
@@ -113,7 +113,11 @@ const chartOptions = shallowRef(markRaw({
<span class="text-amber-600 dark:text-amber-400">Moderate 912</span>,
<span class="text-red-600 dark:text-red-400">Severe 38</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="Glasgow Coma Scale over time chart"
>
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
Chart.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode } = useChartTheme()
const chartData = computed(() => ({
labels: props.history.map(h => formatTime(h.calculatedAt)),
@@ -25,25 +27,24 @@ const chartData = computed(() => ({
}],
}))
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
scales: {
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
},
plugins: {
legend: { display: false },
},
}))
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
scales: {
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
},
}).value
})
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">NEWS2 Score Over Time</h3>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="NEWS2 score over time line chart"
>
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Line } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode, accentLine } = useChartTheme()
const CRITERION_LABELS = [
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
@@ -37,6 +39,7 @@ const sortedHistory = computed(() =>
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.evaluatedAt))
const totalLineColor = accentLine.value
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
label,
@@ -57,7 +60,7 @@ const chartData = computed(() => {
{
label: 'Active criteria',
data: sorted.map(h => h.activeCriteria),
borderColor: '#111827',
borderColor: totalLineColor,
backgroundColor: sorted.map(h => {
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
@@ -78,51 +81,48 @@ const chartData = computed(() => {
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
interaction: { mode: 'index', intersect: false },
scales: {
count: {
type: 'linear',
position: 'left',
min: 0,
max: 3,
ticks: { stepSize: 1 },
title: { display: true, text: 'Criteria count' },
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
interaction: { mode: 'index', intersect: false },
scales: {
count: {
type: 'linear',
position: 'left',
min: 0,
max: 3,
ticks: { stepSize: 1 },
title: { display: true, text: 'Criteria count' },
},
criteria: {
type: 'linear',
position: 'right',
min: 0,
max: 1,
display: false,
},
},
criteria: {
type: 'linear',
position: 'right',
min: 0,
max: 1,
display: false,
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'Active criteria')
if (!total) return ''
const idx = total.dataIndex
const entry = sortedHistory.value[idx]
const lines = [criteriaLabel(total.parsed.y)]
if (entry?.screenAlertFired) lines.push('qSOFA screen alert fired')
return lines.join(' · ')
plugins: {
legend: {
display: true,
position: 'bottom',
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'Active criteria')
if (!total) return ''
const idx = total.dataIndex
const entry = sortedHistory.value[idx]
const lines = [criteriaLabel(total.parsed.y)]
if (entry?.screenAlertFired) lines.push('qSOFA screen alert fired')
return lines.join(' · ')
},
},
},
},
},
}))
}).value
})
</script>
<template>
@@ -134,7 +134,11 @@ const chartOptions = shallowRef(markRaw({
<span class="text-amber-600 dark:text-amber-400">1</span>,
<span class="text-red-600 dark:text-red-400">2</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="qSOFA screening criteria over time chart"
>
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -1,12 +1,14 @@
<script setup>
import { computed, shallowRef, markRaw } from 'vue'
import { computed } from 'vue'
import { Chart } from 'vue-chartjs'
import { Chart as ChartJS, registerables } from 'chart.js'
import { formatTime } from '@/composables/chartFormat'
import { useChartTheme } from '@/composables/useChartTheme'
ChartJS.register(...registerables)
const props = defineProps({ history: { type: Array, required: true } })
const { buildOptions, darkMode, accentLine } = useChartTheme()
const ORGAN_DATASETS = [
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
@@ -30,6 +32,7 @@ const sortedHistory = computed(() =>
const chartData = computed(() => {
const sorted = sortedHistory.value
const labels = sorted.map(h => formatTime(h.calculatedAt))
const totalLineColor = accentLine.value
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
type: 'line',
@@ -53,7 +56,7 @@ const chartData = computed(() => {
type: 'line',
label: 'SOFA Total',
data: sorted.map(h => h.totalScore),
borderColor: '#111827',
borderColor: totalLineColor,
backgroundColor: 'transparent',
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
@@ -67,42 +70,39 @@ const chartData = computed(() => {
}
})
const chartOptions = shallowRef(markRaw({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
interaction: { mode: 'index', intersect: false },
scales: {
x: { stacked: true },
y: {
stacked: true,
min: 0,
max: 24,
title: { display: true, text: 'SOFA Score' },
const chartOptions = computed(() => {
darkMode.value
return buildOptions({
interaction: { mode: 'index', intersect: false },
scales: {
x: { stacked: true },
y: {
stacked: true,
min: 0,
max: 24,
title: { display: true, text: 'SOFA Score' },
},
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { boxWidth: 12, font: { size: 11 } },
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'SOFA Total')
if (!total) return ''
const score = total.parsed.y
if (score >= 10) return 'Risk: High (≥10)'
if (score >= 6) return 'Risk: Moderate (69)'
return 'Risk: Low (05)'
plugins: {
legend: {
display: true,
position: 'bottom',
},
tooltip: {
callbacks: {
footer(items) {
const total = items.find(i => i.dataset.label === 'SOFA Total')
if (!total) return ''
const score = total.parsed.y
if (score >= 10) return 'Risk: High (≥10)'
if (score >= 6) return 'Risk: Moderate (69)'
return 'Risk: Low (05)'
},
},
},
},
},
}))
}).value
})
</script>
<template>
@@ -114,7 +114,11 @@ const chartOptions = shallowRef(markRaw({
<span class="text-amber-600 dark:text-amber-400">69</span>,
<span class="text-red-600 dark:text-red-400">10+</span>.
</p>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
aria-label="SOFA score over time chart with per-organ contributions"
>
<Chart type="line" :data="chartData" :options="chartOptions" />
</div>
</div>
@@ -7,6 +7,7 @@ import {
formatMedicationTooltip,
getMedicationsNearTimestamp,
} from '@/composables/chartMedications'
import { useChartTheme } from '@/composables/useChartTheme'
import { medicationMarkerPlugin } from '@/plugins/medicationMarkerPlugin'
Chart.register(...registerables, medicationMarkerPlugin)
@@ -21,6 +22,8 @@ const props = defineProps({
medications: { type: Array, default: () => [] },
})
const { buildOptions, darkMode } = useChartTheme()
const lineData = computed(() => {
const data = toValue(props.chartData)
return data?.datasets ? data : { labels: [], timestamps: [], datasets: [] }
@@ -30,43 +33,46 @@ const chartMedications = computed(() =>
filterMedicationsForWindow(props.observations, props.observationCode, props.medications),
)
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: true,
animation: {
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
},
scales: {
y: { min: props.yMin, max: props.yMax },
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
},
plugins: {
legend: { display: false },
medicationMarkers: {
medications: chartMedications.value,
timestamps: lineData.value.timestamps ?? [],
const chartOptions = computed(() => {
darkMode.value
const options = buildOptions({
scales: {
y: { min: props.yMin, max: props.yMax },
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
},
tooltip: {
callbacks: {
afterBody(items) {
if (!items.length) return []
const timestamps = lineData.value.timestamps ?? []
const idx = items[0].dataIndex
const targetMs = timestamps[idx] ? new Date(timestamps[idx]).getTime() : null
if (targetMs == null) return []
return getMedicationsNearTimestamp(chartMedications.value, targetMs)
.map(formatMedicationTooltip)
plugins: {
medicationMarkers: {
medications: chartMedications.value,
timestamps: lineData.value.timestamps ?? [],
},
tooltip: {
callbacks: {
afterBody(items) {
if (!items.length) return []
const timestamps = lineData.value.timestamps ?? []
const idx = items[0].dataIndex
const targetMs = timestamps[idx] ? new Date(timestamps[idx]).getTime() : null
if (targetMs == null) return []
return getMedicationsNearTimestamp(chartMedications.value, targetMs)
.map(formatMedicationTooltip)
},
},
},
},
},
}))
}).value
return options
})
</script>
<template>
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">{{ title }}</h3>
<div class="aspect-video w-full min-h-0 overflow-hidden">
<div
class="aspect-video w-full min-h-[220px] min-w-0 overflow-hidden sm:min-h-0"
role="img"
:aria-label="`${title} trend chart`"
>
<Line :data="lineData" :options="chartOptions" />
</div>
</div>
@@ -11,12 +11,18 @@ useCriticalAlertPolling(settingsStore.pollInterval)
</script>
<template>
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-lg focus:bg-blue-600 focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:text-white focus:outline-none focus:ring-2 focus:ring-blue-400"
>
Skip to main content
</a>
<div class="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<AppSidebar />
<div class="flex min-w-0 flex-1 flex-col">
<AppHeader />
<CriticalAlertBanner />
<main class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8">
<main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1">
<div class="mx-auto w-full max-w-7xl">
<slot />
</div>
@@ -1,15 +1,9 @@
<script setup>
import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess'
const route = useRoute()
const links = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward' },
{ to: '/departments', label: 'Departments', icon: 'departments' },
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis' },
{ to: '/alerts', label: 'Alert Center', icon: 'alerts' },
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback' },
]
const { mainNavLinks, adminNavLinks, showAdminSection } = useRoleAccess()
function linkClasses(path) {
const active = route.path.startsWith(path)
@@ -26,7 +20,7 @@ function linkClasses(path) {
</div>
<nav class="flex-1 px-4 py-4" aria-label="Main navigation">
<ul class="space-y-2">
<li v-for="link in links" :key="link.to">
<li v-for="link in mainNavLinks" :key="link.to">
<RouterLink
:to="link.to"
class="flex items-center gap-4 rounded-lg px-4 py-2 text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
@@ -47,21 +41,6 @@ function linkClasses(path) {
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
<svg
v-else-if="link.icon === 'alerts'"
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="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-if="link.icon === 'sepsis'"
class="h-6 w-6 shrink-0"
@@ -77,6 +56,21 @@ function linkClasses(path) {
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'alerts'"
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="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-if="link.icon === 'departments'"
class="h-6 w-6 shrink-0"
@@ -92,6 +86,36 @@ function linkClasses(path) {
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<svg
v-else-if="link.icon === 'reconciliation'"
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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<svg
v-else-if="link.icon === 'ops'"
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="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
<svg
v-else
class="h-6 w-6 shrink-0"
@@ -111,6 +135,74 @@ function linkClasses(path) {
</RouterLink>
</li>
</ul>
<div v-if="showAdminSection" class="mt-8">
<p class="mb-2 px-4 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Admin
</p>
<ul class="space-y-2">
<li v-for="link in adminNavLinks" :key="link.to">
<RouterLink
:to="link.to"
class="flex items-center gap-4 rounded-lg px-4 py-2 text-sm font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="linkClasses(link.to)"
>
<svg
v-if="link.icon === 'users'"
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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'audit'"
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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{{ link.label }}
</RouterLink>
</li>
</ul>
</div>
</nav>
</aside>
</template>
@@ -1,13 +1,10 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess'
const route = useRoute()
const links = [
{ to: '/ward', label: 'Ward', icon: 'ward' },
{ to: '/alerts', label: 'Alerts', icon: 'alerts' },
]
const { mobileNavLinks } = useRoleAccess()
const activePath = computed(() => route.path)
</script>
@@ -18,7 +15,7 @@ const activePath = computed(() => route.path)
aria-label="Mobile navigation"
>
<ul class="flex h-16 items-stretch">
<li v-for="link in links" :key="link.to" class="flex-1">
<li v-for="link in mobileNavLinks" :key="link.to" class="flex-1">
<RouterLink
:to="link.to"
class="flex h-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
@@ -41,6 +38,36 @@ const activePath = computed(() => route.path)
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
<svg
v-else-if="link.icon === 'sepsis'"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'reconciliation'"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<svg
v-else
class="h-6 w-6"
@@ -4,7 +4,7 @@ import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
import { usePolling } from '@/composables/usePolling'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import SeverityBadge from '@/components/ui/SeverityBadge.vue'
import Button from '@/components/ui/Button.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
@@ -32,10 +32,6 @@ const visibleAlerts = computed(() =>
alerts.value.filter(a => a.status !== 'Resolved'),
)
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
async function handleAcknowledge(note) {
if (!confirmingAlert.value) return
await alertStore.acknowledge(confirmingAlert.value.id, note)
@@ -58,50 +54,59 @@ async function resolve(alertId) {
</template>
<EmptyState v-if="!loading && visibleAlerts.length === 0" message="No open alerts" />
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700" role="list" aria-label="Active alerts">
<li
v-for="alert in visibleAlerts"
:id="`alert-row-${alert.id}`"
:key="alert.id"
class="flex cursor-pointer flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''"
@click="emit('select', alert)"
class="py-4 first:pt-0 last:pb-0"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<Badge :variant="severityVariant(alert.severity)">{{ alert.severity }}</Badge>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ alertTypeLabel(alert.alertType) }}
</span>
<button
type="button"
class="flex w-full min-h-11 cursor-pointer flex-col gap-4 rounded-lg px-2 py-2 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 sm:flex-row sm:items-start sm:justify-between"
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800/40'"
:aria-pressed="selectedId === alert.id"
:aria-label="`Select ${alertTypeLabel(alert.alertType)} alert, ${alert.severity} severity`"
@click="emit('select', alert)"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<SeverityBadge :severity="alert.severity" />
<span class="text-sm font-medium text-gray-900 dark:text-white">
{{ alertTypeLabel(alert.alertType) }}
</span>
</div>
<p v-if="alert.details" class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
{{ alert.details }}
</p>
<p
v-if="alert.acknowledgedBy"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
</p>
</div>
<p v-if="alert.details" class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
{{ alert.details }}
</p>
<p
v-if="alert.acknowledgedBy"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
size="sm"
variant="secondary"
@click.stop="confirmingAlert = alert"
>
Ack
</Button>
<Button
v-if="alert.status === 'Acknowledged'"
size="sm"
variant="primary"
@click.stop="resolve(alert.id)"
>
Resolve
</Button>
</div>
<div class="flex shrink-0 gap-2">
<Button
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
size="sm"
variant="secondary"
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
@click.stop="confirmingAlert = alert"
>
Ack
</Button>
<Button
v-if="alert.status === 'Acknowledged'"
size="sm"
variant="primary"
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
@click.stop="resolve(alert.id)"
>
Resolve
</Button>
</div>
</button>
</li>
</ul>
@@ -0,0 +1,122 @@
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import {
fetchDischargeSummaryStatus,
fetchDischargeSummaryContent,
} from '@/api/encounters'
const props = defineProps({
encounterId: { type: String, required: true },
discharged: { type: Boolean, default: false },
})
const loading = ref(true)
const error = ref('')
const status = ref(null)
const content = ref('')
let pollTimer = null
async function loadStatus() {
if (!props.discharged) {
loading.value = false
return
}
error.value = ''
try {
status.value = await fetchDischargeSummaryStatus(props.encounterId)
if (status.value?.status === 'Ready') {
content.value = await fetchDischargeSummaryContent(props.encounterId)
stopPolling()
} else if (status.value?.status === 'Pending') {
content.value = ''
startPolling()
}
} catch (err) {
error.value = err.message
stopPolling()
} finally {
loading.value = false
}
}
function startPolling() {
stopPolling()
pollTimer = setInterval(loadStatus, 5000)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function downloadSummary() {
if (!content.value) return
const blob = new Blob([content.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'discharge-summary.pdf'
link.click()
URL.revokeObjectURL(url)
}
function formatDischargedAt(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString()
}
watch(() => [props.encounterId, props.discharged], () => {
loading.value = true
loadStatus()
}, { immediate: true })
onBeforeUnmount(stopPolling)
</script>
<template>
<Card v-if="discharged">
<template #header>
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Discharge Summary
</h2>
<Button
v-if="content"
size="sm"
variant="secondary"
@click="downloadSummary"
>
Download
</Button>
</div>
</template>
<Skeleton v-if="loading" :rows="4" />
<p v-else-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div v-else-if="status?.status === 'Pending'" class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p>Discharge summary is being generated</p>
<p v-if="status.dischargedAt">
Discharged {{ formatDischargedAt(status.dischargedAt) }}
</p>
</div>
<div v-else-if="status?.status === 'Ready'" class="space-y-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
Generated document · Discharged {{ formatDischargedAt(status.dischargedAt) }}
</p>
<pre class="max-h-96 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs whitespace-pre-wrap text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100">{{ content }}</pre>
</div>
<p v-else class="text-sm text-gray-500 dark:text-gray-400">
Discharge summary is not available for this encounter.
</p>
</Card>
</template>
@@ -21,12 +21,17 @@ const speedPresets = [
<template>
<div class="flex w-full min-w-0 flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900 sm:flex-row sm:items-center">
<Button variant="ghost" size="sm" @click="isPaused ? emit('resume') : emit('pause')">
<Button
variant="ghost"
size="sm"
:aria-label="isPaused ? 'Resume replay' : 'Pause replay'"
@click="isPaused ? emit('resume') : emit('pause')"
>
<span class="sr-only">{{ isPaused ? 'Resume' : 'Pause' }}</span>
{{ isPaused ? '▶' : '⏸' }}
</Button>
<div class="min-w-0 flex-1">
<div class="min-w-0 flex-1" role="progressbar" :aria-valuenow="progress" aria-valuemin="0" aria-valuemax="100" :aria-label="`Replay progress ${progress}%`">
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-full rounded-full bg-blue-500 transition-all duration-300"
@@ -43,10 +48,13 @@ const speedPresets = [
<button
v-for="preset in speedPresets"
:key="preset.value"
class="rounded px-2 py-2 text-xs transition"
type="button"
class="min-h-11 min-w-11 rounded px-3 py-2 text-xs transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="speed === preset.value
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
:aria-pressed="speed === preset.value"
:aria-label="`Set replay speed to ${preset.label}`"
@click="emit('set-speed', preset.value)"
>
{{ preset.label }}
@@ -54,7 +62,7 @@ const speedPresets = [
</div>
<div v-if="alerts.length" class="border-t border-gray-200 pt-4 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0 dark:border-gray-700">
<Button variant="ghost" size="sm" @click="emit('jump-to-alert')">
<Button variant="ghost" size="sm" aria-label="Jump to next alert" @click="emit('jump-to-alert')">
Next Alert &rarr;
</Button>
</div>
@@ -25,9 +25,9 @@ const classes = computed(() => {
const base =
'inline-flex items-center justify-center font-medium rounded-lg transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none'
const sizes = {
sm: 'px-4 py-2 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-2 text-base',
sm: 'min-h-11 px-4 py-2 text-xs',
md: 'min-h-11 px-4 py-2 text-sm',
lg: 'min-h-12 px-6 py-3 text-base',
}
const variants = {
primary:
@@ -0,0 +1,50 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
title: { type: String, required: true },
defaultOpen: { type: Boolean, default: false },
sectionId: { type: String, default: undefined },
})
const open = ref(props.defaultOpen)
const panelId = props.sectionId ? `${props.sectionId}-panel` : undefined
const headerId = props.sectionId ? `${props.sectionId}-header` : undefined
function toggle() {
open.value = !open.value
}
</script>
<template>
<section :aria-labelledby="headerId">
<button
:id="headerId"
type="button"
class="flex min-h-11 w-full items-center justify-between gap-4 rounded-lg border border-gray-200 bg-white px-4 py-3 text-left text-sm font-semibold text-gray-900 transition hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:hover:bg-gray-800"
:aria-expanded="open"
:aria-controls="panelId"
@click="toggle"
>
<span>{{ title }}</span>
<svg
class="h-5 w-5 shrink-0 text-gray-500 transition-transform dark:text-gray-400"
:class="open ? 'rotate-180' : ''"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
v-show="open"
:id="panelId"
:aria-labelledby="headerId"
class="mt-4 space-y-4"
>
<slot />
</div>
</section>
</template>
@@ -1,9 +1,18 @@
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { useFocusTrap } from '@/composables/useFocusTrap'
const props = defineProps({
open: Boolean,
title: String,
titleId: { type: String, default: 'modal-title' },
})
defineProps({ open: Boolean, title: String })
const emit = defineEmits(['close'])
const modalRef = ref(null)
const isActive = computed(() => props.open)
useFocusTrap(modalRef, isActive)
function onKeydown(e) {
if (e.key === 'Escape') emit('close')
@@ -11,17 +20,35 @@ function onKeydown(e) {
onMounted(() => document.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
watch(toRef(props, 'open'), (open) => {
document.body.style.overflow = open ? 'hidden' : ''
}, { immediate: true })
onBeforeUnmount(() => {
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" @click="$emit('close')" />
<div ref="modalRef" role="dialog" aria-modal="true"
class="relative z-10 max-h-[calc(100svh-2rem)] w-full max-w-md overflow-y-auto rounded-lg bg-white p-8 shadow-lg dark:bg-gray-800">
<h2 v-if="title" class="mb-4 text-lg font-semibold dark:text-white">{{ title }}</h2>
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
@click="$emit('close')"
/>
<div
ref="modalRef"
role="dialog"
aria-modal="true"
:aria-labelledby="title ? titleId : undefined"
tabindex="-1"
class="relative z-10 max-h-[calc(100svh-2rem)] w-full max-w-md overflow-y-auto rounded-lg bg-white p-8 shadow-lg dark:bg-gray-800"
>
<h2 v-if="title" :id="titleId" class="mb-4 text-lg font-semibold dark:text-white">{{ title }}</h2>
<slot />
</div>
</div>
</Teleport>
</template>
</template>
@@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
const props = defineProps({
severity: { type: String, required: true },
size: { type: String, default: 'sm' },
})
const variant = computed(() => {
const normalized = props.severity?.toLowerCase()
if (normalized === 'critical') return 'critical'
if (normalized === 'warning') return 'warning'
if (normalized === 'info') return 'info'
return 'success'
})
const isCritical = computed(() => variant.value === 'critical')
const isWarning = computed(() => variant.value === 'warning')
</script>
<template>
<Badge :variant="variant" :size="size">
<span class="inline-flex items-center gap-1">
<svg
v-if="isCritical"
class="h-3.5 w-3.5 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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<svg
v-else-if="isWarning"
class="h-3.5 w-3.5 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="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{{ severity }}</span>
</span>
</Badge>
</template>
@@ -22,11 +22,29 @@ const riskVariant = computed(() => {
const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
const cardLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
)
const emit = defineEmits(['activate'])
function onKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('activate')
}
}
</script>
<template>
<article
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition duration-200 hover:border-gray-300 hover:shadow-md active:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:active:bg-gray-800"
role="button"
tabindex="0"
:aria-label="cardLabel"
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition duration-200 hover:border-gray-300 hover:shadow-md active:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:active:bg-gray-800"
@click="emit('activate')"
@keydown="onKeydown"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
@@ -11,7 +11,12 @@ import {
} from '@/composables/wardFormat'
import { formatDepartment } from '@/composables/sepsisFormat'
const props = defineProps({ patient: { type: Object, required: true } })
const props = defineProps({
patient: { type: Object, required: true },
tabindex: { type: [Number, String], default: 0 },
})
const emit = defineEmits(['activate'])
const riskVariant = computed(() => {
const score = props.patient.news2Score ?? 0
@@ -38,10 +43,27 @@ const gcsVariant = computed(() => {
const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
const rowLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
)
function onKeydown(event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
emit('activate')
}
}
</script>
<template>
<tr>
<tr
data-patient-row
:tabindex="tabindex"
:aria-label="rowLabel"
@keydown="onKeydown"
@click="emit('activate')"
>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ patientRoom(patient) }}
</td>
@@ -1,4 +1,5 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import PatientRow from './PatientRow.vue'
import PatientCard from './PatientCard.vue'
@@ -12,26 +13,50 @@ defineProps({
const emit = defineEmits(['sort'])
const router = useRouter()
const focusedRowIndex = ref(-1)
function goToPatient(encounterId) {
router.push({ name: 'PatientDetail', params: { encounterId } })
}
function onTableKeydown(event) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const rows = [...event.currentTarget.querySelectorAll('tr[data-patient-row]')]
if (!rows.length) return
let nextIndex = focusedRowIndex.value
if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = rows.length - 1
else if (event.key === 'ArrowDown') nextIndex = Math.min(rows.length - 1, nextIndex + 1)
else if (event.key === 'ArrowUp') nextIndex = Math.max(0, nextIndex - 1)
if (nextIndex < 0) nextIndex = 0
focusedRowIndex.value = nextIndex
rows[nextIndex]?.focus()
}
function onRowFocus(index) {
focusedRowIndex.value = index
}
</script>
<template>
<!-- Mobile: card list -->
<div class="space-y-4 md:hidden">
<div class="space-y-4 md:hidden" role="list" aria-label="Ward patients">
<PatientCard
v-for="patient in patients"
:key="patient.encounterId"
:patient="patient"
@click="goToPatient(patient.encounterId)"
@activate="goToPatient(patient.encounterId)"
/>
</div>
<!-- Desktop: scrollable table -->
<div class="hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Ward patients">
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
<tr>
<SortableHeader
@@ -63,10 +88,10 @@ function goToPatient(encounterId) {
align="right"
@sort="emit('sort', $event)"
/>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
SOFA
</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
GCS
</th>
<SortableHeader
@@ -77,13 +102,13 @@ function goToPatient(encounterId) {
align="right"
@sort="emit('sort', $event)"
/>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Attending
</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
LOS
</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
<th scope="col" class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Last vitals
</th>
<SortableHeader
@@ -102,13 +127,18 @@ function goToPatient(encounterId) {
/>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<tbody
class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900"
@keydown="onTableKeydown"
>
<PatientRow
v-for="patient in patients"
v-for="(patient, index) in patients"
:key="patient.encounterId"
:patient="patient"
class="cursor-pointer transition duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
@click="goToPatient(patient.encounterId)"
:tabindex="focusedRowIndex === index || (focusedRowIndex < 0 && index === 0) ? 0 : -1"
class="cursor-pointer transition duration-200 hover:bg-gray-50 focus-visible:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 dark:hover:bg-gray-800 dark:focus-visible:bg-gray-800"
@focus="onRowFocus(index)"
@activate="goToPatient(patient.encounterId)"
/>
</tbody>
</table>
@@ -0,0 +1,65 @@
export const AUDIT_ACTIONS = [
{ value: 'THRESHOLD_CREATED', label: 'Threshold created' },
{ value: 'THRESHOLD_UPDATED', label: 'Threshold updated' },
{ value: 'THRESHOLD_DELETED', label: 'Threshold deleted' },
{ value: 'ALERT_ACKNOWLEDGED', label: 'Alert acknowledged' },
{ value: 'ALERT_RESOLVED', label: 'Alert resolved' },
{ value: 'ENCOUNTER_STATUS_CHANGED', label: 'Encounter status changed' },
{ value: 'PATIENT_REGISTERED', label: 'Patient registered' },
{ value: 'PATIENT_UPDATED', label: 'Patient updated' },
{ value: 'SUPPRESSION_WINDOW_SET', label: 'Suppression window set' },
{ value: 'USER_LOGIN', label: 'User login' },
{ value: 'AUTHORIZATION_DENIED', label: 'Authorization denied' },
]
export const PHI_ACCESS_TYPES = [
{ value: 'VIEW', label: 'View' },
{ value: 'LIST', label: 'List' },
{ value: 'SEARCH', label: 'Search' },
{ value: 'CREATE', label: 'Create' },
{ value: 'UPDATE', label: 'Update' },
]
export function actionLabel(action) {
return AUDIT_ACTIONS.find((a) => a.value === action)?.label ?? action
}
export function accessTypeLabel(accessType) {
return PHI_ACCESS_TYPES.find((a) => a.value === accessType)?.label ?? accessType
}
export function formatAuditTimestamp(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function formatJsonBlock(raw) {
if (!raw) return null
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
export function defaultFromDate() {
const d = new Date()
d.setDate(d.getDate() - 30)
return d.toISOString().slice(0, 16)
}
export function defaultToDate() {
return new Date().toISOString().slice(0, 16)
}
export function toIsoOffset(localDatetime) {
if (!localDatetime) return undefined
return new Date(localDatetime).toISOString()
}
@@ -0,0 +1,41 @@
export const RECONCILIATION_SECTIONS = [
{
checkType: 'UNACKNOWLEDGED_CRITICAL_ALERT',
title: 'Unacknowledged critical alerts',
description: 'Critical alerts open longer than the configured threshold.',
},
{
checkType: 'PENDING_ORDER_NO_RESULT',
title: 'Pending orders without results',
description: 'Orders placed but not resulted within the expected window.',
},
{
checkType: 'ACTIVE_INPATIENT_NO_OBSERVATION',
title: 'Stale observations',
description: 'Active inpatients without recent vital sign recordings.',
},
]
const CHECK_TYPE_LABELS = {
UNACKNOWLEDGED_CRITICAL_ALERT: 'Unacknowledged critical alerts',
UnacknowledgedCriticalAlert: 'Unacknowledged critical alerts',
PENDING_ORDER_NO_RESULT: 'Pending orders without results',
PendingOrderNoResult: 'Pending orders without results',
ACTIVE_INPATIENT_NO_OBSERVATION: 'Stale observations',
ActiveInpatientNoObservation: 'Stale observations',
}
export function checkTypeLabel(checkType) {
return CHECK_TYPE_LABELS[checkType] ?? checkType
}
export function formatReconciliationTimestamp(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -0,0 +1,95 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
export const OPS_NAV_LINK = {
to: '/operations/gateways',
label: 'Operations',
icon: 'ops',
}
export const MAIN_NAV_LINKS = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis', roles: CLINICAL_ROLES },
{ to: '/alerts', label: 'Alert Center', icon: 'alerts', roles: CLINICAL_ROLES },
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback', roles: ['PHYSICIAN', 'ADMIN'] },
{ to: '/admin/reconciliation', label: 'Data Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
]
export function canAccessOps(role) {
if (import.meta.env.VITE_SHOW_OPS === 'true') return isDashboardRole(role)
return role === 'ADMIN'
}
export const ADMIN_NAV_LINKS = [
{ to: '/admin/thresholds', label: 'Alert Thresholds', icon: 'admin', roles: ['ADMIN'] },
{ to: '/admin/users', label: 'User Management', icon: 'users', roles: ['ADMIN'] },
{ to: '/admin/audit', label: 'Audit Logs', icon: 'audit', roles: ['ADMIN'] },
]
export const MOBILE_NAV_LINKS = [
{ to: '/ward', label: 'Ward', icon: 'ward', roles: CLINICAL_ROLES },
{ to: '/alerts', label: 'Alerts', icon: 'alerts', roles: CLINICAL_ROLES },
{ to: '/sepsis', label: 'Sepsis', icon: 'sepsis', roles: CLINICAL_ROLES },
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
]
export function isDashboardRole(role) {
return CLINICAL_ROLES.includes(role)
}
export function defaultRouteForRole(role) {
if (isDashboardRole(role)) return '/ward'
return '/login'
}
export function roleCanAccessRoute(role, meta = {}) {
if (meta.public) return true
if (!role || !isDashboardRole(role)) return false
if (meta.allowedRoles && !meta.allowedRoles.includes(role)) return false
return true
}
export function filterNavLinks(links, role) {
if (!role) return []
return links.filter((link) => link.roles.includes(role))
}
export function useRoleAccess() {
const auth = useAuthStore()
const { role } = storeToRefs(auth)
const isNurse = computed(() => role.value === 'NURSE')
const isPhysician = computed(() => role.value === 'PHYSICIAN')
const isAdmin = computed(() => role.value === 'ADMIN')
const isClinical = computed(() => isDashboardRole(role.value))
const mainNavLinks = computed(() => {
const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
return links
})
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
const mobileNavLinks = computed(() => filterNavLinks(MOBILE_NAV_LINKS, role.value))
const showAdminSection = computed(() => adminNavLinks.value.length > 0)
const nursePatientLayout = computed(() => isNurse.value)
const physicianPatientLayout = computed(() => isPhysician.value)
return {
role,
isNurse,
isPhysician,
isAdmin,
isClinical,
mainNavLinks,
adminNavLinks,
mobileNavLinks,
showAdminSection,
nursePatientLayout,
physicianPatientLayout,
}
}
@@ -0,0 +1,97 @@
export function emptyThresholdForm() {
return {
observationCode: '',
displayName: '',
unit: '',
criticalLow: '',
warningLow: '',
warningHigh: '',
criticalHigh: '',
}
}
function parseOptionalNumber(value) {
if (value === '' || value == null) return null
const numeric = Number(value)
return Number.isNaN(numeric) ? NaN : numeric
}
export function thresholdFormToPayload(values) {
return {
observationCode: values.observationCode.trim(),
displayName: values.displayName.trim(),
unit: values.unit.trim(),
criticalLow: parseOptionalNumber(values.criticalLow),
warningLow: parseOptionalNumber(values.warningLow),
warningHigh: parseOptionalNumber(values.warningHigh),
criticalHigh: parseOptionalNumber(values.criticalHigh),
}
}
export function thresholdToForm(threshold) {
return {
observationCode: threshold.observationCode ?? '',
displayName: threshold.displayName ?? '',
unit: threshold.unit ?? '',
criticalLow: threshold.criticalLow ?? '',
warningLow: threshold.warningLow ?? '',
warningHigh: threshold.warningHigh ?? '',
criticalHigh: threshold.criticalHigh ?? '',
}
}
export function validateThresholdForm(values) {
const errors = {}
if (!values.observationCode?.trim()) errors.observationCode = 'Observation code is required.'
if (!values.displayName?.trim()) errors.displayName = 'Display name is required.'
if (!values.unit?.trim()) errors.unit = 'Unit is required.'
const criticalLow = parseOptionalNumber(values.criticalLow)
const warningLow = parseOptionalNumber(values.warningLow)
const warningHigh = parseOptionalNumber(values.warningHigh)
const criticalHigh = parseOptionalNumber(values.criticalHigh)
for (const [key, value] of Object.entries({
criticalLow,
warningLow,
warningHigh,
criticalHigh,
})) {
if (Number.isNaN(value)) errors[key] = 'Must be a valid number.'
}
if (
criticalLow != null && !Number.isNaN(criticalLow)
&& warningLow != null && !Number.isNaN(warningLow)
&& criticalLow >= warningLow
) {
errors.warningLow = 'Warning low must be greater than critical low.'
}
if (
warningLow != null && !Number.isNaN(warningLow)
&& warningHigh != null && !Number.isNaN(warningHigh)
&& warningLow >= warningHigh
) {
errors.warningHigh = 'Warning high must be greater than warning low.'
}
if (
warningHigh != null && !Number.isNaN(warningHigh)
&& criticalHigh != null && !Number.isNaN(criticalHigh)
&& warningHigh >= criticalHigh
) {
errors.criticalHigh = 'Critical high must be greater than warning high.'
}
return {
valid: Object.keys(errors).length === 0,
errors,
payload: thresholdFormToPayload(values),
}
}
export function formatThresholdValue(value) {
if (value == null || value === '') return '—'
return String(value)
}
@@ -0,0 +1,9 @@
import { computed } from 'vue'
export function useApiMode() {
const baseUrl = import.meta.env.VITE_API_URL ?? ''
const isGatewayProxy = computed(() =>
baseUrl.includes('5081') || import.meta.env.VITE_GATEWAY_MODE === 'true'
)
return { isGatewayProxy, baseUrl }
}
@@ -0,0 +1,138 @@
import { computed, ref, onMounted, onUnmounted } from 'vue'
const THEMES = {
light: {
text: '#6b7280',
grid: 'rgba(0, 0, 0, 0.06)',
title: '#374151',
accentLine: '#111827',
},
dark: {
text: '#9ca3af',
grid: 'rgba(255, 255, 255, 0.08)',
title: '#d1d5db',
accentLine: '#e5e7eb',
},
}
function readDarkMode() {
return typeof document !== 'undefined'
&& document.documentElement.classList.contains('dark')
}
function prefersReducedMotion() {
return typeof window !== 'undefined'
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
function useHtmlDarkMode() {
const darkMode = ref(readDarkMode())
let observer
onMounted(() => {
darkMode.value = readDarkMode()
observer = new MutationObserver(() => {
darkMode.value = readDarkMode()
})
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => observer?.disconnect())
return darkMode
}
export function useChartTheme() {
const darkMode = useHtmlDarkMode()
const theme = computed(() => (darkMode.value ? THEMES.dark : THEMES.light))
const animation = computed(() => ({
duration: prefersReducedMotion() ? 0 : 400,
}))
const scaleDefaults = computed(() => ({
ticks: { color: theme.value.text },
grid: { color: theme.value.grid },
title: { color: theme.value.title },
}))
const legendDefaults = computed(() => ({
labels: {
color: theme.value.text,
boxWidth: 12,
font: { size: 11 },
},
}))
const accentLine = computed(() => theme.value.accentLine)
function buildOptions(overrides = {}) {
return computed(() => {
const {
tooltip: tooltipOverride,
legend: legendOverride,
...restPlugins
} = overrides.plugins ?? {}
const base = {
responsive: true,
maintainAspectRatio: true,
animation: animation.value,
scales: {
x: {
...scaleDefaults.value,
ticks: { ...scaleDefaults.value.ticks, maxRotation: 45 },
...(overrides.scales?.x ?? {}),
},
y: {
...scaleDefaults.value,
...(overrides.scales?.y ?? {}),
},
},
plugins: {
legend: {
display: false,
...legendDefaults.value,
...(legendOverride ?? {}),
},
tooltip: {
padding: 12,
boxPadding: 6,
intersect: false,
mode: 'nearest',
titleFont: { size: 14 },
bodyFont: { size: 14 },
...(tooltipOverride ?? {}),
},
...restPlugins,
},
interaction: overrides.interaction ?? {
mode: 'nearest',
intersect: false,
axis: 'x',
},
}
if (overrides.scales?.count) {
base.scales.count = {
...scaleDefaults.value,
...overrides.scales.count,
}
}
if (overrides.scales?.criteria) {
base.scales.criteria = overrides.scales.criteria
}
return base
})
}
return {
darkMode,
theme,
accentLine,
scaleDefaults,
legendDefaults,
buildOptions,
}
}
@@ -0,0 +1,67 @@
import { onBeforeUnmount, watch } from 'vue'
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
function getFocusableElements(container) {
return [...container.querySelectorAll(FOCUSABLE)]
.filter((el) => el.offsetParent !== null || el === document.activeElement)
}
export function useFocusTrap(containerRef, isActive) {
let previousFocus = null
function onKeydown(event) {
if (event.key !== 'Tab' || !containerRef.value) return
const focusable = getFocusableElements(containerRef.value)
if (!focusable.length) {
event.preventDefault()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
function activate() {
previousFocus = document.activeElement
document.addEventListener('keydown', onKeydown)
requestAnimationFrame(() => {
if (!containerRef.value) return
const focusable = getFocusableElements(containerRef.value)
;(focusable[0] ?? containerRef.value).focus()
})
}
function deactivate() {
document.removeEventListener('keydown', onKeydown)
if (previousFocus && typeof previousFocus.focus === 'function') {
previousFocus.focus()
}
previousFocus = null
}
watch(isActive, (active) => {
if (active) activate()
else deactivate()
}, { immediate: true })
onBeforeUnmount(deactivate)
return { deactivate }
}
@@ -0,0 +1,70 @@
export const ROLE_OPTIONS = [
{ value: 'NURSE', label: 'Nurse' },
{ value: 'PHYSICIAN', label: 'Physician' },
{ value: 'ADMIN', label: 'Admin' },
{ value: 'INTEGRATION', label: 'Integration' },
]
export function roleLabel(role) {
return ROLE_OPTIONS.find((r) => r.value === role)?.label ?? role
}
export function emptyUserForm() {
return {
username: '',
password: '',
displayName: '',
role: 'NURSE',
isActive: true,
}
}
export function userToForm(user = {}) {
return {
username: user.username ?? '',
password: '',
displayName: user.displayName ?? '',
role: user.role ?? 'NURSE',
isActive: user.isActive ?? true,
}
}
export function validateUserForm(values, mode) {
const errors = {}
if (mode === 'create') {
if (!values.username?.trim()) errors.username = 'Username is required'
if (!values.password) errors.password = 'Password is required'
else if (values.password.length < 8) errors.password = 'Password must be at least 8 characters'
}
if (!values.displayName?.trim()) errors.displayName = 'Display name is required'
if (!values.role) errors.role = 'Role is required'
const valid = Object.keys(errors).length === 0
const payload = mode === 'create'
? {
username: values.username.trim(),
password: values.password,
displayName: values.displayName.trim(),
role: values.role,
}
: {
displayName: values.displayName.trim(),
role: values.role,
isActive: values.isActive,
}
return { valid, errors, payload }
}
export function formatLastLogin(iso) {
if (!iso) return 'Never'
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
+47 -8
View File
@@ -1,5 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
const routes = [
{
@@ -16,37 +19,67 @@ const routes = [
path: '/ward',
name: 'WardDashboard',
component: () => import('@/views/WardDashboard.vue'),
meta: { title: 'Virtual Ward', layout: 'default' },
meta: { title: 'Virtual Ward', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/departments',
name: 'DepartmentOverview',
component: () => import('@/views/DepartmentOverviewView.vue'),
meta: { title: 'Department Overview', layout: 'default' },
meta: { title: 'Department Overview', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/patients/:encounterId',
name: 'PatientDetail',
component: () => import('@/views/PatientDetail.vue'),
meta: { title: 'Patient Detail', layout: 'default' },
meta: { title: 'Patient Detail', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/alerts',
name: 'AlertCenter',
component: () => import('@/views/AlertCenter.vue'),
meta: { title: 'Alert Center', layout: 'default' },
meta: { title: 'Alert Center', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/sepsis',
name: 'SepsisBoard',
component: () => import('@/views/SepsisBoardView.vue'),
meta: { title: 'Sepsis Bundle Board', layout: 'default' },
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/feedback',
name: 'FeedbackSummary',
component: () => import('@/views/FeedbackSummary.vue'),
meta: { title: 'Feedback Summary', layout: 'default' },
meta: { title: 'Feedback Summary', layout: 'default', allowedRoles: ['PHYSICIAN', 'ADMIN'] },
},
{
path: '/admin/thresholds',
name: 'ThresholdManagement',
component: () => import('@/views/ThresholdManagementView.vue'),
meta: { title: 'Alert Thresholds', layout: 'default', allowedRoles: ['ADMIN'] },
},
{
path: '/admin/users',
name: 'UserManagement',
component: () => import('@/views/UserManagementView.vue'),
meta: { title: 'User Management', layout: 'default', allowedRoles: ['ADMIN'] },
},
{
path: '/admin/audit',
name: 'AuditLog',
component: () => import('@/views/AuditLogView.vue'),
meta: { title: 'Audit Logs', layout: 'default', allowedRoles: ['ADMIN'] },
},
{
path: '/admin/reconciliation',
name: 'Reconciliation',
component: () => import('@/views/ReconciliationView.vue'),
meta: { title: 'Data Quality', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/operations/gateways',
name: 'GatewayOperations',
component: () => import('@/views/GatewayOperations.vue'),
meta: { title: 'Gateway Operations', layout: 'default', opsRoute: true },
},
]
@@ -62,8 +95,14 @@ router.beforeEach((to) => {
if (!to.meta.public && !auth.isAuthenticated) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (to.path === '/login' && auth.isAuthenticated) {
return { path: '/ward' }
if (to.path === '/login' && auth.isAuthenticated && isDashboardRole(auth.role)) {
return { path: defaultRouteForRole(auth.role) }
}
if (!to.meta.public && auth.isAuthenticated && to.meta.opsRoute && !canAccessOps(auth.role)) {
return { path: defaultRouteForRole(auth.role) }
}
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
return { path: defaultRouteForRole(auth.role) }
}
})
+3
View File
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore('auth', {
getters: {
isAuthenticated: (state) => !!state.token,
role: (state) => state.user?.role ?? null,
isAdmin: (state) => state.user?.role === 'ADMIN',
isNurse: (state) => state.user?.role === 'NURSE',
isPhysician: (state) => state.user?.role === 'PHYSICIAN',
displayName: (state) =>
state.user?.displayName ?? state.user?.username ?? 'Unknown user',
userId: (state) => state.user?.userId ?? null,
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchGatewayFleet, fetchGatewayDetail } from '@/api/operations'
export const useOperationsStore = defineStore('operations', () => {
const fleet = ref([])
const selectedGateway = ref(null)
const loading = ref(false)
const error = ref(null)
const statusFilter = ref(null)
async function fetchFleet() {
loading.value = true
error.value = null
try {
fleet.value = await fetchGatewayFleet(statusFilter.value)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
async function fetchGatewayDetail(id) {
loading.value = true
error.value = null
try {
selectedGateway.value = await fetchGatewayDetail(id)
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function setStatusFilter(status) {
statusFilter.value = status
fetchFleet()
}
return { fleet, selectedGateway, loading, error, statusFilter, fetchFleet, fetchGatewayDetail, setStatusFilter }
})
@@ -0,0 +1,321 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as auditApi from '@/api/audit'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
AUDIT_ACTIONS,
PHI_ACCESS_TYPES,
actionLabel,
accessTypeLabel,
formatAuditTimestamp,
formatJsonBlock,
defaultFromDate,
defaultToDate,
toIsoOffset,
} from '@/composables/auditFormat'
const activeTab = ref('audit')
const loading = ref(true)
const error = ref('')
const page = ref(1)
const pageSize = 50
const totalPages = ref(1)
const totalCount = ref(0)
const items = ref([])
const expandedId = ref(null)
const filters = reactive({
from: defaultFromDate(),
to: defaultToDate(),
entityType: '',
action: '',
accessType: '',
patientId: '',
userId: '',
})
async function load() {
loading.value = true
error.value = ''
try {
const common = {
from: toIsoOffset(filters.from),
to: toIsoOffset(filters.to),
page: page.value,
pageSize,
}
const result = activeTab.value === 'audit'
? await auditApi.fetchAuditLogs({
...common,
entityType: filters.entityType || undefined,
action: filters.action || undefined,
userId: filters.userId || undefined,
})
: await auditApi.fetchPhiAccessLogs({
...common,
accessType: filters.accessType || undefined,
patientId: filters.patientId || undefined,
userId: filters.userId || undefined,
})
items.value = result.items ?? []
totalPages.value = result.totalPages ?? 1
totalCount.value = result.totalCount ?? 0
} catch (err) {
error.value = err.message
items.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
expandedId.value = null
load()
}
function switchTab(tab) {
activeTab.value = tab
page.value = 1
expandedId.value = null
load()
}
function toggleExpand(id) {
expandedId.value = expandedId.value === id ? null : id
}
function prevPage() {
if (page.value > 1) {
page.value -= 1
load()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value += 1
load()
}
}
onMounted(load)
</script>
<template>
<div>
<div class="mb-4">
<h1 class="text-xl font-bold dark:text-white">Audit Logs</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Browse clinical audit events and PHI access history for compliance review.
</p>
</div>
<div class="mb-4 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 === 'audit'
? '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'"
@click="switchTab('audit')"
>
Clinical audit
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'phi'
? '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'"
@click="switchTab('phi')"
>
PHI access
</button>
</div>
<form
class="mb-4 grid gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700 sm:grid-cols-2 lg:grid-cols-4"
@submit.prevent="applyFilters"
>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
From
</label>
<input
v-model="filters.from"
type="datetime-local"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
To
</label>
<input
v-model="filters.to"
type="datetime-local"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<template v-if="activeTab === 'audit'">
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Entity type
</label>
<input
v-model="filters.entityType"
type="text"
placeholder="e.g. Patient"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Action
</label>
<select
v-model="filters.action"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All actions</option>
<option v-for="opt in AUDIT_ACTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</template>
<template v-else>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Patient ID
</label>
<input
v-model="filters.patientId"
type="text"
placeholder="UUID"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Access type
</label>
<select
v-model="filters.accessType"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All types</option>
<option v-for="opt in PHI_ACCESS_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</template>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
User ID
</label>
<input
v-model="filters.userId"
type="text"
placeholder="UUID"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div class="flex items-end sm:col-span-2 lg:col-span-1">
<Button type="submit" size="sm">Apply filters</Button>
</div>
</form>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="8" />
<EmptyState v-else-if="items.length === 0" message="No log entries match the current filters" />
<template v-else>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Time</th>
<th v-if="activeTab === 'audit'" class="px-4 py-3">Action</th>
<th v-else class="px-4 py-3">Access type</th>
<th class="px-4 py-3">User</th>
<th v-if="activeTab === 'audit'" class="px-4 py-3">Entity</th>
<th v-else class="px-4 py-3">Patient</th>
<th v-if="activeTab === 'phi'" class="px-4 py-3">Resource</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template v-for="entry in items" :key="entry.id">
<tr>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatAuditTimestamp(activeTab === 'audit' ? entry.createdAt : entry.accessedAt) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ activeTab === 'audit' ? actionLabel(entry.action) : accessTypeLabel(entry.accessType) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ entry.userDisplayName ?? '—' }}
</td>
<td v-if="activeTab === 'audit'" class="px-4 py-3 text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ entry.entityType }}</span>
<span class="block font-mono text-xs text-gray-500 dark:text-gray-400">{{ entry.entityId }}</span>
</td>
<td v-else class="px-4 py-3 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ entry.patientId ?? '—' }}
</td>
<td v-if="activeTab === 'phi'" class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ entry.resourcePath }}
</td>
<td class="px-4 py-3 text-right">
<Button
v-if="activeTab === 'audit' && (entry.previousValueJson || entry.newValueJson)"
size="sm"
variant="secondary"
@click="toggleExpand(entry.id)"
>
{{ expandedId === entry.id ? 'Hide' : 'Details' }}
</Button>
</td>
</tr>
<tr v-if="activeTab === 'audit' && expandedId === entry.id">
<td colspan="6" class="bg-gray-50 px-4 py-4 dark:bg-gray-800/50">
<div class="grid gap-4 lg:grid-cols-2">
<div v-if="entry.previousValueJson">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Previous</p>
<pre class="overflow-x-auto rounded border border-gray-200 bg-white p-4 text-xs dark:border-gray-700 dark:bg-gray-900">{{ formatJsonBlock(entry.previousValueJson) }}</pre>
</div>
<div v-if="entry.newValueJson">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">New</p>
<pre class="overflow-x-auto rounded border border-gray-200 bg-white p-4 text-xs dark:border-gray-700 dark:bg-gray-900">{{ formatJsonBlock(entry.newValueJson) }}</pre>
</div>
</div>
<p v-if="entry.reason" class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Reason: {{ entry.reason }}
</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{{ totalCount }} entries page {{ page }} of {{ totalPages }}</span>
<div class="flex gap-2">
<Button size="sm" variant="secondary" :disabled="page <= 1" @click="prevPage">Previous</Button>
<Button size="sm" variant="secondary" :disabled="page >= totalPages" @click="nextPage">Next</Button>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { computed, ref } from 'vue'
import { useOperationsStore } from '@/stores/operationsStore'
import { usePolling } from '@/composables/usePolling'
const store = useOperationsStore()
const drawerOpen = ref(false)
const tabs = [
{ label: 'All', value: null },
{ label: 'Degraded', value: 'DEGRADED' },
{ label: 'Offline', value: 'OFFLINE' },
]
usePolling(() => store.fetchFleet(), 15_000)
function statusBadgeClass(status) {
if (status === 'ONLINE') return 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
if (status === 'DEGRADED') return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}
function formatMinutes(m) {
if (m == null) return '—'
return `${Math.round(m)} min ago`
}
async function openDetail(gateway) {
await store.fetchGatewayDetail(gateway.id)
drawerOpen.value = true
}
</script>
<template>
<div class="space-y-8">
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Gateway Fleet</h1>
<div class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.label"
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium transition duration-150"
:class="store.statusFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'"
@click="store.setStatusFilter(tab.value)"
>
{{ tab.label }}
</button>
</div>
</header>
<p v-if="store.error" class="text-sm text-red-600">{{ store.error }}</p>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-2 text-left text-sm font-medium">Site</th>
<th class="px-4 py-2 text-left text-sm font-medium">Gateway</th>
<th class="px-4 py-2 text-left text-sm font-medium">Department</th>
<th class="px-4 py-2 text-left text-sm font-medium">Status</th>
<th class="px-4 py-2 text-left text-sm font-medium">Buffer</th>
<th class="px-4 py-2 text-left text-sm font-medium">Last Heartbeat</th>
<th class="px-4 py-2 text-left text-sm font-medium">Last Sync</th>
<th class="px-4 py-2 text-left text-sm font-medium">Minutes Offline</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<tr
v-for="gw in store.fleet"
:key="gw.id"
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900/50"
@click="openDetail(gw)"
>
<td class="px-4 py-2 text-sm">{{ gw.siteName }}</td>
<td class="px-4 py-2 text-sm font-medium">{{ gw.gatewayCode }}</td>
<td class="px-4 py-2 text-sm">{{ gw.department }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-1 text-xs font-medium" :class="statusBadgeClass(gw.status)">
{{ gw.status }}
</span>
</td>
<td class="px-4 py-2 text-sm">{{ gw.reportedBufferDepth }}</td>
<td class="px-4 py-2 text-sm">{{ formatMinutes(gw.minutesSinceHeartbeat) }}</td>
<td class="px-4 py-2 text-sm">{{ gw.lastSyncAt ? new Date(gw.lastSyncAt).toLocaleString() : '—' }}</td>
<td class="px-4 py-2 text-sm">{{ gw.minutesSinceHeartbeat != null ? Math.round(gw.minutesSinceHeartbeat) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
<aside
v-if="drawerOpen && store.selectedGateway"
class="fixed inset-y-0 right-0 z-50 w-full max-w-md border-l border-gray-200 bg-white p-4 shadow-lg dark:border-gray-800 dark:bg-gray-900"
>
<button type="button" class="mb-4 text-sm text-blue-600" @click="drawerOpen = false">Close</button>
<h2 class="mb-4 text-lg font-semibold">{{ store.selectedGateway.gateway.gatewayCode }}</h2>
<h3 class="mb-2 text-sm font-medium text-gray-500">Recent sync batches</h3>
<ul class="space-y-2">
<li
v-for="batch in store.selectedGateway.recentBatches"
:key="batch.batchId"
class="rounded-lg border border-gray-200 p-4 text-sm dark:border-gray-800"
>
<span class="font-medium">{{ batch.status }}</span>
{{ new Date(batch.submittedAt).toLocaleString() }}
<span v-if="batch.conflictCount"> ({{ batch.conflictCount }} conflicts)</span>
</li>
</ul>
</aside>
</div>
</template>
+7 -1
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { defaultRouteForRole, isDashboardRole } from '@/composables/roleAccess'
import Button from '@/components/ui/Button.vue'
const router = useRouter()
@@ -18,7 +19,12 @@ async function submit() {
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/ward'
if (!isDashboardRole(auth.role)) {
auth.logout()
error.value = 'This account is for API integration only. Sign in with a clinical user.'
return
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : defaultRouteForRole(auth.role)
await router.push(redirect)
} catch (e) {
error.value = e.message ?? 'Login failed'
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePolling } from '@/composables/usePolling'
import { useReplayControls } from '@/composables/useReplayControls'
import { useRoleAccess } from '@/composables/roleAccess'
import { useAlertStore } from '@/stores/alerts'
import { useScoringStore } from '@/stores/scoring'
import * as encountersApi from '@/api/encounters'
@@ -21,11 +22,14 @@ import GcsHistory from '@/components/charts/GcsHistory.vue'
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
import SofaHistory from '@/components/charts/SofaHistory.vue'
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
import ReplayControls from '@/components/replay/ReplayControls.vue'
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
import CollapsibleSection from '@/components/ui/CollapsibleSection.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
const route = useRoute()
const { nursePatientLayout, physicianPatientLayout } = useRoleAccess()
const alertStore = useAlertStore()
const scoringStore = useScoringStore()
const { alerts } = storeToRefs(alertStore)
@@ -66,6 +70,8 @@ const openAlerts = computed(() =>
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
)
const isDischarged = computed(() => encounter.value?.status === 'Discharged')
const replayObservations = computed(() =>
observations.value.filter(o => isAtOrBefore(o.recordedAt)),
)
@@ -198,26 +204,43 @@ onBeforeUnmount(() => {
<template>
<Skeleton v-if="loading && !encounter" :rows="6" />
<div v-else-if="encounter" class="w-full min-w-0 space-y-8">
<div v-else-if="encounter" class="w-full min-w-0 space-y-6 lg:space-y-8">
<div class="flex flex-wrap items-center gap-4">
<RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<RouterLink
to="/ward"
class="inline-flex min-h-11 items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Ward
</RouterLink>
</div>
<PatientBanner :encounter="encounter" />
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-4">
<DischargeSummaryPanel
:encounter-id="route.params.encounterId"
:discharged="isDischarged"
/>
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-3">
<div
class="order-1 space-y-4"
:class="{
'lg:order-3': nursePatientLayout,
'lg:order-1': physicianPatientLayout || (!nursePatientLayout && !physicianPatientLayout),
}"
>
<ScoresPanel />
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
</div>
<VitalsPanel
class="order-2 min-w-0 lg:order-2"
:encounter-id="route.params.encounterId"
:observations="replayObservations"
@recorded="loadAll"
/>
<AlertsList
class="order-3 min-w-0 lg:order-3"
:class="{ 'lg:order-1': nursePatientLayout }"
:encounter-id="route.params.encounterId"
:selected-id="selectedAlert?.id"
@select="onSelectAlert"
@@ -230,12 +253,14 @@ onBeforeUnmount(() => {
:medications="medications"
/>
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
<div class="space-y-4">
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-2">
<div class="order-2 space-y-4 lg:order-1">
<SofaScorePanel :encounter-id="route.params.encounterId" />
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
</div>
<OrdersPanel :orders="orders" />
<div class="order-1 lg:order-2">
<OrdersPanel :orders="orders" />
</div>
</div>
<SepsisBundlePanel
@@ -244,9 +269,19 @@ onBeforeUnmount(() => {
:sofa="sofa"
/>
<EncounterTimeline :events="replayTimelineEvents" />
<div class="hidden lg:block">
<EncounterTimeline :events="replayTimelineEvents" />
</div>
<CollapsibleSection
class="lg:hidden"
section-id="encounter-timeline"
title="Encounter timeline"
:default-open="false"
>
<EncounterTimeline :events="replayTimelineEvents" />
</CollapsibleSection>
<div id="clinical-review" class="w-full min-w-0 space-y-8">
<div id="clinical-review" class="hidden w-full min-w-0 space-y-6 lg:block lg:space-y-8">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
@@ -262,5 +297,37 @@ onBeforeUnmount(() => {
@jump-to-alert="jumpToNextAlert"
/>
</div>
<CollapsibleSection
class="lg:hidden"
section-id="clinical-review"
title="Vital trends & clinical review"
:default-open="false"
>
<div class="space-y-6">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
</div>
</CollapsibleSection>
<CollapsibleSection
class="lg:hidden"
section-id="replay-controls"
title="Scenario replay"
:default-open="false"
>
<ReplayControls
:alerts="openAlerts"
:is-paused="isPaused"
:progress="progress"
:formatted-time="formattedTime"
:speed="speed"
@pause="pause()"
@resume="resume()"
@set-speed="setSpeed"
@jump-to-alert="jumpToNextAlert"
/>
</CollapsibleSection>
</div>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import * as reconciliationApi from '@/api/reconciliation'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
RECONCILIATION_SECTIONS,
formatReconciliationTimestamp,
} from '@/composables/reconciliationFormat'
const includeResolved = ref(false)
const loading = ref(true)
const error = ref('')
const sections = ref([])
async function loadSection(section) {
const result = await reconciliationApi.fetchReconciliationAlerts({
checkType: section.checkType,
resolved: includeResolved.value ? undefined : false,
pageSize: 100,
})
return {
...section,
items: result.items ?? [],
totalCount: result.totalCount ?? 0,
}
}
async function load() {
loading.value = true
error.value = ''
try {
sections.value = await Promise.all(RECONCILIATION_SECTIONS.map(loadSection))
} catch (err) {
error.value = err.message
sections.value = []
} finally {
loading.value = false
}
}
function onToggleResolved() {
load()
}
onMounted(load)
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">Data Quality</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Reconciliation findings from periodic workflow checks across the ward.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="includeResolved"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
@change="onToggleResolved"
>
Include resolved
</label>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="10" />
<div v-else class="space-y-8">
<section
v-for="section in sections"
:key="section.checkType"
class="rounded-lg border border-gray-200 dark:border-gray-700"
>
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="font-semibold text-gray-900 dark:text-white">{{ section.title }}</h2>
<Badge :variant="section.totalCount > 0 ? 'warning' : 'success'">
{{ section.totalCount }} finding{{ section.totalCount === 1 ? '' : 's' }}
</Badge>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ section.description }}</p>
</div>
<EmptyState
v-if="section.items.length === 0"
message="No findings in this category"
/>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Detected</th>
<th class="px-4 py-3">Details</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Patient</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="item in section.items" :key="item.id">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatReconciliationTimestamp(item.createdAt) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ item.details }}</td>
<td class="px-4 py-3">
<Badge :variant="item.resolvedAt ? 'success' : 'warning'">
{{ item.resolvedAt ? 'Resolved' : 'Open' }}
</Badge>
</td>
<td class="px-4 py-3 text-right">
<RouterLink
v-if="item.encounterId"
:to="`/patients/${item.encounterId}`"
class="text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
View patient
</RouterLink>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="mt-4">
<Button size="sm" variant="secondary" @click="load">Refresh</Button>
</div>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as thresholdsApi from '@/api/thresholds'
import ThresholdFormModal from '@/components/admin/ThresholdFormModal.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { emptyThresholdForm, formatThresholdValue, thresholdToForm } from '@/composables/thresholdForm'
const thresholds = ref([])
const loading = ref(true)
const error = ref('')
const modalOpen = ref(false)
const modalMode = ref('edit')
const editingThreshold = ref(null)
const submitting = ref(false)
const submitError = ref('')
const modalTitle = computed(() =>
modalMode.value === 'create' ? 'Create Threshold' : 'Edit Threshold',
)
const modalInitialValues = computed(() =>
modalMode.value === 'create'
? emptyThresholdForm()
: thresholdToForm(editingThreshold.value ?? {}),
)
async function loadThresholds() {
loading.value = true
error.value = ''
try {
thresholds.value = await thresholdsApi.fetchThresholds()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function openCreate() {
modalMode.value = 'create'
editingThreshold.value = null
submitError.value = ''
modalOpen.value = true
}
function openEdit(threshold) {
modalMode.value = 'edit'
editingThreshold.value = threshold
submitError.value = ''
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
async function onSubmit(payload) {
submitting.value = true
submitError.value = ''
try {
if (modalMode.value === 'create') {
await thresholdsApi.createThreshold(payload)
} else {
await thresholdsApi.updateThreshold(editingThreshold.value.id, payload)
}
modalOpen.value = false
await loadThresholds()
} catch (err) {
submitError.value = err.message
} finally {
submitting.value = false
}
}
async function onDelete(threshold) {
if (!window.confirm(`Delete threshold for ${threshold.observationCode}?`)) return
error.value = ''
try {
await thresholdsApi.deleteThreshold(threshold.id)
await loadThresholds()
} catch (err) {
error.value = err.message
}
}
onMounted(loadThresholds)
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">Alert Thresholds</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure warning and critical bounds used for clinical alerting.
</p>
</div>
<Button size="sm" @click="openCreate">Create Threshold</Button>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="6" />
<EmptyState v-else-if="thresholds.length === 0" message="No thresholds configured" />
<div v-else class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Code</th>
<th class="px-4 py-3">Display</th>
<th class="px-4 py-3">Unit</th>
<th class="px-4 py-3 text-right">Critical Low</th>
<th class="px-4 py-3 text-right">Warning Low</th>
<th class="px-4 py-3 text-right">Warning High</th>
<th class="px-4 py-3 text-right">Critical High</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="threshold in thresholds" :key="threshold.id">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ threshold.observationCode }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ threshold.displayName }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ threshold.unit }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.criticalLow) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.warningLow) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.warningHigh) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.criticalHigh) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2">
<Button size="sm" variant="secondary" @click="openEdit(threshold)">Edit</Button>
<Button size="sm" variant="danger" @click="onDelete(threshold)">Delete</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<ThresholdFormModal
:open="modalOpen"
:title="modalTitle"
:initial-values="modalInitialValues"
:submitting="submitting"
:error="submitError"
:read-only-code="modalMode === 'edit'"
@close="closeModal"
@submit="onSubmit"
/>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as usersApi from '@/api/users'
import UserFormModal from '@/components/admin/UserFormModal.vue'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
emptyUserForm,
userToForm,
roleLabel,
formatLastLogin,
} from '@/composables/userForm'
const users = ref([])
const loading = ref(true)
const error = ref('')
const modalOpen = ref(false)
const modalMode = ref('edit')
const editingUser = ref(null)
const submitting = ref(false)
const submitError = ref('')
const modalTitle = computed(() =>
modalMode.value === 'create' ? 'Create User' : 'Edit User',
)
const modalInitialValues = computed(() =>
modalMode.value === 'create'
? emptyUserForm()
: userToForm(editingUser.value ?? {}),
)
async function loadUsers() {
loading.value = true
error.value = ''
try {
users.value = await usersApi.fetchUsers()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function openCreate() {
modalMode.value = 'create'
editingUser.value = null
submitError.value = ''
modalOpen.value = true
}
function openEdit(user) {
modalMode.value = 'edit'
editingUser.value = user
submitError.value = ''
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
async function onSubmit(payload) {
submitting.value = true
submitError.value = ''
try {
if (modalMode.value === 'create') {
await usersApi.createUser(payload)
} else {
await usersApi.updateUser(editingUser.value.id, payload)
}
modalOpen.value = false
await loadUsers()
} catch (err) {
submitError.value = err.message
} finally {
submitting.value = false
}
}
onMounted(loadUsers)
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">User Management</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Create clinical accounts, assign roles, and deactivate users.
</p>
</div>
<Button size="sm" @click="openCreate">Create User</Button>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="6" />
<EmptyState v-else-if="users.length === 0" message="No users found" />
<div v-else class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Username</th>
<th class="px-4 py-3">Display name</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Last login</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="user in users" :key="user.id">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ user.username }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ user.displayName }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ roleLabel(user.role) }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatLastLogin(user.lastLoginAt) }}
</td>
<td class="px-4 py-3">
<Badge :variant="user.isActive ? 'success' : 'critical'">
{{ user.isActive ? 'Active' : 'Inactive' }}
</Badge>
</td>
<td class="px-4 py-3">
<div class="flex justify-end">
<Button size="sm" variant="secondary" @click="openEdit(user)">Edit</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<UserFormModal
:open="modalOpen"
:title="modalTitle"
:mode="modalMode"
:initial-values="modalInitialValues"
:submitting="submitting"
:error="submitError"
@close="closeModal"
@submit="onSubmit"
/>
</div>
</template>