feature: Surface Existing Unused APIs
CI / backend (push) Successful in 5m58s
CI / frontend (push) Failing after 1m15s

This commit is contained in:
2026-08-12 04:56:06 +08:00
parent 4085aaa549
commit 00f832aa73
19 changed files with 1453 additions and 17 deletions
@@ -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)]
+3 -2
View File
@@ -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 1418 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'] },
},
],
},
{
+134
View File
@@ -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,
+90
View File
@@ -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,
}
})
+73
View File
@@ -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,
})