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