From ff46245c6c5edcc4c952f25febdb31cb3ee2565c Mon Sep 17 00:00:00 2001 From: NckNA Date: Sat, 22 Aug 2026 19:49:41 +0500 Subject: [PATCH 1/2] feat: wire laboratory queue pagination UI --- src/pages/LaboratoryPage.test.tsx | 303 +++++++++++++++++++----------- src/pages/LaboratoryPage.tsx | 269 ++++++++++++++------------ 2 files changed, 335 insertions(+), 237 deletions(-) diff --git a/src/pages/LaboratoryPage.test.tsx b/src/pages/LaboratoryPage.test.tsx index b03b560..90267b4 100644 --- a/src/pages/LaboratoryPage.test.tsx +++ b/src/pages/LaboratoryPage.test.tsx @@ -6,18 +6,24 @@ import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { LaboratoryWorkOrderRecord } from '../data/repositories/LaboratoryWorkRepository'; -import { useLaboratoryWorkQueue, type UseLaboratoryWorkQueueResult } from '../data/hooks/useLaboratoryWorkQueue'; -import { useLaboratoryWorkMutations, type UseLaboratoryWorkMutationsResult } from '../data/hooks/useLaboratoryWorkMutations'; import { - usePatientLaboratoryWorkReferences, - type UsePatientLaboratoryWorkReferencesResult, -} from '../data/hooks/usePatientLaboratoryWorkReferences'; + useLaboratoryWorkPagedQueue, + type UseLaboratoryWorkPagedQueueResult, +} from '../data/hooks/useLaboratoryWorkPagedQueue'; +import { + useLaboratoryWorkMutations, + type UseLaboratoryWorkMutationsResult, +} from '../data/hooks/useLaboratoryWorkMutations'; +import { + useLaboratoryWorkRepository, + type UseLaboratoryWorkRepositoryResult, +} from '../data/hooks/useLaboratoryWorkRepository'; import { useTenant } from '../contexts/TenantContext'; import { LaboratoryPage } from './LaboratoryPage'; -vi.mock('../data/hooks/useLaboratoryWorkQueue', () => ({ useLaboratoryWorkQueue: vi.fn() })); +vi.mock('../data/hooks/useLaboratoryWorkPagedQueue', () => ({ useLaboratoryWorkPagedQueue: vi.fn() })); vi.mock('../data/hooks/useLaboratoryWorkMutations', () => ({ useLaboratoryWorkMutations: vi.fn() })); -vi.mock('../data/hooks/usePatientLaboratoryWorkReferences', () => ({ usePatientLaboratoryWorkReferences: vi.fn() })); +vi.mock('../data/hooks/useLaboratoryWorkRepository', () => ({ useLaboratoryWorkRepository: vi.fn() })); vi.mock('../components/laboratory/LaboratoryPatientPicker', () => ({ LaboratoryPatientPicker: ({ onSelect }: { onSelect: (patient: { id: string; fullName: string; phone: string; status: string }) => void }) => ( @@ -39,9 +45,9 @@ vi.mock('../contexts/TenantContext', async () => { return { ...actual, useTenant: vi.fn() }; }); -const mockedQueue = vi.mocked(useLaboratoryWorkQueue); +const mockedPagedQueue = vi.mocked(useLaboratoryWorkPagedQueue); const mockedMutations = vi.mocked(useLaboratoryWorkMutations); -const mockedReferences = vi.mocked(usePatientLaboratoryWorkReferences); +const mockedRepository = vi.mocked(useLaboratoryWorkRepository); const mockedTenant = vi.mocked(useTenant); function makeOrder(options: Partial & Pick): LaboratoryWorkOrderRecord { @@ -69,34 +75,42 @@ function makeOrder(options: Partial & Pick = {}): UseLaboratoryWorkQueueResult { +function pagedResult(overrides: Partial = {}): UseLaboratoryWorkPagedQueueResult { return { orders: [], + totalFiltered: 0, + limit: 50, + offset: 0, isLoading: false, isError: false, error: null, refetch: vi.fn().mockResolvedValue(undefined), + summary: { inProgress: 0, overdue: 0, completed: 0 }, + isSummaryLoading: false, + isSummaryError: false, + summaryError: null, + refetchSummary: vi.fn().mockResolvedValue(undefined), patientNamesById: {}, arePatientNamesLoading: false, arePatientNamesError: false, patientNamesError: null, refetchPatientNames: vi.fn().mockResolvedValue(undefined), - ...overrides, - }; -} - -function referenceResult(overrides: Partial = {}): UsePatientLaboratoryWorkReferencesResult { - return { referencesByOrderId: {}, - isLoading: false, - isError: false, - error: null, - refetch: vi.fn().mockResolvedValue(undefined), + areReferencesLoading: false, + areReferencesError: false, + referencesError: null, + refetchReferences: vi.fn().mockResolvedValue(undefined), + filterOptions: { doctors: [], laboratories: [] }, + areFilterOptionsLoading: false, + areFilterOptionsError: false, + filterOptionsError: null, + refetchFilterOptions: vi.fn().mockResolvedValue(undefined), ...overrides, }; } function mutationResult(overrides: Partial = {}): UseLaboratoryWorkMutationsResult { + const resultOrder = makeOrder({ id: 'mutation-result', patientId: 'patient-a', title: 'Mutation result' }); return { available: false, loading: false, @@ -104,17 +118,28 @@ function mutationResult(overrides: Partial = { error: null, refreshWarning: null, pendingRetryAction: null, - createOrder: vi.fn().mockResolvedValue(makeOrder({ id: 'mutation-result', patientId: 'patient-a', title: 'Mutation result' })), - updateOrder: vi.fn().mockResolvedValue(makeOrder({ id: 'mutation-result', patientId: 'patient-a', title: 'Mutation result' })), - completeOrder: vi.fn().mockResolvedValue(makeOrder({ id: 'mutation-result', patientId: 'patient-a', title: 'Mutation result' })), - reopenOrder: vi.fn().mockResolvedValue(makeOrder({ id: 'mutation-result', patientId: 'patient-a', title: 'Mutation result' })), - retryPendingMutation: vi.fn().mockResolvedValue(makeOrder({ id: 'mutation-result', patientId: 'patient-a', title: 'Mutation result' })), + createOrder: vi.fn().mockResolvedValue(resultOrder), + updateOrder: vi.fn().mockResolvedValue(resultOrder), + completeOrder: vi.fn().mockResolvedValue(resultOrder), + reopenOrder: vi.fn().mockResolvedValue(resultOrder), + retryPendingMutation: vi.fn().mockResolvedValue(resultOrder), clearError: vi.fn(), clearRefreshWarning: vi.fn(), ...overrides, }; } +function repositoryResult(overrides: Partial = {}): UseLaboratoryWorkRepositoryResult { + return { + backend: 'supabase', + tenantId: 'tenant-a', + userId: 'user-a', + ready: true, + repository: null, + ...overrides, + }; +} + async function changeValue(element: HTMLInputElement | HTMLSelectElement, value: string) { await act(async () => { const prototype = element instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLSelectElement.prototype; @@ -124,7 +149,7 @@ async function changeValue(element: HTMLInputElement | HTMLSelectElement, value: }); } -describe('LaboratoryPage', () => { +describe('LaboratoryPage paged queue UI', () => { let container: HTMLDivElement; let root: Root; @@ -133,8 +158,8 @@ describe('LaboratoryPage', () => { patientId: 'patient-a', title: 'Циркониевая коронка', orderNumber: 'LAB-A', - responsibleDoctorId: 'doctor-a-raw', - laboratoryId: 'lab-a-raw', + responsibleDoctorId: 'doctor-a', + laboratoryId: 'lab-a', plannedReadyAt: '2020-08-18T08:00:00.000Z', }); const orderB = makeOrder({ @@ -142,8 +167,8 @@ describe('LaboratoryPage', () => { patientId: 'patient-b', title: 'Керамический мост', orderNumber: 'LAB-B', - responsibleDoctorId: 'doctor-b-raw', - laboratoryId: 'lab-b-raw', + responsibleDoctorId: 'doctor-b', + laboratoryId: 'lab-b', status: 'completed', plannedReadyAt: '2026-08-25T08:00:00.000Z', }); @@ -153,16 +178,21 @@ describe('LaboratoryPage', () => { mockedTenant.mockReturnValue({ activeTenant: { tenantId: 'tenant-a', tenantName: 'Clinic A', role: 'clinic_admin', timezone: 'Asia/Almaty' }, } as unknown as ReturnType); + mockedRepository.mockReturnValue(repositoryResult()); mockedMutations.mockReturnValue(mutationResult()); - mockedQueue.mockReturnValue(queueResult({ + mockedPagedQueue.mockReturnValue(pagedResult({ orders: [orderA, orderB], + totalFiltered: 2, + summary: { inProgress: 17, overdue: 4, completed: 29 }, patientNamesById: { 'patient-a': 'Пациент А', 'patient-b': 'Пациент Б' }, - })); - mockedReferences.mockReturnValue(referenceResult({ referencesByOrderId: { 'order-a': { responsibleDoctorName: 'Доктор А', laboratoryName: 'Лаборатория А', workTypeNames: ['Коронка', 'Цирконий'] }, 'order-b': { responsibleDoctorName: 'Доктор Б', laboratoryName: 'Лаборатория Б', workTypeNames: ['Мост'] }, }, + filterOptions: { + doctors: [{ id: 'doctor-a', label: 'Доктор А' }, { id: 'doctor-b', label: 'Доктор Б' }], + laboratories: [{ id: 'lab-a', label: 'Лаборатория А' }, { id: 'lab-b', label: 'Лаборатория Б' }], + }, })); container = document.createElement('div'); document.body.appendChild(container); @@ -170,6 +200,7 @@ describe('LaboratoryPage', () => { }); afterEach(async () => { + vi.useRealTimers(); await act(async () => root.unmount()); container.remove(); }); @@ -180,119 +211,157 @@ describe('LaboratoryPage', () => { }); } - it('renders a read-only tenant queue with human labels and overdue presentation', async () => { + it('renders server summary, page rows, human labels and pagination range', async () => { await render(); - expect(mockedQueue).toHaveBeenCalledWith(); - expect(mockedReferences).toHaveBeenCalledWith([orderA, orderB]); + expect(mockedPagedQueue).toHaveBeenCalledWith({ + status: undefined, + responsibleDoctorId: undefined, + laboratoryId: undefined, + dueFilter: 'all', + search: undefined, + limit: 50, + offset: 0, + }); + expect(container.querySelector('[data-testid="laboratory-summary"]')?.textContent).toContain('17'); + expect(container.querySelector('[data-testid="laboratory-summary"]')?.textContent).toContain('29'); expect(container.textContent).toContain('Пациент А'); expect(container.textContent).toContain('Доктор А'); expect(container.textContent).toContain('Лаборатория А'); expect(container.textContent).toContain('Коронка, Цирконий'); expect(container.querySelector('[data-testid="laboratory-queue-order-order-a"]')?.textContent).toContain('Просрочено'); - expect(container.querySelector('[data-testid="laboratory-queue-order-order-b"]')?.textContent).toContain('Завершена'); - expect(container.textContent).not.toContain('doctor-a-raw'); - expect(container.textContent).not.toContain('lab-a-raw'); - expect(container.textContent).not.toContain('Создать лабораторную работу'); - expect(container.textContent).not.toContain('Редактировать'); - expect(container.textContent).not.toContain('Удалить'); + expect(container.querySelector('[data-testid="laboratory-pagination-range"]')?.textContent).toContain('Показано 1–2 из 2'); }); - it('filters the loaded queue by status, doctor, laboratory and search without mutations', async () => { + it('sends status, due, doctor and laboratory filters to the paged hook without client-side filtering', async () => { await render(); - const rowA = () => container.querySelector('[data-testid="laboratory-queue-order-order-a"]'); - const rowB = () => container.querySelector('[data-testid="laboratory-queue-order-order-b"]'); + + await changeValue(container.querySelector('[data-testid="laboratory-status-filter"]') as HTMLSelectElement, 'completed'); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'completed', offset: 0 })); + expect(container.querySelector('[data-testid="laboratory-queue-order-order-a"]')).not.toBeNull(); await changeValue(container.querySelector('[data-testid="laboratory-due-filter"]') as HTMLSelectElement, 'overdue'); - expect(rowA()).not.toBeNull(); - expect(rowB()).toBeNull(); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'completed', dueFilter: 'overdue', offset: 0 })); - await changeValue(container.querySelector('[data-testid="laboratory-due-filter"]') as HTMLSelectElement, 'all'); - await changeValue(container.querySelector('[data-testid="laboratory-status-filter"]') as HTMLSelectElement, 'completed'); - expect(rowA()).toBeNull(); - expect(rowB()).not.toBeNull(); - - await changeValue(container.querySelector('[data-testid="laboratory-status-filter"]') as HTMLSelectElement, 'all'); - await changeValue(container.querySelector('[data-testid="laboratory-doctor-filter"]') as HTMLSelectElement, 'doctor-a-raw'); - expect(rowA()).not.toBeNull(); - expect(rowB()).toBeNull(); - - await changeValue(container.querySelector('[data-testid="laboratory-doctor-filter"]') as HTMLSelectElement, 'all'); - await changeValue(container.querySelector('[data-testid="laboratory-lab-filter"]') as HTMLSelectElement, 'lab-b-raw'); - expect(rowA()).toBeNull(); - expect(rowB()).not.toBeNull(); - - await changeValue(container.querySelector('[data-testid="laboratory-lab-filter"]') as HTMLSelectElement, 'all'); - await changeValue(container.querySelector('[data-testid="laboratory-search"]') as HTMLInputElement, 'Пациент А'); - expect(rowA()).not.toBeNull(); - expect(rowB()).toBeNull(); + await changeValue(container.querySelector('[data-testid="laboratory-doctor-filter"]') as HTMLSelectElement, 'doctor-b'); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ responsibleDoctorId: 'doctor-b', offset: 0 })); + + await changeValue(container.querySelector('[data-testid="laboratory-lab-filter"]') as HTMLSelectElement, 'lab-b'); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ laboratoryId: 'lab-b', offset: 0 })); }); - it('shows a primary read error and retries only the queue read', async () => { - const refetch = vi.fn().mockResolvedValue(undefined); - mockedQueue.mockReturnValue(queueResult({ isError: true, error: new Error('queue failed'), refetch })); - mockedReferences.mockReturnValue(referenceResult()); + it('debounces server search by 300ms and resets the page identity to offset zero', async () => { + vi.useFakeTimers(); + mockedPagedQueue.mockReturnValue(pagedResult({ orders: [orderA], totalFiltered: 120, patientNamesById: { 'patient-a': 'Пациент А' } })); + await render(); + + await act(async () => (container.querySelector('[data-testid="laboratory-page-next"]') as HTMLButtonElement).click()); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 50 })); + await changeValue(container.querySelector('[data-testid="laboratory-search"]') as HTMLInputElement, ' Пациент А '); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ search: undefined, offset: 50 })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(299); + }); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ search: undefined, offset: 50 })); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ search: 'Пациент А', offset: 0 })); + }); + + it('advances by server limit and resets to page zero when page size changes', async () => { + mockedPagedQueue.mockReturnValue(pagedResult({ orders: [orderA], totalFiltered: 120, limit: 50 })); await render(); - expect(container.querySelector('[data-testid="laboratory-page-error"]')?.textContent).toContain('Не удалось загрузить лабораторную очередь'); - const retry = container.querySelector('[data-testid="laboratory-page-error"] button') as HTMLButtonElement; - await act(async () => retry.click()); - expect(refetch).toHaveBeenCalledTimes(1); + + await act(async () => (container.querySelector('[data-testid="laboratory-page-next"]') as HTMLButtonElement).click()); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 50, offset: 50 })); + + await changeValue(container.querySelector('[data-testid="laboratory-page-size"]') as HTMLSelectElement, '25'); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ limit: 25, offset: 0 })); + }); + + it('resets to the first page after the mutation refresh contract runs', async () => { + mockedPagedQueue.mockReturnValue(pagedResult({ orders: [orderA], totalFiltered: 120, limit: 50 })); + let refreshAfterMutation: (() => Promise | void) | undefined; + mockedMutations.mockImplementation((options) => { + refreshAfterMutation = options?.refresh; + return mutationResult({ available: true }); + }); + await render(); + + await act(async () => (container.querySelector('[data-testid="laboratory-page-next"]') as HTMLButtonElement).click()); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 50 })); + + await act(async () => { + await refreshAfterMutation?.(); + }); + expect(mockedPagedQueue).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 0 })); }); - it('keeps orders visible when patient/reference labels fail and retries secondary reads separately', async () => { + it('keeps the canonical page visible when summary and secondary enrichments fail', async () => { + const refetchSummary = vi.fn().mockResolvedValue(undefined); const refetchPatientNames = vi.fn().mockResolvedValue(undefined); const refetchReferences = vi.fn().mockResolvedValue(undefined); - mockedQueue.mockReturnValue(queueResult({ + const refetchFilterOptions = vi.fn().mockResolvedValue(undefined); + mockedPagedQueue.mockReturnValue(pagedResult({ orders: [orderA], + totalFiltered: 1, + isSummaryError: true, + refetchSummary, arePatientNamesError: true, - patientNamesError: new Error('names failed'), refetchPatientNames, + areReferencesError: true, + refetchReferences, + areFilterOptionsError: true, + refetchFilterOptions, })); - mockedReferences.mockReturnValue(referenceResult({ isError: true, error: new Error('refs failed'), refetch: refetchReferences })); - await render(); + expect(container.textContent).toContain('Циркониевая коронка'); - expect(container.textContent).toContain('Имя пациента недоступно'); + expect(container.querySelector('[data-testid="laboratory-summary-error"]')).not.toBeNull(); expect(container.querySelector('[data-testid="laboratory-patient-names-error"]')).not.toBeNull(); expect(container.querySelector('[data-testid="laboratory-references-error"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="laboratory-filter-options-error"]')).not.toBeNull(); + }); - const patientRetry = container.querySelector('[data-testid="laboratory-patient-names-error"] button') as HTMLButtonElement; - const referenceRetry = container.querySelector('[data-testid="laboratory-references-error"] button') as HTMLButtonElement; - await act(async () => { - patientRetry.click(); - referenceRetry.click(); - }); - expect(refetchPatientNames).toHaveBeenCalledTimes(1); - expect(refetchReferences).toHaveBeenCalledTimes(1); + it('shows primary server read errors separately and retries only the queue page', async () => { + const refetch = vi.fn().mockResolvedValue(undefined); + mockedPagedQueue.mockReturnValue(pagedResult({ isError: true, error: new Error('queue failed'), refetch })); + await render(); + + expect(container.querySelector('[data-testid="laboratory-page-error"]')?.textContent).toContain('Не удалось загрузить лабораторную очередь'); + await act(async () => (container.querySelector('[data-testid="laboratory-page-error"] button') as HTMLButtonElement).click()); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('fails closed in local prototype mode instead of falling back to the old broad queue', async () => { + mockedRepository.mockReturnValue(repositoryResult({ backend: 'local', tenantId: 'local-tenant', userId: null, ready: true })); + await render(); + + expect(container.querySelector('[data-testid="laboratory-page-server-required"]')?.textContent).toContain('Серверная лабораторная очередь'); + expect(container.querySelector('[data-testid="laboratory-order-list"]')).toBeNull(); }); - it('creates only after explicit patient selection and keeps the selected patient fixed', async () => { + it('keeps explicit patient selection and bounded mutation actions', async () => { const createOrder = vi.fn().mockResolvedValue(orderA); - mockedMutations.mockReturnValue(mutationResult({ available: true, createOrder })); + const updateOrder = vi.fn().mockResolvedValue(orderA); + const completeOrder = vi.fn().mockResolvedValue(orderA); + const reopenOrder = vi.fn().mockResolvedValue(orderB); + mockedMutations.mockReturnValue(mutationResult({ available: true, createOrder, updateOrder, completeOrder, reopenOrder })); await render(); await act(async () => (container.querySelector('[data-testid="laboratory-queue-create"]') as HTMLButtonElement).click()); - expect(container.querySelector('[data-testid="picker-select-patient"]')).not.toBeNull(); await act(async () => (container.querySelector('[data-testid="picker-select-patient"]') as HTMLButtonElement).click()); const dialog = container.querySelector('[data-testid="queue-order-dialog"]') as HTMLElement; expect(dialog.dataset.patientId).toBe('patient-picked'); expect(dialog.dataset.patientLabel).toContain('Выбранный пациент'); - expect(dialog.dataset.patientLabel).toContain('+77001234567'); - await act(async () => (container.querySelector('[data-testid="queue-order-submit"]') as HTMLButtonElement).click()); expect(createOrder).toHaveBeenCalledWith(expect.objectContaining({ patientId: 'patient-picked' })); - }); - - it('reuses bounded edit, complete and reopen actions for an admin', async () => { - const updateOrder = vi.fn().mockResolvedValue(orderA); - const completeOrder = vi.fn().mockResolvedValue(orderA); - const reopenOrder = vi.fn().mockResolvedValue(orderB); - mockedMutations.mockReturnValue(mutationResult({ available: true, updateOrder, completeOrder, reopenOrder })); - await render(); await act(async () => (container.querySelector('[data-testid="laboratory-queue-edit-order-a"]') as HTMLButtonElement).click()); - expect((container.querySelector('[data-testid="queue-order-dialog"]') as HTMLElement).dataset.patientId).toBe('patient-a'); await act(async () => (container.querySelector('[data-testid="queue-order-submit"]') as HTMLButtonElement).click()); expect(updateOrder).toHaveBeenCalledWith(expect.objectContaining({ orderId: 'order-a', expectedVersion: 1 })); @@ -305,7 +374,7 @@ describe('LaboratoryPage', () => { expect(reopenOrder).toHaveBeenCalledWith({ orderId: 'order-b', expectedVersion: 1, reason: 'Исправить цвет' }); }); - it('does not expose reopen to doctor and denies cashier even by direct route', async () => { + it('preserves role and mutation-version gates', async () => { mockedMutations.mockReturnValue(mutationResult({ available: true })); mockedTenant.mockReturnValue({ activeTenant: { tenantId: 'tenant-a', tenantName: 'Clinic A', role: 'doctor', timezone: 'Asia/Almaty' } } as unknown as ReturnType); await render(); @@ -314,18 +383,26 @@ describe('LaboratoryPage', () => { await act(async () => root.unmount()); root = createRoot(container); - mockedTenant.mockReturnValue({ activeTenant: { tenantId: 'tenant-a', tenantName: 'Clinic A', role: 'cashier', timezone: 'Asia/Almaty' } } as unknown as ReturnType); - await render(); - expect(container.querySelector('[data-testid="laboratory-page-no-access"]')?.textContent).toContain('Недостаточно прав'); - expect(container.querySelector('[data-testid="laboratory-queue-create"]')).toBeNull(); - }); - - it('shows a version warning instead of actions when a queue row lacks mutationVersion', async () => { - mockedMutations.mockReturnValue(mutationResult({ available: true })); - mockedQueue.mockReturnValue(queueResult({ orders: [makeOrder({ ...orderA, mutationVersion: undefined })], patientNamesById: { 'patient-a': 'Пациент А' } })); + mockedTenant.mockReturnValue({ activeTenant: { tenantId: 'tenant-a', tenantName: 'Clinic A', role: 'clinic_admin', timezone: 'Asia/Almaty' } } as unknown as ReturnType); + mockedPagedQueue.mockReturnValue(pagedResult({ + orders: [makeOrder({ ...orderA, mutationVersion: undefined })], + totalFiltered: 1, + patientNamesById: { 'patient-a': 'Пациент А' }, + })); await render(); expect(container.querySelector('[data-testid="laboratory-queue-version-warning-order-a"]')).not.toBeNull(); expect(container.querySelector('[data-testid="laboratory-queue-edit-order-a"]')).toBeNull(); - expect(container.querySelector('[data-testid="laboratory-queue-complete-order-a"]')).toBeNull(); + + await act(async () => root.unmount()); + root = createRoot(container); + mockedPagedQueue.mockClear(); + mockedRepository.mockClear(); + mockedMutations.mockClear(); + mockedTenant.mockReturnValue({ activeTenant: { tenantId: 'tenant-a', tenantName: 'Clinic A', role: 'cashier', timezone: 'Asia/Almaty' } } as unknown as ReturnType); + await render(); + expect(container.querySelector('[data-testid="laboratory-page-no-access"]')?.textContent).toContain('Недостаточно прав'); + expect(mockedPagedQueue).not.toHaveBeenCalled(); + expect(mockedRepository).not.toHaveBeenCalled(); + expect(mockedMutations).not.toHaveBeenCalled(); }); }); diff --git a/src/pages/LaboratoryPage.tsx b/src/pages/LaboratoryPage.tsx index b97a563..c8aba28 100644 --- a/src/pages/LaboratoryPage.tsx +++ b/src/pages/LaboratoryPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { AlertTriangle, Building2, @@ -15,9 +15,10 @@ import { UserRound, } from 'lucide-react'; import { useTenant } from '../contexts/TenantContext'; -import { useLaboratoryWorkQueue } from '../data/hooks/useLaboratoryWorkQueue'; +import { useLaboratoryWorkPagedQueue } from '../data/hooks/useLaboratoryWorkPagedQueue'; import { useLaboratoryWorkMutations } from '../data/hooks/useLaboratoryWorkMutations'; -import { usePatientLaboratoryWorkReferences } from '../data/hooks/usePatientLaboratoryWorkReferences'; +import { useLaboratoryWorkRepository } from '../data/hooks/useLaboratoryWorkRepository'; +import type { LaboratoryWorkQueueDueFilter } from '../data/repositories/LaboratoryWorkQueueReadClient'; import type { LaboratoryWorkOrderRecord, LaboratoryWorkOrderStatus } from '../data/repositories/LaboratoryWorkRepository'; import type { PatientLookupRecord } from '../data/repositories/PatientRepository'; import { LaboratoryPatientPicker } from '../components/laboratory/LaboratoryPatientPicker'; @@ -26,9 +27,7 @@ import { LaboratoryWorkCompleteDialog, LaboratoryWorkReopenDialog } from '../com import { getLaboratoryWorkRoleCapabilities } from '../components/patients/patient-card/laboratoryWorkPermissions'; import { compareInstantToTenantDay, formatInstantInTenant, tenantNowDate } from '../domain/timezone'; -type DueFilter = 'all' | 'overdue' | 'today' | 'upcoming' | 'unscheduled'; - -type DueBucket = Exclude | 'completed'; +type DueBucket = Exclude | 'completed'; const STATUS_LABELS: Record = { in_progress: 'В работе', @@ -54,6 +53,10 @@ const DUE_CLASSES: Record, string> = { unscheduled: 'bg-slate-100 text-slate-500', }; +const PAGE_SIZES = [25, 50, 100] as const; +const DEFAULT_PAGE_SIZE = 50; +const SEARCH_DEBOUNCE_MS = 300; + function dueBucket(order: LaboratoryWorkOrderRecord, timezone: string, nowMillis: number): DueBucket { if (order.status === 'completed') return 'completed'; if (!order.plannedReadyAt) return 'unscheduled'; @@ -62,16 +65,6 @@ function dueBucket(order: LaboratoryWorkOrderRecord, timezone: string, nowMillis return compareInstantToTenantDay(order.plannedReadyAt, today, timezone) === 0 ? 'today' : 'upcoming'; } -function orderPriority(order: LaboratoryWorkOrderRecord, timezone: string, nowMillis: number): number { - switch (dueBucket(order, timezone, nowMillis)) { - case 'overdue': return 0; - case 'today': return 1; - case 'upcoming': return 2; - case 'unscheduled': return 3; - case 'completed': return 4; - } -} - function safeTimestamp(value: string | null, timezone: string): string | null { if (!value) return null; return formatInstantInTenant(value, timezone, { dateStyle: 'medium', timeStyle: 'short' }); @@ -89,34 +82,87 @@ type QueueMutationDialog = export function LaboratoryPage() { const { activeTenant } = useTenant(); + const capabilities = getLaboratoryWorkRoleCapabilities(activeTenant?.role); + + if (!capabilities.canView) { + return ( +
+
Недостаточно прав для лабораторных работ.
+
+ ); + } + + return ; +} + +function LaboratoryQueuePage() { + const { activeTenant } = useTenant(); + const repositorySelection = useLaboratoryWorkRepository(); const timezone = activeTenant?.timezone ?? 'Asia/Almaty'; const capabilities = getLaboratoryWorkRoleCapabilities(activeTenant?.role); const [dialog, setDialog] = useState(null); const [statusFilter, setStatusFilter] = useState<'all' | LaboratoryWorkOrderStatus>('all'); const [doctorFilter, setDoctorFilter] = useState('all'); const [laboratoryFilter, setLaboratoryFilter] = useState('all'); - const [dueFilter, setDueFilter] = useState('all'); - const [search, setSearch] = useState(''); + const [dueFilter, setDueFilter] = useState('all'); + const [searchInput, setSearchInput] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [offset, setOffset] = useState(0); const [nowMillis] = useState(() => Date.now()); + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setDebouncedSearch(searchInput.trim()); + setOffset(0); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [searchInput]); + const { orders, + totalFiltered, + limit, patientNamesById, + referencesByOrderId, + filterOptions, + summary, isLoading, isError, error, refetch, + isSummaryLoading, + isSummaryError, + refetchSummary, arePatientNamesLoading, arePatientNamesError, refetchPatientNames, - } = useLaboratoryWorkQueue(); - const { - referencesByOrderId, - isLoading: areReferencesLoading, - isError: areReferencesError, - refetch: refetchReferences, - } = usePatientLaboratoryWorkReferences(orders); - const mutations = useLaboratoryWorkMutations({ refresh: refetch }); + areReferencesLoading, + areReferencesError, + refetchReferences, + areFilterOptionsLoading, + areFilterOptionsError, + refetchFilterOptions, + } = useLaboratoryWorkPagedQueue({ + status: statusFilter === 'all' ? undefined : statusFilter, + responsibleDoctorId: doctorFilter === 'all' ? undefined : doctorFilter, + laboratoryId: laboratoryFilter === 'all' ? undefined : laboratoryFilter, + dueFilter, + search: debouncedSearch || undefined, + limit: pageSize, + offset, + }); + + const refreshAfterMutation = useCallback(async () => { + await refetchSummary(); + if (offset !== 0) { + setOffset(0); + return; + } + await refetch(); + }, [offset, refetch, refetchSummary]); + + const mutations = useLaboratoryWorkMutations({ refresh: refreshAfterMutation }); const handleFormSubmit = async (submission: LaboratoryWorkOrderDialogSubmit) => { try { @@ -148,84 +194,38 @@ export function LaboratoryPage() { } }; - const doctors = useMemo(() => { - const map = new Map(); - for (const order of orders) { - const name = referencesByOrderId[order.id]?.responsibleDoctorName; - if (order.responsibleDoctorId && name) map.set(order.responsibleDoctorId, name); - } - return [...map.entries()] - .map(([id, name]) => ({ id, name })) - .sort((left, right) => left.name.localeCompare(right.name, 'ru')); - }, [orders, referencesByOrderId]); - - const laboratories = useMemo(() => { - const map = new Map(); - for (const order of orders) { - const name = referencesByOrderId[order.id]?.laboratoryName; - if (order.laboratoryId && name) map.set(order.laboratoryId, name); - } - return [...map.entries()] - .map(([id, name]) => ({ id, name })) - .sort((left, right) => left.name.localeCompare(right.name, 'ru')); - }, [orders, referencesByOrderId]); - - const filteredOrders = useMemo(() => { - const normalizedSearch = search.trim().toLocaleLowerCase('ru'); - return orders - .filter((order) => { - if (statusFilter !== 'all' && order.status !== statusFilter) return false; - if (doctorFilter !== 'all' && order.responsibleDoctorId !== doctorFilter) return false; - if (laboratoryFilter !== 'all' && order.laboratoryId !== laboratoryFilter) return false; - const bucket = dueBucket(order, timezone, nowMillis); - if (dueFilter !== 'all' && bucket !== dueFilter) return false; - if (!normalizedSearch) return true; - const references = referencesByOrderId[order.id]; - const patientName = patientNamesById[order.patientId] ?? ''; - const searchable = [ - order.title, - order.orderNumber ?? '', - patientName, - references?.responsibleDoctorName ?? '', - references?.laboratoryName ?? '', - ...(references?.workTypeNames ?? []), - ].join(' ').toLocaleLowerCase('ru'); - return searchable.includes(normalizedSearch); - }) - .sort((left, right) => { - const priority = orderPriority(left, timezone, nowMillis) - orderPriority(right, timezone, nowMillis); - if (priority !== 0) return priority; - const leftReady = left.plannedReadyAt ? Date.parse(left.plannedReadyAt) : Number.MAX_SAFE_INTEGER; - const rightReady = right.plannedReadyAt ? Date.parse(right.plannedReadyAt) : Number.MAX_SAFE_INTEGER; - if (leftReady !== rightReady) return leftReady - rightReady; - return Date.parse(right.updatedAt) - Date.parse(left.updatedAt); - }); - }, [doctorFilter, dueFilter, laboratoryFilter, nowMillis, orders, patientNamesById, referencesByOrderId, search, statusFilter, timezone]); - - const summary = useMemo(() => ({ - inProgress: orders.filter((order) => order.status === 'in_progress').length, - overdue: orders.filter((order) => dueBucket(order, timezone, nowMillis) === 'overdue').length, - completed: orders.filter((order) => order.status === 'completed').length, - }), [nowMillis, orders, timezone]); - const refreshAll = useCallback(async () => { - await refetch(); - await Promise.all([refetchPatientNames(), refetchReferences()]); - }, [refetch, refetchPatientNames, refetchReferences]); - - if (!capabilities.canView) { - return ( -
-
Недостаточно прав для лабораторных работ.
-
- ); - } - - if (isLoading) { + if (offset !== 0) { + setOffset(0); + await Promise.all([refetchSummary(), refetchFilterOptions()]); + return; + } + await Promise.all([ + refetch(), + refetchSummary(), + refetchPatientNames(), + refetchReferences(), + refetchFilterOptions(), + ]); + }, [offset, refetch, refetchFilterOptions, refetchPatientNames, refetchReferences, refetchSummary]); + + const hasActiveQuery = statusFilter !== 'all' + || doctorFilter !== 'all' + || laboratoryFilter !== 'all' + || dueFilter !== 'all' + || debouncedSearch.length > 0; + const currentPage = totalFiltered > 0 ? Math.floor(offset / limit) + 1 : 1; + const totalPages = Math.max(1, Math.ceil(totalFiltered / limit)); + const rangeStart = totalFiltered > 0 ? offset + 1 : 0; + const rangeEnd = Math.min(offset + orders.length, totalFiltered); + const canGoPrevious = offset > 0 && !isLoading; + const canGoNext = offset + limit < totalFiltered && !isLoading; + + if (repositorySelection.backend !== 'supabase') { return ( -
-
- Загружаем лабораторную очередь… +
+
+ Серверная лабораторная очередь доступна в режиме активной клиники. Локальный прототип не имитирует серверную пагинацию и поиск.
); @@ -257,7 +257,7 @@ export function LaboratoryPage() {

Лаборатория

- Общая операционная очередь лабораторных работ клиники. Создание начинается с явного поиска и выбора пациента. + Общая операционная очередь лабораторных работ клиники. Фильтры, поиск и порядок применяются сервером до границы страницы.

@@ -266,7 +266,7 @@ export function LaboratoryPage() { Новая работа )} -
@@ -277,20 +277,21 @@ export function LaboratoryPage() { {mutations.refreshWarning &&
{mutations.refreshWarning}
} {mutations.pendingRetryAction &&
Результат операции пока не подтверждён. Не создавайте новую операцию.
} -
+
В работе
{summary.inProgress}
Просрочено
{summary.overdue}
Завершено
{summary.completed}
+ {isSummaryError &&
Сводку не удалось обновить. Страница заказов остаётся доступной.
} - {(arePatientNamesLoading || areReferencesLoading || arePatientNamesError || areReferencesError) && ( + {(arePatientNamesLoading || areReferencesLoading || arePatientNamesError || areReferencesError || areFilterOptionsError) && (
{(arePatientNamesLoading || areReferencesLoading) && (
- Подгружаются имена пациентов и справочные данные… + Подгружаются имена пациентов и справочные данные текущей страницы…
)} - {(arePatientNamesError || areReferencesError) && ( + {(arePatientNamesError || areReferencesError || areFilterOptionsError) && (
Заказы загружены, но часть справочных названий временно недоступна.
{arePatientNamesError && ( @@ -302,7 +303,13 @@ export function LaboratoryPage() { {areReferencesError && (
Врач, лаборатория или виды работ могут быть временно без названий. - + +
+ )} + {areFilterOptionsError && ( +
+ Справочники фильтров временно недоступны. +
)}
@@ -313,33 +320,31 @@ export function LaboratoryPage() {
- { setStatusFilter(event.target.value as 'all' | LaboratoryWorkOrderStatus); setOffset(0); }} className="rounded-lg border border-slate-300 px-3 py-2.5 text-sm"> - { setDueFilter(event.target.value as LaboratoryWorkQueueDueFilter); setOffset(0); }} className="rounded-lg border border-slate-300 px-3 py-2.5 text-sm"> - { setDoctorFilter(event.target.value); setOffset(0); }} className="rounded-lg border border-slate-300 px-3 py-2.5 text-sm disabled:bg-slate-100"> + {filterOptions.doctors.map((doctor) => )} - { setLaboratoryFilter(event.target.value); setOffset(0); }} className="rounded-lg border border-slate-300 px-3 py-2.5 text-sm disabled:bg-slate-100"> + {filterOptions.laboratories.map((laboratory) => )}
- {orders.length === 0 ? ( -
- Лабораторных работ пока нет. -
- ) : filteredOrders.length === 0 ? ( -
- По выбранным фильтрам работ нет. + {isLoading ? ( +
Загружаем страницу лабораторной очереди…
+ ) : totalFiltered === 0 ? ( +
+ {hasActiveQuery ? 'По выбранным фильтрам работ нет.' : 'Лабораторных работ пока нет.'}
) : (
- {filteredOrders.map((order) => { + {orders.map((order) => { const references = referencesByOrderId[order.id]; const patientName = patientNamesById[order.patientId] ?? null; const bucket = dueBucket(order, timezone, nowMillis); @@ -400,6 +405,22 @@ export function LaboratoryPage() {
)} +
+
+ {totalFiltered > 0 ? `Показано ${rangeStart}–${rangeEnd} из ${totalFiltered}` : '0 работ'} · Страница {currentPage} из {totalPages} +
+
+ + + +
+
+ {dialog?.type === 'patient-picker' && setDialog(null)} onSelect={(patient) => setDialog({ type: 'create', patient })} />} {dialog?.type === 'create' && setDialog(null)} onSubmit={handleFormSubmit} />} {dialog?.type === 'edit' && setDialog(null)} onSubmit={handleFormSubmit} />} From 7028ea8eba5f38159f966ab68592c5c7000ccbcc Mon Sep 17 00:00:00 2001 From: NckNA Date: Sat, 22 Aug 2026 19:54:22 +0500 Subject: [PATCH 2/2] docs: add 001Z verification report --- .../LAB-WORK-QUEUE-PAGINATION-UI-001Z_ui.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 _ai_work/REPORTS/LAB-WORK-QUEUE-PAGINATION-UI-001Z_ui.md diff --git a/_ai_work/REPORTS/LAB-WORK-QUEUE-PAGINATION-UI-001Z_ui.md b/_ai_work/REPORTS/LAB-WORK-QUEUE-PAGINATION-UI-001Z_ui.md new file mode 100644 index 0000000..c350f91 --- /dev/null +++ b/_ai_work/REPORTS/LAB-WORK-QUEUE-PAGINATION-UI-001Z_ui.md @@ -0,0 +1,220 @@ +# LAB-WORK-QUEUE-PAGINATION-UI-001Z + +## Summary + +Final verdict: **PASS** + +001Z wires the laboratory operational page to the frozen 001Y server-paged data layer. The page no longer derives queue truth from a broad client-side order collection. + +The active path is: + +`server filters/search/order -> bounded queue page -> current-page enrichment -> UI` + +with a separate whole-tenant summary and independent doctor/laboratory filter dictionaries. + +## Branch + +`feature/lab-work-queue-pagination-ui-001z` + +## PR URL + +https://github.com/NckNA/codex-test/pull/406 + +- Baseline: `bf9439fc060867f8404fa96c755cbc5e795ac078` (001Y merged/frozen). +- Exact implementation SHA: `ff46245c6c5edcc4c952f25febdb31cb3ee2565c`. +- Fresh implementation CI: run `#888` / `32579902747`, **SUCCESS** on `ff46245c6c5edcc4c952f25febdb31cb3ee2565c`. +- Report update commit: N/A because a report cannot truthfully reference its own future commit; final report-head/CI/merge evidence belongs in the finalization receipt. +- Final report-head and merge evidence is intentionally completed by the normal report-finalization step after this report commit. + +## Changed files summary + +Implementation changes exactly two files: + +1. `src/pages/LaboratoryPage.tsx` +2. `src/pages/LaboratoryPage.test.tsx` + +This report is the third intended PR file. + +No repository/client/data-hook implementation, Supabase migration, schema, seed, package, mutation RPC, patient-card component, or production integration file is changed by 001Z. + +## Study / recon evidence + +Before final UI acceptance, the authenticated read-only MacDent research browser on local CDP port 9366 was passively reconnoitered. + +Evidence found: + +- MacDent production `/app` was loaded read-only; +- loaded scripts/resources and safe redacted source matches were inspected; +- no separate, clearly identifiable laboratory-queue UI/workflow was found in the loaded `main.js` / bundle for the searched laboratory-related terms; +- no navigation clicks, form mutations, storage values, cookies, response bodies, patient field values, or production writes were used. + +Result: 001Z does not invent or mechanically copy a MacDent laboratory screen that the evidence did not establish. MacDent remains a process/engineering reference; the queue UI follows the frozen DentalFlow 001W–001Y semantic contract. + +amoCRM and MacDent research browser capabilities remain available for the next recon task, but amoCRM is not used as a medical/laboratory source of truth. + +## Semantic contract + +001Z preserves the frozen 001W/001X/001Y rules: + +- status, due bucket, doctor, laboratory, search, ordering, limit, offset and `totalFiltered` are server-driven; +- there is no client-side re-filtering or re-sorting of the returned canonical page; +- search is debounced for 300 ms before becoming server query identity; +- status/due/doctor/laboratory changes reset offset to 0; +- debounced search identity changes reset offset to 0; +- page-size changes reset offset to 0; +- successful mutation refresh resets the UI to page 0 before canonical queue refresh when currently off page 0; +- whole-tenant summary is rendered independently from page/search/filter totals; +- row patient/reference labels come only from current-page enrichment supplied by 001Y; +- filter dictionaries remain independent whole-tenant minimal label dictionaries by frozen design; +- local prototype mode does not imitate server pagination by falling back to the old broad queue; +- unsupported roles fail closed before laboratory repository, paged-data or mutation hooks mount. + +## Implementation + +### Server-driven query controls + +`LaboratoryPage` now passes the following state directly to `useLaboratoryWorkPagedQueue`: + +- `status`; +- `responsibleDoctorId`; +- `laboratoryId`; +- `dueFilter`; +- debounced `search`; +- `limit`; +- `offset`. + +Page sizes are bounded to the UI choices 25 / 50 / 100, with 50 as the default. + +### Pagination + +The UI renders: + +- `Показано X–Y из totalFiltered`; +- current page / total pages; +- Back / Next controls; +- page-size selector. + +Next/previous movement uses the canonical server-returned `limit`, not the number of rows currently rendered. + +### Independent summary and enrichment states + +Primary page failure is blocking for the queue surface. Summary, patient-label, row-reference and filter-dictionary failures are secondary and do not erase a successfully loaded canonical page. + +### Role-gate hardening found during browser QA + +Initial real browser QA found that cashier visually received `Недостаточно прав`, but laboratory hooks mounted before that return and attempted denied network reads, producing HTTP errors. + +Fix: + +- exported `LaboratoryPage` now performs the role capability gate first; +- only permitted roles mount the inner `LaboratoryQueuePage`; +- cashier therefore mounts no paged queue hook, laboratory repository hook or laboratory mutation hook. + +The unit test now explicitly proves those hooks are not called for cashier, and the real browser recheck has zero console errors and zero failed requests. + +## Checks + +- Fresh local Supabase reset: **PASS**. +- Guarded local QA user seed: **PASS**. +- Targeted `LaboratoryPage` tests: **10 / 10 PASS**. +- Full Vitest: **132 test files / 1336 tests PASS**. +- ESLint: **PASS**. +- TypeScript + Vite build: **PASS**. +- Static page audit: no legacy `useLaboratoryWorkQueue`, `usePatientLaboratoryWorkReferences`, `listOrders`, or `listPatients` usage in `LaboratoryPage`. +- Git implementation scope before commit: exactly `LaboratoryPage.tsx` + `LaboratoryPage.test.tsx`. +- GitHub CI #888: **SUCCESS** on exact implementation SHA `ff46245c6c5edcc4c952f25febdb31cb3ee2565c`. + +Known unrelated baseline warnings remain in the broader suite: existing React `act(...)` warnings in older tests and Vite's >500 kB bundle-size warning. They are not introduced by 001Z. + +## Browser smoke + +All browser QA used localhost `http://127.0.0.1:5185` tied by `dev_server_context_check` to the exact 001Z worktree/branch. Supabase-active mode was confirmed; prototype mode was false. Login used normal seeded local Supabase Auth credentials supplied from host environment variables. Secrets were not returned. + +### Admin empty-queue baseline + +Clinic admin tenant A: + +- `/laboratory` rendered the real server queue surface; +- pagination rendered `0 работ` on the fresh empty DB; +- default page size 50 rendered; +- default status filter rendered; +- 0 console errors. + +### Role smoke and discovered fix + +After the role-gate correction: + +- doctor tenant A: page accessible, create action visible, reopen action absent, **0 console errors, 0 failed requests**; +- cashier tenant A: direct route shows `Недостаточно прав`, create action absent, **0 console errors, 0 failed requests**; +- cashier laboratory data hooks are also proven not to mount by unit test. + +### Real 55-row pagination smoke + +An atomic local-only smoke session created deterministic QA-only patient/doctor/lab/work-type data plus 55 laboratory orders for tenant A, used the real browser, and cleaned all fixture rows in `finally`. + +Observed: + +- page 2 rendered `Показано 51–55 из 55`; +- a page-2 order was visible; +- a page-1 marker was absent; +- 0 console errors; +- 0 failed requests; +- cleanup verification: `remaining_rows = 0`. + +### Real server-search smoke + +A second atomic 55-row local-only session exercised actual debounced server search. + +Observed: + +- search for `QA 001Z Search Needle` produced the intended order; +- pagination rendered `Показано 1–1 из 1`; +- unrelated marker was absent; +- 0 console errors; +- 0 failed requests; +- cleanup verification: `remaining_rows = 0`. + +Screenshots were stored under the Hermes report workspace, outside the Git implementation scope. + +## Security / isolation + +001Z makes no cloud or production DB change. + +Security-relevant evidence: + +- existing 0037 SECURITY DEFINER queue RPC remains the authoritative tenant/role boundary; +- cashier UI no longer mounts laboratory data hooks; +- doctor access remains allowed as intended; +- no legacy broad local/prod queue fallback is introduced; +- local smoke fixtures used deterministic fake QA identifiers and were verified fully deleted; +- no real patient data was created, read or modified for QA; +- no production MacDent or amoCRM mutation was performed. + +## Issues / fixes + +1. Fresh worktree initially lacked `node_modules`. Fixed with `npm ci`; not a product defect. +2. Old `LaboratoryPage` tests mocked the pre-001Y broad queue contract. Rewritten against the paged contract rather than restoring legacy behavior. +3. ESLint rejected a synchronous `setOffset` inside an effect. The unnecessary fallback effect was removed; required reset events remain explicit. +4. Initial cashier browser smoke exposed denied background queue requests despite the visual role gate. Fixed by moving authorization outside all laboratory data hooks; real browser recheck passed cleanly. +5. Two manual QA-fixture SQL attempts were blocked before execution because the shared global Hermes task policy was concurrently overwritten by another session. No rows were written. Final paging/search QA used the atomic `local_smoke_data_session`, which completed setup/browser/cleanup safely and verified zero leftover rows. + +The shared global Hermes policy race is an infrastructure concern outside DentalFlow source scope. 001Z did not bypass it with raw unguarded database commands. + +## Limitations + +1. Offset pagination can shift under concurrent writes. Frozen mitigation remains page-0 reset after successful mutation and query-identity changes. +2. Doctor/laboratory filter dictionaries are whole-tenant minimal label reads, intentionally independent from current page. +3. No speculative performance indexes were added. Query-plan tuning remains evidence-driven. +4. Existing application bundle-size warning is not addressed in this bounded task. + +## Final verdict + +**PASS** + +The implementation is ready for report commit, fresh report-head CI, independent PR scope review, merge and freeze if the PR remains exactly within the intended three-file scope. + +## Recommended next task + +**LAB-WORK-NEXT-RECON-002A** + +Perform a fresh STUDY/RECON after the pagination track is frozen. Reinspect current DentalFlow laboratory backlog plus passive read-only MacDent/amoCRM evidence, identify the next missing high-value laboratory workflow with a semantic contract and evidence, and only then open another implementation task.