add frontend
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive include="WardDashboard">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AlertCard from '@/components/alerts/AlertCard.vue'
|
||||
|
||||
const openAlert = {
|
||||
id: 'alert-1',
|
||||
alertType: 'SepsisWarning',
|
||||
severity: 'Critical',
|
||||
status: 'Open',
|
||||
details: 'SIRS criteria met',
|
||||
triggeredAt: '2026-06-19T12:00:00Z',
|
||||
}
|
||||
|
||||
const resolvedAlert = {
|
||||
...openAlert,
|
||||
id: 'alert-2',
|
||||
status: 'Resolved',
|
||||
}
|
||||
|
||||
describe('AlertCard', () => {
|
||||
it('showsAlertTypeAndSeverity', () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: openAlert } })
|
||||
expect(wrapper.text()).toContain('Critical')
|
||||
expect(wrapper.text()).toContain('SEPSIS_WARNING')
|
||||
})
|
||||
|
||||
it('acknowledgeButtonEmitsEvent', async () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: openAlert } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('acknowledge')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolvedAlertHidesActions', () => {
|
||||
const wrapper = mount(AlertCard, { props: { alert: resolvedAlert } })
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
describe('Badge', () => {
|
||||
it('rendersCriticalVariant', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { variant: 'critical' },
|
||||
slots: { default: 'High' },
|
||||
})
|
||||
const classes = wrapper.classes().join(' ')
|
||||
expect(classes).toContain('bg-severity-critical/10')
|
||||
expect(classes).toContain('text-severity-critical')
|
||||
})
|
||||
|
||||
it('rendersWarningVariant', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { variant: 'warning' },
|
||||
slots: { default: 'Med' },
|
||||
})
|
||||
const classes = wrapper.classes().join(' ')
|
||||
expect(classes).toContain('bg-severity-warning/10')
|
||||
expect(classes).toContain('text-severity-warning')
|
||||
})
|
||||
|
||||
it('rendersSlotContent', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
slots: { default: 'NEWS2 8' },
|
||||
})
|
||||
expect(wrapper.text()).toBe('NEWS2 8')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import WardTable from '@/components/ward/WardTable.vue'
|
||||
|
||||
const { mockPush } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
const patients = [
|
||||
{
|
||||
encounterId: 'enc-1',
|
||||
firstName: 'Alice',
|
||||
lastName: 'A',
|
||||
mrn: 'M1',
|
||||
room: '101',
|
||||
news2Score: 3,
|
||||
qsofaScore: 0,
|
||||
sepsisActive: false,
|
||||
openAlertCount: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'enc-2',
|
||||
firstName: 'Bob',
|
||||
lastName: 'B',
|
||||
mrn: 'M2',
|
||||
room: '102',
|
||||
news2Score: 5,
|
||||
qsofaScore: 1,
|
||||
sepsisActive: false,
|
||||
openAlertCount: 1,
|
||||
},
|
||||
{
|
||||
encounterId: 'enc-3',
|
||||
firstName: 'Carol',
|
||||
lastName: 'C',
|
||||
mrn: 'M3',
|
||||
room: '103',
|
||||
news2Score: 8,
|
||||
qsofaScore: 2,
|
||||
sepsisActive: true,
|
||||
openAlertCount: 3,
|
||||
},
|
||||
]
|
||||
|
||||
describe('WardTable', () => {
|
||||
it('rendersAllPatientRows', () => {
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('emitsClickWithEncounterId', async () => {
|
||||
mockPush.mockClear()
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
await wrapper.findAll('tbody tr')[2].trigger('click')
|
||||
expect(mockPush).toHaveBeenCalledWith({
|
||||
name: 'PatientDetail',
|
||||
params: { encounterId: 'enc-3' },
|
||||
})
|
||||
})
|
||||
|
||||
it('showsCriticalBadgeForHighNews2', () => {
|
||||
const wrapper = mount(WardTable, { props: { patients } })
|
||||
const highRiskRow = wrapper.findAll('tbody tr')[2]
|
||||
expect(highRiskRow.html()).toContain('text-severity-critical')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
|
||||
function mountPolling(fetchFn, intervalMs = 1_000) {
|
||||
let exposed
|
||||
const Comp = defineComponent({
|
||||
setup() {
|
||||
exposed = usePolling(fetchFn, intervalMs)
|
||||
return exposed
|
||||
},
|
||||
render: () => h('div'),
|
||||
})
|
||||
const wrapper = mount(Comp)
|
||||
return { wrapper, exposed }
|
||||
}
|
||||
|
||||
describe('usePolling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('callsFetchOnMount', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue({ ok: true })
|
||||
mountPolling(fetchFn)
|
||||
await flushPromises()
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('callsFetchAtInterval', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(null)
|
||||
mountPolling(fetchFn, 1_000)
|
||||
await flushPromises()
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await flushPromises()
|
||||
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clearsIntervalOnUnmount', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue(null)
|
||||
const { wrapper } = mountPolling(fetchFn, 1_000)
|
||||
await flushPromises()
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.unmount()
|
||||
vi.advanceTimersByTime(5_000)
|
||||
await flushPromises()
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('setsErrorOnFailure', async () => {
|
||||
const fetchFn = vi.fn().mockRejectedValue(new Error('network error'))
|
||||
const { exposed } = mountPolling(fetchFn)
|
||||
await flushPromises()
|
||||
expect(exposed.error.value).toBe('network error')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchAlerts(encounterId, status) {
|
||||
const params = new URLSearchParams()
|
||||
if (status) params.set('status', status)
|
||||
return api.get(`/api/v1/encounters/${encounterId}/alerts?${params}`)
|
||||
}
|
||||
|
||||
export function fetchAllAlerts(status) {
|
||||
const params = new URLSearchParams()
|
||||
if (status) params.set('status', status)
|
||||
return api.get(`/api/v1/alerts?${params}`)
|
||||
}
|
||||
|
||||
export function acknowledgeAlert(alertId, clinicianId, note) {
|
||||
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { clinicianId, note })
|
||||
}
|
||||
|
||||
export function resolveAlert(alertId) {
|
||||
return api.post(`/api/v1/alerts/${alertId}/resolve`)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5270'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request(path),
|
||||
post: (path, body) => request(path, {
|
||||
method: 'POST',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchCurrentNews2(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/news2/current`)
|
||||
}
|
||||
|
||||
export function fetchCurrentQsofa(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/qsofa/current`)
|
||||
}
|
||||
|
||||
export function fetchSepsisBundle(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/sepsis-bundle/current`)
|
||||
}
|
||||
|
||||
export function fetchOrders(encounterId) {
|
||||
return api.get(`/api/v1/encounters/${encounterId}/orders`)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { api } from './client'
|
||||
|
||||
export function fetchActiveEncounters(department) {
|
||||
const params = new URLSearchParams({ status: 'ACTIVE' })
|
||||
if (department) params.set('department', department)
|
||||
return api.get(`/api/v1/encounters?${params}`)
|
||||
}
|
||||
|
||||
export function fetchEncounter(id) {
|
||||
return api.get(`/api/v1/encounters/${id}`)
|
||||
}
|
||||
|
||||
export async function fetchObservations(encounterId, { limit = 50 } = {}) {
|
||||
const all = []
|
||||
let cursor = null
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const page = await api.get(`/api/v1/encounters/${encounterId}/observations?${params}`)
|
||||
all.push(...(page.items ?? []))
|
||||
cursor = page.hasMore ? page.nextCursor : null
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Map API PascalCase alert types to display labels (e.g. WarningHeartRate → WARNING_HEART_RATE)
|
||||
export function alertTypeLabel(type) {
|
||||
if (!type) return ''
|
||||
return type.replace(/([A-Z])/g, '_$1').slice(1).toUpperCase()
|
||||
}
|
||||
|
||||
export function alertStatusToApiFilter(status) {
|
||||
const map = {
|
||||
Open: 'OPEN',
|
||||
Acknowledged: 'ACKNOWLEDGED',
|
||||
Resolved: 'RESOLVED',
|
||||
Escalated: 'ESCALATED',
|
||||
}
|
||||
return map[status] ?? null
|
||||
}
|
||||
|
||||
const OBSERVATION_LABELS = {
|
||||
HEART_RATE: 'Heart Rate',
|
||||
RESP_RATE: 'Respiratory Rate',
|
||||
SPO2: 'SpO₂',
|
||||
SYSTOLIC_BP: 'Systolic BP',
|
||||
TEMP_C: 'Temperature',
|
||||
AVPU: 'AVPU',
|
||||
SUPPLEMENTAL_O2: 'Supplemental O₂',
|
||||
WBC_K_UL: 'WBC',
|
||||
}
|
||||
|
||||
export function observationCodeLabel(code) {
|
||||
return OBSERVATION_LABELS[code] ?? code?.replace(/_/g, ' ') ?? ''
|
||||
}
|
||||
|
||||
const BUNDLE_ELEMENT_LABELS = {
|
||||
BloodCultures: 'Blood cultures',
|
||||
SerumLactate: 'Serum lactate',
|
||||
BroadSpectrumAntibiotics: 'Broad-spectrum antibiotics',
|
||||
IvFluidResuscitation: 'IV fluid resuscitation',
|
||||
}
|
||||
|
||||
export function bundleElementLabel(elementType) {
|
||||
return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
alert: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'close'])
|
||||
|
||||
function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" title="Acknowledge Alert" @close="emit('close')">
|
||||
<div v-if="alert" class="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
<p v-if="alert.details" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ alert.details }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Confirm that you have reviewed this alert.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="emit('close')">Cancel</Button>
|
||||
<Button variant="primary" @click="emit('confirm')">Acknowledge</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
defineProps({
|
||||
alert: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['acknowledge', 'resolve'])
|
||||
|
||||
function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
|
||||
function showActions(status) {
|
||||
return status !== 'Resolved'
|
||||
}
|
||||
|
||||
function canAcknowledge(status) {
|
||||
return status === 'Open' || status === 'Escalated'
|
||||
}
|
||||
|
||||
function canResolve(status) {
|
||||
return status === 'Acknowledged'
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<div class="flex flex-col gap-3 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>
|
||||
<Badge variant="info" size="xs">{{ alert.status }}</Badge>
|
||||
</div>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ alertTypeLabel(alert.alertType) }}
|
||||
</h3>
|
||||
<p v-if="alert.details" class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ alert.details }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{{ formatTime(alert.triggeredAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions(alert.status)" class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="canAcknowledge(alert.status)"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click="emit('acknowledge')"
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canResolve(alert.status)"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
@click="emit('resolve')"
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
const activeFilter = defineModel({ type: String, default: 'Open' })
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Open', value: 'Open', badgeVariant: 'critical' },
|
||||
{ label: 'Acknowledged', value: 'Acknowledged', badgeVariant: 'warning' },
|
||||
{ label: 'Resolved', value: 'Resolved', badgeVariant: 'success' },
|
||||
{ label: 'Escalated', value: 'Escalated', badgeVariant: 'info' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
class="border-b-2 px-4 py-2 text-sm font-medium transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
:class="activeFilter === tab.value
|
||||
? '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="activeFilter = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex h-14 items-center border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">VigilCare Clinical</h1>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<AppSidebar />
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<AppHeader />
|
||||
<main class="flex-1 overflow-y-auto p-4 lg:p-6">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="hidden w-64 border-r border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900 lg:block">
|
||||
<div class="flex h-14 items-center px-4">
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">VC</span>
|
||||
</div>
|
||||
<nav class="px-2 py-4">
|
||||
<slot />
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({
|
||||
encounterId: { type: String, required: true },
|
||||
})
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const settings = useSettingsStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
|
||||
function loadOpenAlerts() {
|
||||
return alertStore.loadAlerts(props.encounterId, 'OPEN')
|
||||
}
|
||||
|
||||
usePolling(loadOpenAlerts, 5_000)
|
||||
|
||||
function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
|
||||
async function acknowledge(alertId) {
|
||||
await alertStore.acknowledge(alertId, settings.clinicianId)
|
||||
await loadOpenAlerts()
|
||||
}
|
||||
|
||||
async function resolve(alertId) {
|
||||
await alertStore.resolve(alertId)
|
||||
await loadOpenAlerts()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<template #header>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Active Alerts
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<EmptyState v-if="!loading && alerts.length === 0" message="No open alerts" />
|
||||
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<li
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="flex items-start justify-between gap-3 py-3 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>
|
||||
</div>
|
||||
<p v-if="alert.details" class="mt-1 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ alert.details }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<Button
|
||||
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@click.stop="acknowledge(alert.id)"
|
||||
>
|
||||
Ack
|
||||
</Button>
|
||||
<Button
|
||||
v-if="alert.status === 'Acknowledged'"
|
||||
size="sm"
|
||||
variant="primary"
|
||||
@click.stop="resolve(alert.id)"
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
|
||||
const props = defineProps({
|
||||
orders: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const pendingOrders = computed(() =>
|
||||
props.orders.filter(o => o.status === 'Pending' || o.status === 'InProgress'),
|
||||
)
|
||||
|
||||
const resultedOrders = computed(() =>
|
||||
props.orders.filter(o => o.status === 'Resulted'),
|
||||
)
|
||||
|
||||
function statusVariant(status) {
|
||||
if (status === 'Resulted') return 'success'
|
||||
if (status === 'InProgress') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<template #header>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Orders
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<EmptyState v-if="orders.length === 0" message="No orders" />
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<section v-if="pendingOrders.length">
|
||||
<h3 class="mb-2 text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Pending</h3>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<li
|
||||
v-for="order in pendingOrders"
|
||||
:key="order.id"
|
||||
class="flex items-center justify-between gap-3 py-2 first:pt-0"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-gray-900 dark:text-white">{{ order.description }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ order.orderType }}</div>
|
||||
</div>
|
||||
<Badge :variant="statusVariant(order.status)" size="xs">{{ order.status }}</Badge>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="resultedOrders.length">
|
||||
<h3 class="mb-2 text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Resulted</h3>
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<li
|
||||
v-for="order in resultedOrders"
|
||||
:key="order.id"
|
||||
class="py-2 first:pt-0"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-gray-900 dark:text-white">{{ order.description }}</div>
|
||||
<div v-if="order.resultSummary" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ order.resultSummary }}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="success" size="xs">Resulted</Badge>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import * as clinicalApi from '@/api/clinical'
|
||||
|
||||
const props = defineProps({
|
||||
news2: { type: Object, default: null },
|
||||
encounter: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const qsofa = ref(null)
|
||||
|
||||
async function loadQsofa() {
|
||||
if (!props.encounter?.id) return
|
||||
try {
|
||||
qsofa.value = await clinicalApi.fetchCurrentQsofa(props.encounter.id)
|
||||
} catch {
|
||||
qsofa.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.encounter?.id, loadQsofa, { immediate: true })
|
||||
|
||||
const news2Variant = computed(() => {
|
||||
const score = props.news2?.totalScore ?? 0
|
||||
if (score >= 7 || props.news2?.hasSingleParamThree) return 'critical'
|
||||
if (score >= 5) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const qsofaVariant = computed(() => {
|
||||
const count = qsofa.value?.activeCriteria ?? 0
|
||||
if (count >= 2) return 'critical'
|
||||
if (count === 1) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<template #header>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Clinical Scores
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">NEWS2</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<Badge :variant="news2Variant">{{ news2?.totalScore ?? '—' }}</Badge>
|
||||
<span v-if="news2?.riskLevel" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ news2.riskLevel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">qSOFA</div>
|
||||
<div class="mt-1">
|
||||
<Badge :variant="qsofaVariant">{{ qsofa?.activeCriteria ?? '—' }}</Badge>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">/ 3 criteria</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { bundleElementLabel } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({
|
||||
bundle: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const now = ref(Date.now())
|
||||
let timer = null
|
||||
|
||||
onMounted(() => {
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
const remainingMs = computed(() => {
|
||||
const deadline = new Date(props.bundle.deadlineAt).getTime()
|
||||
return Math.max(0, deadline - now.value)
|
||||
})
|
||||
|
||||
const countdown = computed(() => {
|
||||
const ms = remainingMs.value
|
||||
const mins = Math.floor(ms / 60_000)
|
||||
const secs = Math.floor((ms % 60_000) / 1000)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
const complianceVariant = computed(() => {
|
||||
const status = props.bundle.complianceStatus
|
||||
if (status === 'Compliant') return 'success'
|
||||
if (status === 'NonCompliant') return 'critical'
|
||||
return 'warning'
|
||||
})
|
||||
|
||||
function elementComplete(element) {
|
||||
return element.status === 'Completed'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<template #header>
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Sepsis Bundle
|
||||
</h2>
|
||||
<Badge :variant="complianceVariant">{{ bundle.complianceStatus }}</Badge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">Time to deadline</span>
|
||||
<span
|
||||
class="font-mono text-lg font-semibold"
|
||||
:class="remainingMs === 0 ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ countdown }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="element in bundle.elements"
|
||||
:key="element.id"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-xs"
|
||||
:class="elementComplete(element)
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'"
|
||||
>
|
||||
{{ elementComplete(element) ? '✓' : '○' }}
|
||||
</span>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="elementComplete(element)
|
||||
? 'text-gray-500 line-through dark:text-gray-400'
|
||||
: 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ bundleElementLabel(element.elementType) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import { observationCodeLabel } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({
|
||||
observations: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const latestByCode = computed(() => {
|
||||
const map = new Map()
|
||||
for (const obs of props.observations) {
|
||||
const existing = map.get(obs.observationCode)
|
||||
if (!existing || new Date(obs.recordedAt) > new Date(existing.recordedAt)) {
|
||||
map.set(obs.observationCode, obs)
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) =>
|
||||
observationCodeLabel(a.observationCode).localeCompare(observationCodeLabel(b.observationCode)),
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<template #header>
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Latest Vitals
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div v-if="latestByCode.length" class="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="obs in latestByCode"
|
||||
:key="obs.id ?? obs.observationCode"
|
||||
class="rounded-lg bg-gray-50 p-3 dark:bg-gray-800"
|
||||
>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ observationCodeLabel(obs.observationCode) }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ obs.value }}
|
||||
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">{{ obs.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-500 dark:text-gray-400">No observations recorded</p>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'info', validator: v => ['critical', 'warning', 'info', 'success'].includes(v) },
|
||||
size: { type: String, default: 'sm' },
|
||||
})
|
||||
|
||||
const classes = computed(() => {
|
||||
const base = 'inline-flex items-center font-medium rounded-full'
|
||||
const sizes = { xs: 'px-1.5 py-0.5 text-xs', sm: 'px-2 py-0.5 text-xs', md: 'px-2.5 py-1 text-sm' }
|
||||
const variants = {
|
||||
critical: 'bg-severity-critical/10 text-severity-critical dark:bg-severity-critical-dark/20 dark:text-red-300',
|
||||
warning: 'bg-severity-warning/10 text-severity-warning dark:bg-severity-warning-dark/20 dark:text-amber-300',
|
||||
info: 'bg-severity-info/10 text-severity-info dark:bg-severity-info-dark/20 dark:text-blue-300',
|
||||
success: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300',
|
||||
}
|
||||
return twMerge(base, sizes[props.size], variants[props.variant])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="classes"><slot /></span>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { computed, useAttrs } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
validator: v => ['primary', 'secondary', 'danger', 'ghost'].includes(v),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: v => ['sm', 'md', 'lg'].includes(v),
|
||||
},
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
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-3 py-1.5 text-xs',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-5 py-2.5 text-base',
|
||||
}
|
||||
const variants = {
|
||||
primary:
|
||||
'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600',
|
||||
secondary:
|
||||
'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-400 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
|
||||
danger:
|
||||
'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500 dark:bg-red-500 dark:hover:bg-red-600',
|
||||
ghost:
|
||||
'bg-transparent text-gray-700 hover:bg-gray-100 focus-visible:ring-gray-400 dark:text-gray-300 dark:hover:bg-gray-800',
|
||||
}
|
||||
return twMerge(base, sizes[props.size], variants[props.variant], attrs.class)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:class="classes"
|
||||
v-bind="{ ...attrs, class: undefined }"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
const props = defineProps({
|
||||
padding: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: v => ['none', 'sm', 'md', 'lg'].includes(v),
|
||||
},
|
||||
})
|
||||
|
||||
const classes = computed(() => {
|
||||
const base =
|
||||
'rounded-lg border border-gray-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900'
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
}
|
||||
return twMerge(base, paddings[props.padding])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="classes">
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: { type: String, default: 'No data' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-4 text-gray-400 dark:text-gray-500">
|
||||
<slot name="icon">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ message }}</p>
|
||||
<div v-if="$slots.action" class="mt-4">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
|
||||
defineProps({ open: Boolean, title: String })
|
||||
const emit = defineEmits(['close'])
|
||||
const modalRef = ref(null)
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-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 w-full max-w-md rounded-lg bg-white p-6 shadow-lg dark:bg-gray-800">
|
||||
<h2 v-if="title" class="mb-4 text-lg font-semibold dark:text-white">{{ title }}</h2>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
rows: { type: Number, default: 3 },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="animate-pulse space-y-3" role="status" aria-label="Loading">
|
||||
<div
|
||||
v-for="n in rows"
|
||||
:key="n"
|
||||
class="h-12 rounded-lg bg-gray-200 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const props = defineProps({ patient: { type: Object, required: true } })
|
||||
|
||||
const riskVariant = computed(() => {
|
||||
const score = props.patient.news2Score ?? 0
|
||||
if (score >= 7) return 'critical'
|
||||
if (score >= 5) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ patient.room ?? '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ patient.firstName }} {{ patient.lastName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<Badge :variant="riskVariant">{{ patient.news2Score ?? '—' }}</Badge>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ patient.qsofaScore ?? '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<Badge v-if="patient.sepsisActive" variant="critical">Active</Badge>
|
||||
<span v-else class="text-sm text-gray-400">No</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<Badge v-if="patient.openAlertCount > 0" :variant="patient.openAlertCount > 2 ? 'critical' : 'warning'">
|
||||
{{ patient.openAlertCount }}
|
||||
</Badge>
|
||||
<span v-else class="text-sm text-gray-400">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import PatientRow from './PatientRow.vue'
|
||||
|
||||
defineProps({ patients: { type: Array, required: true } })
|
||||
const router = useRouter()
|
||||
|
||||
function goToPatient(encounterId) {
|
||||
router.push({ name: 'PatientDetail', params: { encounterId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 dark:divide-gray-700">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Room</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Patient</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">NEWS2</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">qSOFA</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Sepsis</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Alerts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<PatientRow
|
||||
v-for="patient in patients"
|
||||
:key="patient.encounterId"
|
||||
:patient="patient"
|
||||
class="cursor-pointer transition duration-150 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
@click="goToPatient(patient.encounterId)"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
export function useDarkMode() {
|
||||
const settings = useSettingsStore()
|
||||
const { darkMode } = storeToRefs(settings)
|
||||
return { darkMode, toggle: settings.toggleDarkMode }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
export function usePolling(fetchFn, intervalMs = 10_000) {
|
||||
const data = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
let timer = null
|
||||
|
||||
async function poll() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await fetchFn()
|
||||
error.value = null
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
poll()
|
||||
timer = setInterval(poll, intervalMs)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
onMounted(start)
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
return { data, loading, error, poll, stop }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/ward',
|
||||
},
|
||||
{
|
||||
path: '/ward',
|
||||
name: 'WardDashboard',
|
||||
component: () => import('@/views/WardDashboard.vue'),
|
||||
meta: { title: 'Virtual Ward', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/patients/:encounterId',
|
||||
name: 'PatientDetail',
|
||||
component: () => import('@/views/PatientDetail.vue'),
|
||||
meta: { title: 'Patient Detail', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/alerts',
|
||||
name: 'AlertCenter',
|
||||
component: () => import('@/views/AlertCenter.vue'),
|
||||
meta: { title: 'Alert Center', layout: 'default' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,51 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as alertsApi from '@/api/alerts'
|
||||
|
||||
export const useAlertStore = defineStore('alerts', () => {
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref(null)
|
||||
|
||||
const openAlerts = computed(() => alerts.value.filter(a => a.status === 'Open'))
|
||||
const criticalAlerts = computed(() => alerts.value.filter(a => a.severity === 'Critical'))
|
||||
|
||||
async function loadAlerts(encounterId, status) {
|
||||
loading.value = true
|
||||
try {
|
||||
const filter = status ?? statusFilter.value
|
||||
const data = await alertsApi.fetchAlerts(encounterId, filter)
|
||||
alerts.value = data.items
|
||||
} catch (e) {
|
||||
console.error('Failed to load alerts', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGlobalAlerts(status = 'OPEN') {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await alertsApi.fetchAllAlerts(status)
|
||||
alerts.value = data.items
|
||||
} catch (e) {
|
||||
console.error('Failed to load alerts', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledge(alertId, clinicianId, note) {
|
||||
await alertsApi.acknowledgeAlert(alertId, clinicianId, note)
|
||||
const alert = alerts.value.find(a => a.id === alertId)
|
||||
if (alert) alert.status = 'Acknowledged'
|
||||
}
|
||||
|
||||
async function resolve(alertId) {
|
||||
await alertsApi.resolveAlert(alertId)
|
||||
const alert = alerts.value.find(a => a.id === alertId)
|
||||
if (alert) alert.status = 'Resolved'
|
||||
}
|
||||
|
||||
return { alerts, loading, statusFilter, openAlerts, criticalAlerts, loadAlerts, loadGlobalAlerts, acknowledge, resolve }
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const darkMode = ref(localStorage.getItem('darkMode') === 'true')
|
||||
const pollInterval = ref(10_000)
|
||||
const clinicianId = ref(localStorage.getItem('clinicianId') ?? 'DR-DEMO')
|
||||
|
||||
watch(darkMode, (val) => {
|
||||
localStorage.setItem('darkMode', val)
|
||||
document.documentElement.classList.toggle('dark', val)
|
||||
}, { immediate: true })
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
}
|
||||
|
||||
return { darkMode, pollInterval, clinicianId, toggleDarkMode }
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchActiveEncounters } from '@/api/encounters'
|
||||
import { fetchCurrentNews2 } from '@/api/clinical'
|
||||
|
||||
export const useWardStore = defineStore('ward', () => {
|
||||
const encounters = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const department = ref(null)
|
||||
|
||||
const sortedByRisk = computed(() =>
|
||||
[...encounters.value].sort((a, b) => (b.news2Score ?? 0) - (a.news2Score ?? 0))
|
||||
)
|
||||
|
||||
const criticalCount = computed(() =>
|
||||
encounters.value.filter(e => (e.news2Score ?? 0) >= 7).length
|
||||
)
|
||||
|
||||
async function loadEncounters() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await fetchActiveEncounters(department.value)
|
||||
encounters.value = data.items
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setDepartment(dept) {
|
||||
department.value = dept
|
||||
loadEncounters()
|
||||
}
|
||||
|
||||
return { encounters, loading, error, department, sortedByRisk, criticalCount, loadEncounters, setDepartment }
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { alertStatusToApiFilter } from '@/api/normalize'
|
||||
import AlertCard from '@/components/alerts/AlertCard.vue'
|
||||
import AlertFilters from '@/components/alerts/AlertFilters.vue'
|
||||
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const settings = useSettingsStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
|
||||
const activeFilter = ref('Open')
|
||||
const confirmingAlert = ref(null)
|
||||
|
||||
watch(activeFilter, (status) => {
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(status))
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleAcknowledge() {
|
||||
if (!confirmingAlert.value) return
|
||||
await alertStore.acknowledge(confirmingAlert.value.id, settings.clinicianId)
|
||||
confirmingAlert.value = null
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
|
||||
}
|
||||
|
||||
async function handleResolve(alertId) {
|
||||
await alertStore.resolve(alertId)
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="mb-4 text-xl font-bold dark:text-white">Alert Center</h1>
|
||||
|
||||
<AlertFilters v-model="activeFilter" />
|
||||
|
||||
<Skeleton v-if="loading && alerts.length === 0" class="mt-4" :rows="4" />
|
||||
<EmptyState v-else-if="alerts.length === 0" class="mt-4" message="No alerts for this status" />
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<AlertCard
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
:alert="alert"
|
||||
@acknowledge="confirmingAlert = alert"
|
||||
@resolve="handleResolve(alert.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AcknowledgeModal
|
||||
:open="!!confirmingAlert"
|
||||
:alert="confirmingAlert"
|
||||
@confirm="handleAcknowledge"
|
||||
@close="confirmingAlert = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import * as encountersApi from '@/api/encounters'
|
||||
import * as clinicalApi from '@/api/clinical'
|
||||
import VitalsPanel from '@/components/patient/VitalsPanel.vue'
|
||||
import ScoresPanel from '@/components/patient/ScoresPanel.vue'
|
||||
import AlertsList from '@/components/patient/AlertsList.vue'
|
||||
import OrdersPanel from '@/components/patient/OrdersPanel.vue'
|
||||
import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const encounter = ref(null)
|
||||
const loading = ref(true)
|
||||
const observations = ref([])
|
||||
const news2 = ref(null)
|
||||
const sepsisBundle = ref(null)
|
||||
const orders = ref([])
|
||||
|
||||
async function loadAll() {
|
||||
const id = route.params.encounterId
|
||||
loading.value = true
|
||||
try {
|
||||
const [enc, obs, n2, bundle, ord] = await Promise.all([
|
||||
encountersApi.fetchEncounter(id),
|
||||
encountersApi.fetchObservations(id),
|
||||
clinicalApi.fetchCurrentNews2(id).catch(() => null),
|
||||
clinicalApi.fetchSepsisBundle(id).catch(() => null),
|
||||
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
||||
])
|
||||
encounter.value = enc
|
||||
observations.value = obs
|
||||
news2.value = n2
|
||||
sepsisBundle.value = bundle
|
||||
orders.value = ord.items ?? ord
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
usePolling(loadAll, 5_000)
|
||||
|
||||
watch(() => route.params.encounterId, loadAll)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Skeleton v-if="loading && !encounter" :rows="6" />
|
||||
<div v-else-if="encounter" class="space-y-6">
|
||||
<div class="flex 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">
|
||||
← Ward
|
||||
</RouterLink>
|
||||
<h1 class="text-xl font-bold dark:text-white">
|
||||
{{ encounter.patient?.firstName }} {{ encounter.patient?.lastName }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<ScoresPanel :news2="news2" :encounter="encounter" />
|
||||
<VitalsPanel :observations="observations" />
|
||||
<AlertsList :encounter-id="route.params.encounterId" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<OrdersPanel :orders="orders" />
|
||||
<SepsisBundlePanel v-if="sepsisBundle" :bundle="sepsisBundle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import WardTable from '@/components/ward/WardTable.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
|
||||
const wardStore = useWardStore()
|
||||
const { sortedByRisk, loading, error, criticalCount } = storeToRefs(wardStore)
|
||||
|
||||
usePolling(() => wardStore.loadEncounters(), 10_000)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold dark:text-white">Virtual Ward</h1>
|
||||
<Badge v-if="criticalCount > 0" variant="critical">
|
||||
{{ criticalCount }} critical
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="loading && sortedByRisk.length === 0" :rows="5" />
|
||||
<EmptyState v-else-if="sortedByRisk.length === 0" message="No active patients" />
|
||||
<WardTable v-else :patients="sortedByRisk" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user