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'
|
import { get, post } from '@/api/client'
|
||||||
|
|
||||||
const mockedGet = vi.mocked(get)
|
const mockedGet = vi.mocked(get)
|
||||||
@@ -75,18 +100,33 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('CoverSheetView', () => {
|
describe('CoverSheetView', () => {
|
||||||
it('renders generate form and cover sheet list', async () => {
|
it('renders generate form, preview, and cover sheet list', async () => {
|
||||||
const wrapper = mount(CoverSheetView)
|
const wrapper = mount(CoverSheetView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Generate Cover Sheets')
|
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.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)
|
expect(wrapper.find('select').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads entry clerks and cover sheets on mount', async () => {
|
it('loads entry clerks and cover sheets on mount', async () => {
|
||||||
mount(CoverSheetView)
|
mount(CoverSheetView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
|
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
|
||||||
@@ -104,7 +144,13 @@ describe('CoverSheetView', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount(CoverSheetView)
|
const wrapper = mount(CoverSheetView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const batchTypeSelect = wrapper.findAll('select')[0]
|
const batchTypeSelect = wrapper.findAll('select')[0]
|
||||||
@@ -134,7 +180,13 @@ describe('CoverSheetView', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const wrapper = mount(CoverSheetView)
|
const wrapper = mount(CoverSheetView, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
RouterLink: { template: '<a><slot /></a>' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||||
@@ -142,5 +194,6 @@ describe('CoverSheetView', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Print Cover Sheets')
|
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', () => ({
|
vi.mock('@/components/AppHeader.vue', () => ({
|
||||||
default: { template: '<div />' },
|
default: {
|
||||||
|
template: '<div data-testid="app-header"><slot name="actions" /></div>',
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
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'
|
import { fhirGet, openFhirJsonInNewTab } from '@/api/fhirClient'
|
||||||
|
|
||||||
const mockedFhirGet = vi.mocked(fhirGet)
|
const mockedFhirGet = vi.mocked(fhirGet)
|
||||||
@@ -76,6 +92,7 @@ describe('FhirExplorerView', () => {
|
|||||||
expect(wrapper.text()).toContain('Patient $everything')
|
expect(wrapper.text()).toContain('Patient $everything')
|
||||||
expect(wrapper.find('select').element).toBeTruthy()
|
expect(wrapper.find('select').element).toBeTruthy()
|
||||||
expect(wrapper.text()).toContain('MRN (identifier)')
|
expect(wrapper.text()).toContain('MRN (identifier)')
|
||||||
|
expect(wrapper.text()).toContain('CapabilityStatement')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('searches FHIR Patient resources and shows results table', async () => {
|
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('"resourceType": "Patient"')
|
||||||
expect(wrapper.text()).toContain('"id": "patient-1"')
|
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 () => {
|
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()
|
const uploadBatchMock = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/stores/batches', () => ({
|
vi.mock('@/stores/batches', () => ({
|
||||||
useBatchStore: () => ({
|
useBatchStore: () => ({
|
||||||
batches: [],
|
batches: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
error: null,
|
||||||
listBatches: vi.fn(),
|
listBatches: vi.fn(),
|
||||||
uploadBatch: uploadBatchMock,
|
uploadBatch: uploadBatchMock,
|
||||||
assignBatch: vi.fn(),
|
assignBatch: vi.fn(),
|
||||||
@@ -89,7 +97,9 @@ describe('IntakeView cover sheet upload', () => {
|
|||||||
const wrapper = mount(IntakeView)
|
const wrapper = mount(IntakeView)
|
||||||
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
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.exists()).toBe(true)
|
||||||
expect(input.attributes('autofocus')).toBeDefined()
|
expect(input.attributes('autofocus')).toBeDefined()
|
||||||
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
|
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
|
||||||
@@ -147,7 +157,7 @@ describe('IntakeView cover sheet upload', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
'VCR-CS-A3F7B2D1',
|
'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 () => {
|
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('New Batch')
|
||||||
expect(wrapper.text()).toContain('Upload and Create 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 file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||||
const fileInput = wrapper.find('input[type="file"]')
|
const fileInput = wrapper.find('input[type="file"]')
|
||||||
|
|||||||
@@ -173,4 +173,29 @@
|
|||||||
.ocr-badge-low {
|
.ocr-badge-low {
|
||||||
@apply bg-clinical-danger-bg text-clinical-danger;
|
@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' },
|
APPROVED: { label: 'Approved', tone: 'success', icon: 'approve' },
|
||||||
PROMOTED: { label: 'Promoted', tone: 'success', icon: 'done' },
|
PROMOTED: { label: 'Promoted', tone: 'success', icon: 'done' },
|
||||||
CANCELLED: { label: 'Cancelled', tone: 'muted', icon: 'cancel' },
|
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> = {
|
export const BATCH_STATUS_TONE_CLASSES: Record<BatchStatusTone, string> = {
|
||||||
|
|||||||
@@ -2,210 +2,324 @@
|
|||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Cover Sheets" />
|
<AppHeader title="Cover Sheets" />
|
||||||
|
|
||||||
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1>
|
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
|
||||||
|
<div>
|
||||||
<!-- Generate -->
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||||
<div class="card mb-6">
|
Cover Sheet Management
|
||||||
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2>
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-ink-secondary">
|
||||||
<form @submit.prevent="handleGenerate" class="space-y-4">
|
Generate printable separators that reconnect scanned paper to batch metadata.
|
||||||
<div>
|
</p>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Count (1–100)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model.number="count"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="100"
|
|
||||||
class="form-input max-w-xs"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<option value="">Select batch type...</option>
|
|
||||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
|
||||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
|
||||||
<option value="VITALS_SHEET">Vitals Sheet</option>
|
|
||||||
<option value="LAB_RESULTS">Lab Results</option>
|
|
||||||
<option value="MEDICATION_LIST">Medication List</option>
|
|
||||||
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
|
||||||
<option value="MIXED">Mixed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
|
|
||||||
<select 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>
|
|
||||||
<PatientSearch v-model="patientId" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Assign to Clerk (optional)
|
|
||||||
</label>
|
|
||||||
<select 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 }})
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="generateError" class="text-clinical-danger text-sm">
|
|
||||||
{{ generateError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
|
||||||
{{ generating ? 'Generating...' : 'Generate' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="lastGeneratedIds.length > 0"
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary"
|
|
||||||
:disabled="printing"
|
|
||||||
@click="printLastGenerated"
|
|
||||||
>
|
|
||||||
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- List -->
|
|
||||||
<div 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>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="listError" class="text-clinical-danger text-sm mb-4">
|
<!-- Top: 50 / 50 generation + preview -->
|
||||||
{{ listError }}
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
|
||||||
</div>
|
<section class="card">
|
||||||
|
<h2 class="text-base font-semibold text-ink-strong mb-4">Generate Cover Sheets</h2>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||||
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div>
|
<div>
|
||||||
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4">
|
<label for="cs-count" class="block text-sm font-semibold text-ink mb-2">
|
||||||
No cover sheets found.
|
Quantity (1–100)
|
||||||
</div>
|
</label>
|
||||||
<table v-else class="w-full min-w-[800px] text-sm">
|
<input
|
||||||
<thead>
|
id="cs-count"
|
||||||
<tr class="border-b text-left text-gray-600">
|
v-model.number="count"
|
||||||
<th class="py-2 px-4">Code</th>
|
type="number"
|
||||||
<th class="py-2 px-4">Batch Type</th>
|
min="1"
|
||||||
<th class="py-2 px-4">Track</th>
|
max="100"
|
||||||
<th class="py-2 px-4">Patient</th>
|
class="form-input max-w-xs"
|
||||||
<th class="py-2 px-4">Assigned To</th>
|
required
|
||||||
<th class="py-2 px-4">Status</th>
|
/>
|
||||||
<th class="py-2 px-4">Linked Batch</th>
|
</div>
|
||||||
<th class="py-2 px-4">Created</th>
|
|
||||||
</tr>
|
<div>
|
||||||
</thead>
|
<label for="cs-batch-type" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<tbody>
|
Batch Type
|
||||||
<tr
|
</label>
|
||||||
v-for="sheet in coverSheets"
|
<select id="cs-batch-type" v-model="batchType" class="form-input" required>
|
||||||
:key="sheet.id"
|
<option value="">Select batch type...</option>
|
||||||
class="border-b hover:bg-gray-50"
|
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||||
|
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||||
|
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||||
|
<option value="LAB_RESULTS">Lab Results</option>
|
||||||
|
<option value="MEDICATION_LIST">Medication List</option>
|
||||||
|
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||||
|
<option value="MIXED">Mixed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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-semibold text-ink mb-2">
|
||||||
|
Patient
|
||||||
|
<span class="font-normal text-ink-secondary">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<PatientSearch v-model="patientId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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
|
||||||
|
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 }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</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 pt-1">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
||||||
|
{{ generating ? 'Generating...' : 'Generate Cover Sheets' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="lastGeneratedIds.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="printing"
|
||||||
|
@click="printLastGenerated"
|
||||||
|
>
|
||||||
|
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="lastGeneratedIds.length > 0"
|
||||||
|
class="text-sm text-ink-secondary"
|
||||||
>
|
>
|
||||||
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td>
|
Last run: {{ lastGeneratedIds.length }} cover sheet(s) ready to print.
|
||||||
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
|
</p>
|
||||||
<td class="py-2 px-4">
|
</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>
|
||||||
|
|
||||||
|
<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
|
<span
|
||||||
:class="sheet.track === 'BACKFILL'
|
v-for="n in 25"
|
||||||
? 'bg-blue-100 text-blue-800'
|
:key="n"
|
||||||
: 'bg-green-100 text-green-800'"
|
class="bg-ink-strong"
|
||||||
class="status-badge"
|
:class="qrCellClass(n)"
|
||||||
>
|
/>
|
||||||
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
</div>
|
||||||
</span>
|
|
||||||
</td>
|
<div class="text-center space-y-1">
|
||||||
<td class="py-2 px-4">
|
<p class="font-mono text-xs text-ink-secondary tracking-wider">
|
||||||
<template v-if="sheet.patientName">
|
VCR-CS-XXXXXXXX
|
||||||
{{ sheet.patientName }}
|
</p>
|
||||||
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span>
|
<p class="text-lg font-semibold text-ink-strong">
|
||||||
</template>
|
{{ previewBatchType }}
|
||||||
<span v-else class="text-gray-400">—</span>
|
</p>
|
||||||
</td>
|
<p class="text-sm text-ink-secondary">
|
||||||
<td class="py-2 px-4">
|
{{ track === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)' }}
|
||||||
{{ sheet.assignToUserName ?? '—' }}
|
</p>
|
||||||
</td>
|
</div>
|
||||||
<td class="py-2 px-4">
|
|
||||||
<span
|
<dl class="w-full max-w-xs text-sm space-y-2 border-t border-line pt-4">
|
||||||
:class="sheet.isUsed
|
<div class="flex justify-between gap-3">
|
||||||
? 'bg-gray-100 text-gray-800'
|
<dt class="text-ink-secondary">Quantity</dt>
|
||||||
: 'bg-green-100 text-green-800'"
|
<dd class="font-medium text-ink-strong">{{ previewCount }}</dd>
|
||||||
class="status-badge"
|
</div>
|
||||||
>
|
<div class="flex justify-between gap-3">
|
||||||
{{ sheet.isUsed ? 'Used' : 'Unused' }}
|
<dt class="text-ink-secondary">Patient</dt>
|
||||||
</span>
|
<dd class="font-medium text-ink-strong text-right truncate">
|
||||||
</td>
|
{{ patientId ? 'Linked' : 'Not linked' }}
|
||||||
<td class="py-2 px-4">
|
</dd>
|
||||||
<router-link
|
</div>
|
||||||
v-if="sheet.batchId"
|
<div class="flex justify-between gap-3">
|
||||||
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
<dt class="text-ink-secondary">Entry clerk</dt>
|
||||||
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
<dd class="font-medium text-ink-strong text-right truncate">
|
||||||
>
|
{{ assignedClerkLabel }}
|
||||||
{{ sheet.batchId.substring(0, 8) }}...
|
</dd>
|
||||||
</router-link>
|
</div>
|
||||||
<span v-else class="text-gray-400">—</span>
|
<div class="flex justify-between gap-3">
|
||||||
</td>
|
<dt class="text-ink-secondary">Generated</dt>
|
||||||
<td class="py-2 px-4 text-gray-500">
|
<dd class="font-medium text-ink-strong">{{ previewTimestamp }}</dd>
|
||||||
{{ formatDate(sheet.createdAt) }}
|
</div>
|
||||||
</td>
|
</dl>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
<!-- Existing cover sheets table -->
|
||||||
<p class="text-sm text-gray-500">Page {{ page }}</p>
|
<section class="card">
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
|
||||||
<button
|
<h2 class="text-base font-semibold text-ink-strong">Existing Cover Sheets</h2>
|
||||||
type="button"
|
|
||||||
class="btn-secondary text-sm"
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
:disabled="page <= 1 || listLoading"
|
<div>
|
||||||
@click="goToPage(page - 1)"
|
<label for="cs-status-filter" class="block text-sm font-semibold text-ink mb-2">
|
||||||
>
|
Status
|
||||||
Previous
|
</label>
|
||||||
</button>
|
<select
|
||||||
<button
|
id="cs-status-filter"
|
||||||
type="button"
|
v-model="statusFilter"
|
||||||
class="btn-secondary text-sm"
|
class="form-input"
|
||||||
:disabled="!hasNextPage || listLoading"
|
@change="onFiltersChanged"
|
||||||
@click="goToPage(page + 1)"
|
>
|
||||||
>
|
<option value="all">All</option>
|
||||||
Next
|
<option value="unused">Unused</option>
|
||||||
</button>
|
<option value="used">Used</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-[240px]">
|
||||||
|
<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>
|
||||||
</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">
|
||||||
|
<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-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-primary-50"
|
||||||
|
>
|
||||||
|
<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-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' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<template v-if="sheet.patientName">
|
||||||
|
{{ sheet.patientName }}
|
||||||
|
<span v-if="sheet.patientMrn" class="text-ink-secondary">
|
||||||
|
({{ sheet.patientMrn }})
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-ink-disabled">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
{{ sheet.assignToUserName ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<StatusBadge :status="sheet.isUsed ? 'USED' : 'UNUSED'" />
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4">
|
||||||
|
<router-link
|
||||||
|
v-if="sheet.batchId"
|
||||||
|
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
||||||
|
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
||||||
|
>
|
||||||
|
{{ sheet.batchId.substring(0, 8) }}…
|
||||||
|
</router-link>
|
||||||
|
<span v-else class="text-ink-disabled">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-4 text-ink-secondary">
|
||||||
|
{{ formatDate(sheet.createdAt) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:disabled="page <= 1 || listLoading"
|
||||||
|
@click="goToPage(page - 1)"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
:disabled="!hasNextPage || listLoading"
|
||||||
|
@click="goToPage(page + 1)"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,6 +331,10 @@ import { get, post, postBlob } from '../api/client'
|
|||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.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'
|
import type { CoverSheetResponse, UserSummary } from '../types'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -244,6 +362,25 @@ const filterPatientId = ref<string | undefined>()
|
|||||||
|
|
||||||
const hasNextPage = computed(() => coverSheets.value.length === pageSize)
|
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 {
|
function formatBatchType(type: string): string {
|
||||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
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()
|
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> {
|
async function loadClerks(): Promise<void> {
|
||||||
clerksLoading.value = true
|
clerksLoading.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,223 +1,334 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="FHIR Explorer" />
|
<AppHeader title="FHIR Explorer">
|
||||||
|
<template #actions>
|
||||||
<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>
|
|
||||||
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
||||||
Open CapabilityStatement (/fhir/metadata)
|
CapabilityStatement
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
<!-- Resource browser -->
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<div class="card">
|
<div class="max-w-7xl mx-auto space-y-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">Resource Browser</h2>
|
<div>
|
||||||
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-4">
|
FHIR R4 Explorer
|
||||||
<div>
|
</h1>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Resource Type</label>
|
<p class="mt-1 text-sm text-ink-secondary">
|
||||||
<select v-model="resourceType" class="form-input" @change="clearResults">
|
Read-only inspection of exposed FHIR resources for administrators and integration staff.
|
||||||
<option value="Patient">Patient</option>
|
|
||||||
<option value="Encounter">Encounter</option>
|
|
||||||
<option value="Observation">Observation</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 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" />
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
|
||||||
<select v-model="encounterSearch.status" class="form-input">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="in-progress">in-progress</option>
|
|
||||||
<option value="finished">finished</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 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" />
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
|
||||||
<select v-model="observationSearch.category" class="form-input">
|
|
||||||
<option value="">Any</option>
|
|
||||||
<option value="vital-signs">vital-signs</option>
|
|
||||||
<option value="laboratory">laboratory</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date (FHIR prefix)</label>
|
|
||||||
<input
|
|
||||||
v-model="observationSearch.date"
|
|
||||||
type="text"
|
|
||||||
class="form-input"
|
|
||||||
placeholder="ge2026-06-20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div v-if="searchResults.length > 0" class="mt-6 overflow-x-auto">
|
|
||||||
<p class="text-sm text-gray-600 mb-2">
|
|
||||||
{{ searchTotal }} result(s)
|
|
||||||
</p>
|
</p>
|
||||||
<table class="min-w-full text-sm border border-gray-200 rounded-md overflow-hidden">
|
</div>
|
||||||
<thead class="bg-gray-50 text-left">
|
|
||||||
<tr>
|
<!-- Resource browser -->
|
||||||
<th class="px-3 py-2">ID</th>
|
<section class="card space-y-5">
|
||||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">Name</th>
|
<header>
|
||||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">MRN</th>
|
<h2 class="text-base font-semibold text-ink-strong">Resource Browser</h2>
|
||||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">DOB</th>
|
<p class="text-sm text-ink-secondary mt-1">
|
||||||
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Status</th>
|
Search Patient, Encounter, or Observation, then inspect raw JSON.
|
||||||
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Patient</th>
|
</p>
|
||||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Code</th>
|
</header>
|
||||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Value</th>
|
|
||||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Recorded</th>
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
</tr>
|
<div>
|
||||||
</thead>
|
<label for="fhir-resource-type" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<tbody>
|
Resource Type
|
||||||
<tr
|
</label>
|
||||||
v-for="entry in searchResults"
|
<select
|
||||||
:key="String(entry.resource?.id)"
|
id="fhir-resource-type"
|
||||||
class="border-t border-gray-100 hover:bg-primary-50 cursor-pointer"
|
v-model="resourceType"
|
||||||
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
|
class="form-input"
|
||||||
@click="selectResource(entry.resource)"
|
@change="clearResults"
|
||||||
>
|
>
|
||||||
<td class="px-3 py-2 font-mono text-xs">{{ entry.resource?.id }}</td>
|
<option value="Patient">Patient</option>
|
||||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
<option value="Encounter">Encounter</option>
|
||||||
{{ patientDisplayName(entry.resource) }}
|
<option value="Observation">Observation</option>
|
||||||
</td>
|
</select>
|
||||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
</div>
|
||||||
{{ patientMrn(entry.resource) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
|
||||||
{{ entry.resource?.birthDate ?? '—' }}
|
|
||||||
</td>
|
|
||||||
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
|
|
||||||
{{ entry.resource?.status ?? '—' }}
|
|
||||||
</td>
|
|
||||||
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
|
|
||||||
{{ subjectReference(entry.resource) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
|
||||||
{{ observationCodeDisplay(entry.resource) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
|
||||||
{{ observationValueDisplay(entry.resource) }}
|
|
||||||
</td>
|
|
||||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
|
||||||
{{ observationEffective(entry.resource) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedResource" class="mt-6">
|
<template v-if="resourceType === 'Patient'">
|
||||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Resource JSON</h3>
|
<div>
|
||||||
<pre class="bg-gray-900 text-green-100 text-xs p-4 rounded-md overflow-x-auto max-h-96">{{ formattedSelectedResource }}</pre>
|
<label class="block text-sm font-semibold text-ink mb-2">Name</label>
|
||||||
</div>
|
<input
|
||||||
</div>
|
v-model="patientSearch.name"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="e.g. Santos"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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-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>
|
||||||
|
|
||||||
<!-- Patient $everything -->
|
<template v-else-if="resourceType === 'Encounter'">
|
||||||
<div class="card">
|
<div>
|
||||||
<h2 class="text-lg font-semibold mb-4">Patient $everything</h2>
|
<label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
<input
|
||||||
Load all FHIR resources for a patient in one Bundle.
|
v-model="encounterSearch.patient"
|
||||||
</p>
|
type="text"
|
||||||
|
class="form-input font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
<option value="finished">finished</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="max-w-md mb-4">
|
<template v-else>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
<div>
|
||||||
<PatientSearch v-model="everythingPatientId" />
|
<label class="block text-sm font-semibold text-ink mb-2">Patient ID</label>
|
||||||
</div>
|
<input
|
||||||
|
v-model="observationSearch.patient"
|
||||||
<button
|
type="text"
|
||||||
type="button"
|
class="form-input font-mono"
|
||||||
class="btn-primary"
|
/>
|
||||||
:disabled="!everythingPatientId || everythingLoading"
|
</div>
|
||||||
@click="loadEverything"
|
<div>
|
||||||
>
|
<label class="block text-sm font-semibold text-ink mb-2">LOINC Code</label>
|
||||||
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
|
<input
|
||||||
</button>
|
v-model="observationSearch.code"
|
||||||
|
type="text"
|
||||||
<p v-if="everythingError" class="text-clinical-danger text-sm mt-3">{{ everythingError }}</p>
|
class="form-input font-mono"
|
||||||
|
placeholder="8867-4"
|
||||||
<div v-if="everythingBundle" class="mt-6 space-y-6">
|
/>
|
||||||
<div v-if="everythingPatient" class="card bg-primary-50 border border-primary-100">
|
</div>
|
||||||
<h3 class="font-semibold mb-2">Patient</h3>
|
<div>
|
||||||
<p class="text-sm"><span class="text-gray-500">Name:</span> {{ patientDisplayName(everythingPatient) }}</p>
|
<label class="block text-sm font-semibold text-ink mb-2">Category</label>
|
||||||
<p class="text-sm"><span class="text-gray-500">MRN:</span> {{ patientMrn(everythingPatient) }}</p>
|
<select v-model="observationSearch.category" class="form-input">
|
||||||
<p class="text-sm"><span class="text-gray-500">DOB:</span> {{ everythingPatient.birthDate ?? '—' }}</p>
|
<option value="">Any</option>
|
||||||
<p class="text-sm"><span class="text-gray-500">Gender:</span> {{ everythingPatient.gender ?? '—' }}</p>
|
<option value="vital-signs">vital-signs</option>
|
||||||
|
<option value="laboratory">laboratory</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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 font-mono"
|
||||||
|
placeholder="ge2026-06-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="everythingEncounters.length > 0">
|
<button
|
||||||
<h3 class="font-semibold mb-2">Encounters ({{ everythingEncounters.length }})</h3>
|
type="button"
|
||||||
<ul class="space-y-2">
|
class="btn-primary"
|
||||||
<li
|
:disabled="searching"
|
||||||
v-for="enc in everythingEncounters"
|
@click="runSearch"
|
||||||
:key="String(enc.id)"
|
>
|
||||||
class="text-sm border border-gray-200 rounded-md px-3 py-2"
|
{{ searching ? 'Searching...' : 'Search' }}
|
||||||
>
|
</button>
|
||||||
<span class="font-mono text-xs text-gray-500">{{ enc.id }}</span>
|
|
||||||
— {{ enc.status }}
|
<InlineError
|
||||||
<span v-if="enc.period?.start"> · {{ enc.period.start }}</span>
|
v-if="searchError"
|
||||||
</li>
|
title="FHIR search failed"
|
||||||
</ul>
|
:message="searchError"
|
||||||
|
preserved="Your search fields were kept."
|
||||||
|
retry-label="Retry search"
|
||||||
|
@retry="runSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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="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-b border-line hover:bg-primary-50 cursor-pointer"
|
||||||
|
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
|
||||||
|
@click="selectResource(entry.resource)"
|
||||||
|
>
|
||||||
|
<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="py-2 px-3 font-mono text-xs">
|
||||||
|
{{ patientMrn(entry.resource) }}
|
||||||
|
</td>
|
||||||
|
<td v-if="resourceType === 'Patient'" class="py-2 px-3">
|
||||||
|
{{ entry.resource?.birthDate ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td v-if="resourceType === 'Encounter'" class="py-2 px-3">
|
||||||
|
{{ entry.resource?.status ?? '—' }}
|
||||||
|
</td>
|
||||||
|
<td v-if="resourceType === 'Encounter'" class="py-2 px-3 font-mono text-xs">
|
||||||
|
{{ subjectReference(entry.resource) }}
|
||||||
|
</td>
|
||||||
|
<td v-if="resourceType === 'Observation'" class="py-2 px-3">
|
||||||
|
{{ observationCodeDisplay(entry.resource) }}
|
||||||
|
</td>
|
||||||
|
<td v-if="resourceType === 'Observation'" class="py-2 px-3">
|
||||||
|
{{ observationValueDisplay(entry.resource) }}
|
||||||
|
</td>
|
||||||
|
<td v-if="resourceType === 'Observation'" class="py-2 px-3 text-ink-secondary">
|
||||||
|
{{ observationEffective(entry.resource) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="everythingObservations.length > 0">
|
<EmptyState
|
||||||
<h3 class="font-semibold mb-2">Observations ({{ everythingObservations.length }})</h3>
|
v-else-if="searched && !searching && !searchError"
|
||||||
<ul class="space-y-2">
|
title="No matching FHIR resources."
|
||||||
<li
|
description="Adjust search parameters and try again."
|
||||||
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"
|
<div v-if="selectedResource">
|
||||||
>
|
<h3 class="text-sm font-semibold text-ink-strong mb-2">Resource JSON</h3>
|
||||||
<span class="font-mono text-xs text-gray-500">{{ obs.id }}</span>
|
<pre class="fhir-json-panel">{{ formattedSelectedResource }}</pre>
|
||||||
<span>{{ observationCodeDisplay(obs) }}</span>
|
|
||||||
<span>{{ observationValueDisplay(obs) }}</span>
|
|
||||||
<span class="text-gray-500">{{ observationEffective(obs) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
<!-- Patient $everything -->
|
||||||
|
<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">
|
||||||
|
<label class="block text-sm font-semibold text-ink mb-2">Patient</label>
|
||||||
|
<PatientSearch v-model="everythingPatientId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!everythingPatientId || everythingLoading"
|
||||||
|
@click="loadEverything"
|
||||||
|
>
|
||||||
|
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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="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="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-line rounded-input px-3 py-2 bg-surface"
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs text-ink-secondary">{{ enc.id }}</span>
|
||||||
|
— {{ enc.status }}
|
||||||
|
<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="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="border-b border-line"
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,6 +338,8 @@
|
|||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
|
import InlineError from '../components/InlineError.vue'
|
||||||
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
import {
|
import {
|
||||||
fhirGet,
|
fhirGet,
|
||||||
openFhirJsonInNewTab,
|
openFhirJsonInNewTab,
|
||||||
@@ -239,6 +352,7 @@ type ResourceType = 'Patient' | 'Encounter' | 'Observation'
|
|||||||
|
|
||||||
const resourceType = ref<ResourceType>('Patient')
|
const resourceType = ref<ResourceType>('Patient')
|
||||||
const searching = ref(false)
|
const searching = ref(false)
|
||||||
|
const searched = ref(false)
|
||||||
const searchError = ref('')
|
const searchError = ref('')
|
||||||
const searchResults = ref<FhirBundleEntry[]>([])
|
const searchResults = ref<FhirBundleEntry[]>([])
|
||||||
const searchTotal = ref(0)
|
const searchTotal = ref(0)
|
||||||
@@ -300,6 +414,7 @@ function clearResults(): void {
|
|||||||
searchTotal.value = 0
|
searchTotal.value = 0
|
||||||
selectedResource.value = null
|
selectedResource.value = null
|
||||||
searchError.value = ''
|
searchError.value = ''
|
||||||
|
searched.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectResource(resource: FhirResource | undefined): void {
|
function selectResource(resource: FhirResource | undefined): void {
|
||||||
@@ -374,6 +489,7 @@ function buildSearchParams(): Record<string, string | number | undefined> {
|
|||||||
|
|
||||||
async function runSearch(): Promise<void> {
|
async function runSearch(): Promise<void> {
|
||||||
searching.value = true
|
searching.value = true
|
||||||
|
searched.value = true
|
||||||
searchError.value = ''
|
searchError.value = ''
|
||||||
selectedResource.value = null
|
selectedResource.value = null
|
||||||
|
|
||||||
|
|||||||
@@ -2,192 +2,325 @@
|
|||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Intake" />
|
<AppHeader title="Intake" />
|
||||||
|
|
||||||
<div class="page-container flex-1">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
<!-- Barcode-assisted upload -->
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||||
<div class="card mb-6">
|
Upload Scanned Document
|
||||||
<h2 class="text-lg font-semibold mb-4">Quick Upload with Cover Sheet</h2>
|
</h1>
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
<p class="mt-1 text-sm text-ink-secondary">
|
||||||
Scan or type a cover sheet barcode to auto-populate batch details.
|
Create a digitization batch from a PDF or image scan.
|
||||||
</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
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="coverSheetCode"
|
|
||||||
@keydown.enter.prevent="lookupCoverSheet"
|
|
||||||
type="text"
|
|
||||||
class="form-input"
|
|
||||||
placeholder="VCR-CS-XXXXXXXX"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="lookupCoverSheet"
|
|
||||||
class="btn-secondary"
|
|
||||||
:disabled="!coverSheetCode.trim() || lookupLoading"
|
|
||||||
>
|
|
||||||
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="lookupError" class="text-clinical-danger text-sm mt-3">
|
|
||||||
{{ lookupError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="resolvedCoverSheet"
|
|
||||||
class="mt-4 bg-green-50 border border-green-200 rounded-md p-4"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<div>
|
|
||||||
<dt class="inline font-medium">Type:</dt>
|
|
||||||
<dd class="inline">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="inline font-medium">Track:</dt>
|
|
||||||
<dd class="inline">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="resolvedCoverSheet.patientName">
|
|
||||||
<dt class="inline font-medium">Patient:</dt>
|
|
||||||
<dd class="inline">
|
|
||||||
{{ resolvedCoverSheet.patientName }}
|
|
||||||
({{ resolvedCoverSheet.patientMrn }})
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div v-if="resolvedCoverSheet.assignToUserName">
|
|
||||||
<dt class="inline font-medium">Assign to:</dt>
|
|
||||||
<dd class="inline">{{ 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>
|
</p>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="clearCoverSheet"
|
|
||||||
class="text-xs text-green-700 hover:text-green-900 mt-2"
|
|
||||||
>
|
|
||||||
Clear cover sheet (use manual upload)
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload form -->
|
<div class="grid grid-cols-1 xl:grid-cols-[minmax(0,0.7fr)_minmax(0,0.3fr)] gap-6 lg:gap-8 items-start">
|
||||||
<div class="card mb-6">
|
<!-- Main — ~70%: upload + batch details -->
|
||||||
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
|
<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>
|
||||||
|
|
||||||
<form @submit.prevent="handleUpload" class="space-y-4">
|
<div class="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||||
<!-- File input -->
|
<div class="flex-1 min-w-0">
|
||||||
<div>
|
<label for="cover-sheet-code" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
Code
|
||||||
Scanned Document (PDF, JPEG, PNG — max 25 MB)
|
</label>
|
||||||
</label>
|
<input
|
||||||
<input
|
id="cover-sheet-code"
|
||||||
type="file"
|
v-model="coverSheetCode"
|
||||||
ref="fileInput"
|
@keydown.enter.prevent="lookupCoverSheet"
|
||||||
@change="onFileChange"
|
type="text"
|
||||||
accept=".pdf,.jpg,.jpeg,.png"
|
class="form-input font-mono"
|
||||||
class="block w-full text-sm text-gray-500
|
placeholder="VCR-CS-XXXXXXXX"
|
||||||
file:mr-4 file:py-2 file:px-4
|
autofocus
|
||||||
file:rounded-md file:border-0
|
/>
|
||||||
file:text-sm file:font-semibold
|
</div>
|
||||||
file:bg-primary-50 file:text-primary-700
|
<button
|
||||||
hover:file:bg-primary-100"
|
type="button"
|
||||||
/>
|
@click="lookupCoverSheet"
|
||||||
<p v-if="selectedFile" class="text-sm text-gray-500 mt-2">
|
class="btn-secondary shrink-0"
|
||||||
{{ selectedFile.name }} ({{ (selectedFile.size / 1024 / 1024).toFixed(2) }} MB)
|
:disabled="!coverSheetCode.trim() || lookupLoading"
|
||||||
</p>
|
>
|
||||||
|
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
|
||||||
|
</button>
|
||||||
|
</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 rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<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 text-ink-secondary">Type:</dt>
|
||||||
|
<dd class="inline ml-1">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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 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 text-ink-secondary">Assign to:</dt>
|
||||||
|
<dd class="inline ml-1">{{ resolvedCoverSheet.assignToUserName }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<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 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>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||||
|
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||||
|
<option value="LAB_RESULTS">Lab Results</option>
|
||||||
|
<option value="MEDICATION_LIST">Medication List</option>
|
||||||
|
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||||
|
<option value="MIXED">Mixed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Track -->
|
||||||
|
<div>
|
||||||
|
<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-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="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>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="clearCorrection"
|
||||||
|
class="text-xs font-medium text-primary-700 hover:underline mt-2"
|
||||||
|
>
|
||||||
|
Cancel correction (upload as new batch)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!canUpload"
|
||||||
|
>
|
||||||
|
{{ uploadButtonLabel }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch type -->
|
<!-- Right — ~30%: guidance + recent uploads -->
|
||||||
<div>
|
<aside class="space-y-6 min-w-0 xl:sticky xl:top-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
<section class="card">
|
||||||
<select
|
<h2 class="text-base font-semibold text-ink-strong mb-2">Guidance</h2>
|
||||||
v-model="batchType"
|
<ul class="text-sm text-ink-secondary space-y-2 list-disc pl-4">
|
||||||
class="form-input"
|
<li>Prefer cover sheet lookup when a printed separator is available.</li>
|
||||||
:required="!resolvedCoverSheet"
|
<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>
|
||||||
<option value="">Select batch type...</option>
|
</ul>
|
||||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
</section>
|
||||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
|
||||||
<option value="VITALS_SHEET">Vitals Sheet</option>
|
|
||||||
<option value="LAB_RESULTS">Lab Results</option>
|
|
||||||
<option value="MEDICATION_LIST">Medication List</option>
|
|
||||||
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
|
||||||
<option value="MIXED">Mixed</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Track -->
|
<section class="card">
|
||||||
<div>
|
<h2 class="text-base font-semibold text-ink-strong mb-4">Recent Uploads</h2>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
|
<InlineError
|
||||||
<select v-model="track" class="form-input">
|
v-if="assignError"
|
||||||
<option value="BACKFILL">Backfill (Track A)</option>
|
class="mb-4"
|
||||||
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
title="Assignment failed"
|
||||||
</select>
|
:message="assignError"
|
||||||
</div>
|
preserved="The batch remains unassigned."
|
||||||
|
retry-label=""
|
||||||
<!-- Patient search -->
|
/>
|
||||||
<div>
|
<BatchList
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
:batches="batchStore.batches"
|
||||||
Patient (optional — can link later)
|
:loading="batchStore.loading"
|
||||||
</label>
|
:error="batchStore.error"
|
||||||
<PatientSearch v-model="patientId" />
|
empty-title="No uploaded batches waiting for assignment."
|
||||||
</div>
|
empty-description="New uploads appear here after a scan is submitted."
|
||||||
|
show-assign
|
||||||
<!-- Supersession (correction) -->
|
@assign="openAssignDialog"
|
||||||
<div v-if="supersedesBatchId" class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
@retry="loadRecent"
|
||||||
<p class="text-sm font-medium text-blue-800">Correction Batch</p>
|
/>
|
||||||
<p class="text-sm text-blue-700 mt-1">
|
</section>
|
||||||
This upload will supersede batch
|
</aside>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Cancel correction (upload as new batch)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="uploadError" class="text-clinical-danger text-sm">
|
|
||||||
{{ uploadError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn-primary"
|
|
||||||
:disabled="!canUpload"
|
|
||||||
>
|
|
||||||
{{ uploadButtonLabel }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</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>
|
</div>
|
||||||
<BatchList
|
|
||||||
:batches="batchStore.batches"
|
|
||||||
:loading="batchStore.loading"
|
|
||||||
:error="batchStore.error"
|
|
||||||
empty-title="No uploaded batches waiting for assignment."
|
|
||||||
empty-description="New uploads appear here after a scan is submitted."
|
|
||||||
show-assign
|
|
||||||
@assign="openAssignDialog"
|
|
||||||
@retry="loadRecent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -210,18 +343,27 @@ import AppHeader from '../components/AppHeader.vue'
|
|||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||||
|
import InlineError from '../components/InlineError.vue'
|
||||||
import type { CoverSheetResponse } from '../types'
|
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 route = useRoute()
|
||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const selectedFile = ref<File | 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 batchType = ref((route.query.batchType as string) || '')
|
||||||
const track = ref('BACKFILL')
|
const track = ref('BACKFILL')
|
||||||
const patientId = ref<string | undefined>((route.query.patientId as string) || undefined)
|
const patientId = ref<string | undefined>((route.query.patientId as string) || undefined)
|
||||||
const supersedesBatchId = ref<string | undefined>((route.query.supersedesBatchId as string) || undefined)
|
const supersedesBatchId = ref<string | undefined>((route.query.supersedesBatchId as string) || undefined)
|
||||||
const uploadError = ref('')
|
const uploadError = ref('')
|
||||||
|
const uploadSuccess = ref('')
|
||||||
const assignError = ref('')
|
const assignError = ref('')
|
||||||
const assignDialogOpen = ref(false)
|
const assignDialogOpen = ref(false)
|
||||||
const assignBatchId = ref<string | null>(null)
|
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)'
|
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) {
|
function onFileChange(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
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> {
|
async function lookupCoverSheet(): Promise<void> {
|
||||||
@@ -309,6 +507,7 @@ async function handleUpload() {
|
|||||||
if (!selectedFile.value || !canUpload.value) return
|
if (!selectedFile.value || !canUpload.value) return
|
||||||
|
|
||||||
uploadError.value = ''
|
uploadError.value = ''
|
||||||
|
uploadSuccess.value = ''
|
||||||
try {
|
try {
|
||||||
const batch = await batchStore.uploadBatch(
|
const batch = await batchStore.uploadBatch(
|
||||||
selectedFile.value,
|
selectedFile.value,
|
||||||
@@ -322,18 +521,19 @@ async function handleUpload() {
|
|||||||
const isCorrection = !!supersedesBatchId.value
|
const isCorrection = !!supersedesBatchId.value
|
||||||
const usedCoverSheet = !!resolvedCoverSheet.value
|
const usedCoverSheet = !!resolvedCoverSheet.value
|
||||||
selectedFile.value = null
|
selectedFile.value = null
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
batchType.value = ''
|
batchType.value = ''
|
||||||
track.value = 'BACKFILL'
|
track.value = 'BACKFILL'
|
||||||
patientId.value = undefined
|
patientId.value = undefined
|
||||||
supersedesBatchId.value = undefined
|
supersedesBatchId.value = undefined
|
||||||
clearCoverSheet()
|
clearCoverSheet()
|
||||||
toast.success(
|
const message = isCorrection
|
||||||
isCorrection
|
? 'Correction batch created'
|
||||||
? 'Correction batch created'
|
: usedCoverSheet
|
||||||
: usedCoverSheet
|
? 'Batch uploaded with cover sheet'
|
||||||
? 'Batch uploaded with cover sheet'
|
: 'Batch uploaded successfully'
|
||||||
: 'Batch uploaded successfully',
|
uploadSuccess.value = message
|
||||||
)
|
toast.success(message)
|
||||||
await loadRecent()
|
await loadRecent()
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|||||||
@@ -2,42 +2,69 @@
|
|||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Live Capture" />
|
<AppHeader title="Live Capture" />
|
||||||
|
|
||||||
<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">
|
||||||
<!-- Mode selector tabs -->
|
<div class="max-w-5xl mx-auto space-y-6">
|
||||||
<div class="flex border-b mb-6">
|
<div>
|
||||||
<button
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||||
@click="mode = 'new'"
|
Live Capture
|
||||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
</h1>
|
||||||
:class="mode === 'new'
|
<p class="mt-1 text-sm text-ink-secondary">
|
||||||
? 'border-primary-600 text-primary-600'
|
Bedside observation recording — lighter than backfill digitization.
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'"
|
</p>
|
||||||
>
|
</div>
|
||||||
New Encounter
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="mode = 'existing'"
|
|
||||||
class="px-6 py-3 text-sm font-medium border-b-2 transition-colors"
|
|
||||||
:class="mode === 'existing'
|
|
||||||
? 'border-primary-600 text-primary-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'"
|
|
||||||
>
|
|
||||||
Existing Encounter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New encounter mode -->
|
<!-- Mode selector -->
|
||||||
<div v-if="mode === 'new'" class="space-y-6">
|
<div class="flex border-b border-line" role="tablist">
|
||||||
<fieldset class="card">
|
<button
|
||||||
<legend class="text-sm font-medium text-gray-700 px-2">Patient</legend>
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="mode === 'new'"
|
||||||
|
@click="mode = 'new'"
|
||||||
|
class="px-5 py-3 text-sm font-semibold border-b-2 -mb-px transition-colors"
|
||||||
|
:class="mode === 'new'
|
||||||
|
? '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-5 py-3 text-sm font-semibold border-b-2 -mb-px transition-colors"
|
||||||
|
:class="mode === 'existing'
|
||||||
|
? 'border-primary-600 text-primary-700'
|
||||||
|
: 'border-transparent text-ink-secondary hover:text-ink-strong'"
|
||||||
|
>
|
||||||
|
Existing Encounter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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" />
|
<PatientSearch v-model="patientId" />
|
||||||
</fieldset>
|
</section>
|
||||||
|
|
||||||
<fieldset class="card">
|
<!-- 2. Encounter -->
|
||||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Details</legend>
|
<section class="card space-y-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-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>
|
<div>
|
||||||
<label class="block text-xs text-gray-500 mb-1">Department</label>
|
<label for="lc-department" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<select v-model="department" class="form-input">
|
Department
|
||||||
|
</label>
|
||||||
|
<select id="lc-department" v-model="department" class="form-input">
|
||||||
<option value="">Select department...</option>
|
<option value="">Select department...</option>
|
||||||
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
|
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
|
||||||
{{ dept.label }}
|
{{ dept.label }}
|
||||||
@@ -45,194 +72,273 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs text-gray-500 mb-1">Room / Bed</label>
|
<label for="lc-room" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<input v-model="roomBed" type="text" class="form-input" placeholder="e.g. 4B-12" />
|
Room / Bed
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lc-room"
|
||||||
|
v-model="roomBed"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="e.g. 4B-12"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class="block text-xs text-gray-500 mb-1">Admission Reason</label>
|
<label for="lc-reason" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<input v-model="admissionReason" type="text" class="form-input" placeholder="Chief complaint or reason" />
|
Admission Reason
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lc-reason"
|
||||||
|
v-model="admissionReason"
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="Chief complaint or reason"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Existing encounter mode -->
|
<div v-else>
|
||||||
<div v-if="mode === 'existing'" class="space-y-6">
|
<label for="lc-encounter" class="block text-sm font-semibold text-ink mb-2">
|
||||||
<fieldset class="card">
|
Encounter ID
|
||||||
<legend class="text-sm font-medium text-gray-700 px-2">Encounter</legend>
|
</label>
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Encounter ID</label>
|
|
||||||
<input
|
<input
|
||||||
|
id="lc-encounter"
|
||||||
v-model="encounterId"
|
v-model="encounterId"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-input"
|
class="form-input font-mono"
|
||||||
placeholder="Enter existing encounter UUID"
|
placeholder="Enter existing encounter UUID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vitals entry -->
|
<!-- 3. Observations (dense table) -->
|
||||||
<fieldset class="card mt-6">
|
<section class="card space-y-4">
|
||||||
<legend class="text-sm font-medium text-gray-700 px-2">
|
<header class="flex flex-wrap items-end justify-between gap-3">
|
||||||
Vitals ({{ observations.length }})
|
<div>
|
||||||
</legend>
|
<p class="workstation-form-legend">
|
||||||
|
{{ mode === 'new' ? '3' : '2' }} · Observations
|
||||||
<div class="space-y-3">
|
</p>
|
||||||
<div
|
<h2 class="text-base font-semibold text-ink-strong mt-1">
|
||||||
v-for="(obs, idx) in observations"
|
Vitals ({{ observations.length }})
|
||||||
:key="idx"
|
</h2>
|
||||||
class="p-4 bg-gray-50 rounded-md"
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
{{ code.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs text-gray-500 mb-1">Value</label>
|
|
||||||
<input
|
|
||||||
v-model.number="obs.value"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
class="form-input text-sm"
|
|
||||||
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>
|
|
||||||
<input
|
|
||||||
v-model="obs.recordedAt"
|
|
||||||
type="datetime-local"
|
|
||||||
class="form-input text-sm"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<button
|
|
||||||
@click="removeObservation(idx)"
|
|
||||||
class="mb-0.5 text-clinical-danger hover:text-red-800 text-sm px-2 py-2"
|
|
||||||
:disabled="observations.length <= 1"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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="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="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)"
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option
|
||||||
|
v-for="code in vitalCodes"
|
||||||
|
:key="code.value"
|
||||||
|
:value="code.value"
|
||||||
|
>
|
||||||
|
{{ code.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
v-model.number="obs.value"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
class="form-input text-sm py-1.5"
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2">
|
||||||
|
<input
|
||||||
|
v-model="obs.unit"
|
||||||
|
type="text"
|
||||||
|
class="form-input text-sm py-1.5"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
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>
|
<p v-if="observations.length >= 10" class="text-xs text-ink-disabled">
|
||||||
|
Maximum 10 observations per submission.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button
|
<!-- 4. Attestation -->
|
||||||
@click="addObservation"
|
<section class="card space-y-4">
|
||||||
class="mt-4 text-sm text-primary-600 hover:text-primary-800"
|
<header>
|
||||||
:disabled="observations.length >= 10"
|
<p class="workstation-form-legend">
|
||||||
>
|
{{ mode === 'new' ? '4' : '3' }} · Attestation
|
||||||
+ Add Observation
|
</p>
|
||||||
</button>
|
<h2 class="text-base font-semibold text-ink-strong mt-1">
|
||||||
<p v-if="observations.length >= 10" class="text-xs text-gray-400 mt-1">
|
Clinician attestation
|
||||||
Maximum 10 observations per submission.
|
</h2>
|
||||||
</p>
|
</header>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- 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">
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="clinicianAttestation"
|
v-model="clinicianAttestation"
|
||||||
type="checkbox"
|
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">
|
<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.
|
I attest that I have directly observed or performed these measurements and they are
|
||||||
|
accurate to the best of my clinical judgment.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
|
id="lc-password"
|
||||||
v-model="passwordConfirm"
|
v-model="passwordConfirm"
|
||||||
type="password"
|
type="password"
|
||||||
class="form-input max-w-sm"
|
class="form-input max-w-sm"
|
||||||
placeholder="Re-enter your password to confirm"
|
placeholder="Current password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
<!-- 5. Submit -->
|
||||||
<div class="mt-6 flex flex-col sm:flex-row gap-4">
|
<section class="space-y-4">
|
||||||
<button
|
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
@click="submit"
|
<button
|
||||||
class="btn-primary text-base px-8 py-3"
|
type="button"
|
||||||
:disabled="!canSubmit || store.loading"
|
class="btn-primary text-base px-8 py-3"
|
||||||
>
|
:disabled="!canSubmit || store.loading"
|
||||||
{{ store.loading ? 'Submitting...' : 'Record Vitals' }}
|
@click="submit"
|
||||||
</button>
|
>
|
||||||
<button
|
{{ store.loading ? 'Submitting...' : 'Record Vitals' }}
|
||||||
v-if="lastResult"
|
</button>
|
||||||
@click="resetForm"
|
<button
|
||||||
class="px-4 py-3 text-gray-600 hover:text-gray-800 text-sm"
|
v-if="lastResult"
|
||||||
>
|
type="button"
|
||||||
Record More Vitals
|
class="text-sm font-medium text-ink-secondary hover:text-ink-strong"
|
||||||
</button>
|
@click="resetForm"
|
||||||
</div>
|
>
|
||||||
|
Record More Vitals
|
||||||
<!-- Error -->
|
</button>
|
||||||
<div v-if="errorMessage" class="mt-4 bg-red-50 border border-red-200 rounded-md p-4 text-sm text-clinical-danger">
|
|
||||||
{{ errorMessage }}
|
|
||||||
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Critical alerts -->
|
<InlineError
|
||||||
<div
|
v-if="errorMessage"
|
||||||
v-if="lastResult.criticalAlertCount > 0"
|
title="Submission failed"
|
||||||
class="bg-red-50 border-2 border-red-400 rounded-md p-4"
|
:message="errorMessage"
|
||||||
>
|
preserved="Your observation rows and attestation were kept."
|
||||||
<div class="flex items-center gap-2 mb-3">
|
retry-label="Try again"
|
||||||
<span class="text-red-700 font-bold text-lg">CRITICAL ALERTS ({{ lastResult.criticalAlertCount }})</span>
|
@retry="submit"
|
||||||
</div>
|
/>
|
||||||
<div
|
|
||||||
v-for="obs in lastResult.observations.filter(o => o.criticalAlert)"
|
<!-- API-backed outcome only -->
|
||||||
:key="obs.liveObservationId"
|
<div v-if="lastResult" class="promotion-outcome space-y-3">
|
||||||
class="bg-white border border-red-300 rounded p-3 mb-2 last:mb-0"
|
<p class="font-semibold text-clinical-safe">
|
||||||
>
|
Vitals recorded and promoted successfully
|
||||||
<p class="font-medium text-red-800">
|
|
||||||
{{ formatCode(obs.observationCode) }}: {{ obs.value }} {{ obs.unit }}
|
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-red-700 mt-1">{{ obs.criticalAlert!.message }}</p>
|
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-ink">
|
||||||
<div class="text-xs text-red-600 mt-1">
|
<div>
|
||||||
<span>Severity: {{ obs.criticalAlert!.severity }}</span>
|
<dt class="text-ink-secondary">Batch</dt>
|
||||||
<span class="ml-4">
|
<dd class="font-mono">{{ lastResult.batchId.substring(0, 8) }}…</dd>
|
||||||
Threshold ({{ obs.criticalAlert!.thresholdBound.replace('_', ' ') }}):
|
</div>
|
||||||
{{ obs.criticalAlert!.thresholdValue }}
|
<div>
|
||||||
</span>
|
<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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="lastResult && lastResult.criticalAlertCount > 0"
|
||||||
|
class="clinical-signal clinical-signal--critical"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<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="rounded-input border border-[#FECDCA] bg-surface p-3"
|
||||||
|
>
|
||||||
|
<p class="font-medium text-clinical-danger">
|
||||||
|
{{ formatCode(obs.observationCode) }}: {{ obs.value }} {{ obs.unit }}
|
||||||
|
</p>
|
||||||
|
<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 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,6 +350,7 @@ import { useLiveCaptureStore } from '../stores/liveCapture'
|
|||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
|
import InlineError from '../components/InlineError.vue'
|
||||||
import type { LiveCaptureObservationInput } from '../types'
|
import type { LiveCaptureObservationInput } from '../types'
|
||||||
|
|
||||||
const store = useLiveCaptureStore()
|
const store = useLiveCaptureStore()
|
||||||
|
|||||||
@@ -2,193 +2,260 @@
|
|||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Patient History" />
|
<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">
|
||||||
<!-- Search when no patient selected -->
|
<div class="max-w-5xl mx-auto space-y-6">
|
||||||
<div v-if="!patientId" class="card">
|
<div>
|
||||||
<h2 class="text-lg font-semibold mb-4">Find Patient</h2>
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||||
<PatientSearch v-model="selectedPatientId" />
|
Patient History
|
||||||
<button
|
</h1>
|
||||||
v-if="selectedPatientId"
|
<p class="mt-1 text-sm text-ink-secondary">
|
||||||
@click="router.push(`/patients/${selectedPatientId}/history`)"
|
Digitization lineage, promotion attribution, and audit trail for a patient.
|
||||||
class="btn-primary mt-4"
|
</p>
|
||||||
>
|
|
||||||
View History
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- History view -->
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="batchStore.loading" class="text-gray-500 text-center py-8">
|
|
||||||
Loading history...
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="batchStore.error" class="text-clinical-danger text-center py-8">
|
<!-- Search when no patient selected -->
|
||||||
{{ batchStore.error }}
|
<section v-if="!patientId" class="card space-y-4">
|
||||||
</div>
|
<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`)"
|
||||||
|
>
|
||||||
|
View History
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-else-if="history">
|
<!-- History view -->
|
||||||
<!-- Summary stats -->
|
<div v-else class="space-y-6">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
<SkeletonBlock v-if="batchStore.loading" variant="table" :rows="4" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Timeline -->
|
<InlineError
|
||||||
<div class="space-y-4">
|
v-else-if="batchStore.error"
|
||||||
<div
|
title="Could not load patient history"
|
||||||
v-for="entry in history.entries"
|
:message="batchStore.error"
|
||||||
:key="entry.batchId"
|
preserved="Your patient selection was preserved."
|
||||||
:data-batch-id="entry.batchId"
|
retry-label="Retry"
|
||||||
class="card relative transition-all"
|
@retry="reloadHistory"
|
||||||
:class="{
|
/>
|
||||||
'border-l-4 border-l-green-500': entry.status === 'PROMOTED' && !entry.hasBeenSuperseded,
|
|
||||||
'border-l-4 border-l-gray-300': entry.hasBeenSuperseded,
|
<template v-else-if="history">
|
||||||
'border-l-4 border-l-blue-500': entry.isCorrection && !entry.hasBeenSuperseded,
|
<!-- Summary -->
|
||||||
'border-l-4 border-l-yellow-500': !['PROMOTED', 'CANCELLED'].includes(entry.status) && !entry.hasBeenSuperseded,
|
<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">
|
||||||
<!-- Header row -->
|
{{ history.totalBatches }}
|
||||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
</p>
|
||||||
<span class="font-mono text-xs text-gray-600">
|
<p class="text-xs text-ink-secondary mt-1">Total Batches</p>
|
||||||
{{ 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">
|
|
||||||
{{ formatBatchType(entry.batchType) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="entry.isCorrection"
|
|
||||||
class="status-badge bg-blue-100 text-blue-800"
|
|
||||||
>
|
|
||||||
Correction
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="entry.hasBeenSuperseded"
|
|
||||||
class="status-badge bg-gray-200 text-gray-500"
|
|
||||||
>
|
|
||||||
Superseded
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- Supersession chain info -->
|
<EmptyState
|
||||||
<div
|
v-if="history.entries.length === 0"
|
||||||
v-if="entry.isCorrection && entry.supersedesBatchId"
|
title="No digitization history for this patient."
|
||||||
class="text-xs text-blue-700 bg-blue-50 rounded px-3 py-2 mb-3"
|
description="Uploaded and promoted batches will appear here as a timeline."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<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-shadow"
|
||||||
|
:class="timelineAccentClass(entry)"
|
||||||
>
|
>
|
||||||
Corrects batch
|
<!-- Header -->
|
||||||
<button
|
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||||
@click="scrollToBatch(entry.supersedesBatchId!)"
|
<span class="font-mono text-xs text-ink-secondary">
|
||||||
class="font-mono underline hover:text-blue-900"
|
{{ entry.batchId.substring(0, 8) }}…
|
||||||
>
|
</span>
|
||||||
{{ entry.supersedesBatchId.substring(0, 8) }}...
|
<StatusBadge :status="entry.status" />
|
||||||
</button>
|
<span class="status-badge bg-canvas text-ink border border-line">
|
||||||
</div>
|
{{ formatBatchType(entry.batchType) }}
|
||||||
<div
|
</span>
|
||||||
v-if="entry.hasBeenSuperseded && entry.supersededByBatchId"
|
<span
|
||||||
class="text-xs text-gray-500 bg-gray-50 rounded px-3 py-2 mb-3"
|
v-if="entry.isCorrection"
|
||||||
>
|
class="status-badge bg-primary-50 text-primary-800 border border-primary-100"
|
||||||
Superseded by
|
>
|
||||||
<button
|
Correction
|
||||||
@click="scrollToBatch(entry.supersededByBatchId!)"
|
</span>
|
||||||
class="font-mono underline hover:text-gray-700"
|
<span
|
||||||
>
|
v-if="entry.hasBeenSuperseded"
|
||||||
{{ entry.supersededByBatchId.substring(0, 8) }}...
|
class="status-badge bg-canvas text-ink-disabled border border-line"
|
||||||
</button>
|
>
|
||||||
</div>
|
Superseded
|
||||||
|
|
||||||
<!-- Observation counts -->
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-3">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Draft obs:</span>
|
|
||||||
<span class="font-medium ml-1">{{ entry.draftObservationCount }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Live obs:</span>
|
|
||||||
<span class="font-medium ml-1" :class="{ 'line-through text-gray-400': entry.hasBeenSuperseded }">
|
|
||||||
{{ entry.liveObservationCount }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- People & dates -->
|
<!-- Supersession chain -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs text-gray-500">
|
<div
|
||||||
<div>Created: {{ new Date(entry.createdAt).toLocaleString() }}</div>
|
v-if="entry.isCorrection && entry.supersedesBatchId"
|
||||||
<div v-if="entry.promotedAt">
|
class="text-xs text-primary-800 bg-primary-50 border border-primary-100 rounded-input px-3 py-2 mb-3"
|
||||||
Promoted: {{ new Date(entry.promotedAt).toLocaleString() }}
|
>
|
||||||
</div>
|
Corrects batch
|
||||||
<div v-if="entry.enteredByUserName">
|
<button
|
||||||
Entered by: {{ entry.enteredByUserName }}
|
type="button"
|
||||||
</div>
|
class="font-mono underline hover:text-primary-900"
|
||||||
<div v-if="entry.verifiedByUserName">
|
@click="scrollToBatch(entry.supersedesBatchId!)"
|
||||||
Verified by: {{ entry.verifiedByUserName }}
|
|
||||||
</div>
|
|
||||||
<div v-if="entry.approvedByUserName">
|
|
||||||
Approved by: {{ entry.approvedByUserName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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 ({{ entry.auditTrail.length }} events)
|
|
||||||
</summary>
|
|
||||||
<div class="mt-2 space-y-1 pl-3 border-l-2 border-gray-200">
|
|
||||||
<div
|
|
||||||
v-for="(event, idx) in entry.auditTrail"
|
|
||||||
:key="idx"
|
|
||||||
class="text-xs text-gray-600"
|
|
||||||
>
|
>
|
||||||
<span class="font-medium">{{ formatEventType(event.eventType) }}</span>
|
{{ entry.supersedesBatchId.substring(0, 8) }}…
|
||||||
<span class="text-gray-400 ml-2">
|
</button>
|
||||||
{{ new Date(event.occurredAt).toLocaleString() }}
|
</div>
|
||||||
</span>
|
<div
|
||||||
<span class="ml-2">by {{ event.actorName }}</span>
|
v-if="entry.hasBeenSuperseded && entry.supersededByBatchId"
|
||||||
|
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!)"
|
||||||
|
>
|
||||||
|
{{ entry.supersededByBatchId.substring(0, 8) }}…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Observation counts -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-ink-secondary">Draft obs</span>
|
||||||
|
<p class="font-medium text-ink-strong">{{ entry.draftObservationCount }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<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 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.supersededObservationCount > 0">
|
||||||
|
<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-ink-secondary">Encounter</span>
|
||||||
|
<p class="font-mono text-xs text-ink-strong mt-0.5">
|
||||||
|
{{ entry.promotionEncounterId.substring(0, 8) }}…
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
|
|
||||||
<!-- Create Correction button for promoted, non-superseded batches -->
|
<!-- Attribution (names from payload only) -->
|
||||||
<div
|
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm border-t border-line pt-3">
|
||||||
v-if="entry.status === 'PROMOTED' && !entry.hasBeenSuperseded"
|
<div>
|
||||||
class="mt-3 pt-3 border-t border-gray-100"
|
<dt class="text-xs text-ink-secondary">Created</dt>
|
||||||
>
|
<dd class="text-ink">{{ formatDateTime(entry.createdAt) }}</dd>
|
||||||
<button
|
</div>
|
||||||
@click="createCorrection(entry)"
|
<div v-if="entry.promotedAt">
|
||||||
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
|
<dt class="text-xs text-ink-secondary">Promoted</dt>
|
||||||
|
<dd class="text-ink">{{ formatDateTime(entry.promotedAt) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="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">
|
||||||
|
<dt class="text-xs text-ink-secondary">Verified by</dt>
|
||||||
|
<dd class="text-ink-strong">{{ entry.verifiedByUserName }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="entry.approvedByUserName">
|
||||||
|
<dt class="text-xs text-ink-secondary">Approved / promoted by</dt>
|
||||||
|
<dd class="text-ink-strong">{{ entry.approvedByUserName }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Audit trail -->
|
||||||
|
<details
|
||||||
|
v-if="entry.auditTrail.length > 0"
|
||||||
|
class="mt-4 group"
|
||||||
>
|
>
|
||||||
Create Correction
|
<summary
|
||||||
</button>
|
class="text-sm font-medium text-primary-700 cursor-pointer hover:text-primary-800 list-none flex items-center gap-2"
|
||||||
</div>
|
>
|
||||||
</div>
|
<span
|
||||||
</div>
|
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 v-if="history.entries.length === 0" class="text-gray-500 text-center py-8">
|
<div class="mt-3 overflow-x-auto">
|
||||||
No digitization history for this patient.
|
<table class="w-full min-w-[480px] text-sm">
|
||||||
</div>
|
<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="border-b border-line last:border-0"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-2 text-ink">
|
||||||
|
{{ event.actorName || '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Create Correction -->
|
||||||
|
<div
|
||||||
|
v-if="entry.status === 'PROMOTED' && !entry.hasBeenSuperseded"
|
||||||
|
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)"
|
||||||
|
>
|
||||||
|
Create Correction
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,6 +268,10 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.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'
|
import type { PatientDigitizationHistoryResponse, DigitizationHistoryEntry } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{ patientId?: string }>()
|
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 selectedPatientId = ref<string | undefined>()
|
||||||
const history = ref<PatientDigitizationHistoryResponse | null>(null)
|
const history = ref<PatientDigitizationHistoryResponse | null>(null)
|
||||||
|
|
||||||
|
async function reloadHistory() {
|
||||||
|
if (!patientId.value) return
|
||||||
|
history.value = await batchStore.getPatientHistory(patientId.value)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.patientId ?? (route.params.patientId as string | undefined),
|
() => props.patientId ?? (route.params.patientId as string | undefined),
|
||||||
async (id) => {
|
async (id) => {
|
||||||
patientId.value = id
|
patientId.value = id
|
||||||
if (id) {
|
if (id) {
|
||||||
history.value = await batchStore.getPatientHistory(id)
|
history.value = await batchStore.getPatientHistory(id)
|
||||||
|
} else {
|
||||||
|
history.value = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -228,27 +306,22 @@ function formatBatchType(type: string): string {
|
|||||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
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 {
|
function formatEventType(type: string): string {
|
||||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: string): string {
|
function formatDateTime(value: string): string {
|
||||||
const classes: Record<string, string> = {
|
return new Date(value).toLocaleString()
|
||||||
UPLOADED: 'bg-gray-100 text-gray-800',
|
}
|
||||||
IN_ENTRY: 'bg-yellow-100 text-yellow-800',
|
|
||||||
PENDING_VERIFICATION: 'bg-orange-100 text-orange-800',
|
function timelineAccentClass(entry: DigitizationHistoryEntry): string {
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
if (entry.hasBeenSuperseded) return 'border-l-[3px] border-l-line-strong'
|
||||||
VERIFIED: 'bg-blue-100 text-blue-800',
|
if (entry.status === 'PROMOTED') return 'border-l-[3px] border-l-clinical-safe'
|
||||||
AWAITING_CLINICAL_APPROVAL: 'bg-purple-100 text-purple-800',
|
if (entry.isCorrection) return 'border-l-[3px] border-l-primary-500'
|
||||||
APPROVED: 'bg-green-100 text-green-800',
|
if (!['PROMOTED', 'CANCELLED'].includes(entry.status)) {
|
||||||
PROMOTED: 'bg-emerald-100 text-emerald-800',
|
return 'border-l-[3px] border-l-clinical-warning'
|
||||||
CANCELLED: 'bg-gray-200 text-gray-500',
|
|
||||||
}
|
}
|
||||||
return classes[status] ?? 'bg-gray-100 text-gray-800'
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBatch(batchId: string) {
|
function scrollToBatch(batchId: string) {
|
||||||
|
|||||||
@@ -4,108 +4,145 @@
|
|||||||
<template #actions>
|
<template #actions>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
class="text-sm font-medium text-primary-700 hover:text-primary-800"
|
||||||
|
:disabled="refreshing"
|
||||||
@click="refreshData"
|
@click="refreshData"
|
||||||
class="text-sm text-primary-600 hover:text-primary-800"
|
|
||||||
>
|
>
|
||||||
Refresh
|
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 space-y-6 lg:space-y-8">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<!-- Summary cards -->
|
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 lg:gap-8">
|
<div>
|
||||||
<div class="card-elevated">
|
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||||
<p class="text-sm text-gray-500">Pending Entry</p>
|
Queue Dashboard
|
||||||
<p class="text-3xl font-bold text-yellow-600">
|
</h1>
|
||||||
{{ overview?.statusCounts?.UPLOADED ?? 0 }}
|
<p class="mt-1 text-sm text-ink-secondary">
|
||||||
|
Supervisor view of digitization backlog, aging work, and reject rate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-elevated">
|
|
||||||
<p class="text-sm text-gray-500">In Entry</p>
|
|
||||||
<p class="text-3xl font-bold text-blue-600">
|
|
||||||
{{ 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">
|
|
||||||
{{ overview?.statusCounts?.PENDING_VERIFICATION ?? 0 }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-elevated">
|
|
||||||
<p class="text-sm text-gray-500">Reject Rate</p>
|
|
||||||
<p
|
|
||||||
class="text-3xl font-bold"
|
|
||||||
:class="(overview?.rejectRate ?? 0) > 15
|
|
||||||
? 'text-clinical-danger'
|
|
||||||
: 'text-clinical-safe'"
|
|
||||||
>
|
|
||||||
{{ ((overview?.rejectRate ?? 0) * 100).toFixed(1) }}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Queue age and throughput -->
|
<InlineError
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8">
|
v-if="overviewError"
|
||||||
<div class="card-elevated">
|
title="Could not load queue overview"
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Average Time in Queue</h3>
|
:message="overviewError"
|
||||||
<p class="text-2xl font-bold">
|
preserved="Previously loaded metrics are shown when available."
|
||||||
{{ formatMinutes(overview?.averageTimeInQueueMinutes ?? 0) }}
|
retry-label="Retry"
|
||||||
</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>
|
|
||||||
</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">
|
|
||||||
<div
|
|
||||||
v-for="(count, status) in overview?.statusCounts ?? {}"
|
|
||||||
:key="status"
|
|
||||||
class="flex items-center 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="h-4 rounded-full bg-primary-500"
|
|
||||||
:style="{ width: `${(Number(count) / maxCount) * 100}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium w-8 text-right">{{ count }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent activity -->
|
|
||||||
<div class="card">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-4">All Batches</h3>
|
|
||||||
<BatchList
|
|
||||||
:batches="batchStore.batches"
|
|
||||||
:loading="batchStore.loading"
|
|
||||||
:error="batchStore.error"
|
|
||||||
empty-title="No batches in the queue."
|
|
||||||
empty-description="Batches appear here as they move through digitization."
|
|
||||||
@retry="refreshData"
|
@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-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-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-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-xs font-semibold uppercase tracking-wide text-ink-secondary">
|
||||||
|
Oldest Pending Verification
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
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'"
|
||||||
|
>
|
||||||
|
{{ ((overview?.rejectRate ?? 0) * 100).toFixed(1) }}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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-ink-secondary mt-2">Target: under 24 hours</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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="{ status, count } in statusEntries"
|
||||||
|
:key="status"
|
||||||
|
class="flex items-center gap-3 sm:gap-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-full rounded-full bg-primary-500"
|
||||||
|
:style="{ width: `${(count / maxCount) * 100}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-semibold tabular-nums text-ink-strong w-8 text-right">
|
||||||
|
{{ count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
:error="batchStore.error"
|
||||||
|
empty-title="No batches in the queue."
|
||||||
|
empty-description="Batches appear here as they move through digitization."
|
||||||
|
@retry="refreshData"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,16 +154,28 @@ import { useBatchStore } from '../stores/batches'
|
|||||||
import { get } from '../api/client'
|
import { get } from '../api/client'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
import BatchList from '../components/BatchList.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'
|
import type { WorkQueueOverview } from '../types'
|
||||||
|
|
||||||
const batchStore = useBatchStore()
|
const batchStore = useBatchStore()
|
||||||
const overview = ref<WorkQueueOverview | null>(null)
|
const overview = ref<WorkQueueOverview | null>(null)
|
||||||
|
const overviewError = ref('')
|
||||||
|
const refreshing = ref(false)
|
||||||
|
|
||||||
const maxCount = computed(() => {
|
const statusEntries = computed(() => {
|
||||||
if (!overview.value?.statusCounts) return 1
|
const counts = overview.value?.statusCounts ?? {}
|
||||||
return Math.max(...Object.values(overview.value.statusCounts).map(Number), 1)
|
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 {
|
function formatMinutes(minutes: number): string {
|
||||||
if (minutes < 60) return `${Math.round(minutes)}m`
|
if (minutes < 60) return `${Math.round(minutes)}m`
|
||||||
if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h`
|
if (minutes < 1440) return `${(minutes / 60).toFixed(1)}h`
|
||||||
@@ -134,12 +183,22 @@ function formatMinutes(minutes: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
const response = await get<WorkQueueOverview>('work-queue/overview')
|
refreshing.value = true
|
||||||
if (response.success && response.data) {
|
overviewError.value = ''
|
||||||
overview.value = response.data
|
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 })
|
await batchStore.listBatches({ page: 1, pageSize: 50 })
|
||||||
|
refreshing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refreshData)
|
onMounted(refreshData)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user