feature: Degraded Operations Visibility
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
Reference in New Issue
Block a user