diff --git a/vigilcare-records-web/src/__tests__/components/ApprovalForm.test.ts b/vigilcare-records-web/src/__tests__/components/ApprovalForm.test.ts new file mode 100644 index 0000000..aff4067 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/ApprovalForm.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import ApprovalForm from '@/components/ApprovalForm.vue' +import { useBatchStore } from '@/stores/batches' +import type { BatchDetailResponse } from '@/types' +import { + emptyDraft, + fieldRequirementsForBatchType, +} from '@/__tests__/helpers/fieldRequirements' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn(), + patch: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})) + +const mountOptions = { + global: { + stubs: { + Teleport: { template: '
' }, + }, + }, +} + +function makeBatch(overrides: Partial = {}): BatchDetailResponse { + const batchType = overrides.batchType ?? 'VITALS' + return { + id: 'b1', + status: 'AWAITING_CLINICAL_APPROVAL', + batchType, + track: 'TRACK_A', + fieldRequirements: fieldRequirementsForBatchType(batchType), + patientId: 'p1', + documentRef: 'docs/scan.pdf', + documentUrl: null, + enableRetroactiveAlerts: false, + enteredByUserId: 'u1', + verifiedByUserId: 'v1-aaaaaaa', + approvedByUserId: null, + rejectionReason: null, + promotedAt: null, + promotionEncounterId: null, + supersedesBatchId: null, + clinicianAttestation: false, + isCorrection: false, + supersession: null, + createdAt: '2026-06-27T10:00:00Z', + updatedAt: '2026-06-27T10:00:00Z', + ...overrides, + } +} + +function mountForm(opts: { + batch?: BatchDetailResponse + draft?: ReturnType +} = {}) { + const batch = opts.batch ?? makeBatch() + const draft = + opts.draft ?? + emptyDraft(batch.batchType, { + patient: { + id: 'dp1', + fullName: 'Jane Doe', + dateOfBirth: '1990-05-15', + sex: 'female', + bloodType: 'A+', + emergencyContact: '555-1234', + allergies: ['Penicillin'], + noKnownAllergies: false, + medications: ['Metoprolol 50mg'], + noActiveMedications: false, + }, + encounter: { + id: 'de1', + batchId: 'b1', + admissionDate: '2026-06-20T08:00:00', + department: 'ICU', + roomBed: '3A-12', + admissionReason: 'Chest pain', + dischargeDiagnosis: null, + status: null, + }, + observations: [ + { + id: 'obs-1', + batchId: 'b1', + observationCode: 'TEMP_C', + value: 38.4, + unit: 'C', + recordedAt: '2026-06-27T10:00:00Z', + note: null, + }, + ], + }) + + return mount(ApprovalForm, { + props: { batch, batchId: 'b1', draft }, + ...mountOptions, + }) +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('ApprovalForm', () => { + it('uses clinical framing distinct from verification', () => { + const wrapper = mountForm() + expect(wrapper.find('[data-testid="approval-form"]').classes()).toContain('approval-frame') + expect(wrapper.text()).toContain('Clinical Approval') + expect(wrapper.text()).toContain('Clinical sign-off before promotion') + expect(wrapper.text()).toContain('Verified draft') + }) + + it('highlights high-stakes fields from draft data', () => { + const wrapper = mountForm() + const summary = wrapper.find('[data-testid="high-stakes-summary"]') + expect(summary.exists()).toBe(true) + expect(summary.text()).toContain('Blood type') + expect(summary.text()).toContain('A+') + expect(summary.text()).toContain('Allergies') + expect(summary.text()).toContain('Penicillin') + expect(summary.text()).toContain('Medications') + expect(summary.text()).toContain('Metoprolol') + expect(summary.text()).toContain('TEMP C') + expect(summary.text()).toContain('38.4') + }) + + it('uses design-doc retroactive alerts copy', () => { + const wrapper = mountForm() + expect(wrapper.text()).toContain('Run alert evaluation after promotion') + expect(wrapper.text()).toContain( + 'May generate alerts for clinical criteria represented in historical records' + ) + }) + + it('shows Approve & Promote confirm dialog with live promotion copy', async () => { + const wrapper = mountForm() + await wrapper.find('[data-testid="approve-promote"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Approve & Promote') + expect(wrapper.text()).toContain( + 'Approval will promote the verified records into live clinical tables.' + ) + }) + + it('calls approveBatch with retroactive alerts flag', async () => { + const wrapper = mountForm() + const store = useBatchStore() + store.approveBatch = vi.fn().mockResolvedValue({ + status: 200, + data: { mrn: 'MRN-1', encounterId: 'enc-aaaaaaaa', observationIds: ['o1', 'o2'] }, + }) + + await wrapper.find('[data-testid="retroactive-alerts"]').setValue(true) + await wrapper.find('[data-testid="approve-promote"]').trigger('click') + await wrapper.vm.$nextTick() + + const confirm = wrapper + .findAll('[data-testid="confirm-dialog"] button') + .find((b) => b.text() === 'Approve & Promote') + await confirm!.trigger('click') + await flushPromises() + + expect(store.approveBatch).toHaveBeenCalledWith('b1', true) + expect(wrapper.find('[data-testid="promotion-result"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Promoted to live clinical tables') + expect(wrapper.text()).toContain('MRN-1') + expect(wrapper.text()).toContain('Promotion outcome') + }) + + it('opens Reject Batch dialog and posts reason', async () => { + const wrapper = mountForm() + const store = useBatchStore() + store.rejectBatch = vi.fn().mockResolvedValue(undefined) + + await wrapper.find('[data-testid="reject-batch"]').trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Reject Batch') + await wrapper.find('textarea').setValue('Incorrect patient') + await wrapper.vm.$nextTick() + + const confirm = wrapper + .findAll('[data-testid="confirm-dialog"] button') + .find((b) => b.text() === 'Reject Batch') + await confirm!.trigger('click') + await flushPromises() + + expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Incorrect patient') + expect(wrapper.emitted('rejected')).toHaveLength(1) + }) + + it('shows deferred promotion state', async () => { + const wrapper = mountForm() + const store = useBatchStore() + store.approveBatch = vi.fn().mockResolvedValue({ status: 202 }) + + await wrapper.find('[data-testid="approve-promote"]').trigger('click') + await wrapper.vm.$nextTick() + const confirm = wrapper + .findAll('[data-testid="confirm-dialog"] button') + .find((b) => b.text() === 'Approve & Promote') + await confirm!.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="promotion-deferred"]').exists()).toBe(true) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts index 266c0d6..b7d8f86 100644 --- a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts @@ -110,6 +110,33 @@ describe('EntryForm', () => { const buttons = wrapper.findAll('button') const submitButton = buttons.find((b) => b.text().includes('Submit for Verification')) expect(submitButton).toBeTruthy() + expect(wrapper.find('[data-testid="workstation-action-bar"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Save Draft') + }) + + it('shows autosave status after patient field blur', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1' }, + }) + const store = useBatchStore() + store.saveDraftPatient = vi.fn().mockResolvedValue(undefined) + + const nameInput = wrapper.find('input[type="text"]') + await nameInput.setValue('Jane Doe') + await nameInput.trigger('blur') + await flushPromises() + + expect(wrapper.find('[data-testid="entry-save-status"]').text()).toMatch(/^Saved /) + }) + + it('shows Next when nextBatchId is provided', async () => { + const wrapper = mount(EntryForm, { + props: { batch: makeBatch(), batchId: 'b1', nextBatchId: 'b2' }, + }) + const next = wrapper.find('[data-testid="entry-next-batch"]') + expect(next.exists()).toBe(true) + await next.trigger('click') + expect(wrapper.emitted('open-next')?.[0]).toEqual(['b2']) }) it('displays batch status', () => { diff --git a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts index 08ef2c3..4672522 100644 --- a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts @@ -218,15 +218,23 @@ describe('VerificationForm', () => { }) describe('approve button', () => { + async function selectPass(wrapper: ReturnType) { + const passRadio = wrapper.find('input[type="radio"][value="pass"]') + await passRadio.setValue(true) + await wrapper.vm.$nextTick() + } + it('is disabled when not all fields are checked', async () => { const { wrapper } = mountWithDraft() await wrapper.vm.$nextTick() + await selectPass(wrapper) - const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) - expect(approveBtn!.element.disabled).toBe(true) + const approveBtn = wrapper.find('[data-testid="verify-pass"]') + expect(approveBtn.element).toBeTruthy() + expect((approveBtn.element as HTMLButtonElement).disabled).toBe(true) }) - it('is enabled when all fields are checked', async () => { + it('is enabled when all fields are checked and Pass is selected', async () => { const { wrapper } = mountWithDraft() await wrapper.vm.$nextTick() @@ -234,13 +242,14 @@ describe('VerificationForm', () => { for (const cb of checkboxes) { await cb.setValue(true) } - await wrapper.vm.$nextTick() + await selectPass(wrapper) - const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) - expect(approveBtn!.element.disabled).toBe(false) + const approveBtn = wrapper.find('[data-testid="verify-pass"]') + expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false) + expect(approveBtn.text()).toBe('Pass Verification') }) - it('calls verifyBatch with all field checks on approve', async () => { + it('calls verifyBatch with all field checks on Pass Verification', async () => { const { wrapper, store } = mountWithDraft() await wrapper.vm.$nextTick() @@ -250,10 +259,9 @@ describe('VerificationForm', () => { for (const cb of checkboxes) { await cb.setValue(true) } - await wrapper.vm.$nextTick() + await selectPass(wrapper) - const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) - await approveBtn!.trigger('click') + await wrapper.find('[data-testid="verify-pass"]').trigger('click') await flushPromises() expect(store.verifyBatch).toHaveBeenCalledWith( @@ -267,41 +275,52 @@ describe('VerificationForm', () => { }) describe('reject flow', () => { - it('shows reject dialog when reject button is clicked', async () => { - const wrapper = mountForm() + async function selectReject(wrapper: ReturnType) { + const rejectRadio = wrapper.find('input[type="radio"][value="reject"]') + await rejectRadio.setValue(true) + await wrapper.vm.$nextTick() + } - const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') - await rejectBtn!.trigger('click') + it('shows Return for Rework and opens confirm dialog', async () => { + const wrapper = mountForm() + await selectReject(wrapper) + + const returnBtn = wrapper.find('[data-testid="verify-return"]') + expect(returnBtn.text()).toBe('Return for Rework') + await returnBtn.trigger('click') await wrapper.vm.$nextTick() - expect(wrapper.text()).toContain('Reject Batch') - expect(wrapper.text()).toContain('Confirm Rejection') + expect(wrapper.text()).toContain('Return for Rework') + expect(wrapper.text()).toContain('This returns the batch for rework') }) it('disables confirm button when reason is empty', async () => { const wrapper = mountForm() + await selectReject(wrapper) - const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') - await rejectBtn!.trigger('click') + await wrapper.find('[data-testid="verify-return"]').trigger('click') await wrapper.vm.$nextTick() - const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') - expect(confirmBtn!.element.disabled).toBe(true) + const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button') + .filter((b) => b.text() === 'Return for Rework') + expect(dialogConfirms.length).toBeGreaterThan(0) + expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(true) }) it('enables confirm button when reason is entered', async () => { const wrapper = mountForm() + await selectReject(wrapper) - const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') - await rejectBtn!.trigger('click') + await wrapper.find('[data-testid="verify-return"]').trigger('click') await wrapper.vm.$nextTick() const textarea = wrapper.find('textarea') await textarea.setValue('Temperature value appears incorrect') await wrapper.vm.$nextTick() - const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') - expect(confirmBtn!.element.disabled).toBe(false) + const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button') + .filter((b) => b.text() === 'Return for Rework') + expect((dialogConfirms[0].element as HTMLButtonElement).disabled).toBe(false) }) it('calls rejectBatch on confirm', async () => { @@ -310,16 +329,17 @@ describe('VerificationForm', () => { const store = useBatchStore() store.rejectBatch = vi.fn().mockResolvedValue(undefined) - const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') - await rejectBtn!.trigger('click') + await selectReject(wrapper) + await wrapper.find('[data-testid="verify-return"]').trigger('click') await wrapper.vm.$nextTick() const textarea = wrapper.find('textarea') await textarea.setValue('Value incorrect') await wrapper.vm.$nextTick() - const confirmBtn = wrapper.findAll('button').find((b) => b.text() === 'Confirm Rejection') - await confirmBtn!.trigger('click') + const dialogConfirms = wrapper.findAll('[data-testid="confirm-dialog"] button') + .filter((b) => b.text() === 'Return for Rework') + await dialogConfirms[0].trigger('click') await flushPromises() expect(store.rejectBatch).toHaveBeenCalledWith('b1', 'Value incorrect') @@ -327,18 +347,18 @@ describe('VerificationForm', () => { it('closes reject dialog on cancel', async () => { const wrapper = mountForm() + await selectReject(wrapper) - const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') - await rejectBtn!.trigger('click') + await wrapper.find('[data-testid="verify-return"]').trigger('click') await wrapper.vm.$nextTick() - expect(wrapper.text()).toContain('Reject Batch') + expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(true) const cancelBtn = wrapper.findAll('button').find((b) => b.text() === 'Cancel') await cancelBtn!.trigger('click') await wrapper.vm.$nextTick() - expect(wrapper.text()).not.toContain('Reject Batch') + expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(false) }) }) @@ -362,12 +382,11 @@ describe('VerificationForm', () => { for (const cb of checkboxes) { await cb.setValue(true) } + await wrapper.find('input[type="radio"][value="pass"]').setValue(true) await wrapper.vm.$nextTick() - const approveBtn = wrapper - .findAll('button') - .find((b) => b.text().includes('Approve - Verified')) - expect(approveBtn!.element.disabled).toBe(false) + const approveBtn = wrapper.find('[data-testid="verify-pass"]') + expect((approveBtn.element as HTMLButtonElement).disabled).toBe(false) }) it('blocks Pass when the current user entered the batch', async () => { @@ -384,19 +403,13 @@ describe('VerificationForm', () => { expect(wrapper.text()).toContain('You cannot verify a batch you entered.') - const checkboxes = wrapper.findAll('input[type="checkbox"]') - for (const cb of checkboxes) { - await cb.setValue(true) - } - await wrapper.vm.$nextTick() + const passRadio = wrapper.find('input[type="radio"][value="pass"]') + expect((passRadio.element as HTMLInputElement).disabled).toBe(true) + const rejectRadio = wrapper.find('input[type="radio"][value="reject"]') + expect((rejectRadio.element as HTMLInputElement).disabled).toBe(true) - const approveBtn = wrapper - .findAll('button') - .find((b) => b.text().includes('Approve - Verified')) - expect(approveBtn!.element.disabled).toBe(true) - - const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') - expect(rejectBtn!.element.disabled).toBe(true) + expect(wrapper.find('[data-testid="verify-pass"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="verify-return"]').exists()).toBe(false) }) }) @@ -476,10 +489,10 @@ describe('VerificationForm', () => { for (const cb of checkboxes) { await cb.setValue(true) } + await wrapper.find('input[type="radio"][value="pass"]').setValue(true) await wrapper.vm.$nextTick() - const approveBtn = wrapper.findAll('button').find((b) => b.text().includes('Approve')) - await approveBtn!.trigger('click') + await wrapper.find('[data-testid="verify-pass"]').trigger('click') await flushPromises() expect(wrapper.text()).toContain('Server error') diff --git a/vigilcare-records-web/src/__tests__/components/WorkstationLayout.test.ts b/vigilcare-records-web/src/__tests__/components/WorkstationLayout.test.ts new file mode 100644 index 0000000..326ce61 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/WorkstationLayout.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ScanViewer from '@/components/ScanViewer.vue' +import WorkstationLayout from '@/components/WorkstationLayout.vue' +import WorkstationQueueRail from '@/components/WorkstationQueueRail.vue' +import type { BatchDetailResponse } from '@/types' + +function makeBatch(overrides: Partial = {}): BatchDetailResponse { + return { + id: 'batch-aaaaaaaa', + status: 'IN_ENTRY', + batchType: 'VITALS', + track: 'TRACK_A', + createdAt: '2026-01-15T10:00:00Z', + ...overrides, + } as BatchDetailResponse +} + +describe('WorkstationLayout', () => { + it('renders full-width queue when no batch is open', () => { + const wrapper = mount(WorkstationLayout, { + props: { hasBatch: false }, + slots: { + queue: '

Data Entry Queue

', + scan: '
scan
', + form: '
form
', + }, + }) + expect(wrapper.find('[data-testid="workstation-queue"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workstation-split"]').exists()).toBe(false) + expect(wrapper.text()).toContain('Data Entry Queue') + }) + + it('renders scan + form split when a batch is open', () => { + const wrapper = mount(WorkstationLayout, { + props: { hasBatch: true }, + slots: { + scan: '
scan
', + form: '
form
', + }, + }) + expect(wrapper.find('[data-testid="workstation-split"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workstation-split"]').classes()).toContain( + 'workstation-split--no-rail' + ) + expect(wrapper.find('[data-testid="slot-scan"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="slot-form"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workstation-rail"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="evidence-level-1"]').text()).toContain('Source scan') + }) + + it('shows left rail when rail slot is provided', () => { + const wrapper = mount(WorkstationLayout, { + props: { hasBatch: true }, + slots: { + rail: '
rail items
', + scan: '
scan
', + form: '
form
', + }, + }) + expect(wrapper.find('[data-testid="workstation-rail"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="workstation-split"]').classes()).not.toContain( + 'workstation-split--no-rail' + ) + expect(wrapper.text()).toContain('rail items') + }) +}) + +describe('WorkstationQueueRail', () => { + it('lists batches and emits select / back', async () => { + const wrapper = mount(WorkstationQueueRail, { + props: { + title: 'Entry queue', + batches: [ + makeBatch({ id: 'batch-11111111' }), + makeBatch({ id: 'batch-22222222', status: 'PENDING_VERIFICATION' }), + ], + selectedId: 'batch-11111111', + }, + }) + expect(wrapper.text()).toContain('Entry queue') + expect(wrapper.text()).toContain('batch-11') + expect(wrapper.text()).toContain('Pending Verification') + + await wrapper.findAll('button')[0].trigger('click') + expect(wrapper.emitted('select')?.[0]).toEqual(['batch-11111111']) + + await wrapper.findAll('button').at(-1)!.trigger('click') + expect(wrapper.emitted('back')).toHaveLength(1) + }) +}) + +describe('ScanViewer', () => { + it('renders neutral toolbar with zoom and fit width', () => { + const wrapper = mount(ScanViewer, { + props: { url: 'blob:http://localhost/doc.png' }, + }) + const toolbar = wrapper.find('[data-testid="scan-viewer-toolbar"]') + expect(toolbar.exists()).toBe(true) + expect(toolbar.classes()).toContain('bg-surface') + expect(wrapper.find('[data-testid="scan-fit-width"]').exists()).toBe(true) + expect(wrapper.text()).toContain('Fit width') + expect(wrapper.text()).toContain('Rotate') + }) + + it('shows skeleton while loading', () => { + const wrapper = mount(ScanViewer, { + props: { loading: true }, + }) + expect(wrapper.find('[role="status"]').exists()).toBe(true) + expect(wrapper.find('img').exists()).toBe(false) + }) + + it('shows inline error with retry and preserves draft copy', async () => { + const wrapper = mount(ScanViewer, { + props: { error: 'Network failed', loading: false }, + }) + expect(wrapper.text()).toContain('Could not load document') + expect(wrapper.text()).toContain('Network failed') + expect(wrapper.text()).toContain('Your form draft was not affected.') + const retry = wrapper.findAll('button').find((b) => b.text() === 'Retry') + expect(retry).toBeTruthy() + await retry!.trigger('click') + expect(wrapper.emitted('retry')).toHaveLength(1) + }) + + it('does not put decorative AI chrome on the page surface', () => { + const wrapper = mount(ScanViewer, { + props: { url: 'blob:http://localhost/scan.jpg' }, + }) + expect(wrapper.text()).not.toContain('confidence') + expect(wrapper.text()).not.toContain('OCR') + expect(wrapper.find('.scan-page').exists()).toBe(true) + }) +}) diff --git a/vigilcare-records-web/src/assets/main.css b/vigilcare-records-web/src/assets/main.css index 5e70e4a..3a72849 100644 --- a/vigilcare-records-web/src/assets/main.css +++ b/vigilcare-records-web/src/assets/main.css @@ -79,6 +79,67 @@ @apply grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-8 h-auto lg:flex-1 lg:min-h-0 min-h-0; } + /* Phase 16 workstation: scan-first split (≥1280px / xl) */ + .workstation-split { + @apply grid grid-cols-1 gap-0 + h-auto xl:flex-1 xl:min-h-0 min-h-0 + xl:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]; + } + .workstation-split:not(.workstation-split--no-rail) { + @apply xl:grid-cols-[minmax(160px,0.2fr)_minmax(0,0.42fr)_minmax(0,0.38fr)]; + } + .workstation-rail { + @apply flex flex-col min-h-0 overflow-hidden bg-surface; + } + .workstation-scan { + @apply flex flex-col min-h-[40vh] xl:min-h-0 p-3 xl:p-4 bg-canvas border-b xl:border-b-0 xl:border-r border-line; + } + .workstation-form { + @apply flex flex-col min-h-0 overflow-hidden bg-surface; + } + .workstation-form-section { + @apply border border-line rounded-input p-3; + } + .workstation-form-legend { + @apply text-xs font-semibold uppercase tracking-wide text-ink-secondary px-1; + } + .workstation-field-label { + @apply flex items-center gap-1.5 text-xs text-ink-secondary mb-1; + } + /* Design-doc §34 evidence hierarchy */ + .evidence-level { + @apply flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-ink-secondary mb-2 shrink-0; + } + .evidence-level-mark { + @apply inline-flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold leading-none; + } + .evidence-level--1 .evidence-level-mark { + @apply bg-navy text-white; + } + .evidence-level--2 .evidence-level-mark { + @apply bg-primary-600 text-white; + } + .evidence-level--3 .evidence-level-mark { + @apply bg-ink-secondary text-white; + } + .evidence-level--4 .evidence-level-mark { + @apply bg-clinical-safe text-white; + } + .evidence-level--5 .evidence-level-mark { + @apply bg-clinical-safe text-white ring-2 ring-clinical-safe-bg; + } + .clinical-signal { + @apply rounded-input border border-[#FEDF89] bg-clinical-warning-bg px-3 py-2 text-sm; + } + .clinical-signal--critical { + @apply border-[#FECDCA] bg-clinical-danger-bg; + } + .approval-frame { + @apply border-l-[3px] border-l-navy; + } + .promotion-outcome { + @apply rounded-input border border-[#ABEFC6] bg-clinical-safe-bg p-4 text-sm; + } .nav-link { @apply text-sm text-ink-secondary hover:text-ink-strong transition-colors; } diff --git a/vigilcare-records-web/src/components/ApprovalForm.vue b/vigilcare-records-web/src/components/ApprovalForm.vue new file mode 100644 index 0000000..1cf77fd --- /dev/null +++ b/vigilcare-records-web/src/components/ApprovalForm.vue @@ -0,0 +1,481 @@ +