feature: Degraded Operations Visibility

This commit is contained in:
voltsrage
2026-06-23 23:18:31 +08:00
parent 940e27c0ac
commit 4399996448
81 changed files with 3949 additions and 323 deletions
@@ -0,0 +1,321 @@
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as auditApi from '@/api/audit'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
AUDIT_ACTIONS,
PHI_ACCESS_TYPES,
actionLabel,
accessTypeLabel,
formatAuditTimestamp,
formatJsonBlock,
defaultFromDate,
defaultToDate,
toIsoOffset,
} from '@/composables/auditFormat'
const activeTab = ref('audit')
const loading = ref(true)
const error = ref('')
const page = ref(1)
const pageSize = 50
const totalPages = ref(1)
const totalCount = ref(0)
const items = ref([])
const expandedId = ref(null)
const filters = reactive({
from: defaultFromDate(),
to: defaultToDate(),
entityType: '',
action: '',
accessType: '',
patientId: '',
userId: '',
})
async function load() {
loading.value = true
error.value = ''
try {
const common = {
from: toIsoOffset(filters.from),
to: toIsoOffset(filters.to),
page: page.value,
pageSize,
}
const result = activeTab.value === 'audit'
? await auditApi.fetchAuditLogs({
...common,
entityType: filters.entityType || undefined,
action: filters.action || undefined,
userId: filters.userId || undefined,
})
: await auditApi.fetchPhiAccessLogs({
...common,
accessType: filters.accessType || undefined,
patientId: filters.patientId || undefined,
userId: filters.userId || undefined,
})
items.value = result.items ?? []
totalPages.value = result.totalPages ?? 1
totalCount.value = result.totalCount ?? 0
} catch (err) {
error.value = err.message
items.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
expandedId.value = null
load()
}
function switchTab(tab) {
activeTab.value = tab
page.value = 1
expandedId.value = null
load()
}
function toggleExpand(id) {
expandedId.value = expandedId.value === id ? null : id
}
function prevPage() {
if (page.value > 1) {
page.value -= 1
load()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value += 1
load()
}
}
onMounted(load)
</script>
<template>
<div>
<div class="mb-4">
<h1 class="text-xl font-bold dark:text-white">Audit Logs</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Browse clinical audit events and PHI access history for compliance review.
</p>
</div>
<div class="mb-4 flex gap-2 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'audit'
? '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="switchTab('audit')"
>
Clinical audit
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'phi'
? '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="switchTab('phi')"
>
PHI access
</button>
</div>
<form
class="mb-4 grid gap-4 rounded-lg border border-gray-200 p-4 dark:border-gray-700 sm:grid-cols-2 lg:grid-cols-4"
@submit.prevent="applyFilters"
>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
From
</label>
<input
v-model="filters.from"
type="datetime-local"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
To
</label>
<input
v-model="filters.to"
type="datetime-local"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<template v-if="activeTab === 'audit'">
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Entity type
</label>
<input
v-model="filters.entityType"
type="text"
placeholder="e.g. Patient"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Action
</label>
<select
v-model="filters.action"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All actions</option>
<option v-for="opt in AUDIT_ACTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</template>
<template v-else>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Patient ID
</label>
<input
v-model="filters.patientId"
type="text"
placeholder="UUID"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Access type
</label>
<select
v-model="filters.accessType"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
<option value="">All types</option>
<option v-for="opt in PHI_ACCESS_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</template>
<div>
<label class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
User ID
</label>
<input
v-model="filters.userId"
type="text"
placeholder="UUID"
class="w-full rounded border border-gray-300 px-4 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-white"
>
</div>
<div class="flex items-end sm:col-span-2 lg:col-span-1">
<Button type="submit" size="sm">Apply filters</Button>
</div>
</form>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="8" />
<EmptyState v-else-if="items.length === 0" message="No log entries match the current filters" />
<template v-else>
<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 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Time</th>
<th v-if="activeTab === 'audit'" class="px-4 py-3">Action</th>
<th v-else class="px-4 py-3">Access type</th>
<th class="px-4 py-3">User</th>
<th v-if="activeTab === 'audit'" class="px-4 py-3">Entity</th>
<th v-else class="px-4 py-3">Patient</th>
<th v-if="activeTab === 'phi'" class="px-4 py-3">Resource</th>
<th class="px-4 py-3" />
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template v-for="entry in items" :key="entry.id">
<tr>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatAuditTimestamp(activeTab === 'audit' ? entry.createdAt : entry.accessedAt) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ activeTab === 'audit' ? actionLabel(entry.action) : accessTypeLabel(entry.accessType) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ entry.userDisplayName ?? '—' }}
</td>
<td v-if="activeTab === 'audit'" class="px-4 py-3 text-gray-700 dark:text-gray-300">
<span class="font-medium">{{ entry.entityType }}</span>
<span class="block font-mono text-xs text-gray-500 dark:text-gray-400">{{ entry.entityId }}</span>
</td>
<td v-else class="px-4 py-3 font-mono text-xs text-gray-700 dark:text-gray-300">
{{ entry.patientId ?? '—' }}
</td>
<td v-if="activeTab === 'phi'" class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ entry.resourcePath }}
</td>
<td class="px-4 py-3 text-right">
<Button
v-if="activeTab === 'audit' && (entry.previousValueJson || entry.newValueJson)"
size="sm"
variant="secondary"
@click="toggleExpand(entry.id)"
>
{{ expandedId === entry.id ? 'Hide' : 'Details' }}
</Button>
</td>
</tr>
<tr v-if="activeTab === 'audit' && expandedId === entry.id">
<td colspan="6" class="bg-gray-50 px-4 py-4 dark:bg-gray-800/50">
<div class="grid gap-4 lg:grid-cols-2">
<div v-if="entry.previousValueJson">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Previous</p>
<pre class="overflow-x-auto rounded border border-gray-200 bg-white p-4 text-xs dark:border-gray-700 dark:bg-gray-900">{{ formatJsonBlock(entry.previousValueJson) }}</pre>
</div>
<div v-if="entry.newValueJson">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">New</p>
<pre class="overflow-x-auto rounded border border-gray-200 bg-white p-4 text-xs dark:border-gray-700 dark:bg-gray-900">{{ formatJsonBlock(entry.newValueJson) }}</pre>
</div>
</div>
<p v-if="entry.reason" class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Reason: {{ entry.reason }}
</p>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>{{ totalCount }} entries page {{ page }} of {{ totalPages }}</span>
<div class="flex gap-2">
<Button size="sm" variant="secondary" :disabled="page <= 1" @click="prevPage">Previous</Button>
<Button size="sm" variant="secondary" :disabled="page >= totalPages" @click="nextPage">Next</Button>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,114 @@
<script setup>
import { computed, ref } from 'vue'
import { useOperationsStore } from '@/stores/operationsStore'
import { usePolling } from '@/composables/usePolling'
const store = useOperationsStore()
const drawerOpen = ref(false)
const tabs = [
{ label: 'All', value: null },
{ label: 'Degraded', value: 'DEGRADED' },
{ label: 'Offline', value: 'OFFLINE' },
]
usePolling(() => store.fetchFleet(), 15_000)
function statusBadgeClass(status) {
if (status === 'ONLINE') return 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
if (status === 'DEGRADED') return 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300'
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}
function formatMinutes(m) {
if (m == null) return '—'
return `${Math.round(m)} min ago`
}
async function openDetail(gateway) {
await store.fetchGatewayDetail(gateway.id)
drawerOpen.value = true
}
</script>
<template>
<div class="space-y-8">
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Gateway Fleet</h1>
<div class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.label"
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium transition duration-150"
:class="store.statusFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'"
@click="store.setStatusFilter(tab.value)"
>
{{ tab.label }}
</button>
</div>
</header>
<p v-if="store.error" class="text-sm text-red-600">{{ store.error }}</p>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-2 text-left text-sm font-medium">Site</th>
<th class="px-4 py-2 text-left text-sm font-medium">Gateway</th>
<th class="px-4 py-2 text-left text-sm font-medium">Department</th>
<th class="px-4 py-2 text-left text-sm font-medium">Status</th>
<th class="px-4 py-2 text-left text-sm font-medium">Buffer</th>
<th class="px-4 py-2 text-left text-sm font-medium">Last Heartbeat</th>
<th class="px-4 py-2 text-left text-sm font-medium">Last Sync</th>
<th class="px-4 py-2 text-left text-sm font-medium">Minutes Offline</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
<tr
v-for="gw in store.fleet"
:key="gw.id"
class="cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900/50"
@click="openDetail(gw)"
>
<td class="px-4 py-2 text-sm">{{ gw.siteName }}</td>
<td class="px-4 py-2 text-sm font-medium">{{ gw.gatewayCode }}</td>
<td class="px-4 py-2 text-sm">{{ gw.department }}</td>
<td class="px-4 py-2">
<span class="rounded-full px-2 py-1 text-xs font-medium" :class="statusBadgeClass(gw.status)">
{{ gw.status }}
</span>
</td>
<td class="px-4 py-2 text-sm">{{ gw.reportedBufferDepth }}</td>
<td class="px-4 py-2 text-sm">{{ formatMinutes(gw.minutesSinceHeartbeat) }}</td>
<td class="px-4 py-2 text-sm">{{ gw.lastSyncAt ? new Date(gw.lastSyncAt).toLocaleString() : '—' }}</td>
<td class="px-4 py-2 text-sm">{{ gw.minutesSinceHeartbeat != null ? Math.round(gw.minutesSinceHeartbeat) : '—' }}</td>
</tr>
</tbody>
</table>
</div>
<aside
v-if="drawerOpen && store.selectedGateway"
class="fixed inset-y-0 right-0 z-50 w-full max-w-md border-l border-gray-200 bg-white p-4 shadow-lg dark:border-gray-800 dark:bg-gray-900"
>
<button type="button" class="mb-4 text-sm text-blue-600" @click="drawerOpen = false">Close</button>
<h2 class="mb-4 text-lg font-semibold">{{ store.selectedGateway.gateway.gatewayCode }}</h2>
<h3 class="mb-2 text-sm font-medium text-gray-500">Recent sync batches</h3>
<ul class="space-y-2">
<li
v-for="batch in store.selectedGateway.recentBatches"
:key="batch.batchId"
class="rounded-lg border border-gray-200 p-4 text-sm dark:border-gray-800"
>
<span class="font-medium">{{ batch.status }}</span>
{{ new Date(batch.submittedAt).toLocaleString() }}
<span v-if="batch.conflictCount"> ({{ batch.conflictCount }} conflicts)</span>
</li>
</ul>
</aside>
</div>
</template>
+7 -1
View File
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { defaultRouteForRole, isDashboardRole } from '@/composables/roleAccess'
import Button from '@/components/ui/Button.vue'
const router = useRouter()
@@ -18,7 +19,12 @@ async function submit() {
loading.value = true
try {
await auth.login(username.value, password.value)
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/ward'
if (!isDashboardRole(auth.role)) {
auth.logout()
error.value = 'This account is for API integration only. Sign in with a clinical user.'
return
}
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : defaultRouteForRole(auth.role)
await router.push(redirect)
} catch (e) {
error.value = e.message ?? 'Login failed'
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePolling } from '@/composables/usePolling'
import { useReplayControls } from '@/composables/useReplayControls'
import { useRoleAccess } from '@/composables/roleAccess'
import { useAlertStore } from '@/stores/alerts'
import { useScoringStore } from '@/stores/scoring'
import * as encountersApi from '@/api/encounters'
@@ -21,11 +22,14 @@ import GcsHistory from '@/components/charts/GcsHistory.vue'
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
import SofaHistory from '@/components/charts/SofaHistory.vue'
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
import DischargeSummaryPanel from '@/components/patient/DischargeSummaryPanel.vue'
import ReplayControls from '@/components/replay/ReplayControls.vue'
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
import CollapsibleSection from '@/components/ui/CollapsibleSection.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
const route = useRoute()
const { nursePatientLayout, physicianPatientLayout } = useRoleAccess()
const alertStore = useAlertStore()
const scoringStore = useScoringStore()
const { alerts } = storeToRefs(alertStore)
@@ -66,6 +70,8 @@ const openAlerts = computed(() =>
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
)
const isDischarged = computed(() => encounter.value?.status === 'Discharged')
const replayObservations = computed(() =>
observations.value.filter(o => isAtOrBefore(o.recordedAt)),
)
@@ -198,26 +204,43 @@ onBeforeUnmount(() => {
<template>
<Skeleton v-if="loading && !encounter" :rows="6" />
<div v-else-if="encounter" class="w-full min-w-0 space-y-8">
<div v-else-if="encounter" class="w-full min-w-0 space-y-6 lg:space-y-8">
<div class="flex flex-wrap items-center gap-4">
<RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<RouterLink
to="/ward"
class="inline-flex min-h-11 items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
&larr; Ward
</RouterLink>
</div>
<PatientBanner :encounter="encounter" />
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-4">
<DischargeSummaryPanel
:encounter-id="route.params.encounterId"
:discharged="isDischarged"
/>
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-3">
<div
class="order-1 space-y-4"
:class="{
'lg:order-3': nursePatientLayout,
'lg:order-1': physicianPatientLayout || (!nursePatientLayout && !physicianPatientLayout),
}"
>
<ScoresPanel />
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
</div>
<VitalsPanel
class="order-2 min-w-0 lg:order-2"
:encounter-id="route.params.encounterId"
:observations="replayObservations"
@recorded="loadAll"
/>
<AlertsList
class="order-3 min-w-0 lg:order-3"
:class="{ 'lg:order-1': nursePatientLayout }"
:encounter-id="route.params.encounterId"
:selected-id="selectedAlert?.id"
@select="onSelectAlert"
@@ -230,12 +253,14 @@ onBeforeUnmount(() => {
:medications="medications"
/>
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
<div class="space-y-4">
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-2">
<div class="order-2 space-y-4 lg:order-1">
<SofaScorePanel :encounter-id="route.params.encounterId" />
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
</div>
<OrdersPanel :orders="orders" />
<div class="order-1 lg:order-2">
<OrdersPanel :orders="orders" />
</div>
</div>
<SepsisBundlePanel
@@ -244,9 +269,19 @@ onBeforeUnmount(() => {
:sofa="sofa"
/>
<EncounterTimeline :events="replayTimelineEvents" />
<div class="hidden lg:block">
<EncounterTimeline :events="replayTimelineEvents" />
</div>
<CollapsibleSection
class="lg:hidden"
section-id="encounter-timeline"
title="Encounter timeline"
:default-open="false"
>
<EncounterTimeline :events="replayTimelineEvents" />
</CollapsibleSection>
<div id="clinical-review" class="w-full min-w-0 space-y-8">
<div id="clinical-review" class="hidden w-full min-w-0 space-y-6 lg:block lg:space-y-8">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
@@ -262,5 +297,37 @@ onBeforeUnmount(() => {
@jump-to-alert="jumpToNextAlert"
/>
</div>
<CollapsibleSection
class="lg:hidden"
section-id="clinical-review"
title="Vital trends & clinical review"
:default-open="false"
>
<div class="space-y-6">
<TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
</div>
</CollapsibleSection>
<CollapsibleSection
class="lg:hidden"
section-id="replay-controls"
title="Scenario replay"
:default-open="false"
>
<ReplayControls
:alerts="openAlerts"
:is-paused="isPaused"
:progress="progress"
:formatted-time="formattedTime"
:speed="speed"
@pause="pause()"
@resume="resume()"
@set-speed="setSpeed"
@jump-to-alert="jumpToNextAlert"
/>
</CollapsibleSection>
</div>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import * as reconciliationApi from '@/api/reconciliation'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
RECONCILIATION_SECTIONS,
formatReconciliationTimestamp,
} from '@/composables/reconciliationFormat'
const includeResolved = ref(false)
const loading = ref(true)
const error = ref('')
const sections = ref([])
async function loadSection(section) {
const result = await reconciliationApi.fetchReconciliationAlerts({
checkType: section.checkType,
resolved: includeResolved.value ? undefined : false,
pageSize: 100,
})
return {
...section,
items: result.items ?? [],
totalCount: result.totalCount ?? 0,
}
}
async function load() {
loading.value = true
error.value = ''
try {
sections.value = await Promise.all(RECONCILIATION_SECTIONS.map(loadSection))
} catch (err) {
error.value = err.message
sections.value = []
} finally {
loading.value = false
}
}
function onToggleResolved() {
load()
}
onMounted(load)
</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">Data Quality</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Reconciliation findings from periodic workflow checks across the ward.
</p>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
v-model="includeResolved"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
@change="onToggleResolved"
>
Include resolved
</label>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="10" />
<div v-else class="space-y-8">
<section
v-for="section in sections"
:key="section.checkType"
class="rounded-lg border border-gray-200 dark:border-gray-700"
>
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-700">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="font-semibold text-gray-900 dark:text-white">{{ section.title }}</h2>
<Badge :variant="section.totalCount > 0 ? 'warning' : 'success'">
{{ section.totalCount }} finding{{ section.totalCount === 1 ? '' : 's' }}
</Badge>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ section.description }}</p>
</div>
<EmptyState
v-if="section.items.length === 0"
message="No findings in this category"
/>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Detected</th>
<th class="px-4 py-3">Details</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Patient</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="item in section.items" :key="item.id">
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatReconciliationTimestamp(item.createdAt) }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ item.details }}</td>
<td class="px-4 py-3">
<Badge :variant="item.resolvedAt ? 'success' : 'warning'">
{{ item.resolvedAt ? 'Resolved' : 'Open' }}
</Badge>
</td>
<td class="px-4 py-3 text-right">
<RouterLink
v-if="item.encounterId"
:to="`/patients/${item.encounterId}`"
class="text-sm font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
View patient
</RouterLink>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
<div class="mt-4">
<Button size="sm" variant="secondary" @click="load">Refresh</Button>
</div>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as thresholdsApi from '@/api/thresholds'
import ThresholdFormModal from '@/components/admin/ThresholdFormModal.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { emptyThresholdForm, formatThresholdValue, thresholdToForm } from '@/composables/thresholdForm'
const thresholds = ref([])
const loading = ref(true)
const error = ref('')
const modalOpen = ref(false)
const modalMode = ref('edit')
const editingThreshold = ref(null)
const submitting = ref(false)
const submitError = ref('')
const modalTitle = computed(() =>
modalMode.value === 'create' ? 'Create Threshold' : 'Edit Threshold',
)
const modalInitialValues = computed(() =>
modalMode.value === 'create'
? emptyThresholdForm()
: thresholdToForm(editingThreshold.value ?? {}),
)
async function loadThresholds() {
loading.value = true
error.value = ''
try {
thresholds.value = await thresholdsApi.fetchThresholds()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function openCreate() {
modalMode.value = 'create'
editingThreshold.value = null
submitError.value = ''
modalOpen.value = true
}
function openEdit(threshold) {
modalMode.value = 'edit'
editingThreshold.value = threshold
submitError.value = ''
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
async function onSubmit(payload) {
submitting.value = true
submitError.value = ''
try {
if (modalMode.value === 'create') {
await thresholdsApi.createThreshold(payload)
} else {
await thresholdsApi.updateThreshold(editingThreshold.value.id, payload)
}
modalOpen.value = false
await loadThresholds()
} catch (err) {
submitError.value = err.message
} finally {
submitting.value = false
}
}
async function onDelete(threshold) {
if (!window.confirm(`Delete threshold for ${threshold.observationCode}?`)) return
error.value = ''
try {
await thresholdsApi.deleteThreshold(threshold.id)
await loadThresholds()
} catch (err) {
error.value = err.message
}
}
onMounted(loadThresholds)
</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">Alert Thresholds</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure warning and critical bounds used for clinical alerting.
</p>
</div>
<Button size="sm" @click="openCreate">Create Threshold</Button>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="6" />
<EmptyState v-else-if="thresholds.length === 0" message="No thresholds configured" />
<div v-else class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Code</th>
<th class="px-4 py-3">Display</th>
<th class="px-4 py-3">Unit</th>
<th class="px-4 py-3 text-right">Critical Low</th>
<th class="px-4 py-3 text-right">Warning Low</th>
<th class="px-4 py-3 text-right">Warning High</th>
<th class="px-4 py-3 text-right">Critical High</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="threshold in thresholds" :key="threshold.id">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ threshold.observationCode }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ threshold.displayName }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ threshold.unit }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.criticalLow) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.warningLow) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.warningHigh) }}</td>
<td class="px-4 py-3 text-right">{{ formatThresholdValue(threshold.criticalHigh) }}</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2">
<Button size="sm" variant="secondary" @click="openEdit(threshold)">Edit</Button>
<Button size="sm" variant="danger" @click="onDelete(threshold)">Delete</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<ThresholdFormModal
:open="modalOpen"
:title="modalTitle"
:initial-values="modalInitialValues"
:submitting="submitting"
:error="submitError"
:read-only-code="modalMode === 'edit'"
@close="closeModal"
@submit="onSubmit"
/>
</div>
</template>
@@ -0,0 +1,151 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as usersApi from '@/api/users'
import UserFormModal from '@/components/admin/UserFormModal.vue'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
emptyUserForm,
userToForm,
roleLabel,
formatLastLogin,
} from '@/composables/userForm'
const users = ref([])
const loading = ref(true)
const error = ref('')
const modalOpen = ref(false)
const modalMode = ref('edit')
const editingUser = ref(null)
const submitting = ref(false)
const submitError = ref('')
const modalTitle = computed(() =>
modalMode.value === 'create' ? 'Create User' : 'Edit User',
)
const modalInitialValues = computed(() =>
modalMode.value === 'create'
? emptyUserForm()
: userToForm(editingUser.value ?? {}),
)
async function loadUsers() {
loading.value = true
error.value = ''
try {
users.value = await usersApi.fetchUsers()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
function openCreate() {
modalMode.value = 'create'
editingUser.value = null
submitError.value = ''
modalOpen.value = true
}
function openEdit(user) {
modalMode.value = 'edit'
editingUser.value = user
submitError.value = ''
modalOpen.value = true
}
function closeModal() {
modalOpen.value = false
}
async function onSubmit(payload) {
submitting.value = true
submitError.value = ''
try {
if (modalMode.value === 'create') {
await usersApi.createUser(payload)
} else {
await usersApi.updateUser(editingUser.value.id, payload)
}
modalOpen.value = false
await loadUsers()
} catch (err) {
submitError.value = err.message
} finally {
submitting.value = false
}
}
onMounted(loadUsers)
</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">User Management</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Create clinical accounts, assign roles, and deactivate users.
</p>
</div>
<Button size="sm" @click="openCreate">Create User</Button>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading" :rows="6" />
<EmptyState v-else-if="users.length === 0" message="No users found" />
<div v-else class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr class="text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Username</th>
<th class="px-4 py-3">Display name</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Last login</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="user in users" :key="user.id">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ user.username }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ user.displayName }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">{{ roleLabel(user.role) }}</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ formatLastLogin(user.lastLoginAt) }}
</td>
<td class="px-4 py-3">
<Badge :variant="user.isActive ? 'success' : 'critical'">
{{ user.isActive ? 'Active' : 'Inactive' }}
</Badge>
</td>
<td class="px-4 py-3">
<div class="flex justify-end">
<Button size="sm" variant="secondary" @click="openEdit(user)">Edit</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<UserFormModal
:open="modalOpen"
:title="modalTitle"
:mode="modalMode"
:initial-values="modalInitialValues"
:submitting="submitting"
:error="submitError"
@close="closeModal"
@submit="onSubmit"
/>
</div>
</template>