feature: Barcode/QR Cover Sheet System
This commit is contained in:
Generated
+1609
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.3.0",
|
||||
@@ -16,14 +18,18 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.0",
|
||||
"vitest": "^4.1.9",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import EntryForm from '@/components/EntryForm.vue'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
import type { BatchDetailResponse } from '@/types'
|
||||
|
||||
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(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||
return {
|
||||
id: 'b1',
|
||||
status: 'IN_ENTRY',
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
patientId: null,
|
||||
documentRef: 'docs/scan.pdf',
|
||||
documentUrl: null,
|
||||
enableRetroactiveAlerts: false,
|
||||
enteredByUserId: 'u1',
|
||||
verifiedByUserId: null,
|
||||
approvedByUserId: null,
|
||||
rejectionReason: null,
|
||||
promotedAt: null,
|
||||
promotionEncounterId: null,
|
||||
supersedesBatchId: null,
|
||||
clinicianAttestation: false,
|
||||
createdAt: '2026-06-27T10:00:00Z',
|
||||
updatedAt: '2026-06-27T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('EntryForm', () => {
|
||||
it('renders patient demographics fields', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Patient Demographics')
|
||||
expect(wrapper.text()).toContain('Full Name')
|
||||
expect(wrapper.text()).toContain('Date of Birth')
|
||||
expect(wrapper.text()).toContain('Sex')
|
||||
expect(wrapper.text()).toContain('Blood Type')
|
||||
expect(wrapper.text()).toContain('Emergency Contact')
|
||||
})
|
||||
|
||||
it('renders encounter context fields', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Encounter Context')
|
||||
expect(wrapper.text()).toContain('Admission Date')
|
||||
expect(wrapper.text()).toContain('Department')
|
||||
expect(wrapper.text()).toContain('Room / Bed')
|
||||
expect(wrapper.text()).toContain('Admission Reason')
|
||||
})
|
||||
|
||||
it('renders observations section with add button', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Observations')
|
||||
expect(wrapper.text()).toContain('+ Add Observation')
|
||||
})
|
||||
|
||||
it('renders submit button', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const submitBtn = wrapper.find('button')
|
||||
const buttons = wrapper.findAll('button')
|
||||
const submitButton = buttons.find((b) => b.text().includes('Submit for Verification'))
|
||||
expect(submitButton).toBeTruthy()
|
||||
})
|
||||
|
||||
it('displays batch status', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('IN ENTRY')
|
||||
})
|
||||
|
||||
describe('conditional sections by batch type', () => {
|
||||
it('hides allergies section for VITALS batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Allergies')
|
||||
})
|
||||
|
||||
it('shows allergies section for ALLERGY_UPDATE batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Allergies')
|
||||
expect(wrapper.text()).toContain('No known allergies')
|
||||
})
|
||||
|
||||
it('hides medications section for VITALS batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Medications')
|
||||
})
|
||||
|
||||
it('shows medications section for MEDICATION_LIST batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'MEDICATION_LIST' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Medications')
|
||||
expect(wrapper.text()).toContain('No active medications')
|
||||
})
|
||||
|
||||
it('shows both allergies and medications for MIXED batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'MIXED' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Allergies')
|
||||
expect(wrapper.text()).toContain('Medications')
|
||||
})
|
||||
|
||||
it('hides encounter summary fields for VITALS batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'VITALS' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Encounter Status')
|
||||
expect(wrapper.text()).not.toContain('Discharge Diagnosis')
|
||||
})
|
||||
|
||||
it('shows encounter summary fields for ENCOUNTER_SUMMARY batch', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ENCOUNTER_SUMMARY' }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Encounter Status')
|
||||
expect(wrapper.text()).toContain('Discharge Diagnosis')
|
||||
})
|
||||
})
|
||||
|
||||
describe('draft loading', () => {
|
||||
it('populates patient fields from draft', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'John Doe',
|
||||
dateOfBirth: '1990-05-15',
|
||||
sex: 'male',
|
||||
bloodType: 'O+',
|
||||
emergencyContact: '555-1234',
|
||||
allergiesJson: null,
|
||||
noKnownAllergies: false,
|
||||
medicationsJson: null,
|
||||
noActiveMedications: false,
|
||||
},
|
||||
encounter: null,
|
||||
observations: [],
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const nameInput = wrapper.find('input[type="text"]')
|
||||
expect(nameInput.element.value).toBe('John Doe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('save on blur', () => {
|
||||
it('calls saveDraftPatient on 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')
|
||||
|
||||
expect(store.saveDraftPatient).toHaveBeenCalledWith(
|
||||
'b1',
|
||||
expect.objectContaining({ fullName: 'Jane Doe' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('calls saveDraftEncounter on encounter field blur', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.saveDraftEncounter = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const roomInput = wrapper.findAll('input[type="text"]').find((i) => {
|
||||
const label = i.element.closest('div')?.querySelector('label')
|
||||
return label?.textContent?.includes('Room')
|
||||
})
|
||||
if (roomInput) {
|
||||
await roomInput.setValue('4B-01')
|
||||
await roomInput.trigger('blur')
|
||||
expect(store.saveDraftEncounter).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit for verification', () => {
|
||||
it('calls submitForVerification on click', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||
await submitBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.submitForVerification).toHaveBeenCalledWith('b1')
|
||||
})
|
||||
|
||||
it('displays error message on submit failure', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockRejectedValue(new Error('BATCH_INCOMPLETE'))
|
||||
|
||||
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||
await submitBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('BATCH_INCOMPLETE')
|
||||
})
|
||||
|
||||
it('shows submitting state on button', async () => {
|
||||
let resolvePromise: () => void
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.submitForVerification = vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const submitBtn = wrapper.findAll('button').find((b) => b.text().includes('Submit for Verification'))
|
||||
await submitBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Submitting...')
|
||||
|
||||
resolvePromise!()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Submit for Verification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('observations', () => {
|
||||
it('calls addObservation on add button click', async () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.addObservation = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const addBtn = wrapper.findAll('button').find((b) => b.text().includes('+ Add Observation'))
|
||||
await addBtn!.trigger('click')
|
||||
|
||||
expect(store.addObservation).toHaveBeenCalledWith('b1', expect.objectContaining({
|
||||
observationCode: '',
|
||||
value: 0,
|
||||
unit: '',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('blood type options', () => {
|
||||
it('renders all 8 blood type options plus empty', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const selects = wrapper.findAll('select')
|
||||
const bloodTypeSelect = selects.find((s) => {
|
||||
const opts = s.findAll('option')
|
||||
return opts.some((o) => o.text() === 'A+')
|
||||
})
|
||||
expect(bloodTypeSelect).toBeTruthy()
|
||||
const options = bloodTypeSelect!.findAll('option')
|
||||
expect(options.length).toBe(9) // "Unknown" + 8 blood types
|
||||
})
|
||||
})
|
||||
|
||||
describe('department options', () => {
|
||||
it('renders department dropdown with clinical departments', () => {
|
||||
const wrapper = mount(EntryForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
const selects = wrapper.findAll('select')
|
||||
const deptSelect = selects.find((s) => {
|
||||
const opts = s.findAll('option')
|
||||
return opts.some((o) => o.text() === 'ICU')
|
||||
})
|
||||
expect(deptSelect).toBeTruthy()
|
||||
const options = deptSelect!.findAll('option')
|
||||
expect(options.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ObservationRow from '@/components/ObservationRow.vue'
|
||||
import type { DraftObservation } from '@/types'
|
||||
|
||||
function makeObservation(overrides: Partial<DraftObservation> = {}): DraftObservation {
|
||||
return {
|
||||
id: 'obs-1',
|
||||
batchId: 'b1',
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
note: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ObservationRow', () => {
|
||||
it('renders observation code options', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const options = wrapper.findAll('select option')
|
||||
const values = options.map((o) => o.element.value)
|
||||
expect(values).toContain('HEART_RATE')
|
||||
expect(values).toContain('TEMP_C')
|
||||
expect(values).toContain('BP_SYSTOLIC')
|
||||
expect(values).toContain('SPO2')
|
||||
expect(values).toContain('POTASSIUM_MEQ_L')
|
||||
})
|
||||
|
||||
it('renders observation values in inputs', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation({ value: 98.6, unit: '°F' }) },
|
||||
})
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
expect(numberInput.element.value).toBe('98.6')
|
||||
|
||||
const textInputs = wrapper.findAll('input[type="text"]')
|
||||
const unitInput = textInputs[0]
|
||||
expect(unitInput.element.value).toBe('°F')
|
||||
})
|
||||
|
||||
it('emits update event on code change', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('TEMP_C')
|
||||
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C'])
|
||||
})
|
||||
|
||||
it('emits update event on value change', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
await numberInput.setValue('80')
|
||||
await numberInput.trigger('change')
|
||||
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
const emitted = wrapper.emitted('update')![0]
|
||||
expect(emitted[0]).toBe('value')
|
||||
expect(emitted[1]).toBe(80)
|
||||
})
|
||||
|
||||
it('shows delete button in entry mode (not readonly)', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
const deleteBtn = wrapper.find('button')
|
||||
expect(deleteBtn.exists()).toBe(true)
|
||||
expect(deleteBtn.text()).toBe('Remove')
|
||||
})
|
||||
|
||||
it('emits delete event on remove click', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation({ id: 'obs-42' }) },
|
||||
})
|
||||
await wrapper.find('button').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('delete')).toBeTruthy()
|
||||
expect(wrapper.emitted('delete')![0]).toEqual(['obs-42'])
|
||||
})
|
||||
|
||||
it('disables inputs in readonly mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), readonly: true },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
expect(select.element.disabled).toBe(true)
|
||||
|
||||
const numberInput = wrapper.find('input[type="number"]')
|
||||
expect(numberInput.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('hides delete button in readonly mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), readonly: true },
|
||||
})
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows verification checkbox when showVerified is true', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: {
|
||||
observation: makeObservation(),
|
||||
readonly: true,
|
||||
showVerified: true,
|
||||
verified: false,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.exists()).toBe(true)
|
||||
expect(checkbox.element.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects verified state in checkbox', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: {
|
||||
observation: makeObservation(),
|
||||
readonly: true,
|
||||
showVerified: true,
|
||||
verified: true,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.element.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('emits verify event when checkbox is toggled', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: {
|
||||
observation: makeObservation({ id: 'obs-99' }),
|
||||
readonly: true,
|
||||
showVerified: true,
|
||||
verified: false,
|
||||
},
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
await checkbox.setValue(true)
|
||||
|
||||
expect(wrapper.emitted('verify')).toBeTruthy()
|
||||
expect(wrapper.emitted('verify')![0]).toEqual(['obs-99', true])
|
||||
})
|
||||
|
||||
it('hides verification checkbox when showVerified is false', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), showVerified: false },
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PatientSearch from '@/components/PatientSearch.vue'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
describe('PatientSearch', () => {
|
||||
it('renders a search input', () => {
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('placeholder')).toContain('Search')
|
||||
})
|
||||
|
||||
it('does not search when query is less than 2 characters', async () => {
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('J')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(400)
|
||||
|
||||
expect(mockedGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('searches after debounce when query is 2+ characters', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('Jane')
|
||||
await input.trigger('input')
|
||||
|
||||
expect(mockedGet).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
|
||||
})
|
||||
|
||||
it('shows results dropdown after search', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [
|
||||
{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' },
|
||||
{ id: 'p2', fullName: 'Jane Smith', mrn: 'MRN-002' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const items = wrapper.findAll('li')
|
||||
expect(items.length).toBe(2)
|
||||
expect(items[0].text()).toContain('Jane Doe')
|
||||
expect(items[0].text()).toContain('MRN-001')
|
||||
})
|
||||
|
||||
it('emits update:modelValue on patient selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
||||
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('shows selected patient name after selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Selected: Jane Doe')
|
||||
})
|
||||
|
||||
it('clears results list on patient selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findAll('li').length).toBe(0)
|
||||
})
|
||||
|
||||
it('sets input value to patient name on selection', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'p1', fullName: 'Jane Doe', mrn: 'MRN-001' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
await input.setValue('Jane')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
await wrapper.find('li').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(input.element.value).toBe('Jane Doe')
|
||||
})
|
||||
|
||||
it('clears results on API error', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
await wrapper.find('input').setValue('Jane')
|
||||
await wrapper.find('input').trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.findAll('li').length).toBe(0)
|
||||
})
|
||||
|
||||
it('debounces multiple rapid inputs', async () => {
|
||||
mockedGet.mockResolvedValue({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(PatientSearch)
|
||||
const input = wrapper.find('input')
|
||||
|
||||
await input.setValue('Ja')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
await input.setValue('Jan')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
await input.setValue('Jane')
|
||||
await input.trigger('input')
|
||||
vi.advanceTimersByTime(300)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledTimes(1)
|
||||
expect(mockedGet).toHaveBeenCalledWith('patients/search', { q: 'Jane' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,406 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import VerificationForm from '@/components/VerificationForm.vue'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
import type { BatchDetailResponse } from '@/types'
|
||||
|
||||
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(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||
return {
|
||||
id: 'b1',
|
||||
status: 'PENDING_VERIFICATION',
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
patientId: 'p1',
|
||||
documentRef: 'docs/scan.pdf',
|
||||
documentUrl: null,
|
||||
enableRetroactiveAlerts: false,
|
||||
enteredByUserId: 'u1',
|
||||
verifiedByUserId: null,
|
||||
approvedByUserId: null,
|
||||
rejectionReason: null,
|
||||
promotedAt: null,
|
||||
promotionEncounterId: null,
|
||||
supersedesBatchId: null,
|
||||
clinicianAttestation: false,
|
||||
createdAt: '2026-06-27T10:00:00Z',
|
||||
updatedAt: '2026-06-27T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountWithDraft(batchOverrides: Partial<BatchDetailResponse> = {}) {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(batchOverrides), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'Jane Doe',
|
||||
dateOfBirth: '1990-05-15',
|
||||
sex: 'female',
|
||||
bloodType: 'A+',
|
||||
emergencyContact: '555-1234',
|
||||
allergiesJson: null,
|
||||
noKnownAllergies: false,
|
||||
medicationsJson: null,
|
||||
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: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
note: null,
|
||||
},
|
||||
{
|
||||
id: 'obs-2',
|
||||
batchId: 'b1',
|
||||
observationCode: 'TEMP_C',
|
||||
value: 37.2,
|
||||
unit: 'C',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return { wrapper, store }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('VerificationForm', () => {
|
||||
it('renders verification header', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).toContain('Verification Review')
|
||||
expect(wrapper.text()).toContain('Pending Verification')
|
||||
})
|
||||
|
||||
it('renders patient fields with checkboxes after draft loads', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Patient Demographics')
|
||||
expect(wrapper.text()).toContain('Jane Doe')
|
||||
expect(wrapper.text()).toContain('1990-05-15')
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders encounter fields after draft loads', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Encounter Context')
|
||||
expect(wrapper.text()).toContain('ICU')
|
||||
expect(wrapper.text()).toContain('3A-12')
|
||||
expect(wrapper.text()).toContain('Chest pain')
|
||||
})
|
||||
|
||||
it('shows rejection reason banner when present', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: {
|
||||
batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }),
|
||||
batchId: 'b1',
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('Previous Rejection Reason')
|
||||
expect(wrapper.text()).toContain('Temperature seems incorrect')
|
||||
})
|
||||
|
||||
it('does not show rejection banner when no reason', () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' },
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('Previous Rejection Reason')
|
||||
})
|
||||
|
||||
describe('field check progress', () => {
|
||||
it('shows 0/N checked initially', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Fields verified:')
|
||||
expect(wrapper.text()).toMatch(/0\s*\/\s*\d+/)
|
||||
})
|
||||
|
||||
it('updates count when checkboxes are toggled', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
await checkboxes[0].setValue(true)
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toMatch(/1\s*\/\s*\d+/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('approve button', () => {
|
||||
it('is disabled when not all fields are checked', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
expect(approveBtn!.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is enabled when all fields are checked', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
expect(approveBtn!.element.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls verifyBatch with all field checks on approve', async () => {
|
||||
const { wrapper, store } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
store.verifyBatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
await approveBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.verifyBatch).toHaveBeenCalledWith(
|
||||
'b1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ passed: true }),
|
||||
]),
|
||||
true,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reject flow', () => {
|
||||
it('shows reject dialog when reject button is clicked', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Reject Batch')
|
||||
expect(wrapper.text()).toContain('Confirm Rejection')
|
||||
})
|
||||
|
||||
it('disables confirm button when reason is empty', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
expect(confirmBtn!.element.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables confirm button when reason is entered', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Temperature value appears incorrect')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
expect(confirmBtn!.element.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls rejectBatch on confirm', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.rejectBatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const textarea = wrapper.find('textarea')
|
||||
await textarea.setValue('Value incorrect')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection')
|
||||
await confirmBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect')
|
||||
})
|
||||
|
||||
it('closes reject dialog on cancel', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch(), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject')
|
||||
await rejectBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Reject Batch')
|
||||
|
||||
const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel')
|
||||
await cancelBtn!.trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Reject Batch')
|
||||
})
|
||||
})
|
||||
|
||||
describe('observations display', () => {
|
||||
it('shows observation count in legend', async () => {
|
||||
const { wrapper } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Observations (2)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('allergy and medication verification', () => {
|
||||
it('shows allergy fields for ALLERGY_UPDATE batch', async () => {
|
||||
const { wrapper } = mountWithDraft({ batchType: 'ALLERGY_UPDATE' })
|
||||
const store = useBatchStore()
|
||||
store.currentDraft!.patient!.allergiesJson = JSON.stringify(['Penicillin', 'Latex'])
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Re-trigger the watcher by resetting draft
|
||||
const draft = { ...store.currentDraft! }
|
||||
store.currentDraft = null
|
||||
await wrapper.vm.$nextTick()
|
||||
store.currentDraft = draft
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Allergies')
|
||||
})
|
||||
|
||||
it('shows NKA for noKnownAllergies', async () => {
|
||||
const wrapper = mount(VerificationForm, {
|
||||
props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
store.currentDraft = {
|
||||
patient: {
|
||||
id: 'dp1',
|
||||
batchId: 'b1',
|
||||
fullName: 'Jane',
|
||||
dateOfBirth: '1990-01-01',
|
||||
sex: 'female',
|
||||
bloodType: null,
|
||||
emergencyContact: null,
|
||||
allergiesJson: null,
|
||||
noKnownAllergies: true,
|
||||
medicationsJson: null,
|
||||
noActiveMedications: false,
|
||||
},
|
||||
encounter: {
|
||||
id: 'de1',
|
||||
batchId: 'b1',
|
||||
admissionDate: null,
|
||||
department: null,
|
||||
roomBed: null,
|
||||
admissionReason: null,
|
||||
dischargeDiagnosis: null,
|
||||
status: null,
|
||||
},
|
||||
observations: [],
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('No Known Allergies')
|
||||
expect(wrapper.text()).toContain('Yes (NKA)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('shows error message on approve failure', async () => {
|
||||
const { wrapper, store } = mountWithDraft()
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
store.verifyBatch = vi.fn().mockRejectedValue(new Error('Server error'))
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
for (const cb of checkboxes) {
|
||||
await cb.setValue(true)
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve'))
|
||||
await approveBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Server error')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router'
|
||||
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
function buildRoute(path: string, meta: Record<string, unknown> = {}): RouteLocationNormalized {
|
||||
return {
|
||||
path,
|
||||
meta,
|
||||
name: undefined,
|
||||
params: {},
|
||||
query: {},
|
||||
hash: '',
|
||||
fullPath: path,
|
||||
matched: [],
|
||||
redirectedFrom: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function setupGuard() {
|
||||
const nextCalls: (string | undefined)[] = []
|
||||
const auth = useAuthStore()
|
||||
|
||||
function runGuard(to: RouteLocationNormalized, from?: RouteLocationNormalized) {
|
||||
const _from = from ?? buildRoute('/')
|
||||
const next = vi.fn((dest?: string) => {
|
||||
nextCalls.push(dest)
|
||||
})
|
||||
|
||||
if (to.path === '/login' && auth.isAuthenticated) {
|
||||
next(getDefaultRouteForRole(auth.userRole))
|
||||
return { next, nextCalls }
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
next('/login')
|
||||
return { next, nextCalls }
|
||||
}
|
||||
|
||||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||
const allowedRoles = to.meta.roles as string[]
|
||||
if (!allowedRoles.includes(auth.userRole)) {
|
||||
const fallback = getDefaultRouteForRole(auth.userRole)
|
||||
if (fallback !== '/login' && fallback !== to.path) {
|
||||
next(fallback)
|
||||
return { next, nextCalls }
|
||||
}
|
||||
next('/login')
|
||||
return { next, nextCalls }
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
return { next, nextCalls }
|
||||
}
|
||||
|
||||
return { auth, runGuard }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('router navigation guard', () => {
|
||||
describe('unauthenticated users', () => {
|
||||
it('allows access to /login', () => {
|
||||
const { runGuard } = setupGuard()
|
||||
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('redirects to /login for protected routes', () => {
|
||||
const { runGuard } = setupGuard()
|
||||
const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK'] }))
|
||||
expect(next).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('redirects to /login for dashboard', () => {
|
||||
const { runGuard } = setupGuard()
|
||||
const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated users', () => {
|
||||
function authenticatedGuard(role: string) {
|
||||
const { auth, runGuard } = setupGuard()
|
||||
auth.token = 'valid-token'
|
||||
auth.user = { id: 'u1', username: 'test', fullName: 'Test User', role }
|
||||
return { auth, runGuard }
|
||||
}
|
||||
|
||||
it('redirects authenticated user away from /login to default route', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects admin from /login to /dashboard', () => {
|
||||
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||
const { next } = runGuard(buildRoute('/login', { requiresAuth: false }))
|
||||
expect(next).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('allows access to routes matching user role', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/entry', { requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('allows admin access to any role-restricted route', () => {
|
||||
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||
const routeMetas = [
|
||||
{ requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['DATA_ENTRY_CLERK', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['CLINICIAN', 'ADMINISTRATOR'] },
|
||||
{ requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
]
|
||||
for (const meta of routeMetas) {
|
||||
const { next } = runGuard(buildRoute('/test', meta))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows INTAKE_CLERK access to cover sheets route', () => {
|
||||
const { runGuard } = authenticatedGuard('INTAKE_CLERK')
|
||||
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||
requiresAuth: true,
|
||||
roles: ['INTAKE_CLERK', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||
requiresAuth: true,
|
||||
roles: ['INTAKE_CLERK', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects DATA_ENTRY_CLERK from /intake to /entry', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/intake', { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects INTAKE_CLERK from /verification to /intake', () => {
|
||||
const { runGuard } = authenticatedGuard('INTAKE_CLERK')
|
||||
const { next } = runGuard(buildRoute('/verification', {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith('/intake')
|
||||
})
|
||||
|
||||
it('redirects CLINICIAN from /dashboard to /live-capture', () => {
|
||||
const { runGuard } = authenticatedGuard('CLINICIAN')
|
||||
const { next } = runGuard(buildRoute('/dashboard', { requiresAuth: true, roles: ['ADMINISTRATOR'] }))
|
||||
expect(next).toHaveBeenCalledWith('/live-capture')
|
||||
})
|
||||
|
||||
it('allows access to routes with no role restriction', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/patients', { requiresAuth: true }))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('VERIFIER can access verification routes', () => {
|
||||
const { runGuard } = authenticatedGuard('VERIFIER')
|
||||
const { next } = runGuard(buildRoute('/verification', {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('CLINICAL_APPROVER can access both verification and approval routes', () => {
|
||||
const { runGuard } = authenticatedGuard('CLINICAL_APPROVER')
|
||||
|
||||
const { next: verifyNext } = runGuard(buildRoute('/verification', {
|
||||
requiresAuth: true,
|
||||
roles: ['VERIFIER', 'CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(verifyNext).toHaveBeenCalledWith()
|
||||
|
||||
const { next: approvalNext } = runGuard(buildRoute('/approval', {
|
||||
requiresAuth: true,
|
||||
roles: ['CLINICAL_APPROVER', 'ADMINISTRATOR'],
|
||||
}))
|
||||
expect(approvalNext).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const localStorageData: Record<string, string> = {}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => localStorageData[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageData[key] = value
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageData[key]
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(localStorageData).forEach((k) => delete localStorageData[k])
|
||||
}),
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore, getDefaultRouteForRole } from '@/stores/auth'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
post: vi.fn(),
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/router', () => ({
|
||||
default: { push: vi.fn() },
|
||||
}))
|
||||
|
||||
import { post, get } from '@/api/client'
|
||||
import router from '@/router'
|
||||
|
||||
const mockedPost = vi.mocked(post)
|
||||
const mockedGet = vi.mocked(get)
|
||||
const mockedRouterPush = vi.mocked(router.push)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('getDefaultRouteForRole', () => {
|
||||
it.each([
|
||||
['INTAKE_CLERK', '/intake'],
|
||||
['DATA_ENTRY_CLERK', '/entry'],
|
||||
['VERIFIER', '/verification'],
|
||||
['CLINICAL_APPROVER', '/approval'],
|
||||
['CLINICIAN', '/live-capture'],
|
||||
['ADMINISTRATOR', '/dashboard'],
|
||||
['UNKNOWN_ROLE', '/login'],
|
||||
['', '/login'],
|
||||
])('returns %s for role %s', (role, expected) => {
|
||||
expect(getDefaultRouteForRole(role)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAuthStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('starts unauthenticated when localStorage is empty', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.userRole).toBe('')
|
||||
expect(store.userId).toBe('')
|
||||
expect(store.userFullName).toBe('')
|
||||
})
|
||||
|
||||
it('hydrates from localStorage on creation', () => {
|
||||
localStorage.setItem('vigilcare_token', 'stored-token')
|
||||
localStorage.setItem('vigilcare_refresh_token', 'stored-refresh')
|
||||
localStorage.setItem(
|
||||
'vigilcare_user',
|
||||
JSON.stringify({ id: 'u1', username: 'clerk1', fullName: 'Clerk One', role: 'DATA_ENTRY_CLERK' }),
|
||||
)
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const store = useAuthStore()
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.userRole).toBe('DATA_ENTRY_CLERK')
|
||||
expect(store.userId).toBe('u1')
|
||||
expect(store.userFullName).toBe('Clerk One')
|
||||
})
|
||||
})
|
||||
|
||||
describe('role-based permissions', () => {
|
||||
function storeWithRole(role: string) {
|
||||
const store = useAuthStore()
|
||||
store.user = { id: 'u1', username: 'test', fullName: 'Test', role }
|
||||
store.token = 'tok'
|
||||
return store
|
||||
}
|
||||
|
||||
it('INTAKE_CLERK can intake only', () => {
|
||||
const store = storeWithRole('INTAKE_CLERK')
|
||||
expect(store.canIntake).toBe(true)
|
||||
expect(store.canEntry).toBe(false)
|
||||
expect(store.canVerify).toBe(false)
|
||||
expect(store.canApprove).toBe(false)
|
||||
expect(store.canLiveCapture).toBe(false)
|
||||
expect(store.canSupervise).toBe(false)
|
||||
})
|
||||
|
||||
it('DATA_ENTRY_CLERK can entry only', () => {
|
||||
const store = storeWithRole('DATA_ENTRY_CLERK')
|
||||
expect(store.canEntry).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canVerify).toBe(false)
|
||||
expect(store.canApprove).toBe(false)
|
||||
})
|
||||
|
||||
it('VERIFIER can verify only', () => {
|
||||
const store = storeWithRole('VERIFIER')
|
||||
expect(store.canVerify).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canEntry).toBe(false)
|
||||
expect(store.canApprove).toBe(false)
|
||||
})
|
||||
|
||||
it('CLINICAL_APPROVER can verify and approve', () => {
|
||||
const store = storeWithRole('CLINICAL_APPROVER')
|
||||
expect(store.canVerify).toBe(true)
|
||||
expect(store.canApprove).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canEntry).toBe(false)
|
||||
})
|
||||
|
||||
it('CLINICIAN can live capture only', () => {
|
||||
const store = storeWithRole('CLINICIAN')
|
||||
expect(store.canLiveCapture).toBe(true)
|
||||
expect(store.canIntake).toBe(false)
|
||||
expect(store.canEntry).toBe(false)
|
||||
expect(store.canVerify).toBe(false)
|
||||
expect(store.canSupervise).toBe(false)
|
||||
})
|
||||
|
||||
it('ADMINISTRATOR has all permissions', () => {
|
||||
const store = storeWithRole('ADMINISTRATOR')
|
||||
expect(store.canIntake).toBe(true)
|
||||
expect(store.canEntry).toBe(true)
|
||||
expect(store.canVerify).toBe(true)
|
||||
expect(store.canApprove).toBe(true)
|
||||
expect(store.canLiveCapture).toBe(true)
|
||||
expect(store.canSupervise).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('sets token, user, and localStorage on successful login', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
token: 'jwt-tok',
|
||||
refreshToken: 'ref-tok',
|
||||
userId: 'u-123',
|
||||
username: 'entry1',
|
||||
fullName: 'Entry Clerk',
|
||||
role: 'DATA_ENTRY_CLERK',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'entry1', password: 'password' })
|
||||
|
||||
expect(store.isAuthenticated).toBe(true)
|
||||
expect(store.token).toBe('jwt-tok')
|
||||
expect(store.refreshToken).toBe('ref-tok')
|
||||
expect(store.user).toEqual({
|
||||
id: 'u-123',
|
||||
username: 'entry1',
|
||||
fullName: 'Entry Clerk',
|
||||
role: 'DATA_ENTRY_CLERK',
|
||||
})
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_token', 'jwt-tok')
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('vigilcare_refresh_token', 'ref-tok')
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('navigates to role-specific default route', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: {
|
||||
token: 't',
|
||||
refreshToken: 'r',
|
||||
userId: 'u1',
|
||||
username: 'admin',
|
||||
fullName: 'Admin',
|
||||
role: 'ADMINISTRATOR',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login({ username: 'admin', password: 'password' })
|
||||
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('throws on failed login', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 401,
|
||||
data: null,
|
||||
error: { message: 'Invalid credentials', code: 'AUTH_FAILED' },
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login({ username: 'bad', password: 'wrong' })).rejects.toThrow(
|
||||
'Invalid credentials',
|
||||
)
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('throws generic message when error has no message', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 500,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login({ username: 'x', password: 'y' })).rejects.toThrow('Login failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchCurrentUser', () => {
|
||||
it('updates user from /auth/me response', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { id: 'u-99', username: 'verifier1', fullName: 'Verifier One', role: 'VERIFIER' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.fetchCurrentUser()
|
||||
|
||||
expect(store.user).toEqual({
|
||||
id: 'u-99',
|
||||
username: 'verifier1',
|
||||
fullName: 'Verifier One',
|
||||
role: 'VERIFIER',
|
||||
})
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
'vigilcare_user',
|
||||
JSON.stringify(store.user),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not update user on failed response', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 401,
|
||||
data: null,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
store.user = { id: 'old', username: 'old', fullName: 'Old', role: 'VERIFIER' }
|
||||
await store.fetchCurrentUser()
|
||||
|
||||
expect(store.user?.id).toBe('old')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('clears state, localStorage, and navigates to /login', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.refreshToken = 'ref'
|
||||
store.user = { id: 'u1', username: 'x', fullName: 'X', role: 'VERIFIER' }
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.refreshToken).toBeNull()
|
||||
expect(store.user).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_token')
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_refresh_token')
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith('vigilcare_user')
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('posts refresh token to server on logout', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useAuthStore()
|
||||
store.refreshToken = 'my-refresh-tok'
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('auth/logout', { refreshToken: 'my-refresh-tok' })
|
||||
})
|
||||
|
||||
it('still clears local session if server revoke fails', async () => {
|
||||
mockedPost.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.refreshToken = 'ref'
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(store.token).toBeNull()
|
||||
expect(store.isAuthenticated).toBe(false)
|
||||
expect(mockedRouterPush).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('skips server call when no refresh token', async () => {
|
||||
const store = useAuthStore()
|
||||
store.token = 'tok'
|
||||
store.refreshToken = null
|
||||
|
||||
await store.logout()
|
||||
|
||||
expect(mockedPost).not.toHaveBeenCalled()
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
import { get, post, put, del, patch, uploadFile } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
const mockedPost = vi.mocked(post)
|
||||
const mockedPut = vi.mocked(put)
|
||||
const mockedDel = vi.mocked(del)
|
||||
const mockedPatch = vi.mocked(patch)
|
||||
const mockedUploadFile = vi.mocked(uploadFile)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useBatchStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('has expected defaults', () => {
|
||||
const store = useBatchStore()
|
||||
expect(store.batches).toEqual([])
|
||||
expect(store.currentBatch).toBeNull()
|
||||
expect(store.currentDraft).toBeNull()
|
||||
expect(store.totalCount).toBe(0)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.documentUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('listBatches', () => {
|
||||
it('populates batches and totalCount on success', async () => {
|
||||
const items = [
|
||||
{ id: 'b1', status: 'UPLOADED', batchType: 'VITALS', track: 'TRACK_A' },
|
||||
{ id: 'b2', status: 'IN_ENTRY', batchType: 'LABS', track: 'TRACK_A' },
|
||||
]
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { items, totalCount: 42, page: 1, pageSize: 20, totalPages: 3 },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.listBatches({ status: 'UPLOADED' })
|
||||
|
||||
expect(store.batches).toEqual(items)
|
||||
expect(store.totalCount).toBe(42)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.listBatches({})
|
||||
|
||||
expect(store.error).toBe('Network error')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('sets generic error for non-Error exceptions', async () => {
|
||||
mockedGet.mockRejectedValueOnce('string error')
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.listBatches({})
|
||||
|
||||
expect(store.error).toBe('Failed to load batches')
|
||||
})
|
||||
|
||||
it('sets loading=true during request', async () => {
|
||||
let resolvePromise: (v: unknown) => void
|
||||
mockedGet.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const store = useBatchStore()
|
||||
const promise = store.listBatches({})
|
||||
|
||||
expect(store.loading).toBe(true)
|
||||
|
||||
resolvePromise!({ success: true, statusCode: 200, data: { items: [], totalCount: 0 }, error: null })
|
||||
await promise
|
||||
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBatch', () => {
|
||||
it('sets currentBatch on success', async () => {
|
||||
const batch = { id: 'b1', status: 'UPLOADED', batchType: 'VITALS', documentUrl: 'https://example.com/doc' }
|
||||
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: batch, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.getBatch('b1')
|
||||
|
||||
expect(store.currentBatch).toEqual(batch)
|
||||
expect(store.documentUrl).toBe('https://example.com/doc')
|
||||
})
|
||||
|
||||
it('sets error on failure', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Not found'))
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.getBatch('bad-id')
|
||||
|
||||
expect(store.error).toBe('Not found')
|
||||
expect(store.currentBatch).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadBatch', () => {
|
||||
it('returns batch data on success', async () => {
|
||||
const batch = { id: 'new-batch', status: 'UPLOADED' }
|
||||
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: batch, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File(['content'], 'scan.pdf', { type: 'application/pdf' })
|
||||
const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A')
|
||||
|
||||
expect(result).toEqual(batch)
|
||||
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes optional patientId and supersedesBatchId', async () => {
|
||||
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File(['content'], 'scan.pdf')
|
||||
await store.uploadBatch(file, 'VITALS', 'TRACK_A', 'patient-1', 'old-batch')
|
||||
|
||||
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||
batchType: 'VITALS',
|
||||
track: 'TRACK_A',
|
||||
patientId: 'patient-1',
|
||||
supersedesBatchId: 'old-batch',
|
||||
})
|
||||
})
|
||||
|
||||
it('passes coverSheetCode when provided', async () => {
|
||||
mockedUploadFile.mockResolvedValueOnce({ success: true, statusCode: 201, data: { id: 'b' }, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File(['content'], 'scan.pdf')
|
||||
await store.uploadBatch(
|
||||
file,
|
||||
'VITALS_SHEET',
|
||||
'BACKFILL',
|
||||
undefined,
|
||||
undefined,
|
||||
'VCR-CS-A3F7B2D1',
|
||||
)
|
||||
|
||||
expect(mockedUploadFile).toHaveBeenCalledWith('digitization-batches', file, {
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
coverSheetCode: 'VCR-CS-A3F7B2D1',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null on failure', async () => {
|
||||
mockedUploadFile.mockRejectedValueOnce(new Error('Upload failed'))
|
||||
|
||||
const store = useBatchStore()
|
||||
const file = new File([''], 'scan.pdf')
|
||||
const result = await store.uploadBatch(file, 'VITALS', 'TRACK_A')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.error).toBe('Upload failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('assignBatch', () => {
|
||||
it('calls PATCH with entryClerkUserId', async () => {
|
||||
mockedPatch.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.assignBatch('b1', 'clerk-42')
|
||||
|
||||
expect(mockedPatch).toHaveBeenCalledWith('digitization-batches/b1/assign', {
|
||||
entryClerkUserId: 'clerk-42',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockedPatch.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 409,
|
||||
data: null,
|
||||
error: { message: 'Already assigned', code: 'CONFLICT' },
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await expect(store.assignBatch('b1', 'clerk-42')).rejects.toThrow('Already assigned')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDraft', () => {
|
||||
it('sets currentDraft on success', async () => {
|
||||
const draft = {
|
||||
patient: { fullName: 'Jane', dateOfBirth: '1990-01-01' },
|
||||
encounter: { department: 'ICU' },
|
||||
observations: [{ id: 'obs-1', observationCode: 'HEART_RATE', value: 72 }],
|
||||
}
|
||||
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: draft, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.getDraft('b1')
|
||||
|
||||
expect(store.currentDraft).toEqual(draft)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveDraftPatient', () => {
|
||||
it('calls PUT with patient data', async () => {
|
||||
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.saveDraftPatient('b1', { fullName: 'John Doe' })
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/patient', {
|
||||
fullName: 'John Doe',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveDraftEncounter', () => {
|
||||
it('calls PUT with encounter data', async () => {
|
||||
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.saveDraftEncounter('b1', { department: 'ICU' })
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/encounter', {
|
||||
department: 'ICU',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('addObservation', () => {
|
||||
it('posts observation and refreshes draft', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: {}, error: null })
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { patient: null, encounter: null, observations: [{ id: 'obs-new' }] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.addObservation('b1', { observationCode: 'HEART_RATE', value: 72, unit: 'bpm' })
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/draft/observations', {
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
})
|
||||
expect(mockedGet).toHaveBeenCalledWith('digitization-batches/b1/draft')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateObservation', () => {
|
||||
it('calls PUT with observation data', async () => {
|
||||
mockedPut.mockResolvedValueOnce({ success: true, statusCode: 200, data: {}, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.updateObservation('b1', 'obs-1', { value: 80 })
|
||||
|
||||
expect(mockedPut).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1', {
|
||||
value: 80,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteObservation', () => {
|
||||
it('calls DELETE and refreshes draft', async () => {
|
||||
mockedDel.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { patient: null, encounter: null, observations: [] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.deleteObservation('b1', 'obs-1')
|
||||
|
||||
expect(mockedDel).toHaveBeenCalledWith('digitization-batches/b1/draft/observations/obs-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submitForVerification', () => {
|
||||
it('posts to submit endpoint', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.submitForVerification('b1')
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/submit-for-verification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyBatch', () => {
|
||||
it('posts field checks and pass status', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const checks = [
|
||||
{ fieldPath: 'patient.fullName', passed: true },
|
||||
{ fieldPath: 'patient.dob', passed: true },
|
||||
]
|
||||
const store = useBatchStore()
|
||||
await store.verifyBatch('b1', checks, true)
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/verify', {
|
||||
fieldChecks: checks,
|
||||
passed: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rejectBatch', () => {
|
||||
it('posts rejection reason', async () => {
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: null, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
await store.rejectBatch('b1', 'Temperature value appears incorrect')
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('digitization-batches/b1/reject', {
|
||||
reason: 'Temperature value appears incorrect',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('approveBatch', () => {
|
||||
it('posts with idempotency key and enableRetroactiveAlerts', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: { mrn: 'MRN-001', encounterId: 'enc-1', observationIds: ['o1', 'o2'] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useBatchStore()
|
||||
const result = await store.approveBatch('b1', true)
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith(
|
||||
'digitization-batches/b1/approve',
|
||||
{ enableRetroactiveAlerts: true },
|
||||
expect.objectContaining({ 'Idempotency-Key': expect.any(String) }),
|
||||
)
|
||||
expect(result.data).toEqual({
|
||||
mrn: 'MRN-001',
|
||||
encounterId: 'enc-1',
|
||||
observationIds: ['o1', 'o2'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPatientHistory', () => {
|
||||
it('returns history on success', async () => {
|
||||
const history = {
|
||||
patientId: 'p1',
|
||||
totalBatches: 3,
|
||||
promotedBatches: 2,
|
||||
supersededBatches: 1,
|
||||
pendingBatches: 0,
|
||||
entries: [],
|
||||
}
|
||||
mockedGet.mockResolvedValueOnce({ success: true, statusCode: 200, data: history, error: null })
|
||||
|
||||
const store = useBatchStore()
|
||||
const result = await store.getPatientHistory('p1')
|
||||
|
||||
expect(result).toEqual(history)
|
||||
expect(mockedGet).toHaveBeenCalledWith('patients/p1/digitization-history')
|
||||
})
|
||||
|
||||
it('returns null and sets error on failure', async () => {
|
||||
mockedGet.mockRejectedValueOnce(new Error('Not found'))
|
||||
|
||||
const store = useBatchStore()
|
||||
const result = await store.getPatientHistory('bad')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.error).toBe('Not found')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useLiveCaptureStore } from '@/stores/liveCapture'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
import { post } from '@/api/client'
|
||||
|
||||
const mockedPost = vi.mocked(post)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('useLiveCaptureStore', () => {
|
||||
describe('initial state', () => {
|
||||
it('has expected defaults', () => {
|
||||
const store = useLiveCaptureStore()
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.lastResult).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordObservations', () => {
|
||||
const observations = [
|
||||
{ observationCode: 'HEART_RATE', value: 72, unit: 'bpm', recordedAt: '2026-06-27T10:00:00Z', note: '' },
|
||||
]
|
||||
|
||||
it('returns result on success', async () => {
|
||||
const responseData = {
|
||||
batchId: 'b1',
|
||||
encounterId: 'enc-1',
|
||||
observations: [
|
||||
{
|
||||
draftObservationId: 'do1',
|
||||
liveObservationId: 'lo1',
|
||||
observationCode: 'HEART_RATE',
|
||||
value: 72,
|
||||
unit: 'bpm',
|
||||
recordedAt: '2026-06-27T10:00:00Z',
|
||||
criticalAlert: null,
|
||||
},
|
||||
],
|
||||
criticalAlertCount: 0,
|
||||
promotedAt: '2026-06-27T10:00:01Z',
|
||||
}
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 200, data: responseData, error: null })
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
const result = await store.recordObservations('enc-1', observations, true, 'password')
|
||||
|
||||
expect(result).toEqual(responseData)
|
||||
expect(store.lastResult).toEqual(responseData)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('throws and sets error on failure response', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 400,
|
||||
data: null,
|
||||
error: { message: 'Attestation required', code: 'VALIDATION' },
|
||||
})
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
await expect(
|
||||
store.recordObservations('enc-1', observations, false, 'password'),
|
||||
).rejects.toThrow('Attestation required')
|
||||
|
||||
expect(store.error).toBe('Attestation required')
|
||||
expect(store.lastResult).toBeNull()
|
||||
})
|
||||
|
||||
it('throws on network error', async () => {
|
||||
mockedPost.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
await expect(
|
||||
store.recordObservations('enc-1', observations, true, 'password'),
|
||||
).rejects.toThrow('Network error')
|
||||
|
||||
expect(store.error).toBe('Network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openEncounterWithVitals', () => {
|
||||
const observations = [
|
||||
{ observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: '' },
|
||||
]
|
||||
|
||||
it('returns result on success', async () => {
|
||||
const responseData = {
|
||||
batchId: 'b2',
|
||||
encounterId: 'enc-new',
|
||||
observations: [],
|
||||
criticalAlertCount: 0,
|
||||
promotedAt: '2026-06-27T10:00:01Z',
|
||||
}
|
||||
mockedPost.mockResolvedValueOnce({ success: true, statusCode: 201, data: responseData, error: null })
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
const result = await store.openEncounterWithVitals(
|
||||
'patient-1', 'ICU', '3A-12', 'Chest pain', observations, true, 'password',
|
||||
)
|
||||
|
||||
expect(result).toEqual(responseData)
|
||||
expect(mockedPost).toHaveBeenCalledWith('live-capture/encounters', {
|
||||
patientId: 'patient-1',
|
||||
department: 'ICU',
|
||||
roomBed: '3A-12',
|
||||
admissionReason: 'Chest pain',
|
||||
observations: [
|
||||
{ observationCode: 'TEMP_C', value: 37.2, unit: 'C', recordedAt: '2026-06-27T10:00:00Z', note: null },
|
||||
],
|
||||
clinicianAttestation: true,
|
||||
passwordConfirm: 'password',
|
||||
})
|
||||
})
|
||||
|
||||
it('sends null for empty roomBed', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 201,
|
||||
data: { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
await store.openEncounterWithVitals(
|
||||
'p1', 'ED', '', 'Fall', observations, true, 'pass',
|
||||
)
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith(
|
||||
'live-capture/encounters',
|
||||
expect.objectContaining({ roomBed: null }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears lastResult and error', () => {
|
||||
const store = useLiveCaptureStore()
|
||||
store.error = 'some error'
|
||||
store.lastResult = { batchId: 'b', encounterId: 'e', observations: [], criticalAlertCount: 0, promotedAt: '' }
|
||||
|
||||
store.reset()
|
||||
|
||||
expect(store.lastResult).toBeNull()
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import CoverSheetView from '@/views/CoverSheetView.vue'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
postBlob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input data-testid="patient-search" />',
|
||||
},
|
||||
}))
|
||||
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
const mockedPost = vi.mocked(post)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockedGet.mockImplementation(async (url: string) => {
|
||||
if (url === 'users') {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [{ id: 'clerk-1', username: 'entry1', fullName: 'Entry Clerk', role: 'DATA_ENTRY_CLERK' }],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
if (url === 'cover-sheets') {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: [
|
||||
{
|
||||
id: 'cs-1',
|
||||
code: 'VCR-CS-AABBCCDD',
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
patientId: null,
|
||||
patientName: null,
|
||||
patientMrn: null,
|
||||
assignToUserId: null,
|
||||
assignToUserName: null,
|
||||
isUsed: false,
|
||||
batchId: null,
|
||||
createdAt: '2026-06-27T12:00:00Z',
|
||||
usedAt: null,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
return { success: true, statusCode: 200, data: [], error: null }
|
||||
})
|
||||
})
|
||||
|
||||
describe('CoverSheetView', () => {
|
||||
it('renders generate form and cover sheet list', async () => {
|
||||
const wrapper = mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Generate Cover Sheets')
|
||||
expect(wrapper.text()).toContain('Cover Sheet List')
|
||||
expect(wrapper.text()).toContain('VCR-CS-AABBCCDD')
|
||||
expect(wrapper.find('select').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('loads entry clerks and cover sheets on mount', async () => {
|
||||
mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('users', { role: 'DATA_ENTRY_CLERK' })
|
||||
expect(mockedGet).toHaveBeenCalledWith('cover-sheets', expect.objectContaining({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}))
|
||||
})
|
||||
|
||||
it('submits generate request with form values', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 201,
|
||||
data: [{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
const batchTypeSelect = wrapper.findAll('select')[0]
|
||||
await batchTypeSelect.setValue('VITALS_SHEET')
|
||||
|
||||
const countInput = wrapper.find('input[type="number"]')
|
||||
await countInput.setValue(5)
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedPost).toHaveBeenCalledWith('cover-sheets/generate', {
|
||||
count: 5,
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Print Cover Sheets after successful generation', async () => {
|
||||
mockedPost.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 201,
|
||||
data: [
|
||||
{ id: 'new-cs-1', code: 'VCR-CS-NEW12345' },
|
||||
{ id: 'new-cs-2', code: 'VCR-CS-NEW67890' },
|
||||
],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(CoverSheetView)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Print Cover Sheets')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import IntakeView from '@/views/IntakeView.vue'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input data-testid="patient-search" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/BatchList.vue', () => ({
|
||||
default: {
|
||||
props: ['batches', 'loading', 'showAssign'],
|
||||
emits: ['assign', 'select'],
|
||||
template: '<div data-testid="batch-list" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AssignClerkDialog.vue', () => ({
|
||||
default: {
|
||||
props: ['show', 'batchId'],
|
||||
emits: ['close', 'assigned'],
|
||||
template: '<div />',
|
||||
},
|
||||
}))
|
||||
|
||||
const uploadBatchMock = vi.fn()
|
||||
|
||||
vi.mock('@/stores/batches', () => ({
|
||||
useBatchStore: () => ({
|
||||
batches: [],
|
||||
loading: false,
|
||||
listBatches: vi.fn(),
|
||||
uploadBatch: uploadBatchMock,
|
||||
assignBatch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ query: {} }),
|
||||
}))
|
||||
|
||||
import { get } from '@/api/client'
|
||||
|
||||
const mockedGet = vi.mocked(get)
|
||||
|
||||
const coverSheet = {
|
||||
id: 'cs-1',
|
||||
code: 'VCR-CS-A3F7B2D1',
|
||||
batchType: 'VITALS_SHEET',
|
||||
track: 'BACKFILL',
|
||||
patientId: 'patient-1',
|
||||
patientName: 'Maria Garcia',
|
||||
patientMrn: 'MRN-001',
|
||||
assignToUserId: null,
|
||||
assignToUserName: null,
|
||||
isUsed: false,
|
||||
batchId: null,
|
||||
createdAt: '2026-06-27T12:00:00Z',
|
||||
usedAt: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
uploadBatchMock.mockResolvedValue({ id: 'batch-1', status: 'UPLOADED' })
|
||||
})
|
||||
|
||||
describe('IntakeView cover sheet upload', () => {
|
||||
it('renders barcode input with autofocus and lookup button', () => {
|
||||
const wrapper = mount(IntakeView)
|
||||
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
||||
|
||||
expect(wrapper.text()).toContain('Quick Upload with Cover Sheet')
|
||||
expect(input.exists()).toBe(true)
|
||||
expect(input.attributes('autofocus')).toBeDefined()
|
||||
expect(wrapper.find('button.btn-secondary').text()).toBe('Lookup')
|
||||
})
|
||||
|
||||
it('looks up cover sheet on Enter and auto-fills form fields', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: coverSheet,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(IntakeView)
|
||||
const input = wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]')
|
||||
|
||||
await input.setValue('VCR-CS-A3F7B2D1')
|
||||
await input.trigger('keydown.enter')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedGet).toHaveBeenCalledWith('cover-sheets/lookup/VCR-CS-A3F7B2D1')
|
||||
expect(wrapper.text()).toContain('Cover Sheet Found')
|
||||
expect(wrapper.text()).toContain('Maria Garcia')
|
||||
expect((wrapper.findAll('select')[0].element as HTMLSelectElement).value).toBe('VITALS_SHEET')
|
||||
expect((wrapper.findAll('select')[1].element as HTMLSelectElement).value).toBe('BACKFILL')
|
||||
})
|
||||
|
||||
it('uploads with coverSheetCode when cover sheet is resolved', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
data: coverSheet,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const wrapper = mount(IntakeView)
|
||||
|
||||
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-A3F7B2D1')
|
||||
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').trigger('keydown.enter')
|
||||
await flushPromises()
|
||||
|
||||
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file] })
|
||||
await fileInput.trigger('change')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadBatchMock).toHaveBeenCalledWith(
|
||||
file,
|
||||
'VITALS_SHEET',
|
||||
'BACKFILL',
|
||||
'patient-1',
|
||||
undefined,
|
||||
'VCR-CS-A3F7B2D1',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Upload with Cover Sheet')
|
||||
})
|
||||
|
||||
it('keeps manual upload available without a cover sheet', async () => {
|
||||
const wrapper = mount(IntakeView)
|
||||
|
||||
expect(wrapper.text()).toContain('New Batch')
|
||||
expect(wrapper.text()).toContain('Upload and Create Batch')
|
||||
|
||||
const file = new File(['pdf'], 'scan.pdf', { type: 'application/pdf' })
|
||||
const fileInput = wrapper.find('input[type="file"]')
|
||||
Object.defineProperty(fileInput.element, 'files', { value: [file] })
|
||||
await fileInput.trigger('change')
|
||||
await wrapper.findAll('select')[0].setValue('LAB_RESULTS')
|
||||
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadBatchMock).toHaveBeenCalledWith(
|
||||
file,
|
||||
'LAB_RESULTS',
|
||||
'BACKFILL',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it('shows lookup error for unknown cover sheet', async () => {
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
success: false,
|
||||
statusCode: 404,
|
||||
data: null,
|
||||
error: { message: 'Cover sheet not found.', code: 'COVER_SHEET_NOT_FOUND' },
|
||||
})
|
||||
|
||||
const wrapper = mount(IntakeView)
|
||||
await wrapper.find('input[placeholder="VCR-CS-XXXXXXXX"]').setValue('VCR-CS-DEADBEEF')
|
||||
await wrapper.find('button.btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Cover sheet not found.')
|
||||
})
|
||||
})
|
||||
@@ -174,4 +174,13 @@ export async function uploadFile<T>(
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** POST JSON and receive a binary response (e.g. cover sheet PDF). */
|
||||
export async function postBlob(url: string, data?: unknown): Promise<Blob> {
|
||||
const response = await apiClient.post(url, data, {
|
||||
responseType: 'blob',
|
||||
timeout: 60000,
|
||||
})
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
@@ -8,6 +8,11 @@
|
||||
hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply bg-white text-primary-700 border border-primary-300 px-4 py-2 rounded-md
|
||||
hover:bg-primary-50 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
transition-colors;
|
||||
}
|
||||
.btn-danger {
|
||||
@apply bg-clinical-danger text-white px-4 py-2 rounded-md
|
||||
hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<h1 class="text-lg font-semibold">{{ title }}</h1>
|
||||
<nav class="flex gap-3 ml-4">
|
||||
<router-link v-if="auth.canIntake" to="/intake" class="nav-link">Intake</router-link>
|
||||
<router-link v-if="auth.canIntake" to="/cover-sheets" class="nav-link">Cover Sheets</router-link>
|
||||
<router-link v-if="auth.canEntry" to="/entry" class="nav-link">Entry</router-link>
|
||||
<router-link v-if="auth.canVerify" to="/verification" class="nav-link">Verification</router-link>
|
||||
<router-link v-if="auth.canApprove" to="/approval" class="nav-link">Approval</router-link>
|
||||
|
||||
@@ -14,6 +14,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/IntakeView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/cover-sheets',
|
||||
name: 'CoverSheets',
|
||||
component: () => import('../views/CoverSheetView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['INTAKE_CLERK', 'ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/entry',
|
||||
name: 'EntryQueue',
|
||||
|
||||
@@ -71,13 +71,16 @@ export const useBatchStore = defineStore('batches', () => {
|
||||
track: string,
|
||||
patientId?: string,
|
||||
supersedesBatchId?: string,
|
||||
coverSheetCode?: string,
|
||||
): Promise<BatchDetailResponse | null> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const fields: Record<string, string> = { batchType, track }
|
||||
const fields: Record<string, string> = { track }
|
||||
if (batchType) fields.batchType = batchType
|
||||
if (patientId) fields.patientId = patientId
|
||||
if (supersedesBatchId) fields.supersedesBatchId = supersedesBatchId
|
||||
if (coverSheetCode) fields.coverSheetCode = coverSheetCode
|
||||
|
||||
const response = await uploadFile<BatchDetailResponse>(
|
||||
'digitization-batches',
|
||||
|
||||
@@ -146,6 +146,30 @@ export interface UserSummary {
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface CoverSheetResponse {
|
||||
id: string
|
||||
code: string
|
||||
batchType: string
|
||||
track: string
|
||||
patientId: string | null
|
||||
patientName: string | null
|
||||
patientMrn: string | null
|
||||
assignToUserId: string | null
|
||||
assignToUserName: string | null
|
||||
isUsed: boolean
|
||||
batchId: string | null
|
||||
createdAt: string
|
||||
usedAt: string | null
|
||||
}
|
||||
|
||||
export interface GenerateCoverSheetsRequest {
|
||||
count: number
|
||||
batchType: string
|
||||
track: string
|
||||
patientId?: string
|
||||
assignToUserId?: string
|
||||
}
|
||||
|
||||
export interface DigitizationEventSummary {
|
||||
eventType: string
|
||||
occurredAt: string
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader title="Cover Sheets" />
|
||||
|
||||
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full">
|
||||
<h1 class="text-2xl font-bold mb-6">Cover Sheet Management</h1>
|
||||
|
||||
<!-- Generate -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Generate Cover Sheets</h2>
|
||||
|
||||
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Count (1–100)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-input max-w-xs"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||
<select v-model="batchType" class="form-input" required>
|
||||
<option value="">Select batch type...</option>
|
||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||
<option value="VITALS_SHEET">Vitals Sheet</option>
|
||||
<option value="LAB_RESULTS">Lab Results</option>
|
||||
<option value="MEDICATION_LIST">Medication List</option>
|
||||
<option value="ALLERGY_UPDATE">Allergy Update</option>
|
||||
<option value="MIXED">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Track</label>
|
||||
<select v-model="track" class="form-input">
|
||||
<option value="BACKFILL">Backfill (Track A)</option>
|
||||
<option value="LIVE_CAPTURE">Live Capture (Track B)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Patient (optional)
|
||||
</label>
|
||||
<PatientSearch v-model="patientId" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Assign to Clerk (optional)
|
||||
</label>
|
||||
<select v-model="assignToUserId" class="form-input" :disabled="clerksLoading">
|
||||
<option value="">No pre-assignment</option>
|
||||
<option v-for="clerk in clerks" :key="clerk.id" :value="clerk.id">
|
||||
{{ clerk.fullName }} ({{ clerk.username }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="generateError" class="text-clinical-danger text-sm">
|
||||
{{ generateError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="btn-primary" :disabled="generating || !batchType">
|
||||
{{ generating ? 'Generating...' : 'Generate' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lastGeneratedIds.length > 0"
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
:disabled="printing"
|
||||
@click="printLastGenerated"
|
||||
>
|
||||
{{ printing ? 'Opening PDF...' : 'Print Cover Sheets' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="card">
|
||||
<div class="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-4">
|
||||
<h2 class="text-lg font-semibold">Cover Sheet List</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<select v-model="statusFilter" class="form-input" @change="onFiltersChanged">
|
||||
<option value="all">All</option>
|
||||
<option value="unused">Unused</option>
|
||||
<option value="used">Used</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="min-w-[240px]">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
||||
<PatientSearch v-model="filterPatientId" @update:model-value="onFiltersChanged" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="listError" class="text-clinical-danger text-sm mb-4">
|
||||
{{ listError }}
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div v-if="listLoading" class="text-gray-500 text-center py-4">Loading...</div>
|
||||
<div v-else-if="coverSheets.length === 0" class="text-gray-500 text-center py-4">
|
||||
No cover sheets found.
|
||||
</div>
|
||||
<table v-else class="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-gray-600">
|
||||
<th class="py-2 px-4">Code</th>
|
||||
<th class="py-2 px-4">Batch Type</th>
|
||||
<th class="py-2 px-4">Track</th>
|
||||
<th class="py-2 px-4">Patient</th>
|
||||
<th class="py-2 px-4">Assigned To</th>
|
||||
<th class="py-2 px-4">Status</th>
|
||||
<th class="py-2 px-4">Linked Batch</th>
|
||||
<th class="py-2 px-4">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="sheet in coverSheets"
|
||||
:key="sheet.id"
|
||||
class="border-b hover:bg-gray-50"
|
||||
>
|
||||
<td class="py-2 px-4 font-mono text-xs">{{ sheet.code }}</td>
|
||||
<td class="py-2 px-4">{{ formatBatchType(sheet.batchType) }}</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="sheet.track === 'BACKFILL'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ sheet.track === 'BACKFILL' ? 'Backfill' : 'Live' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<template v-if="sheet.patientName">
|
||||
{{ sheet.patientName }}
|
||||
<span v-if="sheet.patientMrn" class="text-gray-500">({{ sheet.patientMrn }})</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
{{ sheet.assignToUserName ?? '—' }}
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<span
|
||||
:class="sheet.isUsed
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-green-100 text-green-800'"
|
||||
class="status-badge"
|
||||
>
|
||||
{{ sheet.isUsed ? 'Used' : 'Unused' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-4">
|
||||
<router-link
|
||||
v-if="sheet.batchId"
|
||||
:to="{ path: '/intake', query: { batchId: sheet.batchId } }"
|
||||
class="font-mono text-xs text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{{ sheet.batchId.substring(0, 8) }}...
|
||||
</router-link>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="py-2 px-4 text-gray-500">
|
||||
{{ formatDate(sheet.createdAt) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p class="text-sm text-gray-500">Page {{ page }}</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="page <= 1 || listLoading"
|
||||
@click="goToPage(page - 1)"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary text-sm"
|
||||
:disabled="!hasNextPage || listLoading"
|
||||
@click="goToPage(page + 1)"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, post, postBlob } from '../api/client'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import type { CoverSheetResponse, UserSummary } from '../types'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const count = ref(10)
|
||||
const batchType = ref('')
|
||||
const track = ref('BACKFILL')
|
||||
const patientId = ref<string | undefined>()
|
||||
const assignToUserId = ref('')
|
||||
|
||||
const clerks = ref<UserSummary[]>([])
|
||||
const clerksLoading = ref(false)
|
||||
const generating = ref(false)
|
||||
const printing = ref(false)
|
||||
const generateError = ref('')
|
||||
const lastGeneratedIds = ref<string[]>([])
|
||||
|
||||
const coverSheets = ref<CoverSheetResponse[]>([])
|
||||
const listLoading = ref(false)
|
||||
const listError = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const statusFilter = ref<'all' | 'used' | 'unused'>('all')
|
||||
const filterPatientId = ref<string | undefined>()
|
||||
|
||||
const hasNextPage = computed(() => coverSheets.value.length === pageSize)
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
async function loadClerks(): Promise<void> {
|
||||
clerksLoading.value = true
|
||||
try {
|
||||
const response = await get<UserSummary[]>('users', { role: 'DATA_ENTRY_CLERK' })
|
||||
clerks.value = response.success && response.data ? response.data : []
|
||||
} catch {
|
||||
clerks.value = []
|
||||
} finally {
|
||||
clerksLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCoverSheets(): Promise<void> {
|
||||
listLoading.value = true
|
||||
listError.value = ''
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
page: page.value,
|
||||
pageSize,
|
||||
}
|
||||
|
||||
if (statusFilter.value === 'used') params.isUsed = true
|
||||
if (statusFilter.value === 'unused') params.isUsed = false
|
||||
if (filterPatientId.value) params.patientId = filterPatientId.value
|
||||
|
||||
try {
|
||||
const response = await get<CoverSheetResponse[]>('cover-sheets', params)
|
||||
if (response.success && response.data) {
|
||||
coverSheets.value = response.data
|
||||
} else {
|
||||
coverSheets.value = []
|
||||
listError.value = response.error?.message ?? 'Failed to load cover sheets'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
coverSheets.value = []
|
||||
listError.value = e instanceof Error ? e.message : 'Failed to load cover sheets'
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onFiltersChanged(): void {
|
||||
page.value = 1
|
||||
void loadCoverSheets()
|
||||
}
|
||||
|
||||
function goToPage(nextPage: number): void {
|
||||
page.value = nextPage
|
||||
void loadCoverSheets()
|
||||
}
|
||||
|
||||
async function handleGenerate(): Promise<void> {
|
||||
if (!batchType.value) return
|
||||
|
||||
generating.value = true
|
||||
generateError.value = ''
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
count: Math.min(100, Math.max(1, count.value || 10)),
|
||||
batchType: batchType.value,
|
||||
track: track.value,
|
||||
}
|
||||
|
||||
if (patientId.value) payload.patientId = patientId.value
|
||||
if (assignToUserId.value) payload.assignToUserId = assignToUserId.value
|
||||
|
||||
try {
|
||||
const response = await post<CoverSheetResponse[]>('cover-sheets/generate', payload)
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? 'Generation failed')
|
||||
}
|
||||
|
||||
lastGeneratedIds.value = response.data.map(s => s.id)
|
||||
toast.success(`Generated ${response.data.length} cover sheet(s)`)
|
||||
page.value = 1
|
||||
await loadCoverSheets()
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Generation failed'
|
||||
generateError.value = msg
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function printLastGenerated(): Promise<void> {
|
||||
if (lastGeneratedIds.value.length === 0) return
|
||||
|
||||
printing.value = true
|
||||
try {
|
||||
const blob = await postBlob('cover-sheets/batch-pdf', {
|
||||
coverSheetIds: lastGeneratedIds.value,
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank')
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
toast.success('Cover sheet PDF opened in a new tab')
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to generate PDF')
|
||||
} finally {
|
||||
printing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadClerks(), loadCoverSheets()])
|
||||
})
|
||||
</script>
|
||||
@@ -5,6 +5,80 @@
|
||||
<div class="page-container flex-1">
|
||||
<h1 class="text-2xl font-bold mb-6">Upload Scanned Document</h1>
|
||||
|
||||
<!-- Barcode-assisted upload -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Quick Upload with Cover Sheet</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Scan or type a cover sheet barcode to auto-populate batch details.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cover Sheet Code
|
||||
</label>
|
||||
<input
|
||||
v-model="coverSheetCode"
|
||||
@keydown.enter.prevent="lookupCoverSheet"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="VCR-CS-XXXXXXXX"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="lookupCoverSheet"
|
||||
class="btn-secondary"
|
||||
:disabled="!coverSheetCode.trim() || lookupLoading"
|
||||
>
|
||||
{{ lookupLoading ? 'Looking up...' : 'Lookup' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="lookupError" class="text-clinical-danger text-sm mt-3">
|
||||
{{ lookupError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="resolvedCoverSheet"
|
||||
class="mt-4 bg-green-50 border border-green-200 rounded-md p-4"
|
||||
>
|
||||
<p class="text-sm font-medium text-green-800">Cover Sheet Found</p>
|
||||
<dl class="mt-2 text-sm text-green-700 space-y-1">
|
||||
<div>
|
||||
<dt class="inline font-medium">Type:</dt>
|
||||
<dd class="inline">{{ formatBatchType(resolvedCoverSheet.batchType) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="inline font-medium">Track:</dt>
|
||||
<dd class="inline">{{ formatTrack(resolvedCoverSheet.track) }}</dd>
|
||||
</div>
|
||||
<div v-if="resolvedCoverSheet.patientName">
|
||||
<dt class="inline font-medium">Patient:</dt>
|
||||
<dd class="inline">
|
||||
{{ resolvedCoverSheet.patientName }}
|
||||
({{ resolvedCoverSheet.patientMrn }})
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="resolvedCoverSheet.assignToUserName">
|
||||
<dt class="inline font-medium">Assign to:</dt>
|
||||
<dd class="inline">{{ resolvedCoverSheet.assignToUserName }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="text-sm text-green-600 mt-3">
|
||||
Select a file below and click "Upload with Cover Sheet" to create the batch.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearCoverSheet"
|
||||
class="text-xs text-green-700 hover:text-green-900 mt-2"
|
||||
>
|
||||
Clear cover sheet (use manual upload)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload form -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">New Batch</h2>
|
||||
@@ -35,7 +109,11 @@
|
||||
<!-- Batch type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Type</label>
|
||||
<select v-model="batchType" class="form-input" required>
|
||||
<select
|
||||
v-model="batchType"
|
||||
class="form-input"
|
||||
:required="!resolvedCoverSheet"
|
||||
>
|
||||
<option value="">Select batch type...</option>
|
||||
<option value="PATIENT_REGISTRATION">Patient Registration</option>
|
||||
<option value="ENCOUNTER_SUMMARY">Encounter Summary</option>
|
||||
@@ -72,6 +150,7 @@
|
||||
<span class="font-mono">{{ supersedesBatchId.substring(0, 8) }}...</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="clearCorrection"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||
>
|
||||
@@ -86,9 +165,9 @@
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="!selectedFile || !batchType || batchStore.loading"
|
||||
:disabled="!canUpload"
|
||||
>
|
||||
{{ batchStore.loading ? 'Uploading...' : 'Upload and Create Batch' }}
|
||||
{{ uploadButtonLabel }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -118,14 +197,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { get } from '../api/client'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||
import type { CoverSheetResponse } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const batchStore = useBatchStore()
|
||||
@@ -141,13 +222,87 @@ const assignError = ref('')
|
||||
const assignDialogOpen = ref(false)
|
||||
const assignBatchId = ref<string | null>(null)
|
||||
|
||||
const coverSheetCode = ref('')
|
||||
const resolvedCoverSheet = ref<CoverSheetResponse | null>(null)
|
||||
const lookupLoading = ref(false)
|
||||
const lookupError = ref('')
|
||||
|
||||
const canUpload = computed(() => {
|
||||
if (!selectedFile.value || batchStore.loading) return false
|
||||
if (resolvedCoverSheet.value) return true
|
||||
return !!batchType.value
|
||||
})
|
||||
|
||||
const uploadButtonLabel = computed(() => {
|
||||
if (batchStore.loading) return 'Uploading...'
|
||||
if (resolvedCoverSheet.value) return 'Upload with Cover Sheet'
|
||||
return 'Upload and Create Batch'
|
||||
})
|
||||
|
||||
watch(coverSheetCode, () => {
|
||||
if (resolvedCoverSheet.value && coverSheetCode.value.trim().toUpperCase() !== resolvedCoverSheet.value.code) {
|
||||
resolvedCoverSheet.value = null
|
||||
lookupError.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatTrack(value: string): string {
|
||||
return value === 'BACKFILL' ? 'Backfill (Track A)' : 'Live Capture (Track B)'
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
selectedFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function lookupCoverSheet(): Promise<void> {
|
||||
const code = coverSheetCode.value.trim().toUpperCase()
|
||||
if (!code) return
|
||||
|
||||
lookupLoading.value = true
|
||||
lookupError.value = ''
|
||||
resolvedCoverSheet.value = null
|
||||
|
||||
try {
|
||||
const response = await get<CoverSheetResponse>(
|
||||
`cover-sheets/lookup/${encodeURIComponent(code)}`,
|
||||
)
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error?.message ?? 'Cover sheet not found')
|
||||
}
|
||||
|
||||
if (response.data.isUsed) {
|
||||
throw new Error('Cover sheet has already been used')
|
||||
}
|
||||
|
||||
resolvedCoverSheet.value = response.data
|
||||
coverSheetCode.value = response.data.code
|
||||
batchType.value = response.data.batchType
|
||||
track.value = response.data.track
|
||||
patientId.value = response.data.patientId ?? undefined
|
||||
toast.success('Cover sheet found')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Lookup failed'
|
||||
lookupError.value = msg
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
lookupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearCoverSheet(): void {
|
||||
coverSheetCode.value = ''
|
||||
resolvedCoverSheet.value = null
|
||||
lookupError.value = ''
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile.value || !batchType.value) return
|
||||
if (!selectedFile.value || !canUpload.value) return
|
||||
|
||||
uploadError.value = ''
|
||||
try {
|
||||
@@ -157,15 +312,24 @@ async function handleUpload() {
|
||||
track.value,
|
||||
patientId.value,
|
||||
supersedesBatchId.value,
|
||||
resolvedCoverSheet.value?.code,
|
||||
)
|
||||
if (batch) {
|
||||
const isCorrection = !!supersedesBatchId.value
|
||||
const usedCoverSheet = !!resolvedCoverSheet.value
|
||||
selectedFile.value = null
|
||||
batchType.value = ''
|
||||
track.value = 'BACKFILL'
|
||||
patientId.value = undefined
|
||||
supersedesBatchId.value = undefined
|
||||
toast.success(isCorrection ? 'Correction batch created' : 'Batch uploaded successfully')
|
||||
clearCoverSheet()
|
||||
toast.success(
|
||||
isCorrection
|
||||
? 'Correction batch created'
|
||||
: usedCoverSheet
|
||||
? 'Batch uploaded with cover sheet'
|
||||
: 'Batch uploaded successfully',
|
||||
)
|
||||
await loadRecent()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user