feature: Core Digitization Workstation (Entry, Verification, Approval)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -1,7 +1,25 @@
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
{{ saveStatusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge :status="batch?.status" />
|
||||
</div>
|
||||
|
||||
@@ -11,42 +29,42 @@
|
||||
</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">
|
||||
<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="flex items-center gap-2 text-xs text-gray-500">
|
||||
<label class="workstation-field-label">
|
||||
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')]"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.fullName')]"
|
||||
@blur="savePatient"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<label class="workstation-field-label">
|
||||
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')]"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('patient.dateOfBirth')]"
|
||||
@blur="savePatient"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<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"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('patient.sex')]"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="male">Male</option>
|
||||
@@ -55,33 +73,33 @@
|
||||
</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">
|
||||
<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="col-span-2">
|
||||
<label class="block text-xs text-gray-500">Emergency Contact</label>
|
||||
<div class="md:col-span-2">
|
||||
<label class="workstation-field-label">Emergency Contact</label>
|
||||
<input
|
||||
v-model="patient.emergencyContact"
|
||||
@blur="savePatient"
|
||||
type="text"
|
||||
class="form-input text-sm"
|
||||
class="form-input text-sm py-1.5"
|
||||
@blur="savePatient"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
<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="patient.noKnownAllergies"
|
||||
@change="onNoKnownAllergiesChange"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="onNoKnownAllergiesChange"
|
||||
/>
|
||||
<span class="text-sm">No known allergies (NKA)</span>
|
||||
</label>
|
||||
@@ -93,33 +111,34 @@
|
||||
>
|
||||
<input
|
||||
v-model="allergies[idx]"
|
||||
@blur="saveAllergies"
|
||||
type="text"
|
||||
class="form-input text-sm flex-1"
|
||||
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)"
|
||||
class="text-clinical-danger hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addAllergy" class="text-sm text-primary-600 hover:text-primary-800">
|
||||
<button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addAllergy">
|
||||
+ Add Allergy
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<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="patient.noActiveMedications"
|
||||
@change="onNoActiveMedicationsChange"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="onNoActiveMedicationsChange"
|
||||
/>
|
||||
<span class="text-sm">No active medications</span>
|
||||
</label>
|
||||
@@ -131,102 +150,103 @@
|
||||
>
|
||||
<input
|
||||
v-model="medications[idx]"
|
||||
@blur="saveMedications"
|
||||
type="text"
|
||||
class="form-input text-sm flex-1"
|
||||
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)"
|
||||
class="text-clinical-danger hover:text-red-800 text-sm"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<button @click="addMedication" class="text-sm text-primary-600 hover:text-primary-800">
|
||||
<button type="button" class="text-sm text-primary-600 hover:text-primary-800" @click="addMedication">
|
||||
+ Add Medication
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
<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="flex items-center gap-2 text-xs text-gray-500">
|
||||
<label class="workstation-field-label">
|
||||
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')]"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionDate')]"
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<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"
|
||||
:class="['form-input', 'text-sm', fieldConfidenceClass('encounter.department')]"
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option v-for="dept in departments" :key="dept" :value="dept">{{ dept }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<label class="workstation-field-label">
|
||||
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')]"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.roomBed')]"
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||
<label class="workstation-field-label">
|
||||
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')]"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', fieldConfidenceClass('encounter.admissionReason')]"
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</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">
|
||||
<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="col-span-2">
|
||||
<label class="block text-xs text-gray-500">Discharge Diagnosis</label>
|
||||
<div v-if="showEncounterSummary" class="md:col-span-2">
|
||||
<label class="workstation-field-label">Discharge Diagnosis</label>
|
||||
<textarea
|
||||
v-model="encounter.dischargeDiagnosis"
|
||||
@blur="saveEncounter"
|
||||
class="form-input text-sm"
|
||||
class="form-input text-sm py-1.5"
|
||||
rows="2"
|
||||
placeholder="Discharge diagnosis..."
|
||||
@blur="saveEncounter"
|
||||
/>
|
||||
</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>
|
||||
<fieldset class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Observations</legend>
|
||||
<div class="space-y-2">
|
||||
<ObservationRow
|
||||
v-for="obs in observations"
|
||||
@@ -238,26 +258,56 @@
|
||||
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||
@delete="handleObsDelete"
|
||||
/>
|
||||
<button @click="addObservation" class="btn-primary text-sm">
|
||||
<button type="button" class="btn-secondary text-sm py-1.5" @click="addObservation">
|
||||
+ Add Observation
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4 border-t">
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorkstationActionBar>
|
||||
<template #center>
|
||||
<button
|
||||
@click="submitForVerification"
|
||||
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"
|
||||
:disabled="submitting || saveState === 'saving'"
|
||||
@click="submitForVerification"
|
||||
>
|
||||
{{ submitting ? 'Submitting...' : 'Submit for Verification' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</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>
|
||||
</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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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.2s',
|
||||
transition: isPanning ? 'none' : 'transform 0.15s ease-out',
|
||||
}"
|
||||
>
|
||||
<!-- PDF/image rendered from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
|
||||
<!-- PDF/image from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
|
||||
<iframe
|
||||
v-if="isPdf"
|
||||
:src="url"
|
||||
class="w-[800px] h-[1100px] bg-white"
|
||||
class="scan-page w-[800px] h-[1100px] bg-white"
|
||||
frameborder="0"
|
||||
title="Scanned document"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="url"
|
||||
class="max-w-none"
|
||||
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())
|
||||
}
|
||||
|
||||
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,13 +1,23 @@
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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
|
||||
v-if="batch?.rejectionReason"
|
||||
class="rounded-input border border-[#FECDCA] bg-clinical-danger-bg p-3"
|
||||
>
|
||||
<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">
|
||||
@@ -23,25 +33,25 @@
|
||||
/>
|
||||
|
||||
<!-- 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">
|
||||
<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]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
<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-2 pl-8 font-medium"
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
@@ -50,26 +60,26 @@
|
||||
</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">
|
||||
<!-- 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]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
<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-2 pl-8 font-medium"
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
@@ -78,26 +88,26 @@
|
||||
</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">
|
||||
<!-- 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]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
<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-2 pl-8 font-medium"
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
@@ -107,25 +117,25 @@
|
||||
</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">
|
||||
<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]"
|
||||
@change="toggleCheck(field.path)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-gray-500">{{ field.label }}</label>
|
||||
<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-2 pl-8 font-medium"
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
@@ -135,8 +145,8 @@
|
||||
</fieldset>
|
||||
|
||||
<!-- Observations review -->
|
||||
<fieldset class="border border-gray-200 rounded-md p-4">
|
||||
<legend class="text-sm font-medium text-gray-700 px-2">
|
||||
<fieldset class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">
|
||||
Observations ({{ observations.length }})
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
@@ -155,45 +165,87 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Verification progress -->
|
||||
<div class="bg-gray-50 rounded-md p-4">
|
||||
<!-- 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>Fields verified:</span>
|
||||
<span :class="allChecked ? 'text-clinical-safe font-bold' : 'text-gray-600'">
|
||||
<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-gray-200 rounded-full h-2 mt-2">
|
||||
<div class="w-full bg-line rounded-full h-1.5 mt-2">
|
||||
<div
|
||||
class="bg-clinical-safe h-2 rounded-full transition-all"
|
||||
class="bg-clinical-safe h-1.5 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">
|
||||
<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
|
||||
@click="approveVerification"
|
||||
v-if="decision === 'pass'"
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="!allChecked || processing || sodBlocked"
|
||||
data-testid="verify-pass"
|
||||
@click="approveVerification"
|
||||
>
|
||||
{{ processing ? 'Processing...' : 'Approve - Verified' }}
|
||||
{{ processing ? 'Processing...' : 'Pass Verification' }}
|
||||
</button>
|
||||
<button
|
||||
@click="showRejectDialog = true"
|
||||
v-else-if="decision === 'reject'"
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="processing || sodBlocked"
|
||||
data-testid="verify-return"
|
||||
@click="showRejectDialog = true"
|
||||
>
|
||||
Reject
|
||||
Return for Rework
|
||||
</button>
|
||||
</div>
|
||||
</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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
<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">
|
||||
<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
|
||||
@@ -24,278 +24,73 @@
|
||||
@select="openBatch"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Split pane (batch selected) -->
|
||||
<div v-else class="flex-1 split-pane">
|
||||
<template #rail>
|
||||
<WorkstationQueueRail
|
||||
title="Approval queue"
|
||||
:batches="batchStore.batches"
|
||||
:selected-id="batchId"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
@back="router.push('/approval')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #scan>
|
||||
<ScanViewer
|
||||
v-if="documentUrl"
|
||||
:url="documentUrl"
|
||||
:loading="documentLoading"
|
||||
:error="documentError"
|
||||
@retry="refreshUrl"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- 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"
|
||||
<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')"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadQueue() {
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<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>
|
||||
<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"
|
||||
@@ -21,23 +21,38 @@
|
||||
@select="openBatch"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Split pane (batch selected) -->
|
||||
<div v-else class="flex-1 split-pane">
|
||||
<ScanViewer
|
||||
v-if="documentUrl"
|
||||
:url="documentUrl"
|
||||
<template #rail>
|
||||
<WorkstationQueueRail
|
||||
title="Entry queue"
|
||||
:batches="batchStore.batches"
|
||||
:selected-id="batchId"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
@back="router.push('/entry')"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</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()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
<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">
|
||||
<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
|
||||
@@ -24,23 +24,36 @@
|
||||
@select="openBatch"
|
||||
@retry="loadQueue"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Split pane (batch selected) -->
|
||||
<div v-else class="flex-1 split-pane">
|
||||
<ScanViewer
|
||||
v-if="documentUrl"
|
||||
:url="documentUrl"
|
||||
<template #rail>
|
||||
<WorkstationQueueRail
|
||||
title="Verification queue"
|
||||
:batches="batchStore.batches"
|
||||
:selected-id="batchId"
|
||||
:loading="batchStore.loading"
|
||||
@select="openBatch"
|
||||
@back="router.push('/verification')"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<template #scan>
|
||||
<ScanViewer
|
||||
:url="documentUrl"
|
||||
:loading="documentLoading"
|
||||
:error="documentError"
|
||||
@retry="refreshUrl"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #form>
|
||||
<VerificationForm
|
||||
v-if="batchId"
|
||||
:batch="currentBatch"
|
||||
:batch-id="batchId"
|
||||
/>
|
||||
</div>
|
||||
</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()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user