diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs index 0f8c218..96437c7 100644 --- a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs +++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs @@ -252,7 +252,7 @@ public class DigitizationBatchesController : ControllerBase /// Cursor: ISO-8601 timestamp from the previous page's nextCursor field. /// Number of events per page. Default 50, max 200. [HttpGet("{id:guid}/events")] - [Authorize(Roles = "ADMINISTRATOR,VERIFIER,CLINICAL_APPROVER")] + [Authorize(Roles = "ADMINISTRATOR,DATA_ENTRY_CLERK,VERIFIER,CLINICAL_APPROVER")] [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] diff --git a/docs/vigilcare-records-prd.md b/docs/vigilcare-records-prd.md index 4a58775..385ac0a 100644 --- a/docs/vigilcare-records-prd.md +++ b/docs/vigilcare-records-prd.md @@ -25,7 +25,7 @@ | 15 | UI redesign: shared UX primitives (status, OCR badges, SoD, sticky actions) | Planned | | 16 | UI redesign: Entry / Verification / Clinical Approval workstation layouts | Planned | | 17 | UI redesign: Intake, Cover Sheets, Live Capture, History, Dashboard, FHIR Explorer | Planned | -| 18 | UI redesign: surface unused APIs (batch events, work queues, Users admin) | Planned | +| 18 | UI redesign: surface unused APIs (batch events, work queues, Users admin) | Done | **Verification scripts:** `./scripts/run-vigilcare-records-verification-p9.sh` (full workflow + metrics), `./scripts/run-vigilcare-records-phase-10-verification.sh`, `./scripts/run-vigilcare-records-phase-11-verification.sh`, `./scripts/run-vigilcare-records-phase-13-verification.sh`. Phases 14–18 verify via Vitest + manual checklists in each plan. @@ -590,6 +590,7 @@ Vue 3 SPA at `vigilcare-records-web/` (dev server port **3028**, proxies `/api` | **Patient history** | All roles | Digitization timeline with correction chain and audit trail | | **Queue dashboard** | Administrator | Backlog metrics from work-queue overview | | **FHIR Explorer** | Administrator | Browse and search FHIR resources, inspect JSON, load Patient `$everything` | +| **Users** | Administrator | Create/update users, deactivate, reset passwords | Scan viewer loads documents via authenticated `GET /digitization-batches/:id/document` blob URLs (avoids cross-origin MinIO iframe issues). Not a full EMR UI — clinical alerting views remain in VigilCareClinical's ward dashboard. @@ -691,7 +692,7 @@ If VigilCareClinical is unreachable in split deployment, batch remains `approved | 15 | UI redesign: shared UX primitives | Planned | | 16 | UI redesign: Entry / Verification / Approval workstation | Planned | | 17 | UI redesign: supporting screens + dashboard | Planned | -| 18 | UI redesign: surface unused existing APIs | Planned | +| 18 | UI redesign: surface unused existing APIs | Done | --- diff --git a/vigilcare-records-web/src/__tests__/router/guards.test.ts b/vigilcare-records-web/src/__tests__/router/guards.test.ts index 8008f65..43078db 100644 --- a/vigilcare-records-web/src/__tests__/router/guards.test.ts +++ b/vigilcare-records-web/src/__tests__/router/guards.test.ts @@ -147,6 +147,15 @@ describe('router navigation guard', () => { expect(next).toHaveBeenCalledWith() }) + it('allows ADMINISTRATOR access to users route', () => { + const { runGuard } = authenticatedGuard('ADMINISTRATOR') + const { next } = runGuard(buildRoute('/users', { + requiresAuth: true, + roles: ['ADMINISTRATOR'], + })) + expect(next).toHaveBeenCalledWith() + }) + it('redirects DATA_ENTRY_CLERK from fhir-explorer to /entry', () => { const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') const { next } = runGuard(buildRoute('/fhir-explorer', { @@ -156,6 +165,15 @@ describe('router navigation guard', () => { expect(next).toHaveBeenCalledWith('/entry') }) + it('redirects DATA_ENTRY_CLERK from users to /entry', () => { + const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') + const { next } = runGuard(buildRoute('/users', { + requiresAuth: true, + roles: ['ADMINISTRATOR'], + })) + expect(next).toHaveBeenCalledWith('/entry') + }) + it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => { const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK') const { next } = runGuard(buildRoute('/cover-sheets', { diff --git a/vigilcare-records-web/src/__tests__/stores/batches.test.ts b/vigilcare-records-web/src/__tests__/stores/batches.test.ts index 628e83f..6cf525e 100644 --- a/vigilcare-records-web/src/__tests__/stores/batches.test.ts +++ b/vigilcare-records-web/src/__tests__/stores/batches.test.ts @@ -36,6 +36,11 @@ describe('useBatchStore', () => { expect(store.loading).toBe(false) expect(store.error).toBeNull() expect(store.documentUrl).toBeNull() + expect(store.events).toEqual([]) + expect(store.eventsLoading).toBe(false) + expect(store.eventsError).toBeNull() + expect(store.eventsHasMore).toBe(false) + expect(store.eventsNextCursor).toBeNull() }) }) @@ -410,4 +415,198 @@ describe('useBatchStore', () => { expect(store.error).toBe('Not found') }) }) + + describe('fetchEvents', () => { + const eventA = { + id: 'e1', + batchId: 'b1', + eventType: 'uploaded', + actorUserId: 'u1', + actorUsername: 'clerk', + actorFullName: 'Clerk One', + occurredAt: '2026-01-01T10:00:00Z', + metadataJson: null, + } + const eventB = { + ...eventA, + id: 'e2', + eventType: 'entry_started', + occurredAt: '2026-01-01T11:00:00Z', + } + + it('replaces events on first page', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + items: [eventA], + pageSize: 50, + nextCursor: '2026-01-01T10:00:00Z', + hasMore: true, + }, + error: null, + }) + + const store = useBatchStore() + await store.fetchEvents('b1') + + expect(mockedGet).toHaveBeenCalledWith('digitization-batches/b1/events', { pageSize: 50 }) + expect(store.events).toEqual([eventA]) + expect(store.eventsHasMore).toBe(true) + expect(store.eventsNextCursor).toBe('2026-01-01T10:00:00Z') + expect(store.eventsLoading).toBe(false) + }) + + it('appends events when after cursor is passed', async () => { + mockedGet + .mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + items: [eventA], + pageSize: 50, + nextCursor: 'c1', + hasMore: true, + }, + error: null, + }) + .mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + items: [eventB], + pageSize: 50, + nextCursor: null, + hasMore: false, + }, + error: null, + }) + + const store = useBatchStore() + await store.fetchEvents('b1') + await store.fetchEvents('b1', 'c1') + + expect(mockedGet).toHaveBeenLastCalledWith('digitization-batches/b1/events', { + pageSize: 50, + after: 'c1', + }) + expect(store.events).toEqual([eventA, eventB]) + expect(store.eventsHasMore).toBe(false) + expect(store.eventsNextCursor).toBeNull() + }) + + it('sets eventsError on failure', async () => { + mockedGet.mockRejectedValueOnce(new Error('Forbidden')) + + const store = useBatchStore() + await store.fetchEvents('b1') + + expect(store.eventsError).toBe('Forbidden') + expect(store.events).toEqual([]) + }) + + it('clearEvents resets event state', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { items: [eventA], pageSize: 50, nextCursor: 'c', hasMore: true }, + error: null, + }) + + const store = useBatchStore() + await store.fetchEvents('b1') + store.clearEvents() + + expect(store.events).toEqual([]) + expect(store.eventsHasMore).toBe(false) + expect(store.eventsNextCursor).toBeNull() + expect(store.eventsError).toBeNull() + }) + }) + + describe('listWorkQueue', () => { + it('maps entry queue items into batches by id', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + queueName: 'entry', + items: [ + { + batchId: 'wq-1', + status: 'IN_ENTRY', + batchType: 'VITALS', + track: 'TRACK_A', + patientId: null, + enteredByUserId: 'u1', + enteredByUserName: 'Clerk', + rejectionReason: null, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + eventCount: 2, + }, + ], + page: 1, + pageSize: 50, + totalCount: 1, + totalPages: 1, + }, + error: null, + }) + + const store = useBatchStore() + await store.listEntryQueue({ page: 1, pageSize: 50 }) + + expect(mockedGet).toHaveBeenCalledWith('work-queue/entry', { + page: 1, + pageSize: 50, + sortBy: 'updatedAt', + sortDirection: 'asc', + }) + expect(store.batches).toHaveLength(1) + expect(store.batches[0]!.id).toBe('wq-1') + expect(store.batches[0]!.status).toBe('IN_ENTRY') + expect(store.batches[0]!.batchType).toBe('VITALS') + expect(store.totalCount).toBe(1) + }) + + it('calls verification and clinical-approval endpoints', async () => { + mockedGet.mockResolvedValue({ + success: true, + statusCode: 200, + data: { + queueName: 'verification', + items: [], + page: 1, + pageSize: 50, + totalCount: 0, + totalPages: 0, + }, + error: null, + }) + + const store = useBatchStore() + await store.listVerificationQueue() + expect(mockedGet).toHaveBeenCalledWith( + 'work-queue/verification', + expect.objectContaining({ sortDirection: 'asc' }), + ) + + await store.listClinicalApprovalQueue() + expect(mockedGet).toHaveBeenCalledWith( + 'work-queue/clinical-approval', + expect.objectContaining({ page: 1, pageSize: 50 }), + ) + }) + + it('sets error on work queue failure', async () => { + mockedGet.mockRejectedValueOnce(new Error('Queue down')) + + const store = useBatchStore() + await store.listEntryQueue() + + expect(store.error).toBe('Queue down') + expect(store.loading).toBe(false) + }) + }) }) diff --git a/vigilcare-records-web/src/__tests__/stores/users.test.ts b/vigilcare-records-web/src/__tests__/stores/users.test.ts new file mode 100644 index 0000000..76d33ce --- /dev/null +++ b/vigilcare-records-web/src/__tests__/stores/users.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useUsersStore } from '@/stores/users' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), +})) + +import { get, post, patch } from '@/api/client' + +const mockedGet = vi.mocked(get) +const mockedPost = vi.mocked(post) +const mockedPatch = vi.mocked(patch) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +describe('useUsersStore', () => { + it('listUsers populates users', async () => { + const users = [ + { id: 'u1', username: 'admin', fullName: 'Admin', role: 'ADMINISTRATOR' }, + ] + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: users, + error: null, + }) + + const store = useUsersStore() + await store.listUsers() + + expect(mockedGet).toHaveBeenCalledWith('users', undefined) + expect(store.users).toEqual(users) + expect(store.loading).toBe(false) + }) + + it('listUsers passes role filter', async () => { + mockedGet.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: [], + error: null, + }) + + const store = useUsersStore() + await store.listUsers('VERIFIER') + + expect(mockedGet).toHaveBeenCalledWith('users', { role: 'VERIFIER' }) + }) + + it('createUser posts body and returns data', async () => { + const created = { + id: 'u2', + username: 'clerk1', + fullName: 'Clerk One', + role: 'DATA_ENTRY_CLERK', + } + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 201, + data: created, + error: null, + }) + + const store = useUsersStore() + const result = await store.createUser({ + username: 'clerk1', + password: 'Password1', + fullName: 'Clerk One', + role: 'DATA_ENTRY_CLERK', + }) + + expect(mockedPost).toHaveBeenCalledWith('users', { + username: 'clerk1', + password: 'Password1', + fullName: 'Clerk One', + role: 'DATA_ENTRY_CLERK', + }) + expect(result).toEqual(created) + }) + + it('updateUser patches and returns data', async () => { + const updated = { + id: 'u1', + username: 'admin', + fullName: 'New Name', + role: 'ADMINISTRATOR', + } + mockedPatch.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: updated, + error: null, + }) + + const store = useUsersStore() + const result = await store.updateUser('u1', { fullName: 'New Name' }) + + expect(mockedPatch).toHaveBeenCalledWith('users/u1', { fullName: 'New Name' }) + expect(result).toEqual(updated) + }) + + it('resetPassword posts new password', async () => { + mockedPost.mockResolvedValueOnce('' as never) + + const store = useUsersStore() + await store.resetPassword('u1', 'Password2') + + expect(mockedPost).toHaveBeenCalledWith('users/u1/reset-password', { + newPassword: 'Password2', + }) + }) + + it('createUser surfaces API error message', async () => { + mockedPost.mockRejectedValueOnce({ + response: { data: { error: { message: 'Username already taken', code: 'USERNAME_TAKEN' } } }, + }) + + const store = useUsersStore() + await expect( + store.createUser({ + username: 'dup', + password: 'Password1', + fullName: 'Dup', + role: 'VERIFIER', + }), + ).rejects.toThrow('Username already taken') + }) +}) diff --git a/vigilcare-records-web/src/__tests__/views/UsersView.test.ts b/vigilcare-records-web/src/__tests__/views/UsersView.test.ts new file mode 100644 index 0000000..34dc313 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/views/UsersView.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import UsersView from '@/views/UsersView.vue' + +vi.mock('@/api/client', () => ({ + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), +})) + +vi.mock('@/composables/useToast', () => ({ + useToast: () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }), +})) + +vi.mock('@/components/AppHeader.vue', () => ({ + default: { template: '
' }, +})) + +vi.mock('@/components/EmptyState.vue', () => ({ + default: { + props: ['title', 'description'], + template: '
{{ title }}
', + }, +})) + +vi.mock('@/components/SkeletonBlock.vue', () => ({ + default: { template: '
' }, +})) + +vi.mock('@/components/InlineError.vue', () => ({ + default: { + props: ['title', 'message'], + template: '
{{ message }}
', + }, +})) + +vi.mock('@/components/ConfirmDialog.vue', () => ({ + default: { + props: ['open', 'title', 'confirmDisabled'], + emits: ['confirm', 'cancel'], + template: ` +
+ + + +
+ `, + }, +})) + +import { get, post, patch } from '@/api/client' + +const mockedGet = vi.mocked(get) +const mockedPost = vi.mocked(post) +const mockedPatch = vi.mocked(patch) + +const seedUsers = [ + { + id: 'u1', + username: 'entry1', + fullName: 'Entry Clerk', + role: 'DATA_ENTRY_CLERK', + }, + { + id: 'u2', + username: 'verifier1', + fullName: 'Verifier One', + role: 'VERIFIER', + }, +] + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockedGet.mockResolvedValue({ + success: true, + statusCode: 200, + data: seedUsers, + error: null, + }) +}) + +describe('UsersView', () => { + it('loads and lists users on mount', async () => { + const wrapper = mount(UsersView) + await flushPromises() + + expect(mockedGet).toHaveBeenCalledWith('users', undefined) + expect(wrapper.text()).toContain('Entry Clerk') + expect(wrapper.text()).toContain('Verifier One') + expect(wrapper.find('[data-testid="user-row-entry1"]').exists()).toBe(true) + }) + + it('creates a user from the create form', async () => { + mockedPost.mockResolvedValueOnce({ + success: true, + statusCode: 201, + data: { + id: 'u3', + username: 'newclerk', + fullName: 'New Clerk', + role: 'DATA_ENTRY_CLERK', + }, + error: null, + }) + + const wrapper = mount(UsersView) + await flushPromises() + + await wrapper.get('[data-testid="users-toggle-create"]').trigger('click') + expect(wrapper.find('[data-testid="users-create-form"]').exists()).toBe(true) + + await wrapper.get('#create-username').setValue('newclerk') + await wrapper.get('#create-fullname').setValue('New Clerk') + await wrapper.get('#create-password').setValue('Password1') + await wrapper.get('[data-testid="users-create-form"] form').trigger('submit') + await flushPromises() + + expect(mockedPost).toHaveBeenCalledWith('users', { + username: 'newclerk', + fullName: 'New Clerk', + role: 'DATA_ENTRY_CLERK', + password: 'Password1', + }) + expect(mockedGet).toHaveBeenCalledTimes(2) + }) + + it('edits a user full name and role', async () => { + mockedPatch.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: { + id: 'u1', + username: 'entry1', + fullName: 'Updated Clerk', + role: 'VERIFIER', + }, + error: null, + }) + + const wrapper = mount(UsersView) + await flushPromises() + + const row = wrapper.get('[data-testid="user-row-entry1"]') + await row.findAll('button').find((b) => b.text() === 'Edit')!.trigger('click') + expect(wrapper.find('[data-testid="users-edit-form"]').exists()).toBe(true) + + await wrapper.get('#edit-fullname').setValue('Updated Clerk') + await wrapper.get('#edit-role').setValue('VERIFIER') + await wrapper.get('[data-testid="users-edit-form"] form').trigger('submit') + await flushPromises() + + expect(mockedPatch).toHaveBeenCalledWith('users/u1', { + fullName: 'Updated Clerk', + role: 'VERIFIER', + }) + }) + + it('resets password via confirm dialog', async () => { + mockedPost.mockResolvedValueOnce('' as never) + + const wrapper = mount(UsersView) + await flushPromises() + + await wrapper.get('[data-testid="user-row-entry1"] [data-testid="users-reset-password"]').trigger('click') + expect(wrapper.find('[data-testid="confirm-dialog"]').exists()).toBe(true) + + await wrapper.get('[data-testid="users-reset-password-input"]').setValue('Password9') + await wrapper.get('[data-testid="confirm-ok"]').trigger('click') + await flushPromises() + + expect(mockedPost).toHaveBeenCalledWith('users/u1/reset-password', { + newPassword: 'Password9', + }) + }) + + it('deactivates a user', async () => { + mockedPatch.mockResolvedValueOnce({ + success: true, + statusCode: 200, + data: seedUsers[0], + error: null, + }) + + const wrapper = mount(UsersView) + await flushPromises() + + await wrapper.get('[data-testid="user-row-entry1"] [data-testid="users-deactivate"]').trigger('click') + await flushPromises() + + expect(mockedPatch).toHaveBeenCalledWith('users/u1', { isActive: false }) + }) +}) diff --git a/vigilcare-records-web/src/components/AppShell.vue b/vigilcare-records-web/src/components/AppShell.vue index cf92a49..e04cf70 100644 --- a/vigilcare-records-web/src/components/AppShell.vue +++ b/vigilcare-records-web/src/components/AppShell.vue @@ -172,6 +172,9 @@ const icons = { fhir: iconPath( 'M4.5 3A1.5 1.5 0 003 4.5v11A1.5 1.5 0 004.5 17h11a1.5 1.5 0 001.5-1.5v-11A1.5 1.5 0 0015.5 3h-11zM6 7.25a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 016 7.25zm0 3a.75.75 0 01.75-.75h6.5a.75.75 0 010 1.5h-6.5A.75.75 0 016 10.25zm0 3a.75.75 0 01.75-.75h3.5a.75.75 0 010 1.5h-3.5A.75.75 0 016 13.25z', ), + users: iconPath( + 'M7 8a3 3 0 116 0 3 3 0 01-6 0zm-3.5 8.5a5.5 5.5 0 0111 0 .75.75 0 01-.75.75h-9.5a.75.75 0 01-.75-.75zM14.5 9a2.5 2.5 0 100-5 2.5 2.5 0 000 5zm1.75 1.5a4 4 0 013.75 2.75.75.75 0 01-.72.95h-2.28a.75.75 0 01-.75-.75 5.48 5.48 0 00-.75-2.7.75.75 0 01.75-1.25z', + ), } interface NavItem { @@ -197,6 +200,7 @@ const adminItems = computed(() => [ { label: 'Queue Dashboard', to: '/dashboard', icon: icons.dashboard, show: auth.canSupervise }, { label: 'FHIR Explorer', to: '/fhir-explorer', icon: icons.fhir, show: auth.canSupervise }, + { label: 'Users', to: '/users', icon: icons.users, show: auth.canSupervise }, ].filter((item) => item.show), ) diff --git a/vigilcare-records-web/src/components/ApprovalForm.vue b/vigilcare-records-web/src/components/ApprovalForm.vue index 1cf77fd..85bfac1 100644 --- a/vigilcare-records-web/src/components/ApprovalForm.vue +++ b/vigilcare-records-web/src/components/ApprovalForm.vue @@ -222,6 +222,8 @@ + +
@@ -297,6 +299,7 @@ import { useToast } from '../composables/useToast' import ObservationRow from './ObservationRow.vue' import StatusBadge from './StatusBadge.vue' import ConfirmDialog from './ConfirmDialog.vue' +import AuditTrailPanel from './AuditTrailPanel.vue' import WorkstationActionBar from './WorkstationActionBar.vue' import type { BatchDetailResponse, BatchDraft, DraftObservation } from '../types' diff --git a/vigilcare-records-web/src/components/AuditTrailPanel.vue b/vigilcare-records-web/src/components/AuditTrailPanel.vue new file mode 100644 index 0000000..44f08f2 --- /dev/null +++ b/vigilcare-records-web/src/components/AuditTrailPanel.vue @@ -0,0 +1,157 @@ + + + diff --git a/vigilcare-records-web/src/components/EntryForm.vue b/vigilcare-records-web/src/components/EntryForm.vue index 40d87fe..4df3ff2 100644 --- a/vigilcare-records-web/src/components/EntryForm.vue +++ b/vigilcare-records-web/src/components/EntryForm.vue @@ -267,6 +267,8 @@ + +
@@ -317,6 +319,7 @@ import { useBatchStore } from '../stores/batches' import { useToast } from '../composables/useToast' import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence' import ObservationRow from '../components/ObservationRow.vue' +import AuditTrailPanel from '../components/AuditTrailPanel.vue' import StatusBadge from '../components/StatusBadge.vue' import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue' import WorkstationActionBar from '../components/WorkstationActionBar.vue' diff --git a/vigilcare-records-web/src/components/VerificationForm.vue b/vigilcare-records-web/src/components/VerificationForm.vue index 9037bcb..2e6df6d 100644 --- a/vigilcare-records-web/src/components/VerificationForm.vue +++ b/vigilcare-records-web/src/components/VerificationForm.vue @@ -184,6 +184,8 @@ + + @@ -274,6 +276,7 @@ import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue' import SeparationOfDutiesBanner from '../components/SeparationOfDutiesBanner.vue' import ConfirmDialog from '../components/ConfirmDialog.vue' import WorkstationActionBar from '../components/WorkstationActionBar.vue' +import AuditTrailPanel from '../components/AuditTrailPanel.vue' import type { BatchDetailResponse, DraftObservation } from '../types' const props = defineProps<{ diff --git a/vigilcare-records-web/src/router/index.ts b/vigilcare-records-web/src/router/index.ts index 3d6e095..6bfeb69 100644 --- a/vigilcare-records-web/src/router/index.ts +++ b/vigilcare-records-web/src/router/index.ts @@ -116,6 +116,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/FhirExplorerView.vue'), meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, }, + { + path: 'users', + name: 'Users', + component: () => import('../views/UsersView.vue'), + meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] }, + }, ], }, { diff --git a/vigilcare-records-web/src/stores/batches.ts b/vigilcare-records-web/src/stores/batches.ts index d5eeacc..4b6fd01 100644 --- a/vigilcare-records-web/src/stores/batches.ts +++ b/vigilcare-records-web/src/stores/batches.ts @@ -8,10 +8,52 @@ import type { DraftEncounter, DraftObservation, BatchListResponse, + BatchEventResponse, + CursorPagedResult, FieldCheck, PatientDigitizationHistoryResponse, + WorkQueueItemResponse, + WorkQueueResponse, } from '../types' +export type WorkQueueName = 'entry' | 'verification' | 'clinical-approval' + +const EMPTY_FIELD_REQUIREMENTS = { + showPatientDemographics: false, + showEncounterContext: false, + showEncounterSummaryFields: false, + showObservations: false, + showAllergies: false, + showMedications: false, +} + +/** Map work-queue items into BatchDetailResponse shape used by BatchList / queue rail. */ +function mapWorkQueueItem(item: WorkQueueItemResponse): BatchDetailResponse { + return { + id: item.batchId, + status: item.status, + batchType: item.batchType, + track: item.track, + fieldRequirements: { ...EMPTY_FIELD_REQUIREMENTS }, + patientId: item.patientId, + documentRef: '', + documentUrl: null, + enableRetroactiveAlerts: false, + enteredByUserId: item.enteredByUserId, + verifiedByUserId: null, + approvedByUserId: null, + rejectionReason: item.rejectionReason, + promotedAt: null, + promotionEncounterId: null, + supersedesBatchId: null, + clinicianAttestation: false, + isCorrection: false, + supersession: null, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + } +} + function nullIfEmpty(value: string | null | undefined): string | null { if (value == null) return null const trimmed = value.trim() @@ -66,6 +108,12 @@ export const useBatchStore = defineStore('batches', () => { const loading = ref(false) const error = ref(null) + const events = ref([]) + const eventsLoading = ref(false) + const eventsError = ref(null) + const eventsHasMore = ref(false) + const eventsNextCursor = ref(null) + const documentUrl = computed(() => currentBatch.value?.documentUrl ?? null) async function listBatches(params: { @@ -94,6 +142,81 @@ export const useBatchStore = defineStore('batches', () => { } } + async function listWorkQueue( + queue: WorkQueueName, + params: { page?: number; pageSize?: number } = {} + ): Promise { + loading.value = true + error.value = null + try { + const response = await get(`work-queue/${queue}`, { + page: params.page ?? 1, + pageSize: params.pageSize ?? 50, + sortBy: 'updatedAt', + sortDirection: 'asc', + }) + if (response.success && response.data) { + batches.value = response.data.items.map(mapWorkQueueItem) + totalCount.value = response.data.totalCount + } + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Failed to load work queue' + } finally { + loading.value = false + } + } + + async function listEntryQueue(params?: { page?: number; pageSize?: number }): Promise { + await listWorkQueue('entry', params) + } + + async function listVerificationQueue(params?: { page?: number; pageSize?: number }): Promise { + await listWorkQueue('verification', params) + } + + async function listClinicalApprovalQueue(params?: { + page?: number + pageSize?: number + }): Promise { + await listWorkQueue('clinical-approval', params) + } + + function clearEvents(): void { + events.value = [] + eventsError.value = null + eventsHasMore.value = false + eventsNextCursor.value = null + } + + /** + * Load batch audit events. Pass `after` (nextCursor) to append the next page. + */ + async function fetchEvents(batchId: string, after?: string | null): Promise { + eventsLoading.value = true + eventsError.value = null + try { + const params: Record = { pageSize: 50 } + if (after) params.after = after + + const response = await get>( + `digitization-batches/${batchId}/events`, + params + ) + if (response.success && response.data) { + const page = response.data + events.value = after ? [...events.value, ...page.items] : page.items + eventsHasMore.value = page.hasMore + eventsNextCursor.value = page.nextCursor + } else { + eventsError.value = response.error?.message ?? 'Failed to load audit events' + } + } catch (e: unknown) { + eventsError.value = e instanceof Error ? e.message : 'Failed to load audit events' + } finally { + eventsLoading.value = false + } + } + async function getBatch(id: string): Promise { loading.value = true error.value = null @@ -276,7 +399,18 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise } }).response?.data + if (data?.error?.message) return data.error.message + } + if (e instanceof Error && e.message) return e.message + return fallback +} + +export const useUsersStore = defineStore('users', () => { + const users = ref([]) + const loading = ref(false) + const error = ref(null) + + async function listUsers(role?: string): Promise { + loading.value = true + error.value = null + try { + const params = role ? { role } : undefined + const response = await get('users', params) + if (response.success && response.data) { + users.value = response.data + } else { + error.value = response.error?.message ?? 'Failed to load users' + } + } catch (e: unknown) { + error.value = apiErrorMessage(e, 'Failed to load users') + } finally { + loading.value = false + } + } + + async function createUser(body: CreateUserRequest): Promise { + try { + const response = await post('users', body) + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? 'Failed to create user') + } + return response.data + } catch (e: unknown) { + throw new Error(apiErrorMessage(e, 'Failed to create user')) + } + } + + async function updateUser(id: string, body: UpdateUserRequest): Promise { + try { + const response = await patch(`users/${id}`, body) + if (!response.success || !response.data) { + throw new Error(response.error?.message ?? 'Failed to update user') + } + return response.data + } catch (e: unknown) { + throw new Error(apiErrorMessage(e, 'Failed to update user')) + } + } + + async function resetPassword(id: string, newPassword: string): Promise { + try { + const response = await post(`users/${id}/reset-password`, { newPassword }) + // 204 No Content may yield an empty body + if (response && typeof response === 'object' && 'success' in response && !response.success) { + throw new Error( + (response as ApiResponse).error?.message ?? 'Failed to reset password', + ) + } + } catch (e: unknown) { + throw new Error(apiErrorMessage(e, 'Failed to reset password')) + } + } + + return { + users, + loading, + error, + listUsers, + createUser, + updateUser, + resetPassword, + } +}) diff --git a/vigilcare-records-web/src/types/index.ts b/vigilcare-records-web/src/types/index.ts index f1bb3bf..3400e44 100644 --- a/vigilcare-records-web/src/types/index.ts +++ b/vigilcare-records-web/src/types/index.ts @@ -177,6 +177,36 @@ export interface UserSummary { role: string } +export type UserRoleString = + | 'INTAKE_CLERK' + | 'DATA_ENTRY_CLERK' + | 'VERIFIER' + | 'CLINICAL_APPROVER' + | 'CLINICIAN' + | 'ADMINISTRATOR' + +export interface CreateUserRequest { + username: string + password: string + fullName: string + role: string +} + +export interface UpdateUserRequest { + fullName?: string + role?: string + isActive?: boolean +} + +export interface ResetPasswordRequest { + newPassword: string +} + +export interface ChangePasswordRequest { + currentPassword: string + newPassword: string +} + export interface CoverSheetResponse { id: string code: string @@ -209,6 +239,49 @@ export interface DigitizationEventSummary { metadataJson: string | null } +/** Cursor-paginated audit event from GET digitization-batches/{id}/events */ +export interface BatchEventResponse { + id: string + batchId: string + eventType: string + actorUserId: string + actorUsername: string + actorFullName: string + occurredAt: string + metadataJson: string | null +} + +export interface CursorPagedResult { + items: T[] + pageSize: number + nextCursor: string | null + hasMore: boolean +} + +/** Item from GET work-queue/{entry|verification|clinical-approval} */ +export interface WorkQueueItemResponse { + batchId: string + status: string + batchType: string + track: string + patientId: string | null + enteredByUserId: string | null + enteredByUserName: string | null + rejectionReason: string | null + createdAt: string + updatedAt: string + eventCount: number +} + +export interface WorkQueueResponse { + queueName: string + items: WorkQueueItemResponse[] + page: number + pageSize: number + totalCount: number + totalPages: number +} + export interface DigitizationHistoryEntry { batchId: string status: string diff --git a/vigilcare-records-web/src/views/ApprovalView.vue b/vigilcare-records-web/src/views/ApprovalView.vue index 14e0063..af2b8b1 100644 --- a/vigilcare-records-web/src/views/ApprovalView.vue +++ b/vigilcare-records-web/src/views/ApprovalView.vue @@ -121,8 +121,7 @@ onMounted(async () => { }) async function loadQueue() { - await batchStore.listBatches({ - status: 'AWAITING_CLINICAL_APPROVAL', + await batchStore.listClinicalApprovalQueue({ page: 1, pageSize: 50, }) diff --git a/vigilcare-records-web/src/views/EntryView.vue b/vigilcare-records-web/src/views/EntryView.vue index f0b2ecd..d41ca3e 100644 --- a/vigilcare-records-web/src/views/EntryView.vue +++ b/vigilcare-records-web/src/views/EntryView.vue @@ -16,8 +16,8 @@ :batches="batchStore.batches" :loading="batchStore.loading" :error="batchStore.error" - empty-title="No batches are assigned for data entry." - empty-description="Assigned batches appear here after intake assigns them to you." + empty-title="No batches are waiting for data entry." + empty-description="Batches in entry or returned for rework appear here, oldest first." @select="openBatch" @retry="loadQueue" /> @@ -59,7 +59,6 @@ diff --git a/vigilcare-records-web/src/views/VerificationView.vue b/vigilcare-records-web/src/views/VerificationView.vue index 398620e..c0047e6 100644 --- a/vigilcare-records-web/src/views/VerificationView.vue +++ b/vigilcare-records-web/src/views/VerificationView.vue @@ -13,7 +13,7 @@