feature: Improve dashboard functionality

This commit is contained in:
voltsrage
2026-06-23 20:19:32 +08:00
parent dd0fd88731
commit 79b6d9d85e
60 changed files with 2857 additions and 164 deletions
@@ -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>