feature: Surface Existing Unused APIs
This commit is contained in:
@@ -252,7 +252,7 @@ public class DigitizationBatchesController : ControllerBase
|
||||
/// <param name="after">Cursor: ISO-8601 timestamp from the previous page's nextCursor field.</param>
|
||||
/// <param name="pageSize">Number of events per page. Default 50, max 200.</param>
|
||||
[HttpGet("{id:guid}/events")]
|
||||
[Authorize(Roles = "ADMINISTRATOR,VERIFIER,CLINICAL_APPROVER")]
|
||||
[Authorize(Roles = "ADMINISTRATOR,DATA_ENTRY_CLERK,VERIFIER,CLINICAL_APPROVER")]
|
||||
[ProducesResponseType(typeof(ApiResponse<CursorPagedResult<BatchEventResponse>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: '<div data-testid="app-header" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/EmptyState.vue', () => ({
|
||||
default: {
|
||||
props: ['title', 'description'],
|
||||
template: '<div data-testid="empty-state">{{ title }}</div>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/SkeletonBlock.vue', () => ({
|
||||
default: { template: '<div data-testid="skeleton" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/InlineError.vue', () => ({
|
||||
default: {
|
||||
props: ['title', 'message'],
|
||||
template: '<div data-testid="inline-error">{{ message }}</div>',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ConfirmDialog.vue', () => ({
|
||||
default: {
|
||||
props: ['open', 'title', 'confirmDisabled'],
|
||||
emits: ['confirm', 'cancel'],
|
||||
template: `
|
||||
<div v-if="open" data-testid="confirm-dialog">
|
||||
<slot />
|
||||
<button type="button" data-testid="confirm-ok" @click="$emit('confirm')">OK</button>
|
||||
<button type="button" data-testid="confirm-cancel" @click="$emit('cancel')">Cancel</button>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
}))
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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<NavItem[]>(() =>
|
||||
[
|
||||
{ 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),
|
||||
)
|
||||
|
||||
|
||||
@@ -222,6 +222,8 @@
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<AuditTrailPanel :batch-id="batchId" />
|
||||
</div>
|
||||
|
||||
<WorkstationActionBar>
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<details
|
||||
ref="root"
|
||||
class="mt-2 group workstation-form-section !p-3"
|
||||
data-testid="audit-trail-panel"
|
||||
@toggle="onToggle"
|
||||
>
|
||||
<summary
|
||||
class="text-sm font-medium text-primary-700 cursor-pointer hover:text-primary-800 list-none flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-5 w-5 items-center justify-center rounded border border-line text-xs text-ink-secondary group-open:rotate-90 transition-transform"
|
||||
aria-hidden="true"
|
||||
>
|
||||
›
|
||||
</span>
|
||||
Audit trail
|
||||
<span v-if="events.length > 0" class="text-ink-secondary font-normal">
|
||||
({{ events.length }}{{ hasMore ? '+' : '' }} events)
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-3">
|
||||
<p v-if="loading && events.length === 0" class="text-sm text-ink-secondary">
|
||||
Loading events…
|
||||
</p>
|
||||
<p v-else-if="error" class="text-sm text-clinical-danger" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="!loading && events.length === 0" class="text-sm text-ink-secondary">
|
||||
No audit events for this batch yet.
|
||||
</p>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full min-w-[520px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-ink-secondary">
|
||||
<th class="py-2 px-2 font-medium w-[28%]">Timestamp</th>
|
||||
<th class="py-2 px-2 font-medium">Event</th>
|
||||
<th class="py-2 px-2 font-medium w-[22%]">Actor</th>
|
||||
<th class="py-2 px-2 font-medium w-[22%]">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
class="border-b border-line last:border-0 align-top"
|
||||
>
|
||||
<td class="py-2 px-2 text-ink-secondary whitespace-nowrap">
|
||||
{{ formatDateTime(event.occurredAt) }}
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<span class="font-medium text-ink-strong">
|
||||
{{ formatEventType(event.eventType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-ink">
|
||||
{{ event.actorFullName || event.actorUsername || '—' }}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-ink-secondary text-xs">
|
||||
{{ summarizePayload(event.metadataJson) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasMore"
|
||||
type="button"
|
||||
class="mt-3 text-sm font-medium text-primary-700 hover:text-primary-800 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
data-testid="audit-trail-load-more"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loading ? 'Loading…' : 'Load more' }}
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
|
||||
const props = defineProps<{
|
||||
batchId: string
|
||||
}>()
|
||||
|
||||
const batchStore = useBatchStore()
|
||||
const { events, eventsLoading: loading, eventsError: error, eventsHasMore: hasMore } =
|
||||
storeToRefs(batchStore)
|
||||
|
||||
const root = ref<HTMLDetailsElement | null>(null)
|
||||
let loadedForBatchId: string | null = null
|
||||
|
||||
watch(
|
||||
() => props.batchId,
|
||||
async (id) => {
|
||||
loadedForBatchId = null
|
||||
batchStore.clearEvents()
|
||||
if (root.value?.open) {
|
||||
loadedForBatchId = id
|
||||
await batchStore.fetchEvents(id)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function onToggle(e: Event) {
|
||||
const el = e.currentTarget as HTMLDetailsElement
|
||||
if (!el.open) return
|
||||
if (loadedForBatchId === props.batchId) return
|
||||
loadedForBatchId = props.batchId
|
||||
await batchStore.fetchEvents(props.batchId)
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
await batchStore.fetchEvents(props.batchId, batchStore.eventsNextCursor ?? undefined)
|
||||
}
|
||||
|
||||
function formatEventType(type: string): string {
|
||||
return type
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function summarizePayload(metadataJson: string | null): string {
|
||||
if (!metadataJson) return '—'
|
||||
try {
|
||||
const meta = JSON.parse(metadataJson) as Record<string, unknown>
|
||||
const parts: string[] = []
|
||||
const prev = meta.previousStatus ?? meta.previous_status ?? meta.fromStatus
|
||||
const next = meta.newStatus ?? meta.new_status ?? meta.toStatus ?? meta.status
|
||||
if (typeof prev === 'string' && typeof next === 'string') {
|
||||
parts.push(`${prev} → ${next}`)
|
||||
} else if (typeof next === 'string') {
|
||||
parts.push(String(next))
|
||||
}
|
||||
const reason = meta.reason ?? meta.rejectionReason
|
||||
if (typeof reason === 'string' && reason.trim()) {
|
||||
parts.push(reason.trim().length > 80 ? `${reason.trim().slice(0, 80)}…` : reason.trim())
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : '—'
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -267,6 +267,8 @@
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<AuditTrailPanel :batch-id="batchId" />
|
||||
</div>
|
||||
|
||||
<WorkstationActionBar>
|
||||
@@ -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'
|
||||
|
||||
@@ -184,6 +184,8 @@
|
||||
<div v-if="errorMessage" class="text-clinical-danger text-sm" role="alert">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<AuditTrailPanel :batch-id="batchId" />
|
||||
</div>
|
||||
|
||||
<WorkstationActionBar>
|
||||
@@ -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<{
|
||||
|
||||
@@ -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'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
|
||||
const events = ref<BatchEventResponse[]>([])
|
||||
const eventsLoading = ref(false)
|
||||
const eventsError = ref<string | null>(null)
|
||||
const eventsHasMore = ref(false)
|
||||
const eventsNextCursor = ref<string | null>(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<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await get<WorkQueueResponse>(`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<void> {
|
||||
await listWorkQueue('entry', params)
|
||||
}
|
||||
|
||||
async function listVerificationQueue(params?: { page?: number; pageSize?: number }): Promise<void> {
|
||||
await listWorkQueue('verification', params)
|
||||
}
|
||||
|
||||
async function listClinicalApprovalQueue(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
eventsLoading.value = true
|
||||
eventsError.value = null
|
||||
try {
|
||||
const params: Record<string, unknown> = { pageSize: 50 }
|
||||
if (after) params.after = after
|
||||
|
||||
const response = await get<CursorPagedResult<BatchEventResponse>>(
|
||||
`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<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -276,7 +399,18 @@ async function assignBatch(batchId: string, entryClerkUserId: string): Promise<v
|
||||
totalCount,
|
||||
loading,
|
||||
error,
|
||||
events,
|
||||
eventsLoading,
|
||||
eventsError,
|
||||
eventsHasMore,
|
||||
eventsNextCursor,
|
||||
listBatches,
|
||||
listWorkQueue,
|
||||
listEntryQueue,
|
||||
listVerificationQueue,
|
||||
listClinicalApprovalQueue,
|
||||
fetchEvents,
|
||||
clearEvents,
|
||||
getBatch,
|
||||
uploadBatch,
|
||||
assignBatch,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { get, post, patch } from '../api/client'
|
||||
import type {
|
||||
ApiResponse,
|
||||
CreateUserRequest,
|
||||
UpdateUserRequest,
|
||||
UserSummary,
|
||||
} from '../types'
|
||||
|
||||
function apiErrorMessage(e: unknown, fallback: string): string {
|
||||
if (e && typeof e === 'object' && 'response' in e) {
|
||||
const data = (e as { response?: { data?: ApiResponse<unknown> } }).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<UserSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function listUsers(role?: string): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = role ? { role } : undefined
|
||||
const response = await get<UserSummary[]>('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<UserSummary> {
|
||||
try {
|
||||
const response = await post<UserSummary>('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<UserSummary> {
|
||||
try {
|
||||
const response = await patch<UserSummary>(`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<void> {
|
||||
try {
|
||||
const response = await post<void>(`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<void>).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,
|
||||
}
|
||||
})
|
||||
@@ -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<T> {
|
||||
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
|
||||
|
||||
@@ -121,8 +121,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
async function loadQueue() {
|
||||
await batchStore.listBatches({
|
||||
status: 'AWAITING_CLINICAL_APPROVAL',
|
||||
await batchStore.listClinicalApprovalQueue({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
@@ -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 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
@@ -71,7 +70,6 @@ import WorkstationQueueRail from '../components/WorkstationQueueRail.vue'
|
||||
|
||||
const props = defineProps<{ batchId?: string }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const batchStore = useBatchStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -95,11 +93,7 @@ async function openBatch(id: string) {
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
await batchStore.listBatches({
|
||||
assignedTo: auth.userId,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
await batchStore.listEntryQueue({ page: 1, pageSize: 50 })
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col" data-testid="users-view">
|
||||
<AppHeader title="Users" />
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-[1.75rem] font-semibold text-ink-strong leading-tight tracking-tight">
|
||||
Users
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-ink-secondary">
|
||||
Create accounts, update roles, and reset passwords for active staff.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
data-testid="users-toggle-create"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
{{ showCreate ? 'Cancel' : 'Create user' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="showCreate" class="card space-y-4" data-testid="users-create-form">
|
||||
<header>
|
||||
<h2 class="text-base font-semibold text-ink-strong">Create user</h2>
|
||||
<p class="text-sm text-ink-secondary mt-1">
|
||||
Password must be at least 8 characters with one uppercase letter and one digit.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form class="grid gap-4 sm:grid-cols-2" @submit.prevent="submitCreate">
|
||||
<div>
|
||||
<label for="create-username" class="block text-sm font-semibold text-ink mb-2">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="create-username"
|
||||
v-model="createForm.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
autocomplete="off"
|
||||
required
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-fullname" class="block text-sm font-semibold text-ink mb-2">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="create-fullname"
|
||||
v-model="createForm.fullName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
required
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-role" class="block text-sm font-semibold text-ink mb-2">
|
||||
Role
|
||||
</label>
|
||||
<select id="create-role" v-model="createForm.role" class="form-input" required>
|
||||
<option v-for="role in ROLE_OPTIONS" :key="role" :value="role">
|
||||
{{ formatRole(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-password" class="block text-sm font-semibold text-ink mb-2">
|
||||
Temporary password
|
||||
</label>
|
||||
<input
|
||||
id="create-password"
|
||||
v-model="createForm.password"
|
||||
type="password"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" class="btn-primary" :disabled="creating">
|
||||
{{ creating ? 'Creating…' : 'Create user' }}
|
||||
</button>
|
||||
<p v-if="createError" class="text-sm text-clinical-danger" role="alert">
|
||||
{{ createError }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card space-y-4">
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<header>
|
||||
<h2 class="text-base font-semibold text-ink-strong">Active users</h2>
|
||||
<p class="text-sm text-ink-secondary mt-1">
|
||||
Deactivated users are removed from this list.
|
||||
</p>
|
||||
</header>
|
||||
<div class="min-w-[12rem]">
|
||||
<label for="role-filter" class="block text-sm font-semibold text-ink mb-2">
|
||||
Filter by role
|
||||
</label>
|
||||
<select
|
||||
id="role-filter"
|
||||
v-model="roleFilter"
|
||||
class="form-input"
|
||||
data-testid="users-role-filter"
|
||||
@change="loadUsers"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
<option v-for="role in ROLE_OPTIONS" :key="role" :value="role">
|
||||
{{ formatRole(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineError
|
||||
v-if="usersStore.error"
|
||||
title="Could not load users"
|
||||
:message="usersStore.error"
|
||||
retry-label="Retry"
|
||||
@retry="loadUsers"
|
||||
/>
|
||||
<SkeletonBlock v-else-if="usersStore.loading" variant="table" :rows="6" />
|
||||
<EmptyState
|
||||
v-else-if="usersStore.users.length === 0"
|
||||
title="No users found."
|
||||
description="Create a user or clear the role filter."
|
||||
/>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-ink-secondary">
|
||||
<th class="py-2 px-3 font-medium">Full name</th>
|
||||
<th class="py-2 px-3 font-medium">Username</th>
|
||||
<th class="py-2 px-3 font-medium">Role</th>
|
||||
<th class="py-2 px-3 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="user in usersStore.users"
|
||||
:key="user.id"
|
||||
class="border-b border-line last:border-0"
|
||||
:data-testid="`user-row-${user.username}`"
|
||||
>
|
||||
<td class="py-3 px-3 text-ink-strong font-medium">{{ user.fullName }}</td>
|
||||
<td class="py-3 px-3 font-mono text-xs text-ink">{{ user.username }}</td>
|
||||
<td class="py-3 px-3 text-ink">{{ formatRole(user.role) }}</td>
|
||||
<td class="py-3 px-3">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-primary-700 hover:text-primary-800"
|
||||
@click="startEdit(user)"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-primary-700 hover:text-primary-800"
|
||||
data-testid="users-reset-password"
|
||||
@click="openReset(user)"
|
||||
>
|
||||
Reset password
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-clinical-danger hover:opacity-80"
|
||||
data-testid="users-deactivate"
|
||||
@click="deactivateUser(user)"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="editingUser" class="card space-y-4" data-testid="users-edit-form">
|
||||
<header>
|
||||
<h2 class="text-base font-semibold text-ink-strong">
|
||||
Edit {{ editingUser.fullName }}
|
||||
</h2>
|
||||
<p class="text-sm text-ink-secondary mt-1 font-mono">{{ editingUser.username }}</p>
|
||||
</header>
|
||||
|
||||
<form class="grid gap-4 sm:grid-cols-2" @submit.prevent="submitEdit">
|
||||
<div>
|
||||
<label for="edit-fullname" class="block text-sm font-semibold text-ink mb-2">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="edit-fullname"
|
||||
v-model="editForm.fullName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
required
|
||||
maxlength="200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="edit-role" class="block text-sm font-semibold text-ink mb-2">
|
||||
Role
|
||||
</label>
|
||||
<select id="edit-role" v-model="editForm.role" class="form-input" required>
|
||||
<option v-for="role in ROLE_OPTIONS" :key="role" :value="role">
|
||||
{{ formatRole(role) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex flex-wrap items-center gap-3">
|
||||
<button type="submit" class="btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving…' : 'Save changes' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" @click="editingUser = null">
|
||||
Cancel
|
||||
</button>
|
||||
<p v-if="editError" class="text-sm text-clinical-danger" role="alert">
|
||||
{{ editError }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:open="!!resetTarget"
|
||||
title="Reset password"
|
||||
body="Set a new temporary password for this user. They can change it after signing in."
|
||||
confirm-label="Reset password"
|
||||
variant="danger"
|
||||
:confirm-disabled="!resetPasswordValue.trim() || resetting"
|
||||
@confirm="confirmReset"
|
||||
@cancel="closeReset"
|
||||
>
|
||||
<p v-if="resetTarget" class="text-sm text-ink-secondary mb-3">
|
||||
User: <span class="font-medium text-ink-strong">{{ resetTarget.fullName }}</span>
|
||||
({{ resetTarget.username }})
|
||||
</p>
|
||||
<label for="reset-password" class="block text-sm font-semibold text-ink mb-2">
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
v-model="resetPasswordValue"
|
||||
type="password"
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
placeholder="Min. 8 chars, 1 uppercase, 1 digit"
|
||||
data-testid="users-reset-password-input"
|
||||
/>
|
||||
<p v-if="resetError" class="mt-2 text-sm text-clinical-danger" role="alert">
|
||||
{{ resetError }}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useUsersStore } from '../stores/users'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import InlineError from '../components/InlineError.vue'
|
||||
import SkeletonBlock from '../components/SkeletonBlock.vue'
|
||||
import type { UserRoleString, UserSummary } from '../types'
|
||||
|
||||
const ROLE_OPTIONS: UserRoleString[] = [
|
||||
'INTAKE_CLERK',
|
||||
'DATA_ENTRY_CLERK',
|
||||
'VERIFIER',
|
||||
'CLINICAL_APPROVER',
|
||||
'CLINICIAN',
|
||||
'ADMINISTRATOR',
|
||||
]
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
const toast = useToast()
|
||||
|
||||
const roleFilter = ref('')
|
||||
const showCreate = ref(false)
|
||||
const creating = ref(false)
|
||||
const createError = ref('')
|
||||
const createForm = reactive({
|
||||
username: '',
|
||||
fullName: '',
|
||||
role: 'DATA_ENTRY_CLERK' as string,
|
||||
password: '',
|
||||
})
|
||||
|
||||
const editingUser = ref<UserSummary | null>(null)
|
||||
const saving = ref(false)
|
||||
const editError = ref('')
|
||||
const editForm = reactive({
|
||||
fullName: '',
|
||||
role: '',
|
||||
})
|
||||
|
||||
const resetTarget = ref<UserSummary | null>(null)
|
||||
const resetPasswordValue = ref('')
|
||||
const resetError = ref('')
|
||||
const resetting = ref(false)
|
||||
|
||||
function formatRole(role: string): string {
|
||||
return role.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
await usersStore.listUsers(roleFilter.value || undefined)
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.username = ''
|
||||
createForm.fullName = ''
|
||||
createForm.role = 'DATA_ENTRY_CLERK'
|
||||
createForm.password = ''
|
||||
createError.value = ''
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
creating.value = true
|
||||
createError.value = ''
|
||||
try {
|
||||
await usersStore.createUser({
|
||||
username: createForm.username.trim(),
|
||||
fullName: createForm.fullName.trim(),
|
||||
role: createForm.role,
|
||||
password: createForm.password,
|
||||
})
|
||||
toast.success('User created')
|
||||
showCreate.value = false
|
||||
resetCreateForm()
|
||||
await loadUsers()
|
||||
} catch (e: unknown) {
|
||||
createError.value = e instanceof Error ? e.message : 'Failed to create user'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(user: UserSummary) {
|
||||
editingUser.value = user
|
||||
editForm.fullName = user.fullName
|
||||
editForm.role = user.role
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editingUser.value) return
|
||||
saving.value = true
|
||||
editError.value = ''
|
||||
try {
|
||||
await usersStore.updateUser(editingUser.value.id, {
|
||||
fullName: editForm.fullName.trim(),
|
||||
role: editForm.role,
|
||||
})
|
||||
toast.success('User updated')
|
||||
editingUser.value = null
|
||||
await loadUsers()
|
||||
} catch (e: unknown) {
|
||||
editError.value = e instanceof Error ? e.message : 'Failed to update user'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateUser(user: UserSummary) {
|
||||
try {
|
||||
await usersStore.updateUser(user.id, { isActive: false })
|
||||
toast.warning(`${user.fullName} deactivated`)
|
||||
if (editingUser.value?.id === user.id) editingUser.value = null
|
||||
await loadUsers()
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to deactivate user')
|
||||
}
|
||||
}
|
||||
|
||||
function openReset(user: UserSummary) {
|
||||
resetTarget.value = user
|
||||
resetPasswordValue.value = ''
|
||||
resetError.value = ''
|
||||
}
|
||||
|
||||
function closeReset() {
|
||||
resetTarget.value = null
|
||||
resetPasswordValue.value = ''
|
||||
resetError.value = ''
|
||||
}
|
||||
|
||||
async function confirmReset() {
|
||||
if (!resetTarget.value) return
|
||||
resetting.value = true
|
||||
resetError.value = ''
|
||||
try {
|
||||
await usersStore.resetPassword(resetTarget.value.id, resetPasswordValue.value)
|
||||
toast.success(`Password reset for ${resetTarget.value.username}`)
|
||||
closeReset()
|
||||
} catch (e: unknown) {
|
||||
resetError.value = e instanceof Error ? e.message : 'Failed to reset password'
|
||||
} finally {
|
||||
resetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
@@ -13,7 +13,7 @@
|
||||
<template #queue>
|
||||
<h2 class="text-xl font-semibold mb-4 text-ink-strong">Verification Queue</h2>
|
||||
<p class="text-sm text-ink-secondary mb-4">
|
||||
Batches pending verification, sorted by submission time (oldest first).
|
||||
Batches pending verification, oldest first (FIFO by last update).
|
||||
</p>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
@@ -84,8 +84,7 @@ function openBatch(id: string) {
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
await batchStore.listBatches({
|
||||
status: 'PENDING_VERIFICATION',
|
||||
await batchStore.listVerificationQueue({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user