feature: Supporting Screens
This commit is contained in:
@@ -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"]')
|
||||
|
||||
@@ -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–100)
|
||||
<label for="cs-count" class="block text-sm font-semibold text-ink mb-2">
|
||||
Quantity (1–100)
|
||||
</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">—</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">—</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
|
||||
|
||||
|
||||
@@ -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 — 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 — 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: < 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)
|
||||
|
||||
Reference in New Issue
Block a user