feature: Barcode/QR Cover Sheet System

This commit is contained in:
voltsrage
2026-06-27 21:50:32 +08:00
parent 5db60f46eb
commit 756cff332c
43 changed files with 8203 additions and 21 deletions
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -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.')
})
})
+9
View File
@@ -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',
+4 -1
View File
@@ -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',
+24
View File
@@ -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&ndash;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">&mdash;</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">&mdash;</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>
+170 -6
View File
@@ -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) {
+17
View File
@@ -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'],
},
})