feature: Improve dashboard functionality
This commit is contained in:
@@ -19,9 +19,9 @@ watch(activeFilter, (status) => {
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(status))
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleAcknowledge() {
|
||||
async function handleAcknowledge(note) {
|
||||
if (!confirmingAlert.value) return
|
||||
await alertStore.acknowledge(confirmingAlert.value.id)
|
||||
await alertStore.acknowledge(confirmingAlert.value.id, note)
|
||||
confirmingAlert.value = null
|
||||
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useDepartmentsStore } from '@/stores/departments'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import DepartmentCard from '@/components/departments/DepartmentCard.vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const departmentsStore = useDepartmentsStore()
|
||||
const { departments, totals, loading, error } = storeToRefs(departmentsStore)
|
||||
|
||||
usePolling(() => departmentsStore.load(), 10_000)
|
||||
|
||||
function onSelectDepartment(filterValue) {
|
||||
router.push({ name: 'WardDashboard', query: { department: filterValue } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold dark:text-white">Department Overview</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Unit-level snapshot across active patients, acuity, alerts, and sepsis bundles.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-if="totals.criticalCount > 0" variant="critical">
|
||||
{{ totals.criticalCount }} critical
|
||||
</Badge>
|
||||
<Badge v-if="totals.openAlertCount > 0" variant="warning">
|
||||
{{ totals.openAlertCount }} open alerts
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold dark:text-white">{{ totals.patientCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active patients</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-red-600">{{ totals.criticalCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Critical (NEWS2 ≥ 7)</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-amber-600">{{ totals.openAlertCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Open alerts</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ totals.activeBundleCount }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active sepsis bundles</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Skeleton v-if="loading && departments.length === 0" :rows="3" />
|
||||
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<DepartmentCard
|
||||
v-for="department in departments"
|
||||
:key="department.key"
|
||||
:department="department"
|
||||
@select="onSelectDepartment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSepsisStore } from '@/stores/sepsis'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import SepsisBundleTable from '@/components/sepsis/SepsisBundleTable.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const sepsisStore = useSepsisStore()
|
||||
const { sortedBundles, loading, error, summary, now } = storeToRefs(sepsisStore)
|
||||
|
||||
usePolling(() => sepsisStore.loadBundles(), 10_000)
|
||||
|
||||
let countdownTimer = null
|
||||
onMounted(() => {
|
||||
sepsisStore.loadBundles()
|
||||
countdownTimer = setInterval(() => sepsisStore.tick(), 1000)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
})
|
||||
|
||||
const summaryLine = computed(() => {
|
||||
const total = sortedBundles.value.length
|
||||
if (total === 0) return 'No active sepsis bundles'
|
||||
const { on_track: onTrack, at_risk: atRisk, overdue } = summary.value
|
||||
return `${total} active bundle${total === 1 ? '' : 's'} — ${onTrack} on track, ${atRisk} at risk, ${overdue} overdue`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold dark:text-white">Sepsis Bundle Board</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ summaryLine }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge v-if="summary.overdue > 0" variant="critical">{{ summary.overdue }} overdue</Badge>
|
||||
<Badge v-if="summary.at_risk > 0" variant="warning">{{ summary.at_risk }} at risk</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<Skeleton v-if="loading && sortedBundles.length === 0" :rows="4" />
|
||||
<EmptyState v-else-if="sortedBundles.length === 0" message="No active sepsis bundles" />
|
||||
<SepsisBundleTable
|
||||
v-else
|
||||
:bundles="sortedBundles"
|
||||
:now="now"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,32 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useWardStore } from '@/stores/ward'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { usePolling } from '@/composables/usePolling'
|
||||
import { WARD_SORT_FIELDS } from '@/composables/wardSort'
|
||||
import WardToolbar from '@/components/ward/WardToolbar.vue'
|
||||
import WardTable from '@/components/ward/WardTable.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const wardStore = useWardStore()
|
||||
const { sortedByRisk, loading, error, criticalCount, department } = storeToRefs(wardStore)
|
||||
const settingsStore = useSettingsStore()
|
||||
const {
|
||||
encounters,
|
||||
displayEncounters,
|
||||
loading,
|
||||
error,
|
||||
criticalCount,
|
||||
department,
|
||||
} = storeToRefs(wardStore)
|
||||
const { wardSortField, wardSortDirection } = storeToRefs(settingsStore)
|
||||
|
||||
if (route.query.department) {
|
||||
wardStore.setDepartment(String(route.query.department))
|
||||
}
|
||||
|
||||
usePolling(() => wardStore.loadEncounters(), 10_000)
|
||||
|
||||
@@ -22,6 +40,10 @@ const departments = [
|
||||
function onDepartmentChange(event) {
|
||||
wardStore.setDepartment(event.target.value || null)
|
||||
}
|
||||
|
||||
function onMobileSortChange(event) {
|
||||
wardStore.setSort(event.target.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -48,8 +70,38 @@ function onDepartmentChange(event) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<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" />
|
||||
<WardToolbar />
|
||||
|
||||
<label class="mb-4 block md:hidden">
|
||||
<span class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Sort by
|
||||
</span>
|
||||
<select
|
||||
:value="wardSortField"
|
||||
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
@change="onMobileSortChange"
|
||||
>
|
||||
<option v-for="option in WARD_SORT_FIELDS" :key="option.key" :value="option.key">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<Skeleton v-if="loading && encounters.length === 0" :rows="5" />
|
||||
<EmptyState
|
||||
v-else-if="encounters.length === 0"
|
||||
message="No active patients"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="displayEncounters.length === 0"
|
||||
message="No patients match your search or filters"
|
||||
/>
|
||||
<WardTable
|
||||
v-else
|
||||
:patients="displayEncounters"
|
||||
:sort-field="wardSortField"
|
||||
:sort-direction="wardSortDirection"
|
||||
@sort="wardStore.setSort"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user