feature: Core Digitization Workstation (Entry, Verification, Approval)
CI / backend (push) Successful in 6m11s
CI / frontend (push) Failing after 1m7s

This commit is contained in:
2026-08-12 04:22:25 +08:00
parent 9811f2a2ed
commit 5caf928787
16 changed files with 2077 additions and 905 deletions
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import ApprovalForm from '@/components/ApprovalForm.vue'
import { useBatchStore } from '@/stores/batches'
import type { BatchDetailResponse } from '@/types'
import {
emptyDraft,
fieldRequirementsForBatchType,
} from '@/__tests__/helpers/fieldRequirements'
vi.mock('@/api/client', () => ({
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
del: vi.fn(),
patch: vi.fn(),
uploadFile: vi.fn(),
}))
vi.mock('@/composables/useToast', () => ({
useToast: () => ({
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
}),
}))
const mountOptions = {
global: {
stubs: {
Teleport: { template: '<div><slot /></div>' },
},
},
}
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
const batchType = overrides.batchType ?? 'VITALS'
return {
id: 'b1',
status: 'AWAITING_CLINICAL_APPROVAL',
batchType,
track: 'TRACK_A',
fieldRequirements: fieldRequirementsForBatchType(batchType),
patientId: 'p1',
documentRef: 'docs/scan.pdf',
documentUrl: null,
enableRetroactiveAlerts: false,
enteredByUserId: 'u1',
verifiedByUserId: 'v1-aaaaaaa',
approvedByUserId: null,
rejectionReason: null,
promotedAt: null,
promotionEncounterId: null,
supersedesBatchId: null,
clinicianAttestation: false,
isCorrection: false,
supersession: null,
createdAt: '2026-06-27T10:00:00Z',
updatedAt: '2026-06-27T10:00:00Z',
...overrides,
}
}
function mountForm(opts: {
batch?: BatchDetailResponse
draft?: ReturnType<typeof emptyDraft>
} = {}) {
const batch = opts.batch ?? makeBatch()
const draft =
opts.draft ??
emptyDraft(batch.batchType, {
patient: {
id: 'dp1',
fullName: 'Jane Doe',
dateOfBirth: '1990-05-15',
sex: 'female',
bloodType: 'A+',
emergencyContact: '555-1234',
allergies: ['Penicillin'],
noKnownAllergies: false,
medications: ['Metoprolol 50mg'],
noActiveMedications: false,
},
encounter: {
id: 'de1',
batchId: 'b1',
admissionDate: '2026-06-20T08:00:00',
department: 'ICU',
roomBed: '3A-12',
admissionReason: 'Chest pain',
dischargeDiagnosis: null,
status: null,
},
observations: [
{
id: 'obs-1',
batchId: 'b1',
observationCode: 'TEMP_C',
value: 38.4,
unit: 'C',
recordedAt: '2026-06-27T10:00:00Z',
note: null,
},
],
})
return mount(ApprovalForm, {
props: { batch, batchId: 'b1', draft },
...mountOptions,
})
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('ApprovalForm', () => {
it('uses clinical framing distinct from verification', () => {
const wrapper = mountForm()
expect(wrapper.find('[data-testid="approval-form"]').classes()).toContain('approval-frame')
expect(wrapper.text()).toContain('Clinical Approval')
expect(wrapper.text()).toContain('Clinical sign-off before promotion')
expect(wrapper.text()).toContain('Verified draft')
})
it('highlights high-stakes fields from draft data', () => {
const wrapper = mountForm()
const summary = wrapper.find('[data-testid="high-stakes-summary"]')
expect(summary.exists()).toBe(true)
expect(summary.text()).toContain('Blood type')
expect(summary.text()).toContain('A+')
expect(summary.text()).toContain('Allergies')
expect(summary.text()).toContain('Penicillin')
expect(summary.text()).toContain('Medications')
expect(summary.text()).toContain('Metoprolol')
expect(summary.text()).toContain('TEMP C')
expect(summary.text()).toContain('38.4')
})
it('uses design-doc retroactive alerts copy', () => {
const wrapper = mountForm()
expect(wrapper.text()).toContain('Run alert evaluation after promotion')
expect(wrapper.text()).toContain(
'May generate alerts for clinical criteria represented in historical records'
)
})
it('shows Approve & Promote confirm dialog with live promotion copy', async () => {
const wrapper = mountForm()
await wrapper.find('[data-testid="approve-promote"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Approve & Promote')
expect(wrapper.text()).toContain(
'Approval will promote the verified records into live clinical tables.'
)
})
it('calls approveBatch with retroactive alerts flag', async () => {
const wrapper = mountForm()
const store = useBatchStore()
store.approveBatch = vi.fn().mockResolvedValue({
status: 200,
data: { mrn: 'MRN-1', encounterId: 'enc-aaaaaaaa', observationIds: ['o1', 'o2'] },
})
await wrapper.find('[data-testid="retroactive-alerts"]').setValue(true)
await wrapper.find('[data-testid="approve-promote"]').trigger('click')
await wrapper.vm.$nextTick()
const confirm = wrapper
.findAll('[data-testid="confirm-dialog"] button')
.find((b) => b.text() === 'Approve & Promote')
await confirm!.trigger('click')
await flushPromises()
expect(store.approveBatch).toHaveBeenCalledWith('b1', true)
expect(wrapper.find('[data-testid="promotion-result"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Promoted to live clinical tables')
expect(wrapper.text()).toContain('MRN-1')
expect(wrapper.text()).toContain('Promotion outcome')
})
it('opens Reject Batch dialog and posts reason', async () => {
const wrapper = mountForm()
const store = useBatchStore()
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
await wrapper.find('[data-testid="reject-batch"]').trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch')
await wrapper.find('textarea').setValue('Incorrect patient')
await wrapper.vm.$nextTick()
const confirm = wrapper
.findAll('[data-testid="confirm-dialog"] button')
.find((b) => b.text() === 'Reject Batch')
await confirm!.trigger('click')
await flushPromises()
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Incorrect patient')
expect(wrapper.emitted('rejected')).toHaveLength(1)
})
it('shows deferred promotion state', async () => {
const wrapper = mountForm()
const store = useBatchStore()
store.approveBatch = vi.fn().mockResolvedValue({ status: 202 })
await wrapper.find('[data-testid="approve-promote"]').trigger('click')
await wrapper.vm.$nextTick()
const confirm = wrapper
.findAll('[data-testid="confirm-dialog"] button')
.find((b) => b.text() === 'Approve & Promote')
await confirm!.trigger('click')
await flushPromises()
expect(wrapper.find('[data-testid="promotion-deferred"]').exists()).toBe(true)
})
})
@@ -110,6 +110,33 @@ describe('EntryForm', () => {
const buttons = wrapper.findAll('button') const buttons = wrapper.findAll('button')
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification')) const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
expect(submitButton).toBeTruthy() expect(submitButton).toBeTruthy()
expect(wrapper.find('[data-testid="workstation-action-bar"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Save Draft')
})
it('shows autosave status after patient field blur', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1' },
})
const store = useBatchStore()
store.saveDraftPatient = vi.fn().mockResolvedValue(undefined)
const nameInput = wrapper.find('input[type="text"]')
await nameInput.setValue('Jane Doe')
await nameInput.trigger('blur')
await flushPromises()
expect(wrapper.find('[data-testid="entry-save-status"]').text()).toMatch(/^Saved /)
})
it('shows Next when nextBatchId is provided', async () => {
const wrapper = mount(EntryForm, {
props: { batch: makeBatch(), batchId: 'b1', nextBatchId: 'b2' },
})
const next = wrapper.find('[data-testid="entry-next-batch"]')
expect(next.exists()).toBe(true)
await next.trigger('click')
expect(wrapper.emitted('open-next')?.[0]).toEqual(['b2'])
}) })
it('displays batch status', () => { it('displays batch status', () => {
@@ -218,15 +218,23 @@ describe('VerificationForm', () => {
}) })
describe('approve button', () => { describe('approve button', () => {
async function selectPass(wrapper: ReturnType<typeof mountForm>) {
const passRadio = wrapper.find('input[type="radio"][value="pass"]')
await passRadio.setValue(true)
await wrapper.vm.$nextTick()
}
it('is disabled when not all fields are checked', async () => { it('is disabled when not all fields are checked', async () => {
const { wrapper } = mountWithDraft() const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) const approveBtn = wrapper.find('[data-testid="verify-pass"]')
expect(approveBtn!.element.disabled).toBe(true) expect(approveBtn.element).toBeTruthy()
expect((approveBtn.element as HTMLButtonElement).disabled).toBe(true)
}) })
it('is enabled when all fields are checked', async () => { it('is enabled when all fields are checked and Pass is selected', async () => {
const { wrapper } = mountWithDraft() const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -234,13 +242,14 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.vm.$nextTick() await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) const approveBtn = wrapper.find('[data-testid="verify-pass"]')
expect(approveBtn!.element.disabled).toBe(false) expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false)
expect(approveBtn.text()).toBe('Pass Verification')
}) })
it('calls verifyBatch with all field checks on approve', async () => { it('calls verifyBatch with all field checks on Pass Verification', async () => {
const { wrapper, store } = mountWithDraft() const { wrapper, store } = mountWithDraft()
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
@@ -250,10 +259,9 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.vm.$nextTick() await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) await wrapper.find('[data-testid="verify-pass"]').trigger('click')
await approveBtn!.trigger('click')
await flushPromises() await flushPromises()
expect(store.verifyBatch).toHaveBeenCalledWith( expect(store.verifyBatch).toHaveBeenCalledWith(
@@ -267,41 +275,52 @@ describe('VerificationForm', () => {
}) })
describe('reject flow', () => { describe('reject flow', () => {
it('shows reject dialog when reject button is clicked', async () => { async function selectReject(wrapper: ReturnType<typeof mountForm>) {
const wrapper = mountForm() const rejectRadio = wrapper.find('input[type="radio"][value="reject"]')
await rejectRadio.setValue(true)
await wrapper.vm.$nextTick()
}
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') it('shows Return for Rework and opens confirm dialog', async () => {
await rejectBtn!.trigger('click') const wrapper = mountForm()
await selectReject(wrapper)
const returnBtn = wrapper.find('[data-testid="verify-return"]')
expect(returnBtn.text()).toBe('Return for Rework')
await returnBtn.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch') expect(wrapper.text()).toContain('Return for Rework')
expect(wrapper.text()).toContain('Confirm Rejection') expect(wrapper.text()).toContain('This returns the batch for rework')
}) })
it('disables confirm button when reason is empty', async () => { it('disables confirm button when reason is empty', async () => {
const wrapper = mountForm() const wrapper = mountForm()
await selectReject(wrapper)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
expect(confirmBtn!.element.disabled).toBe(true) .filter((b) => b.text() === 'Return for Rework')
expect(dialogConfirms.length).toBeGreaterThan(0)
expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(true)
}) })
it('enables confirm button when reason is entered', async () => { it('enables confirm button when reason is entered', async () => {
const wrapper = mountForm() const wrapper = mountForm()
await selectReject(wrapper)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea') const textarea = wrapper.find('textarea')
await textarea.setValue('Temperature value appears incorrect') await textarea.setValue('Temperature value appears incorrect')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
expect(confirmBtn!.element.disabled).toBe(false) .filter((b) => b.text() === 'Return for Rework')
expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(false)
}) })
it('calls rejectBatch on confirm', async () => { it('calls rejectBatch on confirm', async () => {
@@ -310,16 +329,17 @@ describe('VerificationForm', () => {
const store = useBatchStore() const store = useBatchStore()
store.rejectBatch = vi.fn().mockResolvedValue(undefined) store.rejectBatch = vi.fn().mockResolvedValue(undefined)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await selectReject(wrapper)
await rejectBtn!.trigger('click') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea') const textarea = wrapper.find('textarea')
await textarea.setValue('Value incorrect') await textarea.setValue('Value incorrect')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
await confirmBtn!.trigger('click') .filter((b) => b.text() === 'Return for Rework')
await dialogConfirms[0].trigger('click')
await flushPromises() await flushPromises()
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect') expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
@@ -327,18 +347,18 @@ describe('VerificationForm', () => {
it('closes reject dialog on cancel', async () => { it('closes reject dialog on cancel', async () => {
const wrapper = mountForm() const wrapper = mountForm()
await selectReject(wrapper)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await wrapper.find('[data-testid="verify-return"]').trigger('click')
await rejectBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Reject Batch') expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(true)
const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel') const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
await cancelBtn!.trigger('click') await cancelBtn!.trigger('click')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
expect(wrapper.text()).not.toContain('Reject Batch') expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false)
}) })
}) })
@@ -362,12 +382,11 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.find('input[type="radio"][value="pass"]').setValue(true)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const approveBtn = wrapper const approveBtn = wrapper.find('[data-testid="verify-pass"]')
.findAll('button') expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false)
.find((b) => b.text().includes('Approve - Verified'))
expect(approveBtn!.element.disabled).toBe(false)
}) })
it('blocks Pass when the current user entered the batch', async () => { it('blocks Pass when the current user entered the batch', async () => {
@@ -384,19 +403,13 @@ describe('VerificationForm', () => {
expect(wrapper.text()).toContain('You cannot verify a batch you entered.') expect(wrapper.text()).toContain('You cannot verify a batch you entered.')
const checkboxes = wrapper.findAll('input[type="checkbox"]') const passRadio = wrapper.find('input[type="radio"][value="pass"]')
for (const cb of checkboxes) { expect((passRadio.element as HTMLInputElement).disabled).toBe(true)
await cb.setValue(true) const rejectRadio = wrapper.find('input[type="radio"][value="reject"]')
} expect((rejectRadio.element as HTMLInputElement).disabled).toBe(true)
await wrapper.vm.$nextTick()
const approveBtn = wrapper expect(wrapper.find('[data-testid="verify-pass"]').exists()).toBe(false)
.findAll('button') expect(wrapper.find('[data-testid="verify-return"]').exists()).toBe(false)
.find((b) => b.text().includes('Approve - Verified'))
expect(approveBtn!.element.disabled).toBe(true)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
expect(rejectBtn!.element.disabled).toBe(true)
}) })
}) })
@@ -476,10 +489,10 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) { for (const cb of checkboxes) {
await cb.setValue(true) await cb.setValue(true)
} }
await wrapper.find('input[type="radio"][value="pass"]').setValue(true)
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) await wrapper.find('[data-testid="verify-pass"]').trigger('click')
await approveBtn!.trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.text()).toContain('Server error') expect(wrapper.text()).toContain('Server error')
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ScanViewer from '@/components/ScanViewer.vue'
import WorkstationLayout from '@/components/WorkstationLayout.vue'
import WorkstationQueueRail from '@/components/WorkstationQueueRail.vue'
import type { BatchDetailResponse } from '@/types'
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
return {
id: 'batch-aaaaaaaa',
status: 'IN_ENTRY',
batchType: 'VITALS',
track: 'TRACK_A',
createdAt: '2026-01-15T10:00:00Z',
...overrides,
} as BatchDetailResponse
}
describe('WorkstationLayout', () => {
it('renders full-width queue when no batch is open', () => {
const wrapper = mount(WorkstationLayout, {
props: { hasBatch: false },
slots: {
queue: '<h2>Data Entry Queue</h2>',
scan: '<div>scan</div>',
form: '<div>form</div>',
},
})
expect(wrapper.find('[data-testid="workstation-queue"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-split"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Data Entry Queue')
})
it('renders scan + form split when a batch is open', () => {
const wrapper = mount(WorkstationLayout, {
props: { hasBatch: true },
slots: {
scan: '<div data-testid="slot-scan">scan</div>',
form: '<div data-testid="slot-form">form</div>',
},
})
expect(wrapper.find('[data-testid="workstation-split"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-split"]').classes()).toContain(
'workstation-split--no-rail'
)
expect(wrapper.find('[data-testid="slot-scan"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="slot-form"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-rail"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="evidence-level-1"]').text()).toContain('Source scan')
})
it('shows left rail when rail slot is provided', () => {
const wrapper = mount(WorkstationLayout, {
props: { hasBatch: true },
slots: {
rail: '<div>rail items</div>',
scan: '<div>scan</div>',
form: '<div>form</div>',
},
})
expect(wrapper.find('[data-testid="workstation-rail"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="workstation-split"]').classes()).not.toContain(
'workstation-split--no-rail'
)
expect(wrapper.text()).toContain('rail items')
})
})
describe('WorkstationQueueRail', () => {
it('lists batches and emits select / back', async () => {
const wrapper = mount(WorkstationQueueRail, {
props: {
title: 'Entry queue',
batches: [
makeBatch({ id: 'batch-11111111' }),
makeBatch({ id: 'batch-22222222', status: 'PENDING_VERIFICATION' }),
],
selectedId: 'batch-11111111',
},
})
expect(wrapper.text()).toContain('Entry queue')
expect(wrapper.text()).toContain('batch-11')
expect(wrapper.text()).toContain('Pending Verification')
await wrapper.findAll('button')[0].trigger('click')
expect(wrapper.emitted('select')?.[0]).toEqual(['batch-11111111'])
await wrapper.findAll('button').at(-1)!.trigger('click')
expect(wrapper.emitted('back')).toHaveLength(1)
})
})
describe('ScanViewer', () => {
it('renders neutral toolbar with zoom and fit width', () => {
const wrapper = mount(ScanViewer, {
props: { url: 'blob:http://localhost/doc.png' },
})
const toolbar = wrapper.find('[data-testid="scan-viewer-toolbar"]')
expect(toolbar.exists()).toBe(true)
expect(toolbar.classes()).toContain('bg-surface')
expect(wrapper.find('[data-testid="scan-fit-width"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Fit width')
expect(wrapper.text()).toContain('Rotate')
})
it('shows skeleton while loading', () => {
const wrapper = mount(ScanViewer, {
props: { loading: true },
})
expect(wrapper.find('[role="status"]').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
})
it('shows inline error with retry and preserves draft copy', async () => {
const wrapper = mount(ScanViewer, {
props: { error: 'Network failed', loading: false },
})
expect(wrapper.text()).toContain('Could not load document')
expect(wrapper.text()).toContain('Network failed')
expect(wrapper.text()).toContain('Your form draft was not affected.')
const retry = wrapper.findAll('button').find((b) => b.text() === 'Retry')
expect(retry).toBeTruthy()
await retry!.trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
it('does not put decorative AI chrome on the page surface', () => {
const wrapper = mount(ScanViewer, {
props: { url: 'blob:http://localhost/scan.jpg' },
})
expect(wrapper.text()).not.toContain('confidence')
expect(wrapper.text()).not.toContain('OCR')
expect(wrapper.find('.scan-page').exists()).toBe(true)
})
})
+61
View File
@@ -79,6 +79,67 @@
@apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8 @apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8
h-auto lg:flex-1 lg:min-h-0 min-h-0; h-auto lg:flex-1 lg:min-h-0 min-h-0;
} }
/* Phase 16 workstation: scan-first split (≥1280px / xl) */
.workstation-split {
@apply grid grid-cols-1 gap-0
h-auto xl:flex-1 xl:min-h-0 min-h-0
xl:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)];
}
.workstation-split:not(.workstation-split--no-rail) {
@apply xl:grid-cols-[minmax(160px,0.2fr)_minmax(0,0.42fr)_minmax(0,0.38fr)];
}
.workstation-rail {
@apply flex flex-col min-h-0 overflow-hidden bg-surface;
}
.workstation-scan {
@apply flex flex-col min-h-[40vh] xl:min-h-0 p-3 xl:p-4 bg-canvas border-b xl:border-b-0 xl:border-r border-line;
}
.workstation-form {
@apply flex flex-col min-h-0 overflow-hidden bg-surface;
}
.workstation-form-section {
@apply border border-line rounded-input p-3;
}
.workstation-form-legend {
@apply text-xs font-semibold uppercase tracking-wide text-ink-secondary px-1;
}
.workstation-field-label {
@apply flex items-center gap-1.5 text-xs text-ink-secondary mb-1;
}
/* Design-doc §34 evidence hierarchy */
.evidence-level {
@apply flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-ink-secondary mb-2 shrink-0;
}
.evidence-level-mark {
@apply inline-flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold leading-none;
}
.evidence-level--1 .evidence-level-mark {
@apply bg-navy text-white;
}
.evidence-level--2 .evidence-level-mark {
@apply bg-primary-600 text-white;
}
.evidence-level--3 .evidence-level-mark {
@apply bg-ink-secondary text-white;
}
.evidence-level--4 .evidence-level-mark {
@apply bg-clinical-safe text-white;
}
.evidence-level--5 .evidence-level-mark {
@apply bg-clinical-safe text-white ring-2 ring-clinical-safe-bg;
}
.clinical-signal {
@apply rounded-input border border-[#FEDF89] bg-clinical-warning-bg px-3 py-2 text-sm;
}
.clinical-signal--critical {
@apply border-[#FECDCA] bg-clinical-danger-bg;
}
.approval-frame {
@apply border-l-[3px] border-l-navy;
}
.promotion-outcome {
@apply rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4 text-sm;
}
.nav-link { .nav-link {
@apply text-sm text-ink-secondary hover:text-ink-strong transition-colors; @apply text-sm text-ink-secondary hover:text-ink-strong transition-colors;
} }
@@ -0,0 +1,481 @@
<template>
<div
class="h-full min-h-0 flex flex-col approval-frame"
data-testid="approval-form"
>
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="evidence-level evidence-level--2 mb-1">
<span class="evidence-level-mark" aria-hidden="true">2</span>
Verified draft
</p>
<h2 class="text-base font-semibold text-ink-strong">Clinical Approval</h2>
<p class="mt-1 text-xs text-ink-secondary max-w-md">
Clinical sign-off before promotion to live clinical tables. Review the source scan and verified draft.
</p>
</div>
<StatusBadge :status="batch?.status ?? 'AWAITING_CLINICAL_APPROVAL'" />
</div>
<div
v-if="batch?.supersedesBatchId"
class="rounded-input border border-primary-100 bg-primary-50 p-3 text-sm"
>
<p class="font-medium text-primary-800">Correction Batch</p>
<p class="text-primary-700 mt-1">
This batch corrects and will supersede batch
<span class="font-mono">{{ batch.supersedesBatchId.substring(0, 8) }}...</span>
</p>
<button
v-if="batch.patientId"
type="button"
class="text-xs text-primary-600 hover:text-primary-800 mt-2"
@click="$emit('view-history', batch.patientId)"
>
View patient history
</button>
</div>
<!-- High-stakes clinical signals (design-doc §16) -->
<section
v-if="highStakeItems.length > 0"
class="rounded-input border border-[#FEDF89] bg-clinical-warning-bg/60 p-3"
data-testid="high-stakes-summary"
>
<p class="evidence-level evidence-level--4 mb-2 !text-clinical-warning">
<span class="evidence-level-mark" aria-hidden="true">!</span>
High-stakes clinical signals
</p>
<ul class="space-y-2">
<li
v-for="item in highStakeItems"
:key="item.key"
class="clinical-signal"
:class="{ 'clinical-signal--critical': item.critical }"
>
<span class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
{{ item.label }}
</span>
<p class="font-medium text-ink-strong mt-0.5">{{ item.value }}</p>
<p v-if="item.reason" class="text-xs text-ink-secondary mt-0.5">{{ item.reason }}</p>
</li>
</ul>
</section>
<fieldset v-if="draft?.patient" class="workstation-form-section">
<legend class="workstation-form-legend">Patient</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span class="text-ink-secondary">Full Name</span>
<p class="font-medium text-ink-strong">{{ draft.patient.fullName || '—' }}</p>
</div>
<div>
<span class="text-ink-secondary">DOB</span>
<p class="font-medium text-ink-strong">{{ draft.patient.dateOfBirth ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Sex</span>
<p class="font-medium text-ink-strong">{{ draft.patient.sex ?? 'N/A' }}</p>
</div>
<div :class="draft.patient.bloodType ? 'rounded-input bg-clinical-warning-bg/50 px-2 py-1 -mx-2' : ''">
<span class="text-ink-secondary">Blood Type</span>
<p class="font-medium text-ink-strong">{{ draft.patient.bloodType ?? 'N/A' }}</p>
</div>
</div>
</fieldset>
<fieldset v-if="draft?.encounter" class="workstation-form-section">
<legend class="workstation-form-legend">Encounter</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<span class="text-ink-secondary">Admission</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.admissionDate ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Department</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.department ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Room / Bed</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.roomBed ?? 'N/A' }}</p>
</div>
<div>
<span class="text-ink-secondary">Reason</span>
<p class="font-medium text-ink-strong">{{ draft.encounter.admissionReason ?? 'N/A' }}</p>
</div>
</div>
</fieldset>
<fieldset class="workstation-form-section">
<legend class="workstation-form-legend">
Observations ({{ draft?.observations?.length ?? 0 }})
</legend>
<div class="space-y-2">
<ObservationRow
v-for="obs in (draft?.observations ?? [])"
:key="obs.id"
:observation="obs"
:readonly="true"
/>
<p v-if="!draft?.observations?.length" class="text-sm text-ink-secondary">
No observations recorded.
</p>
</div>
</fieldset>
<div
v-if="batch?.verifiedByUserId"
class="rounded-input border border-line bg-canvas px-3 py-2 text-sm text-ink-secondary"
>
<span class="text-xs uppercase tracking-wide font-semibold">Verified by</span>
<p class="font-mono text-ink mt-0.5">{{ batch.verifiedByUserId.substring(0, 8) }}</p>
</div>
<!-- Level 4 decision controls (copy weight below draft) -->
<section
class="rounded-input border border-line bg-canvas p-3 space-y-3"
data-testid="approval-decision"
>
<p class="evidence-level evidence-level--4 mb-0">
<span class="evidence-level-mark" aria-hidden="true">4</span>
Clinical decision
</p>
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="enableRetroactiveAlerts"
type="checkbox"
class="mt-1 w-4 h-4 text-clinical-safe rounded"
data-testid="retroactive-alerts"
/>
<div>
<span class="text-sm font-medium text-ink-strong">
Run alert evaluation after promotion
</span>
<p class="text-xs text-ink-secondary mt-0.5">
May generate alerts for clinical criteria represented in historical records.
It should not sound like alerts occurred contemporaneously.
</p>
</div>
</label>
</section>
<!-- Level 5 promotion outcome -->
<section
v-if="promotionResult"
class="promotion-outcome"
data-testid="promotion-result"
>
<p class="evidence-level evidence-level--5 mb-2">
<span class="evidence-level-mark" aria-hidden="true">5</span>
Promotion outcome
</p>
<p class="font-semibold text-clinical-safe">Promoted to live clinical tables</p>
<dl class="mt-3 grid gap-2 sm:grid-cols-3 text-sm">
<div>
<dt class="text-xs text-ink-secondary uppercase tracking-wide">Patient MRN</dt>
<dd class="font-mono font-medium text-ink-strong mt-0.5">{{ promotionResult.mrn }}</dd>
</div>
<div>
<dt class="text-xs text-ink-secondary uppercase tracking-wide">Encounter</dt>
<dd class="font-mono font-medium text-ink-strong mt-0.5">
{{ promotionResult.encounterId?.substring(0, 8) }}
</dd>
</div>
<div>
<dt class="text-xs text-ink-secondary uppercase tracking-wide">Observations</dt>
<dd class="font-medium text-ink-strong mt-0.5">
{{ promotionResult.observationIds?.length ?? 0 }} promoted
</dd>
</div>
</dl>
<div class="flex flex-wrap gap-4 mt-3 pt-3 border-t border-[#ABEFC6]">
<button
type="button"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
@click="$emit('create-correction')"
>
Create Correction
</button>
<button
v-if="batch?.patientId"
type="button"
class="text-sm text-ink-secondary hover:text-ink-strong"
@click="$emit('view-history', batch.patientId)"
>
View Patient History
</button>
</div>
</section>
<div
v-if="deferred"
class="rounded-input border border-[#FEDF89] bg-clinical-warning-bg p-4 text-sm"
data-testid="promotion-deferred"
>
<p class="font-medium text-clinical-warning">Approved Promotion Deferred</p>
<p class="text-ink mt-1">
Promotion will be retried automatically due to a temporary infrastructure issue.
</p>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
{{ errorMessage }}
</div>
</div>
<WorkstationActionBar>
<template #left>
<button
type="button"
class="btn-danger"
:disabled="processing || !!promotionResult"
data-testid="reject-batch"
@click="showRejectDialog = true"
>
Reject Batch
</button>
</template>
<template #primary>
<button
type="button"
class="btn-primary"
:disabled="processing || !!promotionResult"
data-testid="approve-promote"
@click="showApproveConfirm = true"
>
{{ processing ? 'Promoting...' : 'Approve & Promote' }}
</button>
</template>
<template #right>
<button
type="button"
class="btn-secondary"
:disabled="processing"
@click="$emit('back')"
>
Back to Queue
</button>
</template>
</WorkstationActionBar>
<ConfirmDialog
:open="showApproveConfirm"
title="Approve & Promote"
body="Approval will promote the verified records into live clinical tables."
confirm-label="Approve & Promote"
variant="primary"
:confirm-disabled="processing"
@confirm="confirmApprove"
@cancel="showApproveConfirm = false"
/>
<ConfirmDialog
:open="showRejectDialog"
title="Reject Batch"
body="This returns the batch for rework. A reason is required."
confirm-label="Reject Batch"
variant="danger"
:confirm-disabled="!rejectionReason.trim() || processing"
@confirm="reject"
@cancel="closeRejectDialog"
>
<textarea
v-model="rejectionReason"
class="form-input"
rows="4"
placeholder="Reason for rejection (required)..."
/>
</ConfirmDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useBatchStore } from '../stores/batches'
import { useToast } from '../composables/useToast'
import ObservationRow from './ObservationRow.vue'
import StatusBadge from './StatusBadge.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import WorkstationActionBar from './WorkstationActionBar.vue'
import type { BatchDetailResponse, BatchDraft, DraftObservation } from '../types'
const props = defineProps<{
batch: BatchDetailResponse | null
batchId: string
draft: BatchDraft | null
}>()
const emit = defineEmits<{
(e: 'back'): void
(e: 'view-history', patientId: string): void
(e: 'create-correction'): void
(e: 'approved'): void
(e: 'rejected'): void
}>()
const batchStore = useBatchStore()
const toast = useToast()
const processing = ref(false)
const errorMessage = ref('')
const enableRetroactiveAlerts = ref(false)
const showApproveConfirm = ref(false)
const showRejectDialog = ref(false)
const rejectionReason = ref('')
const promotionResult = ref<{ mrn: string; encounterId: string; observationIds: string[] } | null>(null)
const deferred = ref(false)
watch(
() => props.batchId,
() => {
promotionResult.value = null
deferred.value = false
errorMessage.value = ''
enableRetroactiveAlerts.value = false
rejectionReason.value = ''
}
)
interface HighStakeItem {
key: string
label: string
value: string
reason?: string
critical?: boolean
}
function isCriticalObservation(obs: DraftObservation): boolean {
const code = (obs.observationCode ?? '').toUpperCase()
const value = Number(obs.value)
if (Number.isNaN(value)) return false
if (code.includes('TEMP') && value >= 38) return true
if (code.includes('SPO2') && value < 92) return true
if ((code.includes('HEART') || code === 'HR') && (value > 120 || value < 40)) return true
if (code.includes('BP_SYSTOLIC') && (value >= 180 || value < 90)) return true
if (code.includes('LACTATE') && value >= 2) return true
if (code.includes('POTASSIUM') && (value < 3 || value > 5.5)) return true
return false
}
function formatObsLabel(code: string): string {
return code.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
const highStakeItems = computed((): HighStakeItem[] => {
const draft = props.draft
if (!draft) return []
const items: HighStakeItem[] = []
const patient = draft.patient
if (patient?.bloodType) {
items.push({
key: 'bloodType',
label: 'Blood type',
value: patient.bloodType,
reason: 'High-stakes demographic for transfusion safety',
})
}
if (patient?.noKnownAllergies) {
items.push({
key: 'nka',
label: 'Allergies',
value: 'No known allergies (NKA)',
})
} else if (patient?.allergies?.length) {
items.push({
key: 'allergies',
label: 'Allergies',
value: patient.allergies.join(', '),
reason: 'Allergy documentation requires clinical oversight',
critical: true,
})
}
if (patient?.noActiveMedications) {
items.push({
key: 'nam',
label: 'Medications',
value: 'No active medications',
})
} else if (patient?.medications?.length) {
items.push({
key: 'medications',
label: 'Medications',
value: patient.medications.join(', '),
reason: 'Medication list requires clinical oversight',
critical: true,
})
}
for (const obs of draft.observations ?? []) {
if (!isCriticalObservation(obs)) continue
items.push({
key: `obs-${obs.id}`,
label: formatObsLabel(obs.observationCode),
value: `${obs.value}${obs.unit ? ` ${obs.unit}` : ''}`,
reason: 'Out-of-range clinical signal',
critical: true,
})
}
return items
})
function closeRejectDialog() {
showRejectDialog.value = false
}
async function confirmApprove() {
showApproveConfirm.value = false
await approve()
}
async function approve() {
processing.value = true
errorMessage.value = ''
promotionResult.value = null
deferred.value = false
try {
const response = await batchStore.approveBatch(props.batchId, enableRetroactiveAlerts.value)
if (response?.status === 202) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else if (response?.data) {
promotionResult.value = response.data
toast.success('Batch approved and promoted successfully')
emit('approved')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Approval failed'
if (msg.includes('PROMOTION_DEFERRED')) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else {
errorMessage.value = msg
toast.error(msg)
}
} finally {
processing.value = false
}
}
async function reject() {
processing.value = true
errorMessage.value = ''
try {
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
emit('rejected')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
}
</script>
+344 -266
View File
@@ -1,263 +1,313 @@
<template> <template>
<div class="h-full overflow-y-auto p-4 space-y-6"> <div class="h-full min-h-0 flex flex-col" data-testid="entry-form">
<div class="flex items-center justify-between"> <div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<h2 class="text-lg font-semibold">Data Entry</h2> <div class="flex items-start justify-between gap-3">
<StatusBadge :status="batch?.status" />
</div>
<div v-if="ocrConfidence" class="ocr-banner">
Pre-filled by OCR ({{ ocrConfidence.provider }}) review against the scan.
OCR is assistive, not authoritative.
</div>
<!-- Patient section -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label class="flex items-center gap-2 text-xs text-gray-500"> <p class="evidence-level evidence-level--2 mb-1">
Full Name <span class="evidence-level-mark" aria-hidden="true">2</span>
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.fullName')" :level="fieldConfidenceLevel('patient.fullName')" /> Structured draft
</label> </p>
<input <h2 class="text-base font-semibold text-ink-strong">Data Entry</h2>
v-model="patient.fullName" <p
@blur="savePatient" class="mt-1 text-xs tabular-nums"
type="text" :class="{
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.fullName')]" 'text-ink-secondary': saveState === 'saved' || saveState === 'idle',
/> 'text-ink-secondary animate-pulse': saveState === 'saving',
</div> 'text-clinical-danger font-medium': saveState === 'failed',
<div> }"
<label class="flex items-center gap-2 text-xs text-gray-500"> data-testid="entry-save-status"
Date of Birth
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.dateOfBirth')" :level="fieldConfidenceLevel('patient.dateOfBirth')" />
</label>
<input
v-model="patient.dateOfBirth"
@blur="savePatient"
type="date"
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.dateOfBirth')]"
/>
</div>
<div>
<label class="flex items-center gap-2 text-xs text-gray-500">
Sex
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.sex')" :level="fieldConfidenceLevel('patient.sex')" />
</label>
<select
v-model="patient.sex"
@change="savePatient"
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.sex')]"
> >
<option value="">Select...</option> {{ saveStatusLabel }}
<option value="male">Male</option> </p>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500">Blood Type</label>
<select v-model="patient.bloodType" @change="savePatient" class="form-input text-sm">
<option value="">Unknown</option>
<option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option>
</select>
</div>
<div class="col-span-2">
<label class="block text-xs text-gray-500">Emergency Contact</label>
<input
v-model="patient.emergencyContact"
@blur="savePatient"
type="text"
class="form-input text-sm"
/>
</div> </div>
<StatusBadge :status="batch?.status" />
</div> </div>
</fieldset>
<!-- Allergies section (ALLERGY_UPDATE or MIXED) --> <div v-if="ocrConfidence" class="ocr-banner">
<fieldset v-if="showAllergies" class="border border-gray-200 rounded-md p-4"> Pre-filled by OCR ({{ ocrConfidence.provider }}) review against the scan.
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend> OCR is assistive, not authoritative.
<label class="flex items-center gap-2 mb-3 cursor-pointer"> </div>
<input
v-model="patient.noKnownAllergies" <!-- Patient section -->
@change="onNoKnownAllergiesChange" <fieldset class="workstation-form-section">
type="checkbox" <legend class="workstation-form-legend">Patient Demographics</legend>
class="w-4 h-4 text-clinical-safe rounded" <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
/> <div>
<span class="text-sm">No known allergies (NKA)</span> <label class="workstation-field-label">
</label> Full Name
<div v-if="!patient.noKnownAllergies" class="space-y-2"> <OcrConfidenceBadge :label="fieldConfidenceLabel('patient.fullName')" :level="fieldConfidenceLevel('patient.fullName')" />
<div </label>
v-for="(_allergy, idx) in allergies" <input
:key="idx" v-model="patient.fullName"
class="flex items-center gap-2" type="text"
> :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.fullName')]"
@blur="savePatient"
/>
</div>
<div>
<label class="workstation-field-label">
Date of Birth
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.dateOfBirth')" :level="fieldConfidenceLevel('patient.dateOfBirth')" />
</label>
<input
v-model="patient.dateOfBirth"
type="date"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.dateOfBirth')]"
@blur="savePatient"
/>
</div>
<div>
<label class="workstation-field-label">
Sex
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.sex')" :level="fieldConfidenceLevel('patient.sex')" />
</label>
<select
v-model="patient.sex"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.sex')]"
@change="savePatient"
>
<option value="">Select...</option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<div>
<label class="workstation-field-label">Blood Type</label>
<select v-model="patient.bloodType" class="form-input text-sm py-1.5" @change="savePatient">
<option value="">Unknown</option>
<option v-for="bt in bloodTypes" :key="bt" :value="bt">{{ bt }}</option>
</select>
</div>
<div class="md:col-span-2">
<label class="workstation-field-label">Emergency Contact</label>
<input
v-model="patient.emergencyContact"
type="text"
class="form-input text-sm py-1.5"
@blur="savePatient"
/>
</div>
</div>
</fieldset>
<!-- Allergies section (ALLERGY_UPDATE or MIXED) -->
<fieldset v-if="showAllergies" class="workstation-form-section">
<legend class="workstation-form-legend">Allergies</legend>
<label class="flex items-center gap-2 mb-2 cursor-pointer">
<input <input
v-model="allergies[idx]" v-model="patient.noKnownAllergies"
@blur="saveAllergies" type="checkbox"
type="text" class="w-4 h-4 text-clinical-safe rounded"
class="form-input text-sm flex-1" @change="onNoKnownAllergiesChange"
placeholder="Allergy (e.g. Penicillin)"
/> />
<button <span class="text-sm">No known allergies (NKA)</span>
@click="removeAllergy(idx)" </label>
class="text-clinical-danger hover:text-red-800 text-sm" <div v-if="!patient.noKnownAllergies" class="space-y-2">
<div
v-for="(_allergy, idx) in allergies"
:key="idx"
class="flex items-center gap-2"
> >
Remove <input
v-model="allergies[idx]"
type="text"
class="form-input text-sm py-1.5 flex-1"
placeholder="Allergy (e.g. Penicillin)"
@blur="saveAllergies"
/>
<button
type="button"
class="text-clinical-danger hover:text-clinical-critical text-sm"
@click="removeAllergy(idx)"
>
Remove
</button>
</div>
<button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addAllergy">
+ Add Allergy
</button> </button>
</div> </div>
<button @click="addAllergy" class="text-sm text-primary-600 hover:text-primary-800"> </fieldset>
+ Add Allergy
</button>
</div>
</fieldset>
<!-- Medications section (MEDICATION_LIST or MIXED) --> <!-- Medications section (MEDICATION_LIST or MIXED) -->
<fieldset v-if="showMedications" class="border border-gray-200 rounded-md p-4"> <fieldset v-if="showMedications" class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend> <legend class="workstation-form-legend">Medications</legend>
<label class="flex items-center gap-2 mb-3 cursor-pointer"> <label class="flex items-center gap-2 mb-2 cursor-pointer">
<input
v-model="patient.noActiveMedications"
@change="onNoActiveMedicationsChange"
type="checkbox"
class="w-4 h-4 text-clinical-safe rounded"
/>
<span class="text-sm">No active medications</span>
</label>
<div v-if="!patient.noActiveMedications" class="space-y-2">
<div
v-for="(_med, idx) in medications"
:key="idx"
class="flex items-center gap-2"
>
<input <input
v-model="medications[idx]" v-model="patient.noActiveMedications"
@blur="saveMedications" type="checkbox"
type="text" class="w-4 h-4 text-clinical-safe rounded"
class="form-input text-sm flex-1" @change="onNoActiveMedicationsChange"
placeholder="Medication (e.g. Metoprolol 50mg BID)"
/> />
<button <span class="text-sm">No active medications</span>
@click="removeMedication(idx)" </label>
class="text-clinical-danger hover:text-red-800 text-sm" <div v-if="!patient.noActiveMedications" class="space-y-2">
<div
v-for="(_med, idx) in medications"
:key="idx"
class="flex items-center gap-2"
> >
Remove <input
v-model="medications[idx]"
type="text"
class="form-input text-sm py-1.5 flex-1"
placeholder="Medication (e.g. Metoprolol 50mg BID)"
@blur="saveMedications"
/>
<button
type="button"
class="text-clinical-danger hover:text-clinical-critical text-sm"
@click="removeMedication(idx)"
>
Remove
</button>
</div>
<button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addMedication">
+ Add Medication
</button> </button>
</div> </div>
<button @click="addMedication" class="text-sm text-primary-600 hover:text-primary-800"> </fieldset>
+ Add Medication
</button>
</div>
</fieldset>
<!-- Encounter section --> <!-- Encounter section -->
<fieldset class="border border-gray-200 rounded-md p-4"> <fieldset class="workstation-form-section">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend> <legend class="workstation-form-legend">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<label class="flex items-center gap-2 text-xs text-gray-500"> <label class="workstation-field-label">
Admission Date Admission Date
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionDate')" :level="fieldConfidenceLevel('encounter.admissionDate')" /> <OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionDate')" :level="fieldConfidenceLevel('encounter.admissionDate')" />
</label> </label>
<input <input
v-model="encounter.admissionDate" v-model="encounter.admissionDate"
@blur="saveEncounter" type="datetime-local"
type="datetime-local" :class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionDate')]"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionDate')]" @blur="saveEncounter"
/> />
</div>
<div>
<label class="workstation-field-label">
Department
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.department')" :level="fieldConfidenceLevel('encounter.department')" />
</label>
<select
v-model="encounter.department"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.department')]"
@change="saveEncounter"
>
<option value=""></option>
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
</select>
</div>
<div>
<label class="workstation-field-label">
Room / Bed
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.roomBed')" :level="fieldConfidenceLevel('encounter.roomBed')" />
</label>
<input
v-model="encounter.roomBed"
type="text"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.roomBed')]"
@blur="saveEncounter"
/>
</div>
<div>
<label class="workstation-field-label">
Admission Reason
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionReason')" :level="fieldConfidenceLevel('encounter.admissionReason')" />
</label>
<input
v-model="encounter.admissionReason"
type="text"
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionReason')]"
@blur="saveEncounter"
/>
</div>
<div v-if="showEncounterSummary">
<label class="workstation-field-label">Encounter Status</label>
<select v-model="encounter.status" class="form-input text-sm py-1.5" @change="saveEncounter">
<option value=""></option>
<option value="active">Active</option>
<option value="discharged">Discharged</option>
</select>
</div>
<div v-if="showEncounterSummary" class="md:col-span-2">
<label class="workstation-field-label">Discharge Diagnosis</label>
<textarea
v-model="encounter.dischargeDiagnosis"
class="form-input text-sm py-1.5"
rows="2"
placeholder="Discharge diagnosis..."
@blur="saveEncounter"
/>
</div>
</div> </div>
<div> </fieldset>
<label class="flex items-center gap-2 text-xs text-gray-500">
Department <!-- Observations section -->
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.department')" :level="fieldConfidenceLevel('encounter.department')" /> <fieldset class="workstation-form-section">
</label> <legend class="workstation-form-legend">Observations</legend>
<select <div class="space-y-2">
v-model="encounter.department" <ObservationRow
@change="saveEncounter" v-for="obs in observations"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.department')]" :key="obs.id"
:observation="obs"
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
:ocr-label="fieldConfidenceLabel(`observation.${obs.observationCode}.value`)"
:ocr-level="fieldConfidenceLevel(`observation.${obs.observationCode}.value`)"
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
@delete="handleObsDelete"
/>
<button type="button" class="btn-secondary text-sm py-1.5" @click="addObservation">
+ Add Observation
</button>
</div>
</fieldset>
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
{{ errorMessage }}
</div>
</div>
<WorkstationActionBar>
<template #center>
<button
type="button"
class="btn-secondary"
:disabled="saveState === 'saving' || submitting"
@click="saveDraft"
>
Save Draft
</button>
</template>
<template #primary>
<div class="flex flex-col items-end gap-1">
<p class="evidence-level evidence-level--3 mb-0 hidden sm:flex">
<span class="evidence-level-mark" aria-hidden="true">3</span>
Submit decision
</p>
<button
type="button"
class="btn-primary"
:disabled="submitting || saveState === 'saving'"
@click="submitForVerification"
> >
<option value=""></option> {{ submitting ? 'Submitting...' : 'Submit for Verification' }}
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option> </button>
</select>
</div> </div>
<div> </template>
<label class="flex items-center gap-2 text-xs text-gray-500"> <template v-if="nextBatchId" #right>
Room / Bed <button
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.roomBed')" :level="fieldConfidenceLevel('encounter.roomBed')" /> type="button"
</label> class="btn-secondary"
<input :disabled="submitting"
v-model="encounter.roomBed" data-testid="entry-next-batch"
@blur="saveEncounter" @click="emit('open-next', nextBatchId)"
type="text" >
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.roomBed')]" Next
/>
</div>
<div>
<label class="flex items-center gap-2 text-xs text-gray-500">
Admission Reason
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionReason')" :level="fieldConfidenceLevel('encounter.admissionReason')" />
</label>
<input
v-model="encounter.admissionReason"
@blur="saveEncounter"
type="text"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionReason')]"
/>
</div>
<div v-if="showEncounterSummary">
<label class="block text-xs text-gray-500">Encounter Status</label>
<select v-model="encounter.status" @change="saveEncounter" class="form-input text-sm">
<option value=""></option>
<option value="active">Active</option>
<option value="discharged">Discharged</option>
</select>
</div>
<div v-if="showEncounterSummary" class="col-span-2">
<label class="block text-xs text-gray-500">Discharge Diagnosis</label>
<textarea
v-model="encounter.dischargeDiagnosis"
@blur="saveEncounter"
class="form-input text-sm"
rows="2"
placeholder="Discharge diagnosis..."
/>
</div>
</div>
</fieldset>
<!-- Observations section -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Observations</legend>
<div class="space-y-2">
<ObservationRow
v-for="obs in observations"
:key="obs.id"
:observation="obs"
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
:ocr-label="fieldConfidenceLabel(`observation.${obs.observationCode}.value`)"
:ocr-level="fieldConfidenceLevel(`observation.${obs.observationCode}.value`)"
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
@delete="handleObsDelete"
/>
<button @click="addObservation" class="btn-primary text-sm">
+ Add Observation
</button> </button>
</div> </template>
</fieldset> </WorkstationActionBar>
<!-- Submit -->
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
<button
@click="submitForVerification"
class="btn-primary"
:disabled="submitting"
>
{{ submitting ? 'Submitting...' : 'Submit for Verification' }}
</button>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
</div> </div>
</template> </template>
@@ -269,17 +319,35 @@ import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
import ObservationRow from '../components/ObservationRow.vue' import ObservationRow from '../components/ObservationRow.vue'
import StatusBadge from '../components/StatusBadge.vue' import StatusBadge from '../components/StatusBadge.vue'
import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue' import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue'
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
import type { BatchDetailResponse, DraftObservation } from '../types' import type { BatchDetailResponse, DraftObservation } from '../types'
const props = defineProps<{ const props = defineProps<{
batch: BatchDetailResponse | null batch: BatchDetailResponse | null
batchId: string batchId: string
/** Next batch in the current queue list, if any */
nextBatchId?: string
}>()
const emit = defineEmits<{
(e: 'open-next', id: string): void
}>() }>()
const batchStore = useBatchStore() const batchStore = useBatchStore()
const toast = useToast() const toast = useToast()
const submitting = ref(false) const submitting = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const saveState = ref<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const lastSavedAt = ref<Date | null>(null)
const saveStatusLabel = computed(() => {
if (saveState.value === 'saving') return 'Saving…'
if (saveState.value === 'failed') return 'Save failed'
if (saveState.value === 'saved' && lastSavedAt.value) {
return `Saved ${lastSavedAt.value.toLocaleTimeString()}`
}
return ''
})
const bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-'] const bloodTypes = ['A+', 'A-', 'B+', 'B-', 'AB+', 'AB-', 'O+', 'O-']
const departments = [ const departments = [
@@ -346,7 +414,6 @@ const encounter = reactive({
const observations = ref<DraftObservation[]>([]) const observations = ref<DraftObservation[]>([])
// Load draft data when batch changes
watch( watch(
() => batchStore.currentDraft, () => batchStore.currentDraft,
(draft) => { (draft) => {
@@ -388,18 +455,29 @@ function removeAllergy(idx: number) {
saveAllergies() saveAllergies()
} }
async function saveAllergies() { async function withSaveFeedback(fn: () => Promise<void>) {
saveState.value = 'saving'
errorMessage.value = ''
try { try {
await fn()
lastSavedAt.value = new Date()
saveState.value = 'saved'
} catch (e: unknown) {
saveState.value = 'failed'
const msg = e instanceof Error ? e.message : 'Failed to save'
errorMessage.value = msg
toast.error(msg)
throw e
}
}
async function saveAllergies() {
await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
allergies: allergies.value.filter(a => a.trim()), allergies: allergies.value.filter(a => a.trim()),
}) })
toast.success('Allergies saved') })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to save allergies'
errorMessage.value = msg
toast.error(msg)
}
} }
function onNoKnownAllergiesChange() { function onNoKnownAllergiesChange() {
@@ -419,17 +497,12 @@ function removeMedication(idx: number) {
} }
async function saveMedications() { async function saveMedications() {
try { await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
medications: medications.value.filter(m => m.trim()), medications: medications.value.filter(m => m.trim()),
}) })
toast.success('Medications saved') })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to save medications'
errorMessage.value = msg
toast.error(msg)
}
} }
function onNoActiveMedicationsChange() { function onNoActiveMedicationsChange() {
@@ -440,28 +513,33 @@ function onNoActiveMedicationsChange() {
} }
async function savePatient() { async function savePatient() {
try { await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, { await batchStore.saveDraftPatient(props.batchId, {
...patient, ...patient,
allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()), allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()),
medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()), medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()),
}) })
toast.success('Patient demographics saved') })
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to save patient'
errorMessage.value = msg
toast.error(msg)
}
} }
async function saveEncounter() { async function saveEncounter() {
try { await withSaveFeedback(async () => {
await batchStore.saveDraftEncounter(props.batchId, encounter) await batchStore.saveDraftEncounter(props.batchId, encounter)
toast.success('Encounter context saved') })
} catch (e: unknown) { }
const msg = e instanceof Error ? e.message : 'Failed to save encounter'
errorMessage.value = msg async function saveDraft() {
toast.error(msg) try {
await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, {
...patient,
allergies: patient.noKnownAllergies ? null : allergies.value.filter(a => a.trim()),
medications: patient.noActiveMedications ? null : medications.value.filter(m => m.trim()),
})
await batchStore.saveDraftEncounter(props.batchId, encounter)
})
} catch {
// Feedback already set
} }
} }
@@ -1,67 +1,160 @@
<template> <template>
<div class="h-full flex flex-col bg-gray-900 rounded-lg overflow-hidden"> <div
<!-- Toolbar --> class="h-full flex flex-col bg-canvas rounded-card border border-line overflow-hidden"
<div class="flex flex-wrap items-center gap-2 p-4 bg-gray-800 text-white text-sm"> data-testid="scan-viewer"
<button @click="zoomIn" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom in"> >
<!-- Toolbar light chrome, no decorative overlays on the page -->
<div
class="flex flex-wrap items-center gap-1 px-3 py-2 bg-surface border-b border-line text-sm text-ink shrink-0"
data-testid="scan-viewer-toolbar"
>
<button
type="button"
class="scan-toolbar-btn"
title="Zoom out"
:disabled="!url"
@click="zoomOut"
>
</button>
<button
type="button"
class="scan-toolbar-btn"
title="Zoom in"
:disabled="!url"
@click="zoomIn"
>
+ +
</button> </button>
<button @click="zoomOut" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom out"> <button
- type="button"
class="scan-toolbar-btn"
title="Fit width"
:disabled="!url"
data-testid="scan-fit-width"
@click="fitWidth"
>
Fit width
</button> </button>
<button @click="resetZoom" class="px-2 py-1 hover:bg-gray-700 rounded" title="Reset"> <button
type="button"
class="scan-toolbar-btn"
title="Reset zoom"
:disabled="!url"
@click="resetZoom"
>
1:1 1:1
</button> </button>
<button @click="rotateCw" class="px-2 py-1 hover:bg-gray-700 rounded" title="Rotate 90"> <button
type="button"
class="scan-toolbar-btn"
title="Rotate 90°"
:disabled="!url"
@click="rotateCw"
>
Rotate Rotate
</button> </button>
<span class="ml-auto text-gray-400 text-xs">{{ Math.round(scale * 100) }}%</span> <span class="ml-auto text-ink-secondary text-xs tabular-nums">
{{ url ? `${Math.round(scale * 100)}%` : '' }}
</span>
</div> </div>
<!-- Document area --> <!-- Document surface -->
<div <div
ref="viewerContainer" ref="viewerContainer"
class="flex-1 overflow-auto cursor-grab active:cursor-grabbing" class="flex-1 min-h-0 overflow-auto bg-[#E8ECF1]"
:class="url && !loading && !error ? 'cursor-grab active:cursor-grabbing' : ''"
@mousedown="startPan" @mousedown="startPan"
@mousemove="pan" @mousemove="pan"
@mouseup="stopPan" @mouseup="stopPan"
@mouseleave="stopPan" @mouseleave="stopPan"
@wheel.prevent="onWheel" @wheel.prevent="onWheel"
> >
<div <div v-if="loading" class="flex h-full items-center justify-center p-8">
:style="{ <SkeletonBlock
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`, variant="block"
transformOrigin: 'top left', height="min(70vh, 640px)"
transition: isPanning ? 'none' : 'transform 0.2s', width="min(100%, 480px)"
}" class="max-w-full shadow-page bg-surface"
>
<!-- PDF/image rendered from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
<iframe
v-if="isPdf"
:src="url"
class="w-[800px] h-[1100px] bg-white"
frameborder="0"
title="Scanned document"
/>
<img
v-else
:src="url"
class="max-w-none"
draggable="false"
@load="onImageLoad"
/> />
</div> </div>
<div v-else-if="error" class="flex h-full items-center justify-center p-6">
<InlineError
class="max-w-md w-full"
title="Could not load document"
:message="error"
preserved="Your form draft was not affected."
retry-label="Retry"
@retry="$emit('retry')"
/>
</div>
<div
v-else-if="url"
class="p-6 inline-block min-w-full"
>
<div
:style="{
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: 'top left',
transition: isPanning ? 'none' : 'transform 0.15s ease-out',
}"
>
<!-- PDF/image from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
<iframe
v-if="isPdf"
:src="url"
class="scan-page w-[800px] h-[1100px] bg-white"
frameborder="0"
title="Scanned document"
/>
<img
v-else
:src="url"
class="scan-page max-w-none bg-white"
draggable="false"
alt="Scanned document"
@load="onImageLoad"
/>
</div>
</div>
<EmptyState
v-else
title="No document available"
description="The source scan will appear here when the batch document loads."
class="h-full"
/>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import SkeletonBlock from './SkeletonBlock.vue'
import InlineError from './InlineError.vue'
import EmptyState from './EmptyState.vue'
const props = defineProps<{ const props = withDefaults(
url: string defineProps<{
url?: string | null
loading?: boolean
error?: string | null
}>(),
{
url: null,
loading: false,
error: null,
}
)
defineEmits<{
(e: 'retry'): void
}>() }>()
const isPdf = computed(() => { const isPdf = computed(() => {
if (!props.url) return false
const lower = props.url.toLowerCase() const lower = props.url.toLowerCase()
return lower.includes('.pdf') || lower.includes('application/pdf') return lower.includes('.pdf') || lower.includes('application/pdf')
}) })
@@ -73,23 +166,44 @@ const panY = ref(0)
const isPanning = ref(false) const isPanning = ref(false)
const lastX = ref(0) const lastX = ref(0)
const lastY = ref(0) const lastY = ref(0)
const viewerContainer = ref<HTMLElement | null>(null)
const naturalPageWidth = ref(800)
function zoomIn() { scale.value = Math.min(scale.value + 0.25, 5) } function zoomIn() {
function zoomOut() { scale.value = Math.max(scale.value - 0.25, 0.25) } scale.value = Math.min(scale.value + 0.25, 5)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.25, 0.25)
}
function resetZoom() { function resetZoom() {
scale.value = 1 scale.value = 1
panX.value = 0 panX.value = 0
panY.value = 0 panY.value = 0
rotation.value = 0 rotation.value = 0
} }
function rotateCw() { rotation.value = (rotation.value + 90) % 360 } function rotateCw() {
rotation.value = (rotation.value + 90) % 360
}
function fitWidth() {
const container = viewerContainer.value
if (!container) return
const padding = 48 // matches p-6
const available = Math.max(container.clientWidth - padding, 120)
scale.value = Math.min(Math.max(available / naturalPageWidth.value, 0.25), 5)
panX.value = 0
panY.value = 0
}
function onWheel(e: WheelEvent) { function onWheel(e: WheelEvent) {
if (!props.url || props.loading || props.error) return
if (e.deltaY < 0) zoomIn() if (e.deltaY < 0) zoomIn()
else zoomOut() else zoomOut()
} }
function startPan(e: MouseEvent) { function startPan(e: MouseEvent) {
if (!props.url || props.loading || props.error) return
if ((e.target as HTMLElement)?.closest('button')) return
isPanning.value = true isPanning.value = true
lastX.value = e.clientX lastX.value = e.clientX
lastY.value = e.clientY lastY.value = e.clientY
@@ -103,12 +217,40 @@ function pan(e: MouseEvent) {
lastY.value = e.clientY lastY.value = e.clientY
} }
function stopPan() { isPanning.value = false } function stopPan() {
isPanning.value = false
}
function onImageLoad() { function onImageLoad(e: Event) {
// Reset view when a new image loads const img = e.target as HTMLImageElement
if (img.naturalWidth > 0) {
naturalPageWidth.value = img.naturalWidth
}
scale.value = 1 scale.value = 1
panX.value = 0 panX.value = 0
panY.value = 0 panY.value = 0
nextTick(() => fitWidth())
} }
watch(
() => props.url,
(url) => {
if (!url) return
if (isPdf.value) {
naturalPageWidth.value = 800
nextTick(() => fitWidth())
}
}
)
</script> </script>
<style scoped>
.scan-toolbar-btn {
@apply px-2 py-1 rounded-control text-ink hover:bg-canvas
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent
transition-colors;
}
.scan-page {
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08);
}
</style>
@@ -1,199 +1,251 @@
<template> <template>
<div class="h-full overflow-y-auto p-4 space-y-6"> <div class="h-full min-h-0 flex flex-col" data-testid="verification-form">
<div class="flex items-center justify-between"> <div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<h2 class="text-lg font-semibold">Verification Review</h2> <div class="flex items-center justify-between gap-3">
<StatusBadge :status="batch?.status ?? 'PENDING_VERIFICATION'" /> <div>
</div> <p class="evidence-level evidence-level--2 mb-1">
<span class="evidence-level-mark" aria-hidden="true">2</span>
<div v-if="batch?.rejectionReason" class="bg-red-50 border border-red-200 rounded-md p-4"> Verified draft
<p class="text-sm font-medium text-red-800">Previous Rejection Reason:</p>
<p class="text-sm text-red-700">{{ batch.rejectionReason }}</p>
</div>
<div v-if="ocrConfidence" class="ocr-banner">
Pre-filled by OCR ({{ ocrConfidence.provider }}) review against the scan.
OCR is assistive, not authoritative. Colored borders and badges indicate extraction confidence only.
</div>
<SeparationOfDutiesBanner
v-model:blocked="sodBlocked"
:entered-by-user-id="batch?.enteredByUserId"
:current-user-id="auth.userId"
:current-user-name="auth.userFullName"
/>
<!-- Patient review -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="field in patientFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
@change="toggleCheck(field.path)"
class="w-4 h-4 text-clinical-safe rounded"
/>
<label class="text-xs text-gray-500">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p> </p>
<h2 class="text-base font-semibold text-ink-strong">Verification Review</h2>
</div> </div>
<StatusBadge :status="batch?.status ?? 'PENDING_VERIFICATION'" />
</div> </div>
</fieldset>
<!-- Allergies review (ALLERGY_UPDATE or MIXED) --> <div
<fieldset v-if="allergyFields.length > 0" class="border border-gray-200 rounded-md p-4"> v-if="batch?.rejectionReason"
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend> class="rounded-input border border-[#FECDCA] bg-clinical-danger-bg p-3"
<div class="space-y-3">
<div v-for="field in allergyFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
@change="toggleCheck(field.path)"
class="w-4 h-4 text-clinical-safe rounded"
/>
<label class="text-xs text-gray-500">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Medications review (MEDICATION_LIST or MIXED) -->
<fieldset v-if="medicationFields.length > 0" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend>
<div class="space-y-3">
<div v-for="field in medicationFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
@change="toggleCheck(field.path)"
class="w-4 h-4 text-clinical-safe rounded"
/>
<label class="text-xs text-gray-500">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Encounter review -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="field in encounterFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
@change="toggleCheck(field.path)"
class="w-4 h-4 text-clinical-safe rounded"
/>
<label class="text-xs text-gray-500">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-2 pl-8 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Observations review -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">
Observations ({{ observations.length }})
</legend>
<div class="space-y-2">
<ObservationRow
v-for="(obs, index) in observations"
:key="obs.id"
:observation="obs"
:readonly="true"
:show-verified="true"
:verified="fieldChecks[`observations[${index}].value`] ?? false"
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
:ocr-label="fieldConfidenceLabel(`observation.${obs.observationCode}.value`)"
:ocr-level="fieldConfidenceLevel(`observation.${obs.observationCode}.value`)"
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
/>
</div>
</fieldset>
<!-- Verification progress -->
<div class="bg-gray-50 rounded-md p-4">
<div class="flex items-center justify-between text-sm">
<span>Fields verified:</span>
<span :class="allChecked ? 'text-clinical-safe font-bold' : 'text-gray-600'">
{{ checkedCount }} / {{ totalFields }}
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
<div
class="bg-clinical-safe h-2 rounded-full transition-all"
:style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }"
/>
</div>
</div>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
<button
@click="approveVerification"
class="btn-primary"
:disabled="!allChecked || processing || sodBlocked"
> >
{{ processing ? 'Processing...' : 'Approve - Verified' }} <p class="text-sm font-medium text-clinical-danger">Previous Rejection Reason:</p>
</button> <p class="text-sm text-ink mt-1">{{ batch.rejectionReason }}</p>
<button </div>
@click="showRejectDialog = true"
class="btn-danger" <div v-if="ocrConfidence" class="ocr-banner">
:disabled="processing || sodBlocked" Pre-filled by OCR ({{ ocrConfidence.provider }}) review against the scan.
> OCR is assistive, not authoritative. Colored borders and badges indicate extraction confidence only.
Reject </div>
</button>
<SeparationOfDutiesBanner
v-model:blocked="sodBlocked"
:entered-by-user-id="batch?.enteredByUserId"
:current-user-id="auth.userId"
:current-user-name="auth.userFullName"
/>
<!-- Patient review -->
<fieldset class="workstation-form-section">
<legend class="workstation-form-legend">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="field in patientFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
class="w-4 h-4 text-clinical-safe rounded"
@change="toggleCheck(field.path)"
/>
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-1.5 pl-6 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Allergies review -->
<fieldset v-if="allergyFields.length > 0" class="workstation-form-section">
<legend class="workstation-form-legend">Allergies</legend>
<div class="space-y-2.5">
<div v-for="field in allergyFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
class="w-4 h-4 text-clinical-safe rounded"
@change="toggleCheck(field.path)"
/>
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-1.5 pl-6 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Medications review -->
<fieldset v-if="medicationFields.length > 0" class="workstation-form-section">
<legend class="workstation-form-legend">Medications</legend>
<div class="space-y-2.5">
<div v-for="field in medicationFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
class="w-4 h-4 text-clinical-safe rounded"
@change="toggleCheck(field.path)"
/>
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-1.5 pl-6 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Encounter review -->
<fieldset class="workstation-form-section">
<legend class="workstation-form-legend">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="field in encounterFields" :key="field.path">
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="fieldChecks[field.path]"
class="w-4 h-4 text-clinical-safe rounded"
@change="toggleCheck(field.path)"
/>
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
<OcrConfidenceBadge
:label="fieldConfidenceLabel(field.path)"
:level="fieldConfidenceLevel(field.path)"
/>
</div>
<p
class="text-sm mt-1.5 pl-6 font-medium"
:class="fieldConfidenceClass(field.path)"
>
{{ field.value || '(empty)' }}
</p>
</div>
</div>
</fieldset>
<!-- Observations review -->
<fieldset class="workstation-form-section">
<legend class="workstation-form-legend">
Observations ({{ observations.length }})
</legend>
<div class="space-y-2">
<ObservationRow
v-for="(obs, index) in observations"
:key="obs.id"
:observation="obs"
:readonly="true"
:show-verified="true"
:verified="fieldChecks[`observations[${index}].value`] ?? false"
:value-input-class="observationValueConfidenceClass(obs.observationCode)"
:ocr-label="fieldConfidenceLabel(`observation.${obs.observationCode}.value`)"
:ocr-level="fieldConfidenceLevel(`observation.${obs.observationCode}.value`)"
@verify="(_obsId, passed) => toggleCheck(`observations[${index}].value`, passed)"
/>
</div>
</fieldset>
<!-- Verification progress (field checks only not analytics chrome) -->
<div class="rounded-input border border-line bg-canvas p-3">
<div class="flex items-center justify-between text-sm">
<span class="text-ink-secondary">Fields verified:</span>
<span :class="allChecked ? 'text-clinical-safe font-semibold' : 'text-ink'">
{{ checkedCount }} / {{ totalFields }}
</span>
</div>
<div class="w-full bg-line rounded-full h-1.5 mt-2">
<div
class="bg-clinical-safe h-1.5 rounded-full transition-all"
:style="{ width: `${(checkedCount / Math.max(totalFields, 1)) * 100}%` }"
/>
</div>
</div>
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
{{ errorMessage }}
</div>
</div> </div>
<WorkstationActionBar>
<template #center>
<div class="flex flex-col gap-2">
<p class="evidence-level evidence-level--3 mb-0">
<span class="evidence-level-mark" aria-hidden="true">3</span>
Verification decision
</p>
<div class="flex flex-wrap items-center gap-4 text-sm" role="radiogroup" aria-label="Verification decision">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
v-model="decision"
type="radio"
value="pass"
class="text-clinical-safe"
:disabled="sodBlocked || processing"
/>
<span :class="sodBlocked ? 'text-ink-disabled' : 'text-ink'">Pass</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
v-model="decision"
type="radio"
value="reject"
class="text-clinical-danger"
:disabled="sodBlocked || processing"
/>
<span :class="sodBlocked ? 'text-ink-disabled' : 'text-ink'">Reject</span>
</label>
</div>
</div>
</template>
<template #primary>
<button
v-if="decision === 'pass'"
type="button"
class="btn-primary"
:disabled="!allChecked || processing || sodBlocked"
data-testid="verify-pass"
@click="approveVerification"
>
{{ processing ? 'Processing...' : 'Pass Verification' }}
</button>
<button
v-else-if="decision === 'reject'"
type="button"
class="btn-danger"
:disabled="processing || sodBlocked"
data-testid="verify-return"
@click="showRejectDialog = true"
>
Return for Rework
</button>
</template>
</WorkstationActionBar>
<ConfirmDialog <ConfirmDialog
:open="showRejectDialog" :open="showRejectDialog"
title="Reject Batch" title="Return for Rework"
body="This returns the batch for rework. A reason is required." body="This returns the batch for rework. A reason is required."
confirm-label="Confirm Rejection" confirm-label="Return for Rework"
variant="danger" variant="danger"
:confirm-disabled="!rejectionReason.trim() || processing" :confirm-disabled="!rejectionReason.trim() || processing"
@confirm="rejectVerification" @confirm="rejectVerification"
@@ -206,10 +258,6 @@
placeholder="Reason for rejection (required)..." placeholder="Reason for rejection (required)..."
/> />
</ConfirmDialog> </ConfirmDialog>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
</div> </div>
</template> </template>
@@ -225,6 +273,7 @@ import StatusBadge from '../components/StatusBadge.vue'
import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue' import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue'
import SeparationOfDutiesBanner from '../components/SeparationOfDutiesBanner.vue' import SeparationOfDutiesBanner from '../components/SeparationOfDutiesBanner.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue' import ConfirmDialog from '../components/ConfirmDialog.vue'
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
import type { BatchDetailResponse, DraftObservation } from '../types' import type { BatchDetailResponse, DraftObservation } from '../types'
const props = defineProps<{ const props = defineProps<{
@@ -241,6 +290,7 @@ const errorMessage = ref('')
const showRejectDialog = ref(false) const showRejectDialog = ref(false)
const rejectionReason = ref('') const rejectionReason = ref('')
const sodBlocked = ref(false) const sodBlocked = ref(false)
const decision = ref<'pass' | 'reject' | null>(null)
const fieldChecks = ref<Record<string, boolean>>({}) const fieldChecks = ref<Record<string, boolean>>({})
const observations = ref<DraftObservation[]>([]) const observations = ref<DraftObservation[]>([])
@@ -280,7 +330,6 @@ watch(
observations.value = draft.observations ?? [] observations.value = draft.observations ?? []
// Build patient field list
if (draft.patient) { if (draft.patient) {
patientFields.value = [ patientFields.value = [
{ path: 'patient.fullName', label: 'Full Name', value: draft.patient.fullName ?? '' }, { path: 'patient.fullName', label: 'Full Name', value: draft.patient.fullName ?? '' },
@@ -290,7 +339,6 @@ watch(
{ path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' }, { path: 'patient.emergencyContact', label: 'Emergency Contact', value: draft.patient.emergencyContact ?? '' },
] ]
// Build allergy fields
allergyFields.value = [] allergyFields.value = []
if (showAllergies.value) { if (showAllergies.value) {
if (draft.patient.noKnownAllergies) { if (draft.patient.noKnownAllergies) {
@@ -312,7 +360,6 @@ watch(
} }
} }
// Build medication fields
medicationFields.value = [] medicationFields.value = []
if (showMedications.value) { if (showMedications.value) {
if (draft.patient.noActiveMedications) { if (draft.patient.noActiveMedications) {
@@ -335,7 +382,6 @@ watch(
} }
} }
// Build encounter field list
if (draft.encounter) { if (draft.encounter) {
encounterFields.value = [ encounterFields.value = [
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' }, { path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
@@ -351,7 +397,6 @@ watch(
} }
} }
// Initialize all checks to false
fieldChecks.value = {} fieldChecks.value = {}
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false }) patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false }) allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
@@ -362,6 +407,10 @@ watch(
{ immediate: true } { immediate: true }
) )
watch(sodBlocked, (blocked) => {
if (blocked) decision.value = null
})
const totalFields = computed( const totalFields = computed(
() => patientFields.value.length + allergyFields.value.length + medicationFields.value.length () => patientFields.value.length + allergyFields.value.length + medicationFields.value.length
+ encounterFields.value.length + observations.value.length + encounterFields.value.length + observations.value.length
@@ -376,7 +425,7 @@ function toggleCheck(path: string, value?: boolean) {
} }
async function approveVerification() { async function approveVerification() {
if (sodBlocked.value) return if (sodBlocked.value || decision.value !== 'pass') return
processing.value = true processing.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
@@ -397,12 +446,13 @@ async function approveVerification() {
} }
async function rejectVerification() { async function rejectVerification() {
if (sodBlocked.value) return
processing.value = true processing.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
await batchStore.rejectBatch(props.batchId, rejectionReason.value) await batchStore.rejectBatch(props.batchId, rejectionReason.value)
showRejectDialog.value = false showRejectDialog.value = false
toast.warning('Batch rejected') toast.warning('Batch returned for rework')
router.push('/verification') router.push('/verification')
} catch (e: unknown) { } catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed' const msg = e instanceof Error ? e.message : 'Rejection failed'
@@ -0,0 +1,50 @@
<template>
<!-- Queue-only: full-width list with empty/skeleton patterns from the list slot -->
<div
v-if="!hasBatch"
class="flex-1 min-h-0 overflow-auto p-4 sm:p-6"
data-testid="workstation-queue"
>
<slot name="queue" />
</div>
<!-- Work mode: optional left rail + dominant scan + form (desktop 1280px) -->
<div
v-else
class="workstation-split flex-1 min-h-0"
:class="{ 'workstation-split--no-rail': !showRail }"
data-testid="workstation-split"
>
<aside
v-if="showRail"
class="workstation-rail hidden xl:flex min-h-0"
data-testid="workstation-rail"
>
<slot name="rail" />
</aside>
<section class="workstation-scan min-h-0 min-w-0" data-testid="workstation-scan">
<p class="evidence-level evidence-level--1" data-testid="evidence-level-1">
<span class="evidence-level-mark" aria-hidden="true">1</span>
Source scan
</p>
<div class="min-h-0 flex-1 flex flex-col">
<slot name="scan" />
</div>
</section>
<section class="workstation-form min-h-0 min-w-0" data-testid="workstation-form">
<slot name="form" />
</section>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
const props = defineProps<{
/** When false, render full-width queue slot only */
hasBatch: boolean
}>()
const slots = useSlots()
const showRail = computed(() => props.hasBatch && !!slots.rail)
</script>
@@ -0,0 +1,89 @@
<template>
<div
class="flex h-full w-full min-h-0 flex-col bg-surface border-r border-line"
data-testid="workstation-queue-rail"
>
<div class="shrink-0 border-b border-line px-3 py-2.5">
<h3 class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
{{ title }}
</h3>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<SkeletonBlock
v-if="loading && batches.length === 0"
variant="row"
:rows="5"
class="p-3"
/>
<EmptyState
v-else-if="batches.length === 0"
title="No batches in queue"
description="Assigned or pending items appear here."
class="!py-6"
/>
<button
v-for="batch in batches"
:key="batch.id"
type="button"
class="w-full border-b border-line border-l-2 px-3 py-2.5 text-left transition-colors hover:bg-primary-50"
:class="
batch.id === selectedId
? 'border-l-primary-600 bg-primary-50'
: 'border-l-transparent'
"
:aria-current="batch.id === selectedId ? 'true' : undefined"
@click="$emit('select', batch.id)"
>
<div class="font-mono text-xs font-medium text-ink-strong">
{{ batch.id.substring(0, 8) }}
</div>
<div class="mt-0.5 text-xs text-ink-secondary truncate">
{{ formatBatchType(batch.batchType) }}
</div>
<div class="mt-1.5">
<StatusBadge :status="batch.status" />
</div>
</button>
</div>
<div class="shrink-0 border-t border-line p-2">
<button
type="button"
class="w-full rounded-input px-2 py-1.5 text-xs font-medium text-primary-600 hover:bg-primary-50"
@click="$emit('back')"
>
Full queue
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { BatchDetailResponse } from '../types'
import StatusBadge from './StatusBadge.vue'
import EmptyState from './EmptyState.vue'
import SkeletonBlock from './SkeletonBlock.vue'
withDefaults(
defineProps<{
title?: string
batches: BatchDetailResponse[]
selectedId?: string
loading?: boolean
}>(),
{
title: 'Queue',
loading: false,
}
)
defineEmits<{
(e: 'select', id: string): void
(e: 'back'): void
}>()
function formatBatchType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
</script>
@@ -6,6 +6,7 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
const batchStore = useBatchStore() const batchStore = useBatchStore()
const documentUrl = ref<string | null>(null) const documentUrl = ref<string | null>(null)
const documentError = ref<string | null>(null) const documentError = ref<string | null>(null)
const documentLoading = ref(false)
let currentBlobUrl: string | null = null let currentBlobUrl: string | null = null
@@ -21,8 +22,12 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
documentUrl.value = null documentUrl.value = null
documentError.value = null documentError.value = null
if (!batchId.value) return if (!batchId.value) {
documentLoading.value = false
return
}
documentLoading.value = true
try { try {
await batchStore.getBatch(batchId.value) await batchStore.getBatch(batchId.value)
const blob = await getBlob(`digitization-batches/${batchId.value}/document`) const blob = await getBlob(`digitization-batches/${batchId.value}/document`)
@@ -33,6 +38,8 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
documentUrl.value = currentBlobUrl documentUrl.value = currentBlobUrl
} catch (e: unknown) { } catch (e: unknown) {
documentError.value = e instanceof Error ? e.message : 'Failed to load document' documentError.value = e instanceof Error ? e.message : 'Failed to load document'
} finally {
documentLoading.value = false
} }
} }
@@ -45,6 +52,7 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
revokeBlobUrl() revokeBlobUrl()
documentUrl.value = null documentUrl.value = null
documentError.value = null documentError.value = null
documentLoading.value = false
} }
}, },
{ immediate: true } { immediate: true }
@@ -52,5 +60,5 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
onUnmounted(revokeBlobUrl) onUnmounted(revokeBlobUrl)
return { documentUrl, documentError, refreshUrl } return { documentUrl, documentError, documentLoading, refreshUrl }
} }
+55 -280
View File
@@ -2,300 +2,95 @@
<div class="h-full min-h-0 flex flex-col"> <div class="h-full min-h-0 flex flex-col">
<AppHeader title="Clinical Approval"> <AppHeader title="Clinical Approval">
<template #subtitle> <template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500"> <span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}... Batch: {{ currentBatch.id.substring(0, 8) }}...
| Type: {{ formatBatchType(currentBatch.batchType) }} | Type: {{ formatBatchType(currentBatch.batchType) }}
</span> </span>
</template> </template>
</AppHeader> </AppHeader>
<!-- Queue view (no batch selected) --> <WorkstationLayout :has-batch="!!batchId">
<div v-if="!batchId" class="flex-1 p-4 sm:p-6"> <template #queue>
<h2 class="text-xl font-semibold mb-4">Clinical Approval Queue</h2> <h2 class="text-xl font-semibold mb-4 text-ink-strong">Clinical Approval Queue</h2>
<p class="text-sm text-gray-500 mb-4"> <p class="text-sm text-ink-secondary mb-4">
Verified batches awaiting clinical sign-off before promotion to live tables. Verified batches awaiting clinical sign-off before promotion to live tables.
</p> </p>
<BatchList <BatchList
:batches="batchStore.batches" :batches="batchStore.batches"
:loading="batchStore.loading" :loading="batchStore.loading"
:error="batchStore.error" :error="batchStore.error"
empty-title="No batches are waiting for clinical approval." empty-title="No batches are waiting for clinical approval."
empty-description="Verified batches appear here after verification is complete." empty-description="Verified batches appear here after verification is complete."
@select="openBatch" @select="openBatch"
@retry="loadQueue" @retry="loadQueue"
/>
</div>
<!-- Split pane (batch selected) -->
<div v-else class="flex-1 split-pane">
<ScanViewer
v-if="documentUrl"
:url="documentUrl"
/>
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg">
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p>
</div>
<!-- Approval panel -->
<div class="h-full overflow-y-auto p-4 space-y-6">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Clinical Review</h2>
<StatusBadge :status="currentBatch?.status ?? 'AWAITING_CLINICAL_APPROVAL'" />
</div>
<!-- Supersession info -->
<div
v-if="currentBatch?.supersedesBatchId"
class="bg-blue-50 border border-blue-200 rounded-md p-4 text-sm"
>
<p class="font-medium text-blue-800">Correction Batch</p>
<p class="text-blue-700 mt-1">
This batch corrects and will supersede batch
<span class="font-mono">{{ currentBatch.supersedesBatchId.substring(0, 8) }}...</span>
</p>
<button
v-if="currentBatch.patientId"
@click="router.push(`/patients/${currentBatch.patientId}/history`)"
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
>
View patient history
</button>
</div>
<!-- Patient summary (read-only) -->
<fieldset v-if="draft?.patient" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-500">Full Name:</span> <span class="font-medium ml-1">{{ draft.patient.fullName }}</span></div>
<div><span class="text-gray-500">DOB:</span> <span class="font-medium ml-1">{{ draft.patient.dateOfBirth ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Sex:</span> <span class="font-medium ml-1">{{ draft.patient.sex ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Blood Type:</span> <span class="font-medium ml-1">{{ draft.patient.bloodType ?? 'N/A' }}</span></div>
</div>
</fieldset>
<!-- Encounter context (read-only) -->
<fieldset v-if="draft?.encounter" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Encounter Context</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-500">Admission:</span> <span class="font-medium ml-1">{{ draft.encounter.admissionDate ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Department:</span> <span class="font-medium ml-1">{{ draft.encounter.department ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Room/Bed:</span> <span class="font-medium ml-1">{{ draft.encounter.roomBed ?? 'N/A' }}</span></div>
<div><span class="text-gray-500">Reason:</span> <span class="font-medium ml-1">{{ draft.encounter.admissionReason ?? 'N/A' }}</span></div>
</div>
</fieldset>
<!-- Observations (read-only) -->
<fieldset class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">
Observations ({{ draft?.observations?.length ?? 0 }})
</legend>
<div class="space-y-2">
<ObservationRow
v-for="obs in (draft?.observations ?? [])"
:key="obs.id"
:observation="obs"
:readonly="true"
/>
<p v-if="!draft?.observations?.length" class="text-sm text-gray-500">
No observations recorded.
</p>
</div>
</fieldset>
<!-- Verification info -->
<div v-if="currentBatch?.verifiedByUserId" class="bg-blue-50 border border-blue-200 rounded-md p-4 text-sm">
<p class="font-medium text-blue-800">Verified by: {{ currentBatch.verifiedByUserId.substring(0, 8) }}...</p>
</div>
<!-- Enable retroactive alerts toggle -->
<div class="bg-gray-50 rounded-md p-4">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="enableRetroactiveAlerts"
type="checkbox"
class="w-4 h-4 text-clinical-safe rounded"
/>
<div>
<span class="text-sm font-medium">Enable retroactive alerts</span>
<p class="text-xs text-gray-500 mt-0.5">
If checked, backfill observations will be evaluated by the alert engine.
</p>
</div>
</label>
</div>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
<button
@click="showApproveConfirm = true"
class="btn-primary"
:disabled="processing"
>
{{ processing ? 'Promoting...' : 'Approve & Promote' }}
</button>
<button
@click="showRejectDialog = true"
class="btn-danger"
:disabled="processing"
>
Reject
</button>
<button
@click="router.push('/approval')"
class="px-4 py-2 text-gray-600 hover:text-gray-800"
:disabled="processing"
>
Back to Queue
</button>
</div>
<!-- Promotion result banner -->
<div v-if="promotionResult" class="bg-green-50 border border-green-200 rounded-md p-4 text-sm">
<p class="font-medium text-green-800">Promotion successful</p>
<p class="text-green-700 mt-1">Patient MRN: {{ promotionResult.mrn }}</p>
<p class="text-green-700">Encounter: {{ promotionResult.encounterId?.substring(0, 8) }}...</p>
<p class="text-green-700">Observations promoted: {{ promotionResult.observationIds?.length }}</p>
<div class="flex gap-4 mt-3 pt-3 border-t border-green-200">
<button
@click="createCorrection"
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
>
Create Correction
</button>
<button
v-if="currentBatch?.patientId"
@click="router.push(`/patients/${currentBatch!.patientId}/history`)"
class="text-sm text-gray-600 hover:text-gray-800"
>
View Patient History
</button>
</div>
</div>
<!-- Deferred banner -->
<div v-if="deferred" class="bg-yellow-50 border border-yellow-200 rounded-md p-4 text-sm">
<p class="font-medium text-yellow-800">Approved - Promotion Deferred</p>
<p class="text-yellow-700 mt-1">
Promotion will be retried automatically due to a temporary infrastructure issue.
</p>
</div>
<ConfirmDialog
:open="showApproveConfirm"
title="Approve & Promote"
body="Approval will promote the verified records into live clinical tables."
confirm-label="Approve & Promote"
variant="primary"
:confirm-disabled="processing"
@confirm="confirmApprove"
@cancel="showApproveConfirm = false"
/> />
</template>
<ConfirmDialog <template #rail>
:open="showRejectDialog" <WorkstationQueueRail
title="Reject Batch" title="Approval queue"
body="This returns the batch for rework. A reason is required." :batches="batchStore.batches"
confirm-label="Confirm Rejection" :selected-id="batchId"
variant="danger" :loading="batchStore.loading"
:confirm-disabled="!rejectionReason.trim() || processing" @select="openBatch"
@confirm="reject" @back="router.push('/approval')"
@cancel="closeRejectDialog" />
> </template>
<textarea
v-model="rejectionReason"
class="form-input"
rows="4"
placeholder="Reason for rejection (required)..."
/>
</ConfirmDialog>
<div v-if="errorMessage" class="text-clinical-danger text-sm"> <template #scan>
{{ errorMessage }} <ScanViewer
</div> :url="documentUrl"
</div> :loading="documentLoading"
</div> :error="documentError"
@retry="refreshUrl"
/>
</template>
<template #form>
<ApprovalForm
v-if="batchId"
:batch="currentBatch"
:batch-id="batchId"
:draft="draft"
@back="router.push('/approval')"
@view-history="(patientId) => router.push(`/patients/${patientId}/history`)"
@create-correction="createCorrection"
@rejected="router.push('/approval')"
/>
</template>
</WorkstationLayout>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches' import { useBatchStore } from '../stores/batches'
import { usePresignedUrl } from '../composables/usePresignedUrl' import { usePresignedUrl } from '../composables/usePresignedUrl'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue' import ScanViewer from '../components/ScanViewer.vue'
import BatchList from '../components/BatchList.vue' import BatchList from '../components/BatchList.vue'
import ObservationRow from '../components/ObservationRow.vue' import WorkstationLayout from '../components/WorkstationLayout.vue'
import StatusBadge from '../components/StatusBadge.vue' import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue' import ApprovalForm from '../components/ApprovalForm.vue'
const props = defineProps<{ batchId?: string }>() const props = defineProps<{ batchId?: string }>()
const batchStore = useBatchStore() const batchStore = useBatchStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const toast = useToast()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const draft = computed(() => batchStore.currentDraft) const draft = computed(() => batchStore.currentDraft)
const { documentUrl, documentError } = usePresignedUrl(batchId) const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
const processing = ref(false)
const errorMessage = ref('')
const enableRetroactiveAlerts = ref(false)
const showApproveConfirm = ref(false)
const showRejectDialog = ref(false)
const rejectionReason = ref('')
const promotionResult = ref<{ mrn: string; encounterId: string; observationIds: string[] } | null>(null)
const deferred = ref(false)
function openBatch(id: string) { function openBatch(id: string) {
router.push(`/approval/${id}`) router.push(`/approval/${id}`)
} }
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())
}
function closeRejectDialog() {
showRejectDialog.value = false
}
async function confirmApprove() {
showApproveConfirm.value = false
await approve()
}
async function approve() {
if (!batchId.value) return
processing.value = true
errorMessage.value = ''
promotionResult.value = null
deferred.value = false
try {
const response = await batchStore.approveBatch(batchId.value, enableRetroactiveAlerts.value)
if (response?.status === 202) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else if (response?.data) {
promotionResult.value = response.data
toast.success('Batch approved and promoted successfully')
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Approval failed'
if (msg.includes('PROMOTION_DEFERRED')) {
deferred.value = true
toast.info('Approved. Promotion will be retried automatically.')
} else {
errorMessage.value = msg
toast.error(msg)
}
} finally {
processing.value = false
}
} }
function createCorrection() { function createCorrection() {
@@ -310,24 +105,6 @@ function createCorrection() {
}) })
} }
async function reject() {
if (!batchId.value) return
processing.value = true
errorMessage.value = ''
try {
await batchStore.rejectBatch(batchId.value, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
router.push('/approval')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed'
errorMessage.value = msg
toast.error(msg)
} finally {
processing.value = false
}
}
watch( watch(
batchId, batchId,
async (id) => { async (id) => {
@@ -340,9 +117,7 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
if (!batchId.value) { await loadQueue()
await loadQueue()
}
}) })
async function loadQueue() { async function loadQueue() {
+57 -32
View File
@@ -2,42 +2,57 @@
<div class="h-full min-h-0 flex flex-col"> <div class="h-full min-h-0 flex flex-col">
<AppHeader title="Data Entry"> <AppHeader title="Data Entry">
<template #subtitle> <template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500"> <span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}... Batch: {{ currentBatch.id.substring(0, 8) }}...
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }} | Type: {{ currentBatch.batchType.replace(/_/g, ' ') }}
</span> </span>
</template> </template>
</AppHeader> </AppHeader>
<!-- Queue view (no batch selected) --> <WorkstationLayout :has-batch="!!batchId">
<div v-if="!batchId" class="flex-1 p-4 sm:p-6"> <template #queue>
<h2 class="text-xl font-semibold mb-4">Data Entry Queue</h2> <h2 class="text-xl font-semibold mb-4 text-ink-strong">Data Entry Queue</h2>
<BatchList <BatchList
:batches="batchStore.batches" :batches="batchStore.batches"
:loading="batchStore.loading" :loading="batchStore.loading"
:error="batchStore.error" :error="batchStore.error"
empty-title="No batches are assigned for data entry." empty-title="No batches are assigned for data entry."
empty-description="Assigned batches appear here after intake assigns them to you." empty-description="Assigned batches appear here after intake assigns them to you."
@select="openBatch" @select="openBatch"
@retry="loadQueue" @retry="loadQueue"
/> />
</div> </template>
<!-- Split pane (batch selected) --> <template #rail>
<div v-else class="flex-1 split-pane"> <WorkstationQueueRail
<ScanViewer title="Entry queue"
v-if="documentUrl" :batches="batchStore.batches"
:url="documentUrl" :selected-id="batchId"
/> :loading="batchStore.loading"
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> @select="openBatch"
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p> @back="router.push('/entry')"
</div> />
</template>
<EntryForm <template #scan>
:batch="currentBatch" <ScanViewer
:batch-id="batchId" :url="documentUrl"
/> :loading="documentLoading"
</div> :error="documentError"
@retry="refreshUrl"
/>
</template>
<template #form>
<EntryForm
v-if="batchId"
:batch="currentBatch"
:batch-id="batchId"
:next-batch-id="nextBatchId"
@open-next="openBatch"
/>
</template>
</WorkstationLayout>
</div> </div>
</template> </template>
@@ -51,6 +66,8 @@ import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue' import ScanViewer from '../components/ScanViewer.vue'
import EntryForm from '../components/EntryForm.vue' import EntryForm from '../components/EntryForm.vue'
import BatchList from '../components/BatchList.vue' import BatchList from '../components/BatchList.vue'
import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
const props = defineProps<{ batchId?: string }>() const props = defineProps<{ batchId?: string }>()
@@ -61,7 +78,17 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl, documentError } = usePresignedUrl(batchId) const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
/** Next item in the loaded entry queue after the open batch (optional action-bar nav). */
const nextBatchId = computed(() => {
const id = batchId.value
if (!id) return undefined
const list = batchStore.batches
const idx = list.findIndex((b) => b.id === id)
if (idx < 0 || idx >= list.length - 1) return undefined
return list[idx + 1]?.id
})
async function openBatch(id: string) { async function openBatch(id: string) {
router.push(`/entry/${id}`) router.push(`/entry/${id}`)
@@ -86,8 +113,6 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
if (!batchId.value) { await loadQueue()
await loadQueue()
}
}) })
</script> </script>
@@ -2,45 +2,58 @@
<div class="h-full min-h-0 flex flex-col"> <div class="h-full min-h-0 flex flex-col">
<AppHeader title="Verification"> <AppHeader title="Verification">
<template #subtitle> <template #subtitle>
<span v-if="currentBatch" class="text-sm text-gray-500"> <span v-if="currentBatch" class="text-sm text-ink-secondary">
Batch: {{ currentBatch.id.substring(0, 8) }}... Batch: {{ currentBatch.id.substring(0, 8) }}...
| Entered by: {{ currentBatch.enteredByUserId?.substring(0, 8) }}... | Entered by: {{ currentBatch.enteredByUserId?.substring(0, 8) }}...
</span> </span>
</template> </template>
</AppHeader> </AppHeader>
<!-- Queue view (no batch selected) --> <WorkstationLayout :has-batch="!!batchId">
<div v-if="!batchId" class="flex-1 p-4 sm:p-6"> <template #queue>
<h2 class="text-xl font-semibold mb-4">Verification Queue</h2> <h2 class="text-xl font-semibold mb-4 text-ink-strong">Verification Queue</h2>
<p class="text-sm text-gray-500 mb-4"> <p class="text-sm text-ink-secondary mb-4">
Batches pending verification, sorted by submission time (oldest first). Batches pending verification, sorted by submission time (oldest first).
</p> </p>
<BatchList <BatchList
:batches="batchStore.batches" :batches="batchStore.batches"
:loading="batchStore.loading" :loading="batchStore.loading"
:error="batchStore.error" :error="batchStore.error"
empty-title="No batches are waiting for verification." empty-title="No batches are waiting for verification."
empty-description="New batches appear here after data entry is submitted." empty-description="New batches appear here after data entry is submitted."
@select="openBatch" @select="openBatch"
@retry="loadQueue" @retry="loadQueue"
/> />
</div> </template>
<!-- Split pane (batch selected) --> <template #rail>
<div v-else class="flex-1 split-pane"> <WorkstationQueueRail
<ScanViewer title="Verification queue"
v-if="documentUrl" :batches="batchStore.batches"
:url="documentUrl" :selected-id="batchId"
/> :loading="batchStore.loading"
<div v-else class="flex items-center justify-center bg-gray-100 rounded-lg"> @select="openBatch"
<p class="text-gray-500">{{ documentError ?? 'Loading document...' }}</p> @back="router.push('/verification')"
</div> />
</template>
<VerificationForm <template #scan>
:batch="currentBatch" <ScanViewer
:batch-id="batchId" :url="documentUrl"
/> :loading="documentLoading"
</div> :error="documentError"
@retry="refreshUrl"
/>
</template>
<template #form>
<VerificationForm
v-if="batchId"
:batch="currentBatch"
:batch-id="batchId"
/>
</template>
</WorkstationLayout>
</div> </div>
</template> </template>
@@ -53,6 +66,8 @@ import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue' import ScanViewer from '../components/ScanViewer.vue'
import VerificationForm from '../components/VerificationForm.vue' import VerificationForm from '../components/VerificationForm.vue'
import BatchList from '../components/BatchList.vue' import BatchList from '../components/BatchList.vue'
import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
const props = defineProps<{ batchId?: string }>() const props = defineProps<{ batchId?: string }>()
@@ -62,7 +77,7 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined)) const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch) const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl, documentError } = usePresignedUrl(batchId) const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
function openBatch(id: string) { function openBatch(id: string) {
router.push(`/verification/${id}`) router.push(`/verification/${id}`)
@@ -87,8 +102,6 @@ watch(
) )
onMounted(async () => { onMounted(async () => {
if (!batchId.value) { await loadQueue()
await loadQueue()
}
}) })
</script> </script>
+1
View File
@@ -56,6 +56,7 @@ export default {
boxShadow: { boxShadow: {
card: '0 1px 2px rgba(16, 24, 40, 0.04)', card: '0 1px 2px rgba(16, 24, 40, 0.04)',
dialog: '0 8px 24px rgba(16, 24, 40, 0.12)', dialog: '0 8px 24px rgba(16, 24, 40, 0.12)',
page: '0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08)',
}, },
spacing: { spacing: {
18: '4.5rem', 18: '4.5rem',