feature: Supporting Screens
CI / backend (push) Successful in 6m0s
CI / frontend (push) Failing after 1m7s

This commit is contained in:
2026-08-12 04:39:32 +08:00
parent 5caf928787
commit 4085aaa549
11 changed files with 1876 additions and 1065 deletions
@@ -28,6 +28,31 @@ vi.mock('@/components/PatientSearch.vue', () => ({
},
}))
vi.mock('@/components/StatusBadge.vue', () => ({
default: {
props: ['status'],
template: '<span data-testid="status-badge">{{ status }}</span>',
},
}))
vi.mock('@/components/EmptyState.vue', () => ({
default: {
props: ['title', 'description'],
template: '<div data-testid="empty-state">{{ title }}</div>',
},
}))
vi.mock('@/components/SkeletonBlock.vue', () => ({
default: { template: '<div data-testid="skeleton" />' },
}))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error">{{ message }}</div>',
},
}))
import { get, post } from '@/api/client'
const mockedGet = vi.mocked(get)
@@ -75,18 +100,33 @@ beforeEach(() => {
})
describe('CoverSheetView', () => {
it('renders generate form and cover sheet list', async () => {
const wrapper = mount(CoverSheetView)
it('renders generate form, preview, and cover sheet list', async () => {
const wrapper = mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Generate Cover Sheets')
expect(wrapper.text()).toContain('Cover Sheet List')
expect(wrapper.text()).toContain('Preview')
expect(wrapper.text()).toContain('Existing Cover Sheets')
expect(wrapper.text()).toContain('VCR-CS-AABBCCDD')
expect(wrapper.find('.cover-sheet-preview').exists()).toBe(true)
expect(wrapper.find('[data-testid="status-badge"]').text()).toBe('UNUSED')
expect(wrapper.find('select').exists()).toBe(true)
})
it('loads entry clerks and cover sheets on mount', async () => {
mount(CoverSheetView)
mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises()
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
@@ -104,7 +144,13 @@ describe('CoverSheetView', () => {
error: null,
})
const wrapper = mount(CoverSheetView)
const wrapper = mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises()
const batchTypeSelect = wrapper.findAll('select')[0]
@@ -134,7 +180,13 @@ describe('CoverSheetView', () => {
error: null,
})
const wrapper = mount(CoverSheetView)
const wrapper = mount(CoverSheetView, {
global: {
stubs: {
RouterLink: { template: '<a><slot /></a>' },
},
},
})
await flushPromises()
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
@@ -142,5 +194,6 @@ describe('CoverSheetView', () => {
await flushPromises()
expect(wrapper.text()).toContain('Print Cover Sheets')
expect(wrapper.find('button.btn-primary').text()).toContain('Generate Cover Sheets')
})
})
@@ -9,7 +9,9 @@ vi.mock('@/api/fhirClient', () => ({
}))
vi.mock('@/components/AppHeader.vue', () => ({
default: { template: '<div />' },
default: {
template: '<div data-testid="app-header"><slot name="actions" /></div>',
},
}))
vi.mock('@/components/PatientSearch.vue', () => ({
@@ -20,6 +22,20 @@ vi.mock('@/components/PatientSearch.vue', () => ({
},
}))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error">{{ message }}</div>',
},
}))
vi.mock('@/components/EmptyState.vue', () => ({
default: {
props: ['title', 'description'],
template: '<div data-testid="empty-state">{{ title }}</div>',
},
}))
import { fhirGet, openFhirJsonInNewTab } from '@/api/fhirClient'
const mockedFhirGet = vi.mocked(fhirGet)
@@ -76,6 +92,7 @@ describe('FhirExplorerView', () => {
expect(wrapper.text()).toContain('Patient $everything')
expect(wrapper.find('select').element).toBeTruthy()
expect(wrapper.text()).toContain('MRN (identifier)')
expect(wrapper.text()).toContain('CapabilityStatement')
})
it('searches FHIR Patient resources and shows results table', async () => {
@@ -106,6 +123,7 @@ describe('FhirExplorerView', () => {
expect(wrapper.text()).toContain('"resourceType": "Patient"')
expect(wrapper.text()).toContain('"id": "patient-1"')
expect(wrapper.find('.fhir-json-panel').exists()).toBe(true)
})
it('loads Patient $everything bundle grouped by resource type', async () => {
@@ -42,12 +42,20 @@ vi.mock('@/components/AssignClerkDialog.vue', () => ({
},
}))
vi.mock('@/components/InlineError.vue', () => ({
default: {
props: ['title', 'message'],
template: '<div data-testid="inline-error"><slot /><p>{{ title }}</p><p>{{ message }}</p></div>',
},
}))
const uploadBatchMock = vi.fn()
vi.mock('@/stores/batches', () => ({
useBatchStore: () => ({
batches: [],
loading: false,
error: null,
listBatches: vi.fn(),
uploadBatch: uploadBatchMock,
assignBatch: vi.fn(),
@@ -89,7 +97,9 @@ describe('IntakeView cover sheet upload', () => {
const wrapper = mount(IntakeView)
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
expect(wrapper.text()).toContain('Quick Upload with Cover Sheet')
expect(wrapper.text()).toContain('Cover Sheet Code')
expect(wrapper.text()).toContain('Upload Scanned Document')
expect(wrapper.find('.upload-dropzone').exists()).toBe(true)
expect(input.exists()).toBe(true)
expect(input.attributes('autofocus')).toBeDefined()
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
@@ -147,7 +157,7 @@ describe('IntakeView cover sheet upload', () => {
undefined,
'VCR-CS-A3F7B2D1',
)
expect(wrapper.text()).toContain('Upload with Cover Sheet')
expect(wrapper.text()).toContain('Batch uploaded with cover sheet')
})
it('keeps manual upload available without a cover sheet', async () => {
@@ -155,6 +165,7 @@ describe('IntakeView cover sheet upload', () => {
expect(wrapper.text()).toContain('New Batch')
expect(wrapper.text()).toContain('Upload and Create Batch')
expect(wrapper.text()).toContain('Recent Uploads')
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
const fileInput = wrapper.find('input[type="file"]')
+25
View File
@@ -173,4 +173,29 @@
.ocr-badge-low {
@apply bg-clinical-danger-bg text-clinical-danger;
}
/* Phase 17 — Intake upload zone */
.upload-dropzone {
@apply flex items-center justify-center min-h-[200px] sm:min-h-[240px]
rounded-card border-2 border-dashed border-primary-300 bg-primary-50/40
px-6 py-10 cursor-pointer transition-colors
hover:border-primary-500 hover:bg-primary-50;
}
.upload-dropzone--active {
@apply border-primary-600 bg-primary-50;
}
.upload-dropzone--error {
@apply border-clinical-danger bg-clinical-danger-bg;
}
/* Phase 17 — Cover sheet paper preview */
.cover-sheet-preview {
@apply bg-white border border-line-strong rounded-sm
aspect-[210/297] max-h-[520px] w-full mx-auto overflow-hidden
flex flex-col;
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08);
}
/* Phase 17 — FHIR raw JSON panel */
.fhir-json-panel {
@apply bg-navy text-[#D1E0FF] text-[13px] leading-5 font-mono
p-4 rounded-input overflow-x-auto max-h-96 border border-navy;
}
}
@@ -36,6 +36,9 @@ export const BATCH_STATUS_META: Record<string, BatchStatusMeta> = {
APPROVED: { label: 'Approved', tone: 'success', icon: 'approve' },
PROMOTED: { label: 'Promoted', tone: 'success', icon: 'done' },
CANCELLED: { label: 'Cancelled', tone: 'muted', icon: 'cancel' },
/** Cover sheet list statuses */
UNUSED: { label: 'Unused', tone: 'success', icon: 'check' },
USED: { label: 'Used', tone: 'muted', icon: 'done' },
}
export const BATCH_STATUS_TONE_CLASSES: Record<BatchStatusTone, string> = {
@@ -2,19 +2,29 @@
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Cover Sheets" />
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full">
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Cover Sheet Management
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Generate printable separators that reconnect scanned paper to batch metadata.
</p>
</div>
<!-- Generate -->
<div class="card mb-6">
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2>
<!-- Top: 50 / 50 generation + preview -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-4">Generate Cover Sheets</h2>
<form @submit.prevent="handleGenerate" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Count (1&ndash;100)
<label for="cs-count" class="block text-sm font-semibold text-ink mb-2">
Quantity (1100)
</label>
<input
id="cs-count"
v-model.number="count"
type="number"
min="1"
@@ -25,8 +35,10 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
<select v-model="batchType" class="form-input" required>
<label for="cs-batch-type" class="block text-sm font-semibold text-ink mb-2">
Batch Type
</label>
<select id="cs-batch-type" v-model="batchType" class="form-input" required>
<option value="">Select batch type...</option>
<option value="PATIENT_REGISTRATION">Patient Registration</option>
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
@@ -39,25 +51,32 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
<select v-model="track" class="form-input">
<label for="cs-track" class="block text-sm font-semibold text-ink mb-2">Track</label>
<select id="cs-track" v-model="track" class="form-input">
<option value="BACKFILL">Backfill (Track A)</option>
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Patient (optional)
<label class="block text-sm font-semibold text-ink mb-2">
Patient
<span class="font-normal text-ink-secondary">(optional)</span>
</label>
<PatientSearch v-model="patientId" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Assign to Clerk (optional)
<label for="cs-clerk" class="block text-sm font-semibold text-ink mb-2">
Entry Clerk
<span class="font-normal text-ink-secondary">(optional)</span>
</label>
<select v-model="assignToUserId" class="form-input" :disabled="clerksLoading">
<select
id="cs-clerk"
v-model="assignToUserId"
class="form-input"
:disabled="clerksLoading"
>
<option value="">No pre-assignment</option>
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
{{ clerk.fullName }} ({{ clerk.username }})
@@ -65,13 +84,18 @@
</select>
</div>
<div v-if="generateError" class="text-clinical-danger text-sm">
{{ generateError }}
</div>
<InlineError
v-if="generateError"
title="Generation failed"
:message="generateError"
preserved="Your form values were kept."
retry-label="Try again"
@retry="handleGenerate"
/>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap gap-3 pt-1">
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
{{ generating ? 'Generating...' : 'Generate' }}
{{ generating ? 'Generating...' : 'Generate Cover Sheets' }}
</button>
<button
v-if="lastGeneratedIds.length > 0"
@@ -83,65 +107,159 @@
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
</button>
</div>
<p
v-if="lastGeneratedIds.length > 0"
class="text-sm text-ink-secondary"
>
Last run: {{ lastGeneratedIds.length }} cover sheet(s) ready to print.
</p>
</form>
</section>
<!-- Live paper preview -->
<section class="card flex flex-col">
<h2 class="text-base font-semibold text-ink-strong mb-1">Preview</h2>
<p class="text-sm text-ink-secondary mb-4">
Approximate printable layout for the current form values.
</p>
<div class="cover-sheet-preview">
<div class="flex items-center gap-2 px-5 py-4 border-b border-line bg-navy text-white">
<svg viewBox="0 0 32 32" class="w-7 h-7 shrink-0" aria-hidden="true">
<path fill="#155EEF" d="M16 2 4 6.5v8.2c0 8 5.1 14.6 12 15.3 6.9-.7 12-7.3 12-15.3V6.5z"/>
<path fill="white" d="M16 8.5c-3.6 0-6.6 2.8-6.6 6.3 0 3.4 2.7 6.4 6.6 9.6 3.9-3.2 6.6-6.2 6.6-9.6 0-3.5-3-6.3-6.6-6.3m0 3.4c1.7 0 3.1 1.3 3.1 2.9s-1.4 2.9-3.1 2.9-3.1-1.3-3.1-2.9 1.4-2.9 3.1-2.9"/>
</svg>
<div class="leading-tight">
<p class="text-sm font-bold tracking-tight">VigilCare</p>
<p class="text-[11px] text-white/60">Records · Cover Sheet</p>
</div>
</div>
<!-- List -->
<div class="card">
<div class="flex-1 flex flex-col items-center justify-center px-6 py-8 gap-5">
<!-- QR placeholder -->
<div
class="w-28 h-28 border-2 border-ink-strong grid grid-cols-5 grid-rows-5 gap-0.5 p-1.5"
aria-hidden="true"
>
<span
v-for="n in 25"
:key="n"
class="bg-ink-strong"
:class="qrCellClass(n)"
/>
</div>
<div class="text-center space-y-1">
<p class="font-mono text-xs text-ink-secondary tracking-wider">
VCR-CS-XXXXXXXX
</p>
<p class="text-lg font-semibold text-ink-strong">
{{ previewBatchType }}
</p>
<p class="text-sm text-ink-secondary">
{{ track === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)' }}
</p>
</div>
<dl class="w-full max-w-xs text-sm space-y-2 border-t border-line pt-4">
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Quantity</dt>
<dd class="font-medium text-ink-strong">{{ previewCount }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Patient</dt>
<dd class="font-medium text-ink-strong text-right truncate">
{{ patientId ? 'Linked' : 'Not linked' }}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Entry clerk</dt>
<dd class="font-medium text-ink-strong text-right truncate">
{{ assignedClerkLabel }}
</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-ink-secondary">Generated</dt>
<dd class="font-medium text-ink-strong">{{ previewTimestamp }}</dd>
</div>
</dl>
</div>
</div>
</section>
</div>
<!-- Existing cover sheets table -->
<section class="card">
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
<h2 class="text-lg font-semibold">Cover Sheet List</h2>
<h2 class="text-base font-semibold text-ink-strong">Existing Cover Sheets</h2>
<div class="flex flex-col sm:flex-row gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<select v-model="statusFilter" class="form-input" @change="onFiltersChanged">
<label for="cs-status-filter" class="block text-sm font-semibold text-ink mb-2">
Status
</label>
<select
id="cs-status-filter"
v-model="statusFilter"
class="form-input"
@change="onFiltersChanged"
>
<option value="all">All</option>
<option value="unused">Unused</option>
<option value="used">Used</option>
</select>
</div>
<div class="min-w-[240px]">
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
<label class="block text-sm font-semibold text-ink mb-2">Patient</label>
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
</div>
</div>
</div>
<div v-if="listError" class="text-clinical-danger text-sm mb-4">
{{ listError }}
</div>
<InlineError
v-if="listError"
class="mb-4"
title="Could not load cover sheets"
:message="listError"
preserved="Your filters were preserved."
retry-label="Retry"
@retry="loadCoverSheets"
/>
<div class="overflow-x-auto">
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div>
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4">
No cover sheets found.
</div>
<SkeletonBlock v-if="listLoading" variant="table" :rows="5" />
<EmptyState
v-else-if="coverSheets.length === 0"
title="No cover sheets found."
description="Generate cover sheets above, or adjust your filters."
/>
<table v-else class="w-full min-w-[800px] text-sm">
<thead>
<tr class="border-b text-left text-gray-600">
<th class="py-2 px-4">Code</th>
<th class="py-2 px-4">Batch Type</th>
<th class="py-2 px-4">Track</th>
<th class="py-2 px-4">Patient</th>
<th class="py-2 px-4">Assigned To</th>
<th class="py-2 px-4">Status</th>
<th class="py-2 px-4">Linked Batch</th>
<th class="py-2 px-4">Created</th>
<tr class="border-b text-left text-ink-secondary">
<th class="py-2 px-4 font-medium">Code</th>
<th class="py-2 px-4 font-medium">Batch Type</th>
<th class="py-2 px-4 font-medium">Track</th>
<th class="py-2 px-4 font-medium">Patient</th>
<th class="py-2 px-4 font-medium">Assigned To</th>
<th class="py-2 px-4 font-medium">Status</th>
<th class="py-2 px-4 font-medium">Linked Batch</th>
<th class="py-2 px-4 font-medium">Created</th>
</tr>
</thead>
<tbody>
<tr
v-for="sheet in coverSheets"
:key="sheet.id"
class="border-b hover:bg-gray-50"
class="border-b hover:bg-primary-50"
>
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td>
<td class="py-2 px-4 font-mono text-xs text-ink-strong">{{ sheet.code }}</td>
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
<td class="py-2 px-4">
<span
:class="sheet.track === 'BACKFILL'
? 'bg-blue-100 text-blue-800'
: 'bg-green-100 text-green-800'"
? 'bg-primary-50 text-primary-800 border border-primary-100'
: 'bg-clinical-safe-bg text-clinical-safe border border-[#ABEFC6]'"
class="status-badge"
>
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
@@ -150,22 +268,17 @@
<td class="py-2 px-4">
<template v-if="sheet.patientName">
{{ sheet.patientName }}
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span>
<span v-if="sheet.patientMrn" class="text-ink-secondary">
({{ sheet.patientMrn }})
</span>
</template>
<span v-else class="text-gray-400">&mdash;</span>
<span v-else class="text-ink-disabled"></span>
</td>
<td class="py-2 px-4">
{{ sheet.assignToUserName ?? '—' }}
</td>
<td class="py-2 px-4">
<span
:class="sheet.isUsed
? 'bg-gray-100 text-gray-800'
: 'bg-green-100 text-green-800'"
class="status-badge"
>
{{ sheet.isUsed ? 'Used' : 'Unused' }}
</span>
<StatusBadge :status="sheet.isUsed ? 'USED' : 'UNUSED'" />
</td>
<td class="py-2 px-4">
<router-link
@@ -173,11 +286,11 @@
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
class="font-mono text-xs text-primary-600 hover:text-primary-800"
>
{{ sheet.batchId.substring(0, 8) }}...
{{ sheet.batchId.substring(0, 8) }}
</router-link>
<span v-else class="text-gray-400">&mdash;</span>
<span v-else class="text-ink-disabled"></span>
</td>
<td class="py-2 px-4 text-gray-500">
<td class="py-2 px-4 text-ink-secondary">
{{ formatDate(sheet.createdAt) }}
</td>
</tr>
@@ -185,8 +298,8 @@
</table>
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t">
<p class="text-sm text-gray-500">Page {{ page }}</p>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-line">
<p class="text-sm text-ink-secondary">Page {{ page }}</p>
<div class="flex gap-2">
<button
type="button"
@@ -206,6 +319,7 @@
</button>
</div>
</div>
</section>
</div>
</div>
</div>
@@ -217,6 +331,10 @@ import { get, post, postBlob } from '../api/client'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import StatusBadge from '../components/StatusBadge.vue'
import EmptyState from '../components/EmptyState.vue'
import SkeletonBlock from '../components/SkeletonBlock.vue'
import InlineError from '../components/InlineError.vue'
import type { CoverSheetResponse, UserSummary } from '../types'
const toast = useToast()
@@ -244,6 +362,25 @@ const filterPatientId = ref<string | undefined>()
const hasNextPage = computed(() => coverSheets.value.length === pageSize)
const previewCount = computed(() => Math.min(100, Math.max(1, count.value || 1)))
const previewBatchType = computed(() =>
batchType.value ? formatBatchType(batchType.value) : 'Select a batch type',
)
const assignedClerkLabel = computed(() => {
if (!assignToUserId.value) return 'None'
const clerk = clerks.value.find(c => c.id === assignToUserId.value)
return clerk?.fullName ?? 'Selected'
})
const previewTimestamp = computed(() =>
new Date().toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}),
)
function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
@@ -252,6 +389,15 @@ function formatDate(value: string): string {
return new Date(value).toLocaleString()
}
/** Sparse checker pattern for QR placeholder cells */
function qrCellClass(n: number): string {
const row = Math.ceil(n / 5)
const col = ((n - 1) % 5) + 1
const corner = (row <= 2 && col <= 2) || (row <= 2 && col >= 4) || (row >= 4 && col <= 2)
const mid = (row + col) % 2 === 0
return corner || mid ? 'opacity-100' : 'opacity-20'
}
async function loadClerks(): Promise<void> {
clerksLoading.value = true
try {
@@ -1,23 +1,44 @@
<template>
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="FHIR Explorer" />
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold">FHIR R4 Explorer</h1>
<AppHeader title="FHIR Explorer">
<template #actions>
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
Open CapabilityStatement (/fhir/metadata)
CapabilityStatement
</button>
</template>
</AppHeader>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
FHIR R4 Explorer
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Read-only inspection of exposed FHIR resources for administrators and integration staff.
</p>
</div>
<!-- Resource browser -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Resource Browser</h2>
<section class="card space-y-5">
<header>
<h2 class="text-base font-semibold text-ink-strong">Resource Browser</h2>
<p class="text-sm text-ink-secondary mt-1">
Search Patient, Encounter, or Observation, then inspect raw JSON.
</p>
</header>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-4">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Resource Type</label>
<select v-model="resourceType" class="form-input" @change="clearResults">
<label for="fhir-resource-type" class="block text-sm font-semibold text-ink mb-2">
Resource Type
</label>
<select
id="fhir-resource-type"
v-model="resourceType"
class="form-input"
@change="clearResults"
>
<option value="Patient">Patient</option>
<option value="Encounter">Encounter</option>
<option value="Observation">Observation</option>
@@ -26,26 +47,40 @@
<template v-if="resourceType === 'Patient'">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
<input v-model="patientSearch.name" type="text" class="form-input" placeholder="e.g. Santos" />
<label class="block text-sm font-semibold text-ink mb-2">Name</label>
<input
v-model="patientSearch.name"
type="text"
class="form-input"
placeholder="e.g. Santos"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Birth Date</label>
<label class="block text-sm font-semibold text-ink mb-2">Birth Date</label>
<input v-model="patientSearch.birthdate" type="date" class="form-input" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">MRN (identifier)</label>
<input v-model="patientSearch.identifier" type="text" class="form-input" placeholder="VCR-000001" />
<label class="block text-sm font-semibold text-ink mb-2">MRN (identifier)</label>
<input
v-model="patientSearch.identifier"
type="text"
class="form-input font-mono"
placeholder="VCR-000001"
/>
</div>
</template>
<template v-else-if="resourceType === 'Encounter'">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
<input v-model="encounterSearch.patient" type="text" class="form-input" />
<label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
<input
v-model="encounterSearch.patient"
type="text"
class="form-input font-mono"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
<label class="block text-sm font-semibold text-ink mb-2">Status</label>
<select v-model="encounterSearch.status" class="form-input">
<option value="">Any</option>
<option value="in-progress">in-progress</option>
@@ -53,22 +88,31 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
<label class="block text-sm font-semibold text-ink mb-2">Date</label>
<input v-model="encounterSearch.date" type="date" class="form-input" />
</div>
</template>
<template v-else>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
<input v-model="observationSearch.patient" type="text" class="form-input" />
<label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
<input
v-model="observationSearch.patient"
type="text"
class="form-input font-mono"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">LOINC Code</label>
<input v-model="observationSearch.code" type="text" class="form-input" placeholder="8867-4" />
<label class="block text-sm font-semibold text-ink mb-2">LOINC Code</label>
<input
v-model="observationSearch.code"
type="text"
class="form-input font-mono"
placeholder="8867-4"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label>
<label class="block text-sm font-semibold text-ink mb-2">Category</label>
<select v-model="observationSearch.category" class="form-input">
<option value="">Any</option>
<option value="vital-signs">vital-signs</option>
@@ -76,72 +120,86 @@
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Date (FHIR prefix)</label>
<label class="block text-sm font-semibold text-ink mb-2">Date (FHIR prefix)</label>
<input
v-model="observationSearch.date"
type="text"
class="form-input"
class="form-input font-mono"
placeholder="ge2026-06-20"
/>
</div>
</template>
</div>
<button type="button" class="btn-primary" :disabled="searching" @click="runSearch">
<button
type="button"
class="btn-primary"
:disabled="searching"
@click="runSearch"
>
{{ searching ? 'Searching...' : 'Search' }}
</button>
<p v-if="searchError" class="text-clinical-danger text-sm mt-3">{{ searchError }}</p>
<InlineError
v-if="searchError"
title="FHIR search failed"
:message="searchError"
preserved="Your search fields were kept."
retry-label="Retry search"
@retry="runSearch"
/>
<div v-if="searchResults.length > 0" class="mt-6 overflow-x-auto">
<p class="text-sm text-gray-600 mb-2">
<div v-if="searchResults.length > 0" class="overflow-x-auto">
<p class="text-sm text-ink-secondary mb-2">
{{ searchTotal }} result(s)
</p>
<table class="min-w-full text-sm border border-gray-200 rounded-md overflow-hidden">
<thead class="bg-gray-50 text-left">
<tr>
<th class="px-3 py-2">ID</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">Name</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">MRN</th>
<th v-if="resourceType === 'Patient'" class="px-3 py-2">DOB</th>
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Status</th>
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Patient</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Code</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Value</th>
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Recorded</th>
<table class="w-full min-w-[640px] text-sm">
<thead>
<tr class="border-b border-line text-left text-ink-secondary">
<th class="py-2 px-3 font-medium">ID</th>
<th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">Name</th>
<th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">MRN</th>
<th v-if="resourceType === 'Patient'" class="py-2 px-3 font-medium">DOB</th>
<th v-if="resourceType === 'Encounter'" class="py-2 px-3 font-medium">Status</th>
<th v-if="resourceType === 'Encounter'" class="py-2 px-3 font-medium">Patient</th>
<th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Code</th>
<th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Value</th>
<th v-if="resourceType === 'Observation'" class="py-2 px-3 font-medium">Recorded</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in searchResults"
:key="String(entry.resource?.id)"
class="border-t border-gray-100 hover:bg-primary-50 cursor-pointer"
class="border-b border-line hover:bg-primary-50 cursor-pointer"
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
@click="selectResource(entry.resource)"
>
<td class="px-3 py-2 font-mono text-xs">{{ entry.resource?.id }}</td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
<td class="py-2 px-3 font-mono text-xs text-ink-strong">
{{ entry.resource?.id }}
</td>
<td v-if="resourceType === 'Patient'" class="py-2 px-3">
{{ patientDisplayName(entry.resource) }}
</td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
<td v-if="resourceType === 'Patient'" class="py-2 px-3 font-mono text-xs">
{{ patientMrn(entry.resource) }}
</td>
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
<td v-if="resourceType === 'Patient'" class="py-2 px-3">
{{ entry.resource?.birthDate ?? '' }}
</td>
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
<td v-if="resourceType === 'Encounter'" class="py-2 px-3">
{{ entry.resource?.status ?? '' }}
</td>
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
<td v-if="resourceType === 'Encounter'" class="py-2 px-3 font-mono text-xs">
{{ subjectReference(entry.resource) }}
</td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
<td v-if="resourceType === 'Observation'" class="py-2 px-3">
{{ observationCodeDisplay(entry.resource) }}
</td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
<td v-if="resourceType === 'Observation'" class="py-2 px-3">
{{ observationValueDisplay(entry.resource) }}
</td>
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
<td v-if="resourceType === 'Observation'" class="py-2 px-3 text-ink-secondary">
{{ observationEffective(entry.resource) }}
</td>
</tr>
@@ -149,21 +207,29 @@
</table>
</div>
<div v-if="selectedResource" class="mt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Resource JSON</h3>
<pre class="bg-gray-900 text-green-100 text-xs p-4 rounded-md overflow-x-auto max-h-96">{{ formattedSelectedResource }}</pre>
</div>
<EmptyState
v-else-if="searched && !searching && !searchError"
title="No matching FHIR resources."
description="Adjust search parameters and try again."
/>
<div v-if="selectedResource">
<h3 class="text-sm font-semibold text-ink-strong mb-2">Resource JSON</h3>
<pre class="fhir-json-panel">{{ formattedSelectedResource }}</pre>
</div>
</section>
<!-- Patient $everything -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Patient $everything</h2>
<p class="text-sm text-gray-600 mb-4">
<section class="card space-y-4">
<header>
<h2 class="text-base font-semibold text-ink-strong">Patient $everything</h2>
<p class="text-sm text-ink-secondary mt-1">
Load all FHIR resources for a patient in one Bundle.
</p>
</header>
<div class="max-w-md mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
<div class="max-w-md">
<label class="block text-sm font-semibold text-ink mb-2">Patient</label>
<PatientSearch v-model="everythingPatientId" />
</div>
@@ -176,49 +242,94 @@
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
</button>
<p v-if="everythingError" class="text-clinical-danger text-sm mt-3">{{ everythingError }}</p>
<InlineError
v-if="everythingError"
title="Failed to load patient Bundle"
:message="everythingError"
preserved="Your patient selection was kept."
retry-label="Retry"
@retry="loadEverything"
/>
<div v-if="everythingBundle" class="mt-6 space-y-6">
<div v-if="everythingPatient" class="card bg-primary-50 border border-primary-100">
<h3 class="font-semibold mb-2">Patient</h3>
<p class="text-sm"><span class="text-gray-500">Name:</span> {{ patientDisplayName(everythingPatient) }}</p>
<p class="text-sm"><span class="text-gray-500">MRN:</span> {{ patientMrn(everythingPatient) }}</p>
<p class="text-sm"><span class="text-gray-500">DOB:</span> {{ everythingPatient.birthDate ?? '—' }}</p>
<p class="text-sm"><span class="text-gray-500">Gender:</span> {{ everythingPatient.gender ?? '—' }}</p>
<div v-if="everythingBundle" class="space-y-5 pt-2">
<div
v-if="everythingPatient"
class="rounded-input border border-primary-100 bg-primary-50 p-4"
>
<h3 class="text-sm font-semibold text-ink-strong mb-2">Patient</h3>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
<div>
<dt class="text-ink-secondary">Name</dt>
<dd class="text-ink-strong">{{ patientDisplayName(everythingPatient) }}</dd>
</div>
<div>
<dt class="text-ink-secondary">MRN</dt>
<dd class="font-mono text-ink-strong">{{ patientMrn(everythingPatient) }}</dd>
</div>
<div>
<dt class="text-ink-secondary">DOB</dt>
<dd class="text-ink">{{ everythingPatient.birthDate ?? '—' }}</dd>
</div>
<div>
<dt class="text-ink-secondary">Gender</dt>
<dd class="text-ink">{{ everythingPatient.gender ?? '—' }}</dd>
</div>
</dl>
</div>
<div v-if="everythingEncounters.length > 0">
<h3 class="font-semibold mb-2">Encounters ({{ everythingEncounters.length }})</h3>
<h3 class="text-sm font-semibold text-ink-strong mb-2">
Encounters ({{ everythingEncounters.length }})
</h3>
<ul class="space-y-2">
<li
v-for="enc in everythingEncounters"
:key="String(enc.id)"
class="text-sm border border-gray-200 rounded-md px-3 py-2"
class="text-sm border border-line rounded-input px-3 py-2 bg-surface"
>
<span class="font-mono text-xs text-gray-500">{{ enc.id }}</span>
<span class="font-mono text-xs text-ink-secondary">{{ enc.id }}</span>
{{ enc.status }}
<span v-if="enc.period?.start"> · {{ enc.period.start }}</span>
<span v-if="enc.period?.start" class="text-ink-secondary">
· {{ enc.period.start }}
</span>
</li>
</ul>
</div>
<div v-if="everythingObservations.length > 0">
<h3 class="font-semibold mb-2">Observations ({{ everythingObservations.length }})</h3>
<ul class="space-y-2">
<li
<h3 class="text-sm font-semibold text-ink-strong mb-2">
Observations ({{ everythingObservations.length }})
</h3>
<div class="overflow-x-auto">
<table class="w-full min-w-[480px] text-sm">
<thead>
<tr class="border-b border-line text-left text-ink-secondary">
<th class="py-2 px-2 font-medium">ID</th>
<th class="py-2 px-2 font-medium">Code</th>
<th class="py-2 px-2 font-medium">Value</th>
<th class="py-2 px-2 font-medium">Recorded</th>
</tr>
</thead>
<tbody>
<tr
v-for="obs in everythingObservations"
:key="String(obs.id)"
class="text-sm border border-gray-200 rounded-md px-3 py-2 flex flex-wrap gap-x-3"
class="border-b border-line"
>
<span class="font-mono text-xs text-gray-500">{{ obs.id }}</span>
<span>{{ observationCodeDisplay(obs) }}</span>
<span>{{ observationValueDisplay(obs) }}</span>
<span class="text-gray-500">{{ observationEffective(obs) }}</span>
</li>
</ul>
<td class="py-2 px-2 font-mono text-xs text-ink-secondary">{{ obs.id }}</td>
<td class="py-2 px-2">{{ observationCodeDisplay(obs) }}</td>
<td class="py-2 px-2">{{ observationValueDisplay(obs) }}</td>
<td class="py-2 px-2 text-ink-secondary">
{{ observationEffective(obs) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
@@ -227,6 +338,8 @@
import { computed, reactive, ref } from 'vue'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import InlineError from '../components/InlineError.vue'
import EmptyState from '../components/EmptyState.vue'
import {
fhirGet,
openFhirJsonInNewTab,
@@ -239,6 +352,7 @@ type ResourceType = 'Patient' | 'Encounter' | 'Observation'
const resourceType = ref<ResourceType>('Patient')
const searching = ref(false)
const searched = ref(false)
const searchError = ref('')
const searchResults = ref<FhirBundleEntry[]>([])
const searchTotal = ref(0)
@@ -300,6 +414,7 @@ function clearResults(): void {
searchTotal.value = 0
selectedResource.value = null
searchError.value = ''
searched.value = false
}
function selectResource(resource: FhirResource | undefined): void {
@@ -374,6 +489,7 @@ function buildSearchParams(): Record<string, string | number | undefined> {
async function runSearch(): Promise<void> {
searching.value = true
searched.value = true
searchError.value = ''
selectedResource.value = null
+277 -77
View File
@@ -2,26 +2,38 @@
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Intake" />
<div class="page-container flex-1">
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto">
<div class="mb-6">
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Upload Scanned Document
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Create a digitization batch from a PDF or image scan.
</p>
</div>
<!-- Barcode-assisted upload -->
<div class="card mb-6">
<h2 class="text-lg font-semibold mb-4">Quick Upload with Cover Sheet</h2>
<p class="text-sm text-gray-600 mb-4">
Scan or type a cover sheet barcode to auto-populate batch details.
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)] gap-6 lg:gap-8 items-start">
<!-- Main ~70%: upload + batch details -->
<div class="space-y-6 min-w-0">
<!-- Cover sheet quick lookup -->
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-1">Cover Sheet Code</h2>
<p class="text-sm text-ink-secondary mb-4">
Optional. Scan or type a barcode to auto-fill batch details.
</p>
<div class="flex gap-4 items-end">
<div class="flex-1">
<label class="block text-sm font-medium text-gray-700 mb-2">
Cover Sheet Code
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
<div class="flex-1 min-w-0">
<label for="cover-sheet-code" class="block text-sm font-semibold text-ink mb-2">
Code
</label>
<input
id="cover-sheet-code"
v-model="coverSheetCode"
@keydown.enter.prevent="lookupCoverSheet"
type="text"
class="form-input"
class="form-input font-mono"
placeholder="VCR-CS-XXXXXXXX"
autofocus
/>
@@ -29,90 +41,162 @@
<button
type="button"
@click="lookupCoverSheet"
class="btn-secondary"
class="btn-secondary shrink-0"
:disabled="!coverSheetCode.trim() || lookupLoading"
>
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
</button>
</div>
<div v-if="lookupError" class="text-clinical-danger text-sm mt-3">
{{ lookupError }}
</div>
<InlineError
v-if="lookupError"
class="mt-4"
title="Cover sheet lookup failed"
:message="lookupError"
preserved="You can still upload manually without a cover sheet."
retry-label="Retry lookup"
@retry="lookupCoverSheet"
/>
<div
v-if="resolvedCoverSheet"
class="mt-4 bg-green-50 border border-green-200 rounded-md p-4"
class="mt-4 rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4"
role="status"
>
<p class="text-sm font-medium text-green-800">Cover Sheet Found</p>
<dl class="mt-2 text-sm text-green-700 space-y-1">
<p class="text-sm font-semibold text-clinical-safe">Cover Sheet Found</p>
<dl class="mt-2 text-sm text-ink space-y-1">
<div>
<dt class="inline font-medium">Type:</dt>
<dd class="inline">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
<dt class="inline font-medium text-ink-secondary">Type:</dt>
<dd class="inline ml-1">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
</div>
<div>
<dt class="inline font-medium">Track:</dt>
<dd class="inline">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
<dt class="inline font-medium text-ink-secondary">Track:</dt>
<dd class="inline ml-1">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
</div>
<div v-if="resolvedCoverSheet.patientName">
<dt class="inline font-medium">Patient:</dt>
<dd class="inline">
<dt class="inline font-medium text-ink-secondary">Patient:</dt>
<dd class="inline ml-1">
{{ resolvedCoverSheet.patientName }}
<span v-if="resolvedCoverSheet.patientMrn" class="text-ink-secondary">
({{ resolvedCoverSheet.patientMrn }})
</span>
</dd>
</div>
<div v-if="resolvedCoverSheet.assignToUserName">
<dt class="inline font-medium">Assign to:</dt>
<dd class="inline">{{ resolvedCoverSheet.assignToUserName }}</dd>
<dt class="inline font-medium text-ink-secondary">Assign to:</dt>
<dd class="inline ml-1">{{ resolvedCoverSheet.assignToUserName }}</dd>
</div>
</dl>
<p class="text-sm text-green-600 mt-3">
Select a file below and click "Upload with Cover Sheet" to create the batch.
<p class="text-sm text-ink-secondary mt-3">
Select a file and upload to create the batch with this cover sheet.
</p>
<button
type="button"
@click="clearCoverSheet"
class="text-xs text-green-700 hover:text-green-900 mt-2"
class="text-xs font-medium text-clinical-safe hover:underline mt-2"
>
Clear cover sheet (use manual upload)
</button>
</div>
</section>
<!-- Upload + batch form -->
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-4">New Batch</h2>
<form @submit.prevent="handleUpload" class="space-y-5">
<!-- Drop zone -->
<div>
<label class="block text-sm font-semibold text-ink mb-2">
Scanned Document
</label>
<div
v-if="!selectedFile"
class="upload-dropzone"
:class="{
'upload-dropzone--active': dragActive,
'upload-dropzone--error': !!fileError,
}"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFilePicker"
@keydown.enter.prevent="triggerFilePicker"
@keydown.space.prevent="triggerFilePicker"
role="button"
tabindex="0"
aria-label="Choose or drop a scanned document"
>
<input
ref="fileInput"
type="file"
class="sr-only"
accept=".pdf,.jpg,.jpeg,.png"
@change="onFileChange"
/>
<div class="flex flex-col items-center text-center pointer-events-none">
<svg
class="w-10 h-10 text-primary-500 mb-3"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M12 16V4m0 0L8 8m4-4l4 4M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p class="text-sm font-semibold text-ink-strong">Upload Files</p>
<p class="mt-1 text-sm text-ink-secondary">
Drag and drop, or click to browse
</p>
<p class="mt-3 text-xs text-ink-disabled">
PDF, JPEG, PNG max 25 MB
</p>
</div>
</div>
<!-- Upload form -->
<div class="card mb-6">
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
<form @submit.prevent="handleUpload" class="space-y-4">
<!-- File input -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Scanned Document (PDF, JPEG, PNG &mdash; max 25 MB)
</label>
<input
type="file"
ref="fileInput"
@change="onFileChange"
accept=".pdf,.jpg,.jpeg,.png"
class="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-primary-50 file:text-primary-700
hover:file:bg-primary-100"
/>
<p v-if="selectedFile" class="text-sm text-gray-500 mt-2">
{{ selectedFile.name }} ({{ (selectedFile.size / 1024 / 1024).toFixed(2) }} MB)
<div
v-else
class="rounded-input border border-line bg-canvas p-4 flex items-start justify-between gap-3"
>
<div class="min-w-0">
<p class="text-sm font-medium text-ink-strong truncate">
{{ selectedFile.name }}
</p>
<p class="text-xs text-ink-secondary mt-0.5">
{{ formatFileSize(selectedFile.size) }}
</p>
</div>
<button
type="button"
class="text-sm font-medium text-ink-secondary hover:text-ink-strong shrink-0"
:disabled="batchStore.loading"
@click="clearFile"
>
Remove
</button>
</div>
<p v-if="fileError" class="text-sm text-clinical-danger mt-2">{{ fileError }}</p>
</div>
<!-- Batch type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
<label for="batch-type" class="block text-sm font-semibold text-ink mb-2">
Batch Type
</label>
<select
id="batch-type"
v-model="batchType"
class="form-input"
:required="!resolvedCoverSheet"
:disabled="!!resolvedCoverSheet"
>
<option value="">Select batch type...</option>
<option value="PATIENT_REGISTRATION">Patient Registration</option>
@@ -127,39 +211,69 @@
<!-- Track -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
<select v-model="track" class="form-input">
<label for="track" class="block text-sm font-semibold text-ink mb-2">Track</label>
<select
id="track"
v-model="track"
class="form-input"
:disabled="!!resolvedCoverSheet"
>
<option value="BACKFILL">Backfill (Track A)</option>
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
</select>
<p class="mt-1.5 text-xs text-ink-secondary">
{{ track === 'BACKFILL'
? 'Historical or archival document workflow.'
: 'Documents from current bedside workflows.' }}
</p>
</div>
<!-- Patient search -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Patient (optional &mdash; can link later)
<label class="block text-sm font-semibold text-ink mb-2">
Patient Link
<span class="font-normal text-ink-secondary">(optional)</span>
</label>
<PatientSearch v-model="patientId" />
</div>
<!-- Supersession (correction) -->
<div v-if="supersedesBatchId" class="bg-blue-50 border border-blue-200 rounded-md p-4">
<p class="text-sm font-medium text-blue-800">Correction Batch</p>
<p class="text-sm text-blue-700 mt-1">
<div
v-if="supersedesBatchId"
class="rounded-input border border-primary-100 bg-primary-50 p-4"
>
<p class="text-sm font-semibold text-primary-800">Correction Batch</p>
<p class="text-sm text-primary-700 mt-1">
This upload will supersede batch
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}</span>
</p>
<button
type="button"
@click="clearCorrection"
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
class="text-xs font-medium text-primary-700 hover:underline mt-2"
>
Cancel correction (upload as new batch)
</button>
</div>
<div v-if="uploadError" class="text-clinical-danger text-sm">
{{ uploadError }}
<InlineError
v-if="uploadError"
title="Upload failed"
:message="uploadError"
preserved="Your selected file and form values were kept."
retry-label="Try again"
@retry="handleUpload"
/>
<div
v-if="uploadSuccess"
class="rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4"
role="status"
>
<p class="text-sm font-semibold text-clinical-safe">{{ uploadSuccess }}</p>
<p class="text-sm text-ink-secondary mt-1">
The batch appears in Recent Uploads and is ready for assignment.
</p>
</div>
<button
@@ -170,14 +284,30 @@
{{ uploadButtonLabel }}
</button>
</form>
</section>
</div>
<!-- Recent uploads -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">Recent Uploads</h2>
<div v-if="assignError" class="text-clinical-danger text-sm mb-4">
{{ assignError }}
</div>
<!-- Right ~30%: guidance + recent uploads -->
<aside class="space-y-6 min-w-0 xl:sticky xl:top-4">
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-2">Guidance</h2>
<ul class="text-sm text-ink-secondary space-y-2 list-disc pl-4">
<li>Prefer cover sheet lookup when a printed separator is available.</li>
<li>Link a patient now when known; otherwise assign later in the batch workflow.</li>
<li>Uploaded batches stay here until an entry clerk is assigned.</li>
</ul>
</section>
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-4">Recent Uploads</h2>
<InlineError
v-if="assignError"
class="mb-4"
title="Assignment failed"
:message="assignError"
preserved="The batch remains unassigned."
retry-label=""
/>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
@@ -188,6 +318,9 @@
@assign="openAssignDialog"
@retry="loadRecent"
/>
</section>
</aside>
</div>
</div>
</div>
@@ -210,18 +343,27 @@ import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import BatchList from '../components/BatchList.vue'
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
import InlineError from '../components/InlineError.vue'
import type { CoverSheetResponse } from '../types'
const MAX_FILE_BYTES = 25 * 1024 * 1024
const ACCEPTED_TYPES = new Set(['application/pdf', 'image/jpeg', 'image/png'])
const route = useRoute()
const batchStore = useBatchStore()
const toast = useToast()
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const dragActive = ref(false)
const dragDepth = ref(0)
const fileError = ref('')
const batchType = ref((route.query.batchType as string) || '')
const track = ref('BACKFILL')
const patientId = ref<string | undefined>((route.query.patientId as string) || undefined)
const supersedesBatchId = ref<string | undefined>((route.query.supersedesBatchId as string) || undefined)
const uploadError = ref('')
const uploadSuccess = ref('')
const assignError = ref('')
const assignDialogOpen = ref(false)
const assignBatchId = ref<string | null>(null)
@@ -258,9 +400,65 @@ function formatTrack(value: string): string {
return value === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)'
}
function formatFileSize(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
function triggerFilePicker() {
fileInput.value?.click()
}
function validateFile(file: File): string | null {
if (file.size > MAX_FILE_BYTES) return 'File exceeds the 25 MB limit.'
if (file.type && !ACCEPTED_TYPES.has(file.type)) {
return 'Unsupported file type. Use PDF, JPEG, or PNG.'
}
return null
}
function setSelectedFile(file: File | null) {
fileError.value = ''
uploadError.value = ''
uploadSuccess.value = ''
if (!file) {
selectedFile.value = null
return
}
const error = validateFile(file)
if (error) {
selectedFile.value = null
fileError.value = error
return
}
selectedFile.value = file
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
selectedFile.value = input.files?.[0] ?? null
setSelectedFile(input.files?.[0] ?? null)
}
function clearFile() {
selectedFile.value = null
fileError.value = ''
if (fileInput.value) fileInput.value.value = ''
}
function onDragEnter() {
dragDepth.value += 1
dragActive.value = true
}
function onDragLeave() {
dragDepth.value = Math.max(0, dragDepth.value - 1)
if (dragDepth.value === 0) dragActive.value = false
}
function onDrop(event: DragEvent) {
dragDepth.value = 0
dragActive.value = false
const file = event.dataTransfer?.files?.[0] ?? null
setSelectedFile(file)
}
async function lookupCoverSheet(): Promise<void> {
@@ -309,6 +507,7 @@ async function handleUpload() {
if (!selectedFile.value || !canUpload.value) return
uploadError.value = ''
uploadSuccess.value = ''
try {
const batch = await batchStore.uploadBatch(
selectedFile.value,
@@ -322,18 +521,19 @@ async function handleUpload() {
const isCorrection = !!supersedesBatchId.value
const usedCoverSheet = !!resolvedCoverSheet.value
selectedFile.value = null
if (fileInput.value) fileInput.value.value = ''
batchType.value = ''
track.value = 'BACKFILL'
patientId.value = undefined
supersedesBatchId.value = undefined
clearCoverSheet()
toast.success(
isCorrection
const message = isCorrection
? 'Correction batch created'
: usedCoverSheet
? 'Batch uploaded with cover sheet'
: 'Batch uploaded successfully',
)
: 'Batch uploaded successfully'
uploadSuccess.value = message
toast.success(message)
await loadRecent()
}
} catch (e: unknown) {
@@ -2,42 +2,69 @@
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Live Capture" />
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
<!-- Mode selector tabs -->
<div class="flex border-b mb-6">
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-5xl mx-auto space-y-6">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Live Capture
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Bedside observation recording lighter than backfill digitization.
</p>
</div>
<!-- Mode selector -->
<div class="flex border-b border-line" role="tablist">
<button
type="button"
role="tab"
:aria-selected="mode === 'new'"
@click="mode = 'new'"
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
class="px-5 py-3 text-sm font-semibold border-b-2 -mb-px transition-colors"
:class="mode === 'new'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'"
? 'border-primary-600 text-primary-700'
: 'border-transparent text-ink-secondary hover:text-ink-strong'"
>
New Encounter
</button>
<button
type="button"
role="tab"
:aria-selected="mode === 'existing'"
@click="mode = 'existing'"
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
class="px-5 py-3 text-sm font-semibold border-b-2 -mb-px transition-colors"
:class="mode === 'existing'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700'"
? 'border-primary-600 text-primary-700'
: 'border-transparent text-ink-secondary hover:text-ink-strong'"
>
Existing Encounter
</button>
</div>
<!-- New encounter mode -->
<div v-if="mode === 'new'" class="space-y-6">
<fieldset class="card">
<legend class="text-sm font-medium text-gray-700 px-2">Patient</legend>
<!-- 1. Patient -->
<section v-if="mode === 'new'" class="card space-y-4">
<header>
<p class="workstation-form-legend">1 · Patient</p>
<h2 class="text-base font-semibold text-ink-strong mt-1">Select patient</h2>
</header>
<PatientSearch v-model="patientId" />
</fieldset>
</section>
<fieldset class="card">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Details</legend>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- 2. Encounter -->
<section class="card space-y-4">
<header>
<p class="workstation-form-legend">{{ mode === 'new' ? '2' : '1' }} · Encounter</p>
<h2 class="text-base font-semibold text-ink-strong mt-1">
{{ mode === 'new' ? 'Encounter details' : 'Existing encounter' }}
</h2>
</header>
<div v-if="mode === 'new'" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-gray-500 mb-1">Department</label>
<select v-model="department" class="form-input">
<label for="lc-department" class="block text-sm font-semibold text-ink mb-2">
Department
</label>
<select id="lc-department" v-model="department" class="form-input">
<option value="">Select department...</option>
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
{{ dept.label }}
@@ -45,194 +72,273 @@
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Room / Bed</label>
<input v-model="roomBed" type="text" class="form-input" placeholder="e.g. 4B-12" />
</div>
<div class="sm:col-span-2">
<label class="block text-xs text-gray-500 mb-1">Admission Reason</label>
<input v-model="admissionReason" type="text" class="form-input" placeholder="Chief complaint or reason" />
</div>
</div>
</fieldset>
</div>
<!-- Existing encounter mode -->
<div v-if="mode === 'existing'" class="space-y-6">
<fieldset class="card">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter</legend>
<div>
<label class="block text-xs text-gray-500 mb-1">Encounter ID</label>
<label for="lc-room" class="block text-sm font-semibold text-ink mb-2">
Room / Bed
</label>
<input
v-model="encounterId"
id="lc-room"
v-model="roomBed"
type="text"
class="form-input"
placeholder="e.g. 4B-12"
/>
</div>
<div class="sm:col-span-2">
<label for="lc-reason" class="block text-sm font-semibold text-ink mb-2">
Admission Reason
</label>
<input
id="lc-reason"
v-model="admissionReason"
type="text"
class="form-input"
placeholder="Chief complaint or reason"
/>
</div>
</div>
<div v-else>
<label for="lc-encounter" class="block text-sm font-semibold text-ink mb-2">
Encounter ID
</label>
<input
id="lc-encounter"
v-model="encounterId"
type="text"
class="form-input font-mono"
placeholder="Enter existing encounter UUID"
/>
</div>
</fieldset>
</div>
</section>
<!-- Vitals entry -->
<fieldset class="card mt-6">
<legend class="text-sm font-medium text-gray-700 px-2">
<!-- 3. Observations (dense table) -->
<section class="card space-y-4">
<header class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="workstation-form-legend">
{{ mode === 'new' ? '3' : '2' }} · Observations
</p>
<h2 class="text-base font-semibold text-ink-strong mt-1">
Vitals ({{ observations.length }})
</legend>
</h2>
</div>
<button
type="button"
class="text-sm font-semibold text-primary-700 hover:text-primary-800"
:disabled="observations.length >= 10"
@click="addObservation"
>
+ Add Observation
</button>
</header>
<div class="space-y-3">
<div
<div class="overflow-x-auto -mx-1">
<table class="w-full min-w-[720px] text-sm">
<thead>
<tr class="border-b border-line text-left text-ink-secondary">
<th class="py-2 px-2 font-medium w-[18%]">Time</th>
<th class="py-2 px-2 font-medium w-[20%]">Type</th>
<th class="py-2 px-2 font-medium w-[12%]">Value</th>
<th class="py-2 px-2 font-medium w-[12%]">Unit</th>
<th class="py-2 px-2 font-medium">Notes</th>
<th class="py-2 px-2 font-medium w-[5rem]"></th>
</tr>
</thead>
<tbody>
<tr
v-for="(obs, idx) in observations"
:key="idx"
class="p-4 bg-gray-50 rounded-md"
class="border-b border-line align-top"
>
<td class="py-2 px-2">
<input
v-model="obs.recordedAt"
type="datetime-local"
class="form-input text-sm py-1.5"
/>
</td>
<td class="py-2 px-2">
<select
v-model="obs.observationCode"
class="form-input text-sm py-1.5"
@change="autoFillUnit(obs)"
>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<div class="col-span-2 sm:col-span-1">
<label class="block text-xs text-gray-500 mb-1">Code</label>
<select v-model="obs.observationCode" class="form-input text-sm" @change="autoFillUnit(obs)">
<option value="">Select...</option>
<option v-for="code in vitalCodes" :key="code.value" :value="code.value">
<option
v-for="code in vitalCodes"
:key="code.value"
:value="code.value"
>
{{ code.label }}
</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Value</label>
</td>
<td class="py-2 px-2">
<input
v-model.number="obs.value"
type="number"
step="0.01"
class="form-input text-sm"
class="form-input text-sm py-1.5"
inputmode="decimal"
/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Unit</label>
<input v-model="obs.unit" type="text" class="form-input text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Recorded At</label>
</td>
<td class="py-2 px-2">
<input
v-model="obs.recordedAt"
type="datetime-local"
class="form-input text-sm"
v-model="obs.unit"
type="text"
class="form-input text-sm py-1.5"
/>
</div>
<div class="flex items-end gap-2">
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">Note</label>
<input v-model="obs.note" type="text" class="form-input text-sm" placeholder="Optional" />
</div>
</td>
<td class="py-2 px-2">
<input
v-model="obs.note"
type="text"
class="form-input text-sm py-1.5"
placeholder="Optional"
/>
</td>
<td class="py-2 px-2 text-right">
<button
@click="removeObservation(idx)"
class="mb-0.5 text-clinical-danger hover:text-red-800 text-sm px-2 py-2"
type="button"
class="text-xs font-medium text-clinical-danger hover:underline py-1.5"
:disabled="observations.length <= 1"
@click="removeObservation(idx)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<button
@click="addObservation"
class="mt-4 text-sm text-primary-600 hover:text-primary-800"
:disabled="observations.length >= 10"
>
+ Add Observation
</button>
<p v-if="observations.length >= 10" class="text-xs text-gray-400 mt-1">
<p v-if="observations.length >= 10" class="text-xs text-ink-disabled">
Maximum 10 observations per submission.
</p>
</fieldset>
</section>
<!-- 4. Attestation -->
<section class="card space-y-4">
<header>
<p class="workstation-form-legend">
{{ mode === 'new' ? '4' : '3' }} · Attestation
</p>
<h2 class="text-base font-semibold text-ink-strong mt-1">
Clinician attestation
</h2>
</header>
<!-- Attestation & password -->
<fieldset class="card mt-6">
<legend class="text-sm font-medium text-gray-700 px-2">Clinician Attestation</legend>
<div class="space-y-4">
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="clinicianAttestation"
type="checkbox"
class="w-5 h-5 mt-0.5 text-clinical-safe rounded"
class="w-5 h-5 mt-0.5 rounded-input border-line-strong text-clinical-safe focus:ring-primary-500"
/>
<span class="text-sm">
I attest that I have directly observed or performed these measurements and they are accurate to the best of my clinical judgment.
<span class="text-sm text-ink">
I attest that I have directly observed or performed these measurements and they are
accurate to the best of my clinical judgment.
</span>
</label>
<div>
<label class="block text-xs text-gray-500 mb-1">Password Confirmation</label>
<div class="pt-2 border-t border-line">
<label for="lc-password" class="block text-sm font-semibold text-ink mb-2">
Password confirmation
</label>
<p class="text-xs text-ink-secondary mb-2">
Re-enter your password to confirm this clinical attestation.
</p>
<input
id="lc-password"
v-model="passwordConfirm"
type="password"
class="form-input max-w-sm"
placeholder="Re-enter your password to confirm"
placeholder="Current password"
autocomplete="current-password"
/>
</div>
</div>
</fieldset>
</section>
<!-- Submit -->
<div class="mt-6 flex flex-col sm:flex-row gap-4">
<!-- 5. Submit -->
<section class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<button
@click="submit"
type="button"
class="btn-primary text-base px-8 py-3"
:disabled="!canSubmit || store.loading"
@click="submit"
>
{{ store.loading ? 'Submitting...' : 'Record Vitals' }}
</button>
<button
v-if="lastResult"
type="button"
class="text-sm font-medium text-ink-secondary hover:text-ink-strong"
@click="resetForm"
class="px-4 py-3 text-gray-600 hover:text-gray-800 text-sm"
>
Record More Vitals
</button>
</div>
<!-- Error -->
<div v-if="errorMessage" class="mt-4 bg-red-50 border border-red-200 rounded-md p-4 text-sm text-clinical-danger">
{{ errorMessage }}
<InlineError
v-if="errorMessage"
title="Submission failed"
:message="errorMessage"
preserved="Your observation rows and attestation were kept."
retry-label="Try again"
@retry="submit"
/>
<!-- API-backed outcome only -->
<div v-if="lastResult" class="promotion-outcome space-y-3">
<p class="font-semibold text-clinical-safe">
Vitals recorded and promoted successfully
</p>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-ink">
<div>
<dt class="text-ink-secondary">Batch</dt>
<dd class="font-mono">{{ lastResult.batchId.substring(0, 8) }}</dd>
</div>
<div>
<dt class="text-ink-secondary">Encounter</dt>
<dd class="font-mono">{{ lastResult.encounterId.substring(0, 8) }}</dd>
</div>
<div>
<dt class="text-ink-secondary">Observations promoted</dt>
<dd>{{ lastResult.observations.length }}</dd>
</div>
<div>
<dt class="text-ink-secondary">Promoted at</dt>
<dd>{{ new Date(lastResult.promotedAt).toLocaleString() }}</dd>
</div>
</dl>
</div>
<!-- Success result -->
<div v-if="lastResult" class="mt-6 space-y-4">
<div class="bg-green-50 border border-green-200 rounded-md p-4">
<p class="font-medium text-green-800">Vitals recorded and promoted successfully</p>
<div class="text-sm text-green-700 mt-2 space-y-1">
<p>Batch: {{ lastResult.batchId.substring(0, 8) }}...</p>
<p>Encounter: {{ lastResult.encounterId.substring(0, 8) }}...</p>
<p>Observations promoted: {{ lastResult.observations.length }}</p>
<p>Promoted at: {{ new Date(lastResult.promotedAt).toLocaleString() }}</p>
</div>
</div>
<!-- Critical alerts -->
<div
v-if="lastResult.criticalAlertCount > 0"
class="bg-red-50 border-2 border-red-400 rounded-md p-4"
v-if="lastResult && lastResult.criticalAlertCount > 0"
class="clinical-signal clinical-signal--critical"
role="alert"
>
<div class="flex items-center gap-2 mb-3">
<span class="text-red-700 font-bold text-lg">CRITICAL ALERTS ({{ lastResult.criticalAlertCount }})</span>
</div>
<p class="font-semibold text-clinical-critical">
Critical alerts ({{ lastResult.criticalAlertCount }})
</p>
<div class="mt-3 space-y-2">
<div
v-for="obs in lastResult.observations.filter(o => o.criticalAlert)"
:key="obs.liveObservationId"
class="bg-white border border-red-300 rounded p-3 mb-2 last:mb-0"
class="rounded-input border border-[#FECDCA] bg-surface p-3"
>
<p class="font-medium text-red-800">
<p class="font-medium text-clinical-danger">
{{ formatCode(obs.observationCode) }}: {{ obs.value }} {{ obs.unit }}
</p>
<p class="text-sm text-red-700 mt-1">{{ obs.criticalAlert!.message }}</p>
<div class="text-xs text-red-600 mt-1">
<span>Severity: {{ obs.criticalAlert!.severity }}</span>
<span class="ml-4">
Threshold ({{ obs.criticalAlert!.thresholdBound.replace('_', ' ') }}):
<p class="text-sm text-ink mt-1">{{ obs.criticalAlert!.message }}</p>
<p class="text-xs text-ink-secondary mt-1">
Severity: {{ obs.criticalAlert!.severity }}
· Threshold ({{ obs.criticalAlert!.thresholdBound.replace('_', ' ') }}):
{{ obs.criticalAlert!.thresholdValue }}
</span>
</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
@@ -244,6 +350,7 @@ import { useLiveCaptureStore } from '../stores/liveCapture'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import InlineError from '../components/InlineError.vue'
import type { LiveCaptureObservationInput } from '../types'
const store = useLiveCaptureStore()
@@ -2,193 +2,260 @@
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Patient History" />
<div class="flex-1 p-4 sm:p-6 lg:p-8 max-w-5xl mx-auto w-full">
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-5xl mx-auto space-y-6">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Patient History
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Digitization lineage, promotion attribution, and audit trail for a patient.
</p>
</div>
<!-- Search when no patient selected -->
<div v-if="!patientId" class="card">
<h2 class="text-lg font-semibold mb-4">Find Patient</h2>
<section v-if="!patientId" class="card space-y-4">
<h2 class="text-base font-semibold text-ink-strong">Find Patient</h2>
<p class="text-sm text-ink-secondary">Search by MRN or name.</p>
<PatientSearch v-model="selectedPatientId" />
<button
v-if="selectedPatientId"
type="button"
class="btn-primary"
@click="router.push(`/patients/${selectedPatientId}/history`)"
class="btn-primary mt-4"
>
View History
</button>
</div>
</section>
<!-- History view -->
<div v-else>
<div v-if="batchStore.loading" class="text-gray-500 text-center py-8">
Loading history...
<div v-else class="space-y-6">
<SkeletonBlock v-if="batchStore.loading" variant="table" :rows="4" />
<InlineError
v-else-if="batchStore.error"
title="Could not load patient history"
:message="batchStore.error"
preserved="Your patient selection was preserved."
retry-label="Retry"
@retry="reloadHistory"
/>
<template v-else-if="history">
<!-- Summary -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
<div class="card-elevated text-center py-4">
<p class="text-2xl font-bold text-ink-strong tabular-nums">
{{ history.totalBatches }}
</p>
<p class="text-xs text-ink-secondary mt-1">Total Batches</p>
</div>
<div class="card-elevated text-center py-4">
<p class="text-2xl font-bold text-clinical-safe tabular-nums">
{{ history.promotedBatches }}
</p>
<p class="text-xs text-ink-secondary mt-1">Promoted</p>
</div>
<div class="card-elevated text-center py-4">
<p class="text-2xl font-bold text-clinical-warning tabular-nums">
{{ history.pendingBatches }}
</p>
<p class="text-xs text-ink-secondary mt-1">Pending</p>
</div>
<div class="card-elevated text-center py-4">
<p class="text-2xl font-bold text-ink-disabled tabular-nums">
{{ history.supersededBatches }}
</p>
<p class="text-xs text-ink-secondary mt-1">Superseded</p>
</div>
</div>
<div v-else-if="batchStore.error" class="text-clinical-danger text-center py-8">
{{ batchStore.error }}
</div>
<div v-else-if="history">
<!-- Summary stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div class="card-elevated text-center">
<p class="text-2xl font-bold">{{ history.totalBatches }}</p>
<p class="text-xs text-gray-500">Total Batches</p>
</div>
<div class="card-elevated text-center">
<p class="text-2xl font-bold text-green-700">{{ history.promotedBatches }}</p>
<p class="text-xs text-gray-500">Promoted</p>
</div>
<div class="card-elevated text-center">
<p class="text-2xl font-bold text-orange-600">{{ history.pendingBatches }}</p>
<p class="text-xs text-gray-500">Pending</p>
</div>
<div class="card-elevated text-center">
<p class="text-2xl font-bold text-gray-400">{{ history.supersededBatches }}</p>
<p class="text-xs text-gray-500">Superseded</p>
</div>
</div>
<EmptyState
v-if="history.entries.length === 0"
title="No digitization history for this patient."
description="Uploaded and promoted batches will appear here as a timeline."
/>
<!-- Timeline -->
<div class="space-y-4">
<div
<div v-else class="space-y-4">
<h2 class="text-base font-semibold text-ink-strong">Digitization timeline</h2>
<article
v-for="entry in history.entries"
:key="entry.batchId"
:data-batch-id="entry.batchId"
class="card relative transition-all"
:class="{
'border-l-4 border-l-green-500': entry.status === 'PROMOTED' && !entry.hasBeenSuperseded,
'border-l-4 border-l-gray-300': entry.hasBeenSuperseded,
'border-l-4 border-l-blue-500': entry.isCorrection && !entry.hasBeenSuperseded,
'border-l-4 border-l-yellow-500': !['PROMOTED', 'CANCELLED'].includes(entry.status) && !entry.hasBeenSuperseded,
}"
class="card relative transition-shadow"
:class="timelineAccentClass(entry)"
>
<!-- Header row -->
<!-- Header -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<span class="font-mono text-xs text-gray-600">
{{ entry.batchId.substring(0, 8) }}...
<span class="font-mono text-xs text-ink-secondary">
{{ entry.batchId.substring(0, 8) }}
</span>
<span :class="statusBadgeClass(entry.status)" class="status-badge">
{{ formatStatus(entry.status) }}
</span>
<span class="status-badge bg-gray-100 text-gray-700">
<StatusBadge :status="entry.status" />
<span class="status-badge bg-canvas text-ink border border-line">
{{ formatBatchType(entry.batchType) }}
</span>
<span
v-if="entry.isCorrection"
class="status-badge bg-blue-100 text-blue-800"
class="status-badge bg-primary-50 text-primary-800 border border-primary-100"
>
Correction
</span>
<span
v-if="entry.hasBeenSuperseded"
class="status-badge bg-gray-200 text-gray-500"
class="status-badge bg-canvas text-ink-disabled border border-line"
>
Superseded
</span>
</div>
<!-- Supersession chain info -->
<!-- Supersession chain -->
<div
v-if="entry.isCorrection && entry.supersedesBatchId"
class="text-xs text-blue-700 bg-blue-50 rounded px-3 py-2 mb-3"
class="text-xs text-primary-800 bg-primary-50 border border-primary-100 rounded-input px-3 py-2 mb-3"
>
Corrects batch
<button
type="button"
class="font-mono underline hover:text-primary-900"
@click="scrollToBatch(entry.supersedesBatchId!)"
class="font-mono underline hover:text-blue-900"
>
{{ entry.supersedesBatchId.substring(0, 8) }}...
{{ entry.supersedesBatchId.substring(0, 8) }}
</button>
</div>
<div
v-if="entry.hasBeenSuperseded && entry.supersededByBatchId"
class="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-3"
class="text-xs text-ink-secondary bg-canvas border border-line rounded-input px-3 py-2 mb-3"
>
Superseded by
<button
type="button"
class="font-mono underline hover:text-ink-strong"
@click="scrollToBatch(entry.supersededByBatchId!)"
class="font-mono underline hover:text-gray-700"
>
{{ entry.supersededByBatchId.substring(0, 8) }}...
{{ entry.supersededByBatchId.substring(0, 8) }}
</button>
</div>
<!-- Observation counts -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-3">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-4">
<div>
<span class="text-gray-500">Draft obs:</span>
<span class="font-medium ml-1">{{ entry.draftObservationCount }}</span>
<span class="text-ink-secondary">Draft obs</span>
<p class="font-medium text-ink-strong">{{ entry.draftObservationCount }}</p>
</div>
<div>
<span class="text-gray-500">Live obs:</span>
<span class="font-medium ml-1" :class="{ 'line-through text-gray-400': entry.hasBeenSuperseded }">
<span class="text-ink-secondary">Live obs</span>
<p
class="font-medium text-ink-strong"
:class="{ 'line-through text-ink-disabled': entry.hasBeenSuperseded }"
>
{{ entry.liveObservationCount }}
</span>
</p>
</div>
<div v-if="entry.supersededObservationCount > 0">
<span class="text-gray-500">Superseded obs:</span>
<span class="font-medium ml-1 text-gray-400">{{ entry.supersededObservationCount }}</span>
<span class="text-ink-secondary">Superseded obs</span>
<p class="font-medium text-ink-disabled">
{{ entry.supersededObservationCount }}
</p>
</div>
<div v-if="entry.promotionEncounterId">
<span class="text-gray-500">Encounter:</span>
<span class="font-mono text-xs ml-1">{{ entry.promotionEncounterId.substring(0, 8) }}...</span>
<span class="text-ink-secondary">Encounter</span>
<p class="font-mono text-xs text-ink-strong mt-0.5">
{{ entry.promotionEncounterId.substring(0, 8) }}
</p>
</div>
</div>
<!-- People & dates -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-gray-500">
<div>Created: {{ new Date(entry.createdAt).toLocaleString() }}</div>
<!-- Attribution (names from payload only) -->
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm border-t border-line pt-3">
<div>
<dt class="text-xs text-ink-secondary">Created</dt>
<dd class="text-ink">{{ formatDateTime(entry.createdAt) }}</dd>
</div>
<div v-if="entry.promotedAt">
Promoted: {{ new Date(entry.promotedAt).toLocaleString() }}
<dt class="text-xs text-ink-secondary">Promoted</dt>
<dd class="text-ink">{{ formatDateTime(entry.promotedAt) }}</dd>
</div>
<div v-if="entry.enteredByUserName">
Entered by: {{ entry.enteredByUserName }}
<dt class="text-xs text-ink-secondary">Entered by</dt>
<dd class="text-ink-strong">{{ entry.enteredByUserName }}</dd>
</div>
<div v-if="entry.verifiedByUserName">
Verified by: {{ entry.verifiedByUserName }}
<dt class="text-xs text-ink-secondary">Verified by</dt>
<dd class="text-ink-strong">{{ entry.verifiedByUserName }}</dd>
</div>
<div v-if="entry.approvedByUserName">
Approved by: {{ entry.approvedByUserName }}
</div>
<dt class="text-xs text-ink-secondary">Approved / promoted by</dt>
<dd class="text-ink-strong">{{ entry.approvedByUserName }}</dd>
</div>
</dl>
<!-- Audit trail (collapsible) -->
<details v-if="entry.auditTrail.length > 0" class="mt-3">
<summary class="text-xs text-primary-600 cursor-pointer hover:text-primary-800">
<!-- Audit trail -->
<details
v-if="entry.auditTrail.length > 0"
class="mt-4 group"
>
<summary
class="text-sm font-medium text-primary-700 cursor-pointer hover:text-primary-800 list-none flex items-center gap-2"
>
<span
class="inline-flex h-5 w-5 items-center justify-center rounded border border-line text-xs text-ink-secondary group-open:rotate-90 transition-transform"
aria-hidden="true"
>
</span>
Audit trail ({{ entry.auditTrail.length }} events)
</summary>
<div class="mt-2 space-y-1 pl-3 border-l-2 border-gray-200">
<div
<div class="mt-3 overflow-x-auto">
<table class="w-full min-w-[480px] text-sm">
<thead>
<tr class="border-b border-line text-left text-ink-secondary">
<th class="py-2 px-2 font-medium w-[34%]">Timestamp</th>
<th class="py-2 px-2 font-medium">Event</th>
<th class="py-2 px-2 font-medium w-[28%]">Actor</th>
</tr>
</thead>
<tbody>
<tr
v-for="(event, idx) in entry.auditTrail"
:key="idx"
class="text-xs text-gray-600"
class="border-b border-line last:border-0"
>
<span class="font-medium">{{ formatEventType(event.eventType) }}</span>
<span class="text-gray-400 ml-2">
{{ new Date(event.occurredAt).toLocaleString() }}
<td class="py-2 px-2 text-ink-secondary whitespace-nowrap">
{{ formatDateTime(event.occurredAt) }}
</td>
<td class="py-2 px-2">
<span class="font-medium text-ink-strong">
{{ formatEventType(event.eventType) }}
</span>
<span class="ml-2">by {{ event.actorName }}</span>
</div>
</td>
<td class="py-2 px-2 text-ink">
{{ event.actorName || '—' }}
</td>
</tr>
</tbody>
</table>
</div>
</details>
<!-- Create Correction button for promoted, non-superseded batches -->
<!-- Create Correction -->
<div
v-if="entry.status === 'PROMOTED' && !entry.hasBeenSuperseded"
class="mt-3 pt-3 border-t border-gray-100"
class="mt-4 pt-3 border-t border-line"
>
<button
type="button"
class="text-sm font-semibold text-primary-700 hover:text-primary-800"
@click="createCorrection(entry)"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
>
Create Correction
</button>
</div>
</article>
</div>
</div>
<div v-if="history.entries.length === 0" class="text-gray-500 text-center py-8">
No digitization history for this patient.
</div>
</template>
</div>
</div>
</div>
@@ -201,6 +268,10 @@ import { useRoute, useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches'
import AppHeader from '../components/AppHeader.vue'
import PatientSearch from '../components/PatientSearch.vue'
import StatusBadge from '../components/StatusBadge.vue'
import EmptyState from '../components/EmptyState.vue'
import SkeletonBlock from '../components/SkeletonBlock.vue'
import InlineError from '../components/InlineError.vue'
import type { PatientDigitizationHistoryResponse, DigitizationHistoryEntry } from '../types'
const props = defineProps<{ patientId?: string }>()
@@ -213,12 +284,19 @@ const patientId = ref(props.patientId ?? (route.params.patientId as string | und
const selectedPatientId = ref<string | undefined>()
const history = ref<PatientDigitizationHistoryResponse | null>(null)
async function reloadHistory() {
if (!patientId.value) return
history.value = await batchStore.getPatientHistory(patientId.value)
}
watch(
() => props.patientId ?? (route.params.patientId as string | undefined),
async (id) => {
patientId.value = id
if (id) {
history.value = await batchStore.getPatientHistory(id)
} else {
history.value = null
}
},
{ immediate: true },
@@ -228,27 +306,22 @@ function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function formatEventType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function statusBadgeClass(status: string): string {
const classes: Record<string, string> = {
UPLOADED: 'bg-gray-100 text-gray-800',
IN_ENTRY: 'bg-yellow-100 text-yellow-800',
PENDING_VERIFICATION: 'bg-orange-100 text-orange-800',
REJECTED: 'bg-red-100 text-red-800',
VERIFIED: 'bg-blue-100 text-blue-800',
AWAITING_CLINICAL_APPROVAL: 'bg-purple-100 text-purple-800',
APPROVED: 'bg-green-100 text-green-800',
PROMOTED: 'bg-emerald-100 text-emerald-800',
CANCELLED: 'bg-gray-200 text-gray-500',
function formatDateTime(value: string): string {
return new Date(value).toLocaleString()
}
return classes[status] ?? 'bg-gray-100 text-gray-800'
function timelineAccentClass(entry: DigitizationHistoryEntry): string {
if (entry.hasBeenSuperseded) return 'border-l-[3px] border-l-line-strong'
if (entry.status === 'PROMOTED') return 'border-l-[3px] border-l-clinical-safe'
if (entry.isCorrection) return 'border-l-[3px] border-l-primary-500'
if (!['PROMOTED', 'CANCELLED'].includes(entry.status)) {
return 'border-l-[3px] border-l-clinical-warning'
}
return ''
}
function scrollToBatch(batchId: string) {
@@ -4,40 +4,81 @@
<template #actions>
<button
type="button"
class="text-sm font-medium text-primary-700 hover:text-primary-800"
:disabled="refreshing"
@click="refreshData"
class="text-sm text-primary-600 hover:text-primary-800"
>
Refresh
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
</button>
</template>
</AppHeader>
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 space-y-6 lg:space-y-8">
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-8">
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
<div>
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
Queue Dashboard
</h1>
<p class="mt-1 text-sm text-ink-secondary">
Supervisor view of digitization backlog, aging work, and reject rate.
</p>
</div>
<InlineError
v-if="overviewError"
title="Could not load queue overview"
:message="overviewError"
preserved="Previously loaded metrics are shown when available."
retry-label="Retry"
@retry="refreshData"
/>
<!-- Priority work metrics -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-3 sm:gap-4">
<div class="card-elevated">
<p class="text-sm text-gray-500">Pending Entry</p>
<p class="text-3xl font-bold text-yellow-600">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Pending Entry
</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-clinical-warning">
{{ overview?.statusCounts?.UPLOADED ?? 0 }}
</p>
</div>
<div class="card-elevated">
<p class="text-sm text-gray-500">In Entry</p>
<p class="text-3xl font-bold text-blue-600">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
In Entry
</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-primary-700">
{{ overview?.statusCounts?.IN_ENTRY ?? 0 }}
</p>
</div>
<div class="card-elevated">
<p class="text-sm text-gray-500">Pending Verification</p>
<p class="text-3xl font-bold text-orange-600">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Pending Verification
</p>
<p class="mt-2 text-3xl font-bold tabular-nums text-clinical-warning">
{{ overview?.statusCounts?.PENDING_VERIFICATION ?? 0 }}
</p>
</div>
<div class="card-elevated">
<p class="text-sm text-gray-500">Reject Rate</p>
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Oldest Pending Verification
</p>
<p
class="text-3xl font-bold"
:class="(overview?.rejectRate ?? 0) > 15
class="mt-2 text-3xl font-bold tabular-nums"
:class="(overview?.oldestPendingVerificationMinutes ?? 0) > 1440
? 'text-clinical-danger'
: 'text-ink-strong'"
>
{{ formatMinutes(overview?.oldestPendingVerificationMinutes ?? 0) }}
</p>
</div>
<div class="card-elevated">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Reject Rate
</p>
<p
class="mt-2 text-3xl font-bold tabular-nums"
:class="(overview?.rejectRate ?? 0) > 0.15
? 'text-clinical-danger'
: 'text-clinical-safe'"
>
@@ -46,58 +87,53 @@
</div>
</div>
<!-- Queue age and throughput -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8">
<div class="card-elevated">
<h3 class="text-sm font-medium text-gray-700 mb-2">Average Time in Queue</h3>
<p class="text-2xl font-bold">
<!-- Secondary age metric -->
<div class="card-elevated max-w-md">
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
Average Time in Queue
</p>
<p class="mt-2 text-2xl font-bold tabular-nums text-ink-strong">
{{ formatMinutes(overview?.averageTimeInQueueMinutes ?? 0) }}
</p>
<p class="text-xs text-gray-500 mt-2">
Target: &lt; 24 hours
</p>
</div>
<div class="card-elevated">
<h3 class="text-sm font-medium text-gray-700 mb-2">
Oldest Pending Verification
</h3>
<p
class="text-2xl font-bold"
:class="(overview?.oldestPendingVerificationMinutes ?? 0) > 1440
? 'text-clinical-danger'
: 'text-gray-900'"
>
{{ formatMinutes(overview?.oldestPendingVerificationMinutes ?? 0) }}
</p>
</div>
<p class="text-xs text-ink-secondary mt-2">Target: under 24 hours</p>
</div>
<!-- Status breakdown -->
<div class="card">
<h3 class="text-sm font-medium text-gray-700 mb-4">Batches by Status</h3>
<div class="space-y-2">
<!-- Status breakdown from overview.statusCounts -->
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-4">Batches by Status</h2>
<div v-if="!overview && refreshing" class="py-2">
<SkeletonBlock variant="row" :rows="5" />
</div>
<EmptyState
v-else-if="statusEntries.length === 0"
title="No status counts yet."
description="Refresh to load work-queue overview metrics."
/>
<div v-else class="space-y-3">
<div
v-for="(count, status) in overview?.statusCounts ?? {}"
v-for="{ status, count } in statusEntries"
:key="status"
class="flex items-center gap-4"
class="flex items-center gap-3 sm:gap-4"
>
<span class="w-40 shrink-0 text-sm text-gray-600">
{{ String(status).replace(/_/g, ' ') }}
</span>
<div class="flex-1 bg-gray-100 rounded-full h-4">
<div class="w-44 shrink-0">
<StatusBadge :status="status" />
</div>
<div class="flex-1 h-2.5 rounded-full bg-canvas border border-line overflow-hidden">
<div
class="h-4 rounded-full bg-primary-500"
:style="{ width: `${(Number(count) / maxCount) * 100}%` }"
class="h-full rounded-full bg-primary-500"
:style="{ width: `${(count / maxCount) * 100}%` }"
/>
</div>
<span class="text-sm font-medium w-8 text-right">{{ count }}</span>
</div>
<span class="text-sm font-semibold tabular-nums text-ink-strong w-8 text-right">
{{ count }}
</span>
</div>
</div>
</section>
<!-- Recent activity -->
<div class="card">
<h3 class="text-sm font-medium text-gray-700 mb-4">All Batches</h3>
<!-- Existing batch list via batch list API -->
<section class="card">
<h2 class="text-base font-semibold text-ink-strong mb-4">All Batches</h2>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
@@ -106,6 +142,7 @@
empty-description="Batches appear here as they move through digitization."
@retry="refreshData"
/>
</section>
</div>
</div>
</div>
@@ -117,16 +154,28 @@ import { useBatchStore } from '../stores/batches'
import { get } from '../api/client'
import AppHeader from '../components/AppHeader.vue'
import BatchList from '../components/BatchList.vue'
import StatusBadge from '../components/StatusBadge.vue'
import EmptyState from '../components/EmptyState.vue'
import SkeletonBlock from '../components/SkeletonBlock.vue'
import InlineError from '../components/InlineError.vue'
import type { WorkQueueOverview } from '../types'
const batchStore = useBatchStore()
const overview = ref<WorkQueueOverview | null>(null)
const overviewError = ref('')
const refreshing = ref(false)
const maxCount = computed(() => {
if (!overview.value?.statusCounts) return 1
return Math.max(...Object.values(overview.value.statusCounts).map(Number), 1)
const statusEntries = computed(() => {
const counts = overview.value?.statusCounts ?? {}
return Object.entries(counts)
.map(([status, count]) => ({ status, count: Number(count) }))
.sort((a, b) => b.count - a.count)
})
const maxCount = computed(() =>
Math.max(...statusEntries.value.map(e => e.count), 1),
)
function formatMinutes(minutes: number): string {
if (minutes < 60) return `${Math.round(minutes)}m`
if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h`
@@ -134,11 +183,21 @@ function formatMinutes(minutes: number): string {
}
async function refreshData() {
refreshing.value = true
overviewError.value = ''
try {
const response = await get<WorkQueueOverview>('work-queue/overview')
if (response.success && response.data) {
overview.value = response.data
} else {
overviewError.value = response.error?.message ?? 'Failed to load work-queue overview'
}
} catch (e: unknown) {
overviewError.value = e instanceof Error ? e.message : 'Failed to load work-queue overview'
}
await batchStore.listBatches({ page: 1, pageSize: 50 })
refreshing.value = false
}
onMounted(refreshData)