add frontend

This commit is contained in:
voltsrage
2026-06-19 17:42:09 +08:00
parent 49973271e5
commit 5f69ce1a95
53 changed files with 6591 additions and 0 deletions
@@ -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>