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 submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
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', () => {
@@ -218,15 +218,23 @@ describe('VerificationForm', () => {
})
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 () => {
const { wrapper } = mountWithDraft()
await wrapper.vm.$nextTick()
await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
expect(approveBtn!.element.disabled).toBe(true)
const approveBtn = wrapper.find('[data-testid="verify-pass"]')
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()
await wrapper.vm.$nextTick()
@@ -234,13 +242,14 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.vm.$nextTick()
await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
expect(approveBtn!.element.disabled).toBe(false)
const approveBtn = wrapper.find('[data-testid="verify-pass"]')
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()
await wrapper.vm.$nextTick()
@@ -250,10 +259,9 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.vm.$nextTick()
await selectPass(wrapper)
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
await approveBtn!.trigger('click')
await wrapper.find('[data-testid="verify-pass"]').trigger('click')
await flushPromises()
expect(store.verifyBatch).toHaveBeenCalledWith(
@@ -267,41 +275,52 @@ describe('VerificationForm', () => {
})
describe('reject flow', () => {
it('shows reject dialog when reject button is clicked', async () => {
const wrapper = mountForm()
async function selectReject(wrapper: ReturnType<typeof 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')
await rejectBtn!.trigger('click')
it('shows Return for Rework and opens confirm dialog', async () => {
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()
expect(wrapper.text()).toContain('Reject Batch')
expect(wrapper.text()).toContain('Confirm Rejection')
expect(wrapper.text()).toContain('Return for Rework')
expect(wrapper.text()).toContain('This returns the batch for rework')
})
it('disables confirm button when reason is empty', async () => {
const wrapper = mountForm()
await selectReject(wrapper)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.find('[data-testid="verify-return"]').trigger('click')
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
expect(confirmBtn!.element.disabled).toBe(true)
const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
.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 () => {
const wrapper = mountForm()
await selectReject(wrapper)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.find('[data-testid="verify-return"]').trigger('click')
await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea')
await textarea.setValue('Temperature value appears incorrect')
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
expect(confirmBtn!.element.disabled).toBe(false)
const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
.filter((b) => b.text() === 'Return for Rework')
expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(false)
})
it('calls rejectBatch on confirm', async () => {
@@ -310,16 +329,17 @@ describe('VerificationForm', () => {
const store = useBatchStore()
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await selectReject(wrapper)
await wrapper.find('[data-testid="verify-return"]').trigger('click')
await wrapper.vm.$nextTick()
const textarea = wrapper.find('textarea')
await textarea.setValue('Value incorrect')
await wrapper.vm.$nextTick()
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
await confirmBtn!.trigger('click')
const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button')
.filter((b) => b.text() === 'Return for Rework')
await dialogConfirms[0].trigger('click')
await flushPromises()
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
@@ -327,18 +347,18 @@ describe('VerificationForm', () => {
it('closes reject dialog on cancel', async () => {
const wrapper = mountForm()
await selectReject(wrapper)
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
await rejectBtn!.trigger('click')
await wrapper.find('[data-testid="verify-return"]').trigger('click')
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')
await cancelBtn!.trigger('click')
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) {
await cb.setValue(true)
}
await wrapper.find('input[type="radio"][value="pass"]').setValue(true)
await wrapper.vm.$nextTick()
const approveBtn = wrapper
.findAll('button')
.find((b) => b.text().includes('Approve - Verified'))
expect(approveBtn!.element.disabled).toBe(false)
const approveBtn = wrapper.find('[data-testid="verify-pass"]')
expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false)
})
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.')
const checkboxes = wrapper.findAll('input[type="checkbox"]')
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.vm.$nextTick()
const passRadio = wrapper.find('input[type="radio"][value="pass"]')
expect((passRadio.element as HTMLInputElement).disabled).toBe(true)
const rejectRadio = wrapper.find('input[type="radio"][value="reject"]')
expect((rejectRadio.element as HTMLInputElement).disabled).toBe(true)
const approveBtn = wrapper
.findAll('button')
.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)
expect(wrapper.find('[data-testid="verify-pass"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="verify-return"]').exists()).toBe(false)
})
})
@@ -476,10 +489,10 @@ describe('VerificationForm', () => {
for (const cb of checkboxes) {
await cb.setValue(true)
}
await wrapper.find('input[type="radio"][value="pass"]').setValue(true)
await wrapper.vm.$nextTick()
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
await approveBtn!.trigger('click')
await wrapper.find('[data-testid="verify-pass"]').trigger('click')
await flushPromises()
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
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 {
@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>
+345 -267
View File
@@ -1,263 +1,313 @@
<template>
<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">Data Entry</h2>
<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 class="h-full min-h-0 flex flex-col" data-testid="entry-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>
<label class="flex items-center gap-2 text-xs text-gray-500">
Full Name
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.fullName')" :level="fieldConfidenceLevel('patient.fullName')" />
</label>
<input
v-model="patient.fullName"
@blur="savePatient"
type="text"
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.fullName')]"
/>
</div>
<div>
<label class="flex items-center gap-2 text-xs text-gray-500">
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')]"
<p class="evidence-level evidence-level--2 mb-1">
<span class="evidence-level-mark" aria-hidden="true">2</span>
Structured draft
</p>
<h2 class="text-base font-semibold text-ink-strong">Data Entry</h2>
<p
class="mt-1 text-xs tabular-nums"
:class="{
'text-ink-secondary': saveState === 'saved' || saveState === 'idle',
'text-ink-secondary animate-pulse': saveState === 'saving',
'text-clinical-danger font-medium': saveState === 'failed',
}"
data-testid="entry-save-status"
>
<option value="">Select...</option>
<option value="male">Male</option>
<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"
/>
{{ saveStatusLabel }}
</p>
</div>
<StatusBadge :status="batch?.status" />
</div>
</fieldset>
<!-- Allergies section (ALLERGY_UPDATE or MIXED) -->
<fieldset v-if="showAllergies" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend>
<label class="flex items-center gap-2 mb-3 cursor-pointer">
<input
v-model="patient.noKnownAllergies"
@change="onNoKnownAllergiesChange"
type="checkbox"
class="w-4 h-4 text-clinical-safe rounded"
/>
<span class="text-sm">No known allergies (NKA)</span>
</label>
<div v-if="!patient.noKnownAllergies" class="space-y-2">
<div
v-for="(_allergy, idx) in allergies"
:key="idx"
class="flex items-center gap-2"
>
<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="workstation-form-section">
<legend class="workstation-form-legend">Patient Demographics</legend>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="workstation-field-label">
Full Name
<OcrConfidenceBadge :label="fieldConfidenceLabel('patient.fullName')" :level="fieldConfidenceLevel('patient.fullName')" />
</label>
<input
v-model="patient.fullName"
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
v-model="allergies[idx]"
@blur="saveAllergies"
type="text"
class="form-input text-sm flex-1"
placeholder="Allergy (e.g. Penicillin)"
v-model="patient.noKnownAllergies"
type="checkbox"
class="w-4 h-4 text-clinical-safe rounded"
@change="onNoKnownAllergiesChange"
/>
<button
@click="removeAllergy(idx)"
class="text-clinical-danger hover:text-red-800 text-sm"
<span class="text-sm">No known allergies (NKA)</span>
</label>
<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>
</div>
<button @click="addAllergy" class="text-sm text-primary-600 hover:text-primary-800">
+ Add Allergy
</button>
</div>
</fieldset>
</fieldset>
<!-- Medications section (MEDICATION_LIST or MIXED) -->
<fieldset v-if="showMedications" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Medications</legend>
<label class="flex items-center gap-2 mb-3 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"
>
<!-- Medications section (MEDICATION_LIST or MIXED) -->
<fieldset v-if="showMedications" class="workstation-form-section">
<legend class="workstation-form-legend">Medications</legend>
<label class="flex items-center gap-2 mb-2 cursor-pointer">
<input
v-model="medications[idx]"
@blur="saveMedications"
type="text"
class="form-input text-sm flex-1"
placeholder="Medication (e.g. Metoprolol 50mg BID)"
v-model="patient.noActiveMedications"
type="checkbox"
class="w-4 h-4 text-clinical-safe rounded"
@change="onNoActiveMedicationsChange"
/>
<button
@click="removeMedication(idx)"
class="text-clinical-danger hover:text-red-800 text-sm"
<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"
>
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>
</div>
<button @click="addMedication" class="text-sm text-primary-600 hover:text-primary-800">
+ Add Medication
</button>
</div>
</fieldset>
</fieldset>
<!-- Encounter section -->
<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>
<label class="flex items-center gap-2 text-xs text-gray-500">
Admission Date
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionDate')" :level="fieldConfidenceLevel('encounter.admissionDate')" />
</label>
<input
v-model="encounter.admissionDate"
@blur="saveEncounter"
type="datetime-local"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.admissionDate')]"
/>
<!-- Encounter section -->
<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>
<label class="workstation-field-label">
Admission Date
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.admissionDate')" :level="fieldConfidenceLevel('encounter.admissionDate')" />
</label>
<input
v-model="encounter.admissionDate"
type="datetime-local"
:class="['form-input', 'text-sm', 'py-1.5', 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>
<label class="flex items-center gap-2 text-xs text-gray-500">
Department
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.department')" :level="fieldConfidenceLevel('encounter.department')" />
</label>
<select
v-model="encounter.department"
@change="saveEncounter"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.department')]"
</fieldset>
<!-- Observations section -->
<fieldset class="workstation-form-section">
<legend class="workstation-form-legend">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 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>
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
</select>
{{ submitting ? 'Submitting...' : 'Submit for Verification' }}
</button>
</div>
<div>
<label class="flex items-center gap-2 text-xs text-gray-500">
Room / Bed
<OcrConfidenceBadge :label="fieldConfidenceLabel('encounter.roomBed')" :level="fieldConfidenceLevel('encounter.roomBed')" />
</label>
<input
v-model="encounter.roomBed"
@blur="saveEncounter"
type="text"
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.roomBed')]"
/>
</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
</template>
<template v-if="nextBatchId" #right>
<button
type="button"
class="btn-secondary"
:disabled="submitting"
data-testid="entry-next-batch"
@click="emit('open-next', nextBatchId)"
>
Next
</button>
</div>
</fieldset>
<!-- 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>
</template>
</WorkstationActionBar>
</div>
</template>
@@ -269,17 +319,35 @@ import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
import ObservationRow from '../components/ObservationRow.vue'
import StatusBadge from '../components/StatusBadge.vue'
import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue'
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
import type { BatchDetailResponse, DraftObservation } from '../types'
const props = defineProps<{
batch: BatchDetailResponse | null
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 toast = useToast()
const submitting = ref(false)
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 departments = [
@@ -346,7 +414,6 @@ const encounter = reactive({
const observations = ref<DraftObservation[]>([])
// Load draft data when batch changes
watch(
() => batchStore.currentDraft,
(draft) => {
@@ -388,18 +455,29 @@ function removeAllergy(idx: number) {
saveAllergies()
}
async function saveAllergies() {
async function withSaveFeedback(fn: () => Promise<void>) {
saveState.value = 'saving'
errorMessage.value = ''
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, {
...patient,
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() {
@@ -419,17 +497,12 @@ function removeMedication(idx: number) {
}
async function saveMedications() {
try {
await withSaveFeedback(async () => {
await batchStore.saveDraftPatient(props.batchId, {
...patient,
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() {
@@ -440,28 +513,33 @@ function onNoActiveMedicationsChange() {
}
async function savePatient() {
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()),
})
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() {
try {
await withSaveFeedback(async () => {
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
toast.error(msg)
})
}
async function saveDraft() {
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
}
}
@@ -502,4 +580,4 @@ async function submitForVerification() {
submitting.value = false
}
}
</script>
</script>
@@ -1,67 +1,160 @@
<template>
<div class="h-full flex flex-col bg-gray-900 rounded-lg overflow-hidden">
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2 p-4 bg-gray-800 text-white text-sm">
<button @click="zoomIn" class="px-2 py-1 hover:bg-gray-700 rounded" title="Zoom in">
<div
class="h-full flex flex-col bg-canvas rounded-card border border-line overflow-hidden"
data-testid="scan-viewer"
>
<!-- 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 @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 @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
</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
</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>
<!-- Document area -->
<!-- Document surface -->
<div
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"
@mousemove="pan"
@mouseup="stopPan"
@mouseleave="stopPan"
@wheel.prevent="onWheel"
>
<div
:style="{
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
transformOrigin: 'top left',
transition: isPanning ? 'none' : 'transform 0.2s',
}"
>
<!-- 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 v-if="loading" class="flex h-full items-center justify-center p-8">
<SkeletonBlock
variant="block"
height="min(70vh, 640px)"
width="min(100%, 480px)"
class="max-w-full shadow-page bg-surface"
/>
</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>
</template>
<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<{
url: string
const props = withDefaults(
defineProps<{
url?: string | null
loading?: boolean
error?: string | null
}>(),
{
url: null,
loading: false,
error: null,
}
)
defineEmits<{
(e: 'retry'): void
}>()
const isPdf = computed(() => {
if (!props.url) return false
const lower = props.url.toLowerCase()
return lower.includes('.pdf') || lower.includes('application/pdf')
})
@@ -73,23 +166,44 @@ const panY = ref(0)
const isPanning = ref(false)
const lastX = 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 zoomOut() { scale.value = Math.max(scale.value - 0.25, 0.25) }
function zoomIn() {
scale.value = Math.min(scale.value + 0.25, 5)
}
function zoomOut() {
scale.value = Math.max(scale.value - 0.25, 0.25)
}
function resetZoom() {
scale.value = 1
panX.value = 0
panY.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) {
if (!props.url || props.loading || props.error) return
if (e.deltaY < 0) zoomIn()
else zoomOut()
}
function startPan(e: MouseEvent) {
if (!props.url || props.loading || props.error) return
if ((e.target as HTMLElement)?.closest('button')) return
isPanning.value = true
lastX.value = e.clientX
lastY.value = e.clientY
@@ -103,12 +217,40 @@ function pan(e: MouseEvent) {
lastY.value = e.clientY
}
function stopPan() { isPanning.value = false }
function stopPan() {
isPanning.value = false
}
function onImageLoad() {
// Reset view when a new image loads
function onImageLoad(e: Event) {
const img = e.target as HTMLImageElement
if (img.naturalWidth > 0) {
naturalPageWidth.value = img.naturalWidth
}
scale.value = 1
panX.value = 0
panY.value = 0
nextTick(() => fitWidth())
}
</script>
watch(
() => props.url,
(url) => {
if (!url) return
if (isPdf.value) {
naturalPageWidth.value = 800
nextTick(() => fitWidth())
}
}
)
</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>
<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">Verification Review</h2>
<StatusBadge :status="batch?.status ?? 'PENDING_VERIFICATION'" />
</div>
<div v-if="batch?.rejectionReason" class="bg-red-50 border border-red-200 rounded-md p-4">
<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)' }}
<div class="h-full min-h-0 flex flex-col" data-testid="verification-form">
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
<div class="flex items-center 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">Verification Review</h2>
</div>
<StatusBadge :status="batch?.status ?? 'PENDING_VERIFICATION'" />
</div>
</fieldset>
<!-- Allergies review (ALLERGY_UPDATE or MIXED) -->
<fieldset v-if="allergyFields.length > 0" class="border border-gray-200 rounded-md p-4">
<legend class="text-sm font-medium text-gray-700 px-2">Allergies</legend>
<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"
<div
v-if="batch?.rejectionReason"
class="rounded-input border border-[#FECDCA] bg-clinical-danger-bg p-3"
>
{{ processing ? 'Processing...' : 'Approve - Verified' }}
</button>
<button
@click="showRejectDialog = true"
class="btn-danger"
:disabled="processing || sodBlocked"
>
Reject
</button>
<p class="text-sm font-medium text-clinical-danger">Previous Rejection Reason:</p>
<p class="text-sm text-ink mt-1">{{ 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="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>
<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
:open="showRejectDialog"
title="Reject Batch"
title="Return for Rework"
body="This returns the batch for rework. A reason is required."
confirm-label="Confirm Rejection"
confirm-label="Return for Rework"
variant="danger"
:confirm-disabled="!rejectionReason.trim() || processing"
@confirm="rejectVerification"
@@ -206,10 +258,6 @@
placeholder="Reason for rejection (required)..."
/>
</ConfirmDialog>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
</div>
</template>
@@ -225,6 +273,7 @@ import StatusBadge from '../components/StatusBadge.vue'
import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue'
import SeparationOfDutiesBanner from '../components/SeparationOfDutiesBanner.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
import type { BatchDetailResponse, DraftObservation } from '../types'
const props = defineProps<{
@@ -241,6 +290,7 @@ const errorMessage = ref('')
const showRejectDialog = ref(false)
const rejectionReason = ref('')
const sodBlocked = ref(false)
const decision = ref<'pass' | 'reject' | null>(null)
const fieldChecks = ref<Record<string, boolean>>({})
const observations = ref<DraftObservation[]>([])
@@ -280,7 +330,6 @@ watch(
observations.value = draft.observations ?? []
// Build patient field list
if (draft.patient) {
patientFields.value = [
{ 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 ?? '' },
]
// Build allergy fields
allergyFields.value = []
if (showAllergies.value) {
if (draft.patient.noKnownAllergies) {
@@ -312,7 +360,6 @@ watch(
}
}
// Build medication fields
medicationFields.value = []
if (showMedications.value) {
if (draft.patient.noActiveMedications) {
@@ -335,7 +382,6 @@ watch(
}
}
// Build encounter field list
if (draft.encounter) {
encounterFields.value = [
{ path: 'encounter.admissionDate', label: 'Admission Date', value: draft.encounter.admissionDate ?? '' },
@@ -351,7 +397,6 @@ watch(
}
}
// Initialize all checks to false
fieldChecks.value = {}
patientFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
allergyFields.value.forEach((f) => { fieldChecks.value[f.path] = false })
@@ -362,6 +407,10 @@ watch(
{ immediate: true }
)
watch(sodBlocked, (blocked) => {
if (blocked) decision.value = null
})
const totalFields = computed(
() => patientFields.value.length + allergyFields.value.length + medicationFields.value.length
+ encounterFields.value.length + observations.value.length
@@ -376,7 +425,7 @@ function toggleCheck(path: string, value?: boolean) {
}
async function approveVerification() {
if (sodBlocked.value) return
if (sodBlocked.value || decision.value !== 'pass') return
processing.value = true
errorMessage.value = ''
try {
@@ -397,12 +446,13 @@ async function approveVerification() {
}
async function rejectVerification() {
if (sodBlocked.value) return
processing.value = true
errorMessage.value = ''
try {
await batchStore.rejectBatch(props.batchId, rejectionReason.value)
showRejectDialog.value = false
toast.warning('Batch rejected')
toast.warning('Batch returned for rework')
router.push('/verification')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Rejection failed'
@@ -412,4 +462,4 @@ async function rejectVerification() {
processing.value = false
}
}
</script>
</script>
@@ -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 documentUrl = ref<string | null>(null)
const documentError = ref<string | null>(null)
const documentLoading = ref(false)
let currentBlobUrl: string | null = null
@@ -21,8 +22,12 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
documentUrl.value = null
documentError.value = null
if (!batchId.value) return
if (!batchId.value) {
documentLoading.value = false
return
}
documentLoading.value = true
try {
await batchStore.getBatch(batchId.value)
const blob = await getBlob(`digitization-batches/${batchId.value}/document`)
@@ -33,6 +38,8 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
documentUrl.value = currentBlobUrl
} catch (e: unknown) {
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()
documentUrl.value = null
documentError.value = null
documentLoading.value = false
}
},
{ immediate: true }
@@ -52,5 +60,5 @@ export function usePresignedUrl(batchId: Ref<string | undefined>) {
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">
<AppHeader title="Clinical Approval">
<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) }}...
| Type: {{ formatBatchType(currentBatch.batchType) }}
</span>
</template>
</AppHeader>
<!-- Queue view (no batch selected) -->
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
<h2 class="text-xl font-semibold mb-4">Clinical Approval Queue</h2>
<p class="text-sm text-gray-500 mb-4">
Verified batches awaiting clinical sign-off before promotion to live tables.
</p>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are waiting for clinical approval."
empty-description="Verified batches appear here after verification is complete."
@select="openBatch"
@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"
<WorkstationLayout :has-batch="!!batchId">
<template #queue>
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Clinical Approval Queue</h2>
<p class="text-sm text-ink-secondary mb-4">
Verified batches awaiting clinical sign-off before promotion to live tables.
</p>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are waiting for clinical approval."
empty-description="Verified batches appear here after verification is complete."
@select="openBatch"
@retry="loadQueue"
/>
</template>
<ConfirmDialog
:open="showRejectDialog"
title="Reject Batch"
body="This returns the batch for rework. A reason is required."
confirm-label="Confirm Rejection"
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>
<template #rail>
<WorkstationQueueRail
title="Approval queue"
:batches="batchStore.batches"
:selected-id="batchId"
:loading="batchStore.loading"
@select="openBatch"
@back="router.push('/approval')"
/>
</template>
<div v-if="errorMessage" class="text-clinical-danger text-sm">
{{ errorMessage }}
</div>
</div>
</div>
<template #scan>
<ScanViewer
:url="documentUrl"
:loading="documentLoading"
: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>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useBatchStore } from '../stores/batches'
import { usePresignedUrl } from '../composables/usePresignedUrl'
import { useToast } from '../composables/useToast'
import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue'
import BatchList from '../components/BatchList.vue'
import ObservationRow from '../components/ObservationRow.vue'
import StatusBadge from '../components/StatusBadge.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
import ApprovalForm from '../components/ApprovalForm.vue'
const props = defineProps<{ batchId?: string }>()
const batchStore = useBatchStore()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const draft = computed(() => batchStore.currentDraft)
const { documentUrl, documentError } = 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)
const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
function openBatch(id: string) {
router.push(`/approval/${id}`)
}
function formatBatchType(type: string): string {
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
}
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
}
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(
batchId,
async (id) => {
@@ -340,9 +117,7 @@ watch(
)
onMounted(async () => {
if (!batchId.value) {
await loadQueue()
}
await loadQueue()
})
async function loadQueue() {
+57 -32
View File
@@ -2,42 +2,57 @@
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Data Entry">
<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) }}...
| Type: {{ currentBatch.batchType.replace(/_/g, ' ') }}
</span>
</template>
</AppHeader>
<!-- Queue view (no batch selected) -->
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
<h2 class="text-xl font-semibold mb-4">Data Entry Queue</h2>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are assigned for data entry."
empty-description="Assigned batches appear here after intake assigns them to you."
@select="openBatch"
@retry="loadQueue"
/>
</div>
<WorkstationLayout :has-batch="!!batchId">
<template #queue>
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Data Entry Queue</h2>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are assigned for data entry."
empty-description="Assigned batches appear here after intake assigns them to you."
@select="openBatch"
@retry="loadQueue"
/>
</template>
<!-- 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>
<template #rail>
<WorkstationQueueRail
title="Entry queue"
:batches="batchStore.batches"
:selected-id="batchId"
:loading="batchStore.loading"
@select="openBatch"
@back="router.push('/entry')"
/>
</template>
<EntryForm
:batch="currentBatch"
:batch-id="batchId"
/>
</div>
<template #scan>
<ScanViewer
:url="documentUrl"
:loading="documentLoading"
: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>
</template>
@@ -51,6 +66,8 @@ import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue'
import EntryForm from '../components/EntryForm.vue'
import BatchList from '../components/BatchList.vue'
import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
const props = defineProps<{ batchId?: string }>()
@@ -61,7 +78,17 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
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) {
router.push(`/entry/${id}`)
@@ -86,8 +113,6 @@ watch(
)
onMounted(async () => {
if (!batchId.value) {
await loadQueue()
}
await loadQueue()
})
</script>
@@ -2,45 +2,58 @@
<div class="h-full min-h-0 flex flex-col">
<AppHeader title="Verification">
<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) }}...
| Entered by: {{ currentBatch.enteredByUserId?.substring(0, 8) }}...
</span>
</template>
</AppHeader>
<!-- Queue view (no batch selected) -->
<div v-if="!batchId" class="flex-1 p-4 sm:p-6">
<h2 class="text-xl font-semibold mb-4">Verification Queue</h2>
<p class="text-sm text-gray-500 mb-4">
Batches pending verification, sorted by submission time (oldest first).
</p>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are waiting for verification."
empty-description="New batches appear here after data entry is submitted."
@select="openBatch"
@retry="loadQueue"
/>
</div>
<WorkstationLayout :has-batch="!!batchId">
<template #queue>
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Verification Queue</h2>
<p class="text-sm text-ink-secondary mb-4">
Batches pending verification, sorted by submission time (oldest first).
</p>
<BatchList
:batches="batchStore.batches"
:loading="batchStore.loading"
:error="batchStore.error"
empty-title="No batches are waiting for verification."
empty-description="New batches appear here after data entry is submitted."
@select="openBatch"
@retry="loadQueue"
/>
</template>
<!-- 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>
<template #rail>
<WorkstationQueueRail
title="Verification queue"
:batches="batchStore.batches"
:selected-id="batchId"
:loading="batchStore.loading"
@select="openBatch"
@back="router.push('/verification')"
/>
</template>
<VerificationForm
:batch="currentBatch"
:batch-id="batchId"
/>
</div>
<template #scan>
<ScanViewer
:url="documentUrl"
:loading="documentLoading"
:error="documentError"
@retry="refreshUrl"
/>
</template>
<template #form>
<VerificationForm
v-if="batchId"
:batch="currentBatch"
:batch-id="batchId"
/>
</template>
</WorkstationLayout>
</div>
</template>
@@ -53,6 +66,8 @@ import AppHeader from '../components/AppHeader.vue'
import ScanViewer from '../components/ScanViewer.vue'
import VerificationForm from '../components/VerificationForm.vue'
import BatchList from '../components/BatchList.vue'
import WorkstationLayout from '../components/WorkstationLayout.vue'
import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
const props = defineProps<{ batchId?: string }>()
@@ -62,7 +77,7 @@ const router = useRouter()
const batchId = computed(() => props.batchId ?? (route.params.batchId as string | undefined))
const currentBatch = computed(() => batchStore.currentBatch)
const { documentUrl, documentError } = usePresignedUrl(batchId)
const { documentUrl, documentError, documentLoading, refreshUrl } = usePresignedUrl(batchId)
function openBatch(id: string) {
router.push(`/verification/${id}`)
@@ -87,8 +102,6 @@ watch(
)
onMounted(async () => {
if (!batchId.value) {
await loadQueue()
}
await loadQueue()
})
</script>
+1
View File
@@ -56,6 +56,7 @@ export default {
boxShadow: {
card: '0 1px 2px rgba(16, 24, 40, 0.04)',
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: {
18: '4.5rem',