feature: Improve dashboard functionality
This commit is contained in:
@@ -1,19 +1,45 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import {
|
||||
formatRoleLabel,
|
||||
previewAcknowledgmentNote,
|
||||
roleAcknowledgmentMessage,
|
||||
} from '@/composables/alertAcknowledge'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
alert: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['confirm', 'close'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const { displayName, role } = storeToRefs(authStore)
|
||||
const note = ref('')
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) note.value = ''
|
||||
})
|
||||
|
||||
const roleLabel = computed(() => formatRoleLabel(role.value))
|
||||
const roleMessage = computed(() => roleAcknowledgmentMessage(role.value))
|
||||
const notePreview = computed(() =>
|
||||
previewAcknowledgmentNote(role.value, displayName.value, note.value),
|
||||
)
|
||||
|
||||
function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
emit('confirm', note.value.trim())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -28,12 +54,36 @@ function severityVariant(severity) {
|
||||
<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.
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ displayName }}
|
||||
<span class="text-gray-500 dark:text-gray-400">· {{ roleLabel }}</span>
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ roleMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Optional note
|
||||
</span>
|
||||
<textarea
|
||||
v-model="note"
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Add clinical context (optional)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Audit record: {{ notePreview }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" @click="emit('close')">Cancel</Button>
|
||||
<Button variant="primary" @click="emit('confirm')">Acknowledge</Button>
|
||||
<Button variant="primary" @click="onConfirm">Acknowledge</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
|
||||
|
||||
const props = defineProps({
|
||||
alert: { type: Object, required: true },
|
||||
@@ -62,6 +63,13 @@ function formatTime(iso) {
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{{ formatTime(alert.triggeredAt) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="alert.acknowledgedBy"
|
||||
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
|
||||
<span v-if="alert.acknowledgedAt">at {{ formatTime(alert.acknowledgedAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showActions(alert.status)" class="flex w-full shrink-0 gap-2 sm:w-auto">
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertStore } from '@/stores/alerts'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import {
|
||||
formatAlertPatientLabel,
|
||||
requestNotificationPermission,
|
||||
stopCriticalTitleFlash,
|
||||
} from '@/composables/useAlertNotification'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const alertStore = useAlertStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { bannerAlerts } = storeToRefs(alertStore)
|
||||
const { alertSoundMuted } = storeToRefs(settingsStore)
|
||||
|
||||
const showNotificationPrompt = computed(() => {
|
||||
if (!('Notification' in window)) return false
|
||||
return Notification.permission === 'default'
|
||||
})
|
||||
|
||||
function dismissAll() {
|
||||
alertStore.dismissAllBannerAlerts()
|
||||
stopCriticalTitleFlash()
|
||||
}
|
||||
|
||||
function dismissOne(alertId) {
|
||||
alertStore.dismissBannerAlert(alertId)
|
||||
if (bannerAlerts.value.length === 0) {
|
||||
stopCriticalTitleFlash()
|
||||
}
|
||||
}
|
||||
|
||||
function openAlertCenter() {
|
||||
router.push({ name: 'AlertCenter' })
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
await requestNotificationPermission()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="bannerAlerts.length > 0"
|
||||
class="border-b border-red-700 bg-red-600 text-white"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 lg:px-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-wide">Critical alert</p>
|
||||
<p class="text-sm text-red-100">
|
||||
{{ bannerAlerts.length }} new critical alert{{ bannerAlerts.length === 1 ? '' : 's' }} require attention
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-700" @click="settingsStore.toggleAlertSoundMute()">
|
||||
{{ alertSoundMuted ? 'Unmute sound' : 'Mute sound' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="showNotificationPrompt"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="!text-white hover:!bg-red-700"
|
||||
@click="enableNotifications"
|
||||
>
|
||||
Enable notifications
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" @click="openAlertCenter">
|
||||
Open Alert Center
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" @click="dismissAll">
|
||||
Dismiss all
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="alert in bannerAlerts"
|
||||
:key="alert.id"
|
||||
class="flex flex-wrap items-start justify-between gap-4 rounded-lg bg-red-700/60 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">{{ alertTypeLabel(alert.alertType) }}</p>
|
||||
<p class="text-sm text-red-100">{{ formatAlertPatientLabel(alert) }}</p>
|
||||
<p v-if="alert.details" class="mt-1 text-sm text-red-50">{{ alert.details }}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-800" @click="dismissOne(alert.id)">
|
||||
Dismiss
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
acuity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const segments = computed(() => {
|
||||
const total = props.acuity.low + props.acuity.medium + props.acuity.high
|
||||
if (total === 0) {
|
||||
return [
|
||||
{ key: 'empty', label: 'No patients', pct: 100, className: 'bg-gray-200 dark:bg-gray-700' },
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ key: 'low', label: 'Low', count: props.acuity.low, pct: (props.acuity.low / total) * 100, className: 'bg-green-500' },
|
||||
{ key: 'medium', label: 'Medium', count: props.acuity.medium, pct: (props.acuity.medium / total) * 100, className: 'bg-amber-500' },
|
||||
{ key: 'high', label: 'High', count: props.acuity.high, pct: (props.acuity.high / total) * 100, className: 'bg-red-500' },
|
||||
].filter(segment => segment.count > 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
v-for="segment in segments"
|
||||
:key="segment.key"
|
||||
class="h-full transition-all"
|
||||
:class="segment.className"
|
||||
:style="{ width: `${segment.pct}%` }"
|
||||
:title="segment.label"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-for="segment in segments" :key="`${segment.key}-legend`">
|
||||
<span class="mr-1 inline-block h-2 w-2 rounded-full" :class="segment.className" />
|
||||
{{ segment.label }}: {{ segment.count ?? 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import AcuityBar from '@/components/departments/AcuityBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
department: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const hasPatients = computed(() => props.department.patientCount > 0)
|
||||
|
||||
function onSelect() {
|
||||
if (!hasPatients.value) return
|
||||
emit('select', props.department.filterValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
|
||||
:class="hasPatients ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default opacity-80'"
|
||||
:disabled="!hasPatients"
|
||||
@click="onSelect"
|
||||
>
|
||||
<Card padding="lg" class="h-full">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.label }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ department.patientCount }} active patient{{ department.patientCount === 1 ? '' : 's' }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge v-if="department.acuity.high > 0" variant="critical">
|
||||
{{ department.acuity.high }} critical
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
NEWS2 acuity
|
||||
</p>
|
||||
<AcuityBar :acuity="department.acuity" />
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Avg NEWS2</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.averageNews2 ?? '—' }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Open alerts</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.openAlertCount }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Sepsis bundles</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.activeBundleCount }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 dark:text-gray-400">Alert volume</dt>
|
||||
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{{ department.alertVolume }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p v-if="hasPatients" class="mt-4 text-xs font-medium text-blue-600 dark:text-blue-400">
|
||||
View patients in Virtual Ward
|
||||
</p>
|
||||
</Card>
|
||||
</button>
|
||||
</template>
|
||||
@@ -3,11 +3,14 @@ import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
|
||||
const route = useRoute()
|
||||
const wardStore = useWardStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { department } = storeToRefs(wardStore)
|
||||
const { alertSoundMuted } = storeToRefs(settingsStore)
|
||||
const { darkMode, toggle } = useDarkMode()
|
||||
|
||||
const pageTitle = computed(() => route.meta.title ?? 'VigilCare')
|
||||
@@ -51,6 +54,50 @@ function onDepartmentChange(event) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
:aria-label="alertSoundMuted ? 'Unmute critical alert sound' : 'Mute critical alert sound'"
|
||||
@click="settingsStore.toggleAlertSoundMute()"
|
||||
>
|
||||
<svg
|
||||
v-if="alertSoundMuted"
|
||||
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="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
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="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import AppHeader from './AppHeader.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import MobileNav from './MobileNav.vue'
|
||||
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
useCriticalAlertPolling(settingsStore.pollInterval)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,6 +15,7 @@ import MobileNav from './MobileNav.vue'
|
||||
<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">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
<slot />
|
||||
|
||||
@@ -5,6 +5,8 @@ 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' },
|
||||
]
|
||||
@@ -60,6 +62,36 @@ function linkClasses(path) {
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'sepsis'"
|
||||
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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'departments'"
|
||||
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="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
|
||||
class="h-6 w-6 shrink-0"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
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 Button from '@/components/ui/Button.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
|
||||
|
||||
const props = defineProps({
|
||||
encounterId: { type: String, required: true },
|
||||
@@ -18,6 +19,7 @@ const emit = defineEmits(['select'])
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
const confirmingAlert = ref(null)
|
||||
|
||||
function loadEncounterAlerts() {
|
||||
return alertStore.loadAlerts(props.encounterId)
|
||||
@@ -33,8 +35,10 @@ function severityVariant(severity) {
|
||||
return severity === 'Critical' ? 'critical' : 'warning'
|
||||
}
|
||||
|
||||
async function acknowledge(alertId) {
|
||||
await alertStore.acknowledge(alertId)
|
||||
async function handleAcknowledge(note) {
|
||||
if (!confirmingAlert.value) return
|
||||
await alertStore.acknowledge(confirmingAlert.value.id, note)
|
||||
confirmingAlert.value = null
|
||||
await loadEncounterAlerts()
|
||||
}
|
||||
|
||||
@@ -72,13 +76,19 @@ async function resolve(alertId) {
|
||||
<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="acknowledge(alert.id)"
|
||||
@click.stop="confirmingAlert = alert"
|
||||
>
|
||||
Ack
|
||||
</Button>
|
||||
@@ -93,5 +103,12 @@ async function resolve(alertId) {
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<AcknowledgeModal
|
||||
:open="!!confirmingAlert"
|
||||
:alert="confirmingAlert"
|
||||
@confirm="handleAcknowledge"
|
||||
@close="confirmingAlert = null"
|
||||
/>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { bundleElementLabel } from '@/api/normalize'
|
||||
import {
|
||||
bundleUrgency,
|
||||
formatCountdown,
|
||||
formatDepartment,
|
||||
outstandingElements,
|
||||
urgencyLabel,
|
||||
urgencyVariant,
|
||||
} from '@/composables/sepsisFormat'
|
||||
|
||||
const props = defineProps({
|
||||
bundle: { type: Object, required: true },
|
||||
now: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const urgency = computed(() => bundleUrgency(props.bundle, props.now))
|
||||
const outstanding = computed(() => outstandingElements(props.bundle))
|
||||
</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"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ bundle.firstName }} {{ bundle.lastName }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.mrn }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatDepartment(bundle.department) }}
|
||||
<span v-if="bundle.roomBed"> · {{ bundle.roomBed }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Badge :variant="urgencyVariant(urgency)">{{ urgencyLabel(urgency) }}</Badge>
|
||||
</div>
|
||||
|
||||
<dl class="mt-4 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4 dark:border-gray-800">
|
||||
<div>
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Time remaining</dt>
|
||||
<dd
|
||||
class="mt-2 font-mono text-sm font-semibold"
|
||||
:class="urgency === 'overdue' ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ formatCountdown(bundle.deadlineAt, now) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Outstanding</dt>
|
||||
<dd class="mt-2 text-sm text-gray-900 dark:text-white">
|
||||
<span v-if="outstanding.length === 0">None</span>
|
||||
<ul v-else class="space-y-1">
|
||||
<li v-for="element in outstanding" :key="element.id" class="text-xs">
|
||||
{{ bundleElementLabel(element.elementType) }}
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import { bundleElementLabel } from '@/api/normalize'
|
||||
import {
|
||||
bundleUrgency,
|
||||
complianceStatusLabel,
|
||||
formatCountdown,
|
||||
formatDepartment,
|
||||
outstandingElements,
|
||||
urgencyLabel,
|
||||
urgencyVariant,
|
||||
} from '@/composables/sepsisFormat'
|
||||
|
||||
const props = defineProps({
|
||||
bundle: { type: Object, required: true },
|
||||
now: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const urgency = computed(() => bundleUrgency(props.bundle, props.now))
|
||||
const outstanding = computed(() => outstandingElements(props.bundle))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4 py-4">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ bundle.firstName }} {{ bundle.lastName }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.mrn }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDepartment(bundle.department) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ bundle.roomBed ?? '—' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ new Date(bundle.recognizedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ new Date(bundle.deadlineAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-right">
|
||||
<span
|
||||
class="font-mono text-sm font-semibold"
|
||||
:class="urgency === 'overdue' ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
|
||||
>
|
||||
{{ formatCountdown(bundle.deadlineAt, now) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4">
|
||||
<Badge :variant="urgencyVariant(urgency)">{{ urgencyLabel(urgency) }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ complianceStatusLabel(bundle.complianceStatus) }}</span>
|
||||
<ul v-if="outstanding.length" class="mt-2 space-y-1">
|
||||
<li v-for="element in outstanding" :key="element.id" class="text-xs text-amber-700 dark:text-amber-300">
|
||||
{{ bundleElementLabel(element.elementType) }}
|
||||
</li>
|
||||
</ul>
|
||||
<span v-else class="mt-1 block text-xs text-green-700 dark:text-green-300">All elements complete</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import SepsisBundleRow from './SepsisBundleRow.vue'
|
||||
import SepsisBundleCard from './SepsisBundleCard.vue'
|
||||
|
||||
defineProps({
|
||||
bundles: { type: Array, required: true },
|
||||
now: { type: Number, required: true },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goToPatient(encounterId) {
|
||||
router.push({ name: 'PatientDetail', params: { encounterId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 md:hidden">
|
||||
<SepsisBundleCard
|
||||
v-for="bundle in bundles"
|
||||
:key="bundle.id"
|
||||
:bundle="bundle"
|
||||
:now="now"
|
||||
@click="goToPatient(bundle.encounterId)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Patient</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Department</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Room</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Started</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Deadline</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Remaining</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Elements</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
<SepsisBundleRow
|
||||
v-for="bundle in bundles"
|
||||
:key="bundle.id"
|
||||
:bundle="bundle"
|
||||
:now="now"
|
||||
class="cursor-pointer transition duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
@click="goToPatient(bundle.encounterId)"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
patientRoom,
|
||||
stalenessClass,
|
||||
} from '@/composables/wardFormat'
|
||||
import { formatDepartment } from '@/composables/sepsisFormat'
|
||||
|
||||
const props = defineProps({ patient: { type: Object, required: true } })
|
||||
|
||||
@@ -50,6 +51,9 @@ const vitalsStaleness = computed(() =>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDepartment(patient.department) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-right">
|
||||
<Badge :variant="riskVariant">{{ patient.news2Score ?? '—' }}</Badge>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: { type: String, required: true },
|
||||
field: { type: String, required: true },
|
||||
activeField: { type: String, required: true },
|
||||
direction: { type: String, required: true },
|
||||
align: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
validator: value => ['left', 'right'].includes(value),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sort'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
class="px-4 py-4 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
:class="align === 'right' ? 'text-right' : 'text-left'"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 transition duration-200 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:text-gray-200"
|
||||
:class="[
|
||||
align === 'right' ? 'ml-auto' : '',
|
||||
activeField === field ? 'text-gray-900 dark:text-white' : '',
|
||||
]"
|
||||
@click="emit('sort', field)"
|
||||
>
|
||||
<span>{{ label }}</span>
|
||||
<svg
|
||||
v-if="activeField === field"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-if="direction === 'asc'"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</th>
|
||||
</template>
|
||||
@@ -2,8 +2,15 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import PatientRow from './PatientRow.vue'
|
||||
import PatientCard from './PatientCard.vue'
|
||||
import SortableHeader from './SortableHeader.vue'
|
||||
|
||||
defineProps({ patients: { type: Array, required: true } })
|
||||
defineProps({
|
||||
patients: { type: Array, required: true },
|
||||
sortField: { type: String, required: true },
|
||||
sortDirection: { type: String, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sort'])
|
||||
const router = useRouter()
|
||||
|
||||
function goToPatient(encounterId) {
|
||||
@@ -27,17 +34,72 @@ function goToPatient(encounterId) {
|
||||
<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-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Room</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Patient</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">NEWS2</th>
|
||||
<th 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">GCS</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">qSOFA</th>
|
||||
<th 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">LOS</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Last vitals</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Sepsis</th>
|
||||
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Alerts</th>
|
||||
<SortableHeader
|
||||
label="Room"
|
||||
field="roomBed"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="Patient"
|
||||
field="name"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="Department"
|
||||
field="department"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="NEWS2"
|
||||
field="news2Score"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
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">
|
||||
SOFA
|
||||
</th>
|
||||
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
GCS
|
||||
</th>
|
||||
<SortableHeader
|
||||
label="qSOFA"
|
||||
field="qsofaScore"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
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">
|
||||
Attending
|
||||
</th>
|
||||
<th 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">
|
||||
Last vitals
|
||||
</th>
|
||||
<SortableHeader
|
||||
label="Sepsis"
|
||||
field="sepsis"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
<SortableHeader
|
||||
label="Alerts"
|
||||
field="openAlertCount"
|
||||
:active-field="sortField"
|
||||
:direction="sortDirection"
|
||||
@sort="emit('sort', $event)"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const wardStore = useWardStore()
|
||||
const { searchInput, filters, hasActiveFilters } = storeToRefs(wardStore)
|
||||
|
||||
const filterOptions = [
|
||||
{ key: 'hasAlerts', label: 'Has alerts' },
|
||||
{ key: 'sepsisActive', label: 'Sepsis active' },
|
||||
{ key: 'critical', label: 'Critical (NEWS2 ≥ 7)' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<label class="block">
|
||||
<span class="sr-only">Search patients</span>
|
||||
<input
|
||||
:value="searchInput"
|
||||
type="search"
|
||||
placeholder="Search by name or MRN…"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
@input="wardStore.setSearchInput($event.target.value)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
v-for="option in filterOptions"
|
||||
:key="option.key"
|
||||
size="sm"
|
||||
:variant="filters[option.key] ? 'primary' : 'secondary'"
|
||||
@click="wardStore.toggleFilter(option.key)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="wardStore.clearFilters()"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user