From ca19d654da55b6ebd397811fca20a275b3b25168 Mon Sep 17 00:00:00 2001 From: Veronika Tsygankova Date: Tue, 10 Nov 2026 17:43:01 +0300 Subject: [PATCH 1/3] =?UTF-8?q?[AURORA-1916]=20-=20=D0=9F=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=C2=AB=D0=9F=D0=B5=D1=80=D0=B2=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=C2=BB:=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=85=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=81=D0=BE=D1=82=D0=BD=D0=B8=20=D0=BD=D0=B5=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B0=D1=8E=D1=82=D1=81=D1=8F:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - В панели отображается общее число файлов и догружаются следующие страницы (при прокрутке). - Опрос статусов обработки файлов (раз в 5 секунд) не сбрасывает уже подгруженные страницы. - Тот же список используется на экране проверки распознавания --- web/src/hooks/useLegalEntityFiles.ts | 92 +++++++++++++++++++ web/src/hooks/useScrollSentinel.ts | 24 +++++ web/src/layouts/DocumentPage.tsx | 79 ++++++++++++---- web/src/services/api/files.test.ts | 88 ++++++++++++++++-- web/src/services/api/files.ts | 31 ++++++- web/src/widgets/FilesDrawer/FilesDrawer.tsx | 20 +++- web/src/widgets/FilesDrawer/FilesList.tsx | 45 ++++++--- .../FilesDrawer/useLegalEntityFiles.ts | 65 ------------- 8 files changed, 331 insertions(+), 113 deletions(-) create mode 100644 web/src/hooks/useLegalEntityFiles.ts create mode 100644 web/src/hooks/useScrollSentinel.ts delete mode 100644 web/src/widgets/FilesDrawer/useLegalEntityFiles.ts diff --git a/web/src/hooks/useLegalEntityFiles.ts b/web/src/hooks/useLegalEntityFiles.ts new file mode 100644 index 0000000..60c7e8d --- /dev/null +++ b/web/src/hooks/useLegalEntityFiles.ts @@ -0,0 +1,92 @@ +import { message } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError } from '../api/client'; +import { hasPendingFiles, listLegalEntityFilesFrom } from '../services/api/files'; +import type { FileOut } from '../types/api'; + +const POLL_MS = 5000; + +/** + * Файлы рабочего стола консолидации (`consolidationId`) или все файлы ЮЛ (`null`) — страницами по `FILES_PAGE_LIMIT`: + * первая сразу, следующие при прокрутке до конца списка (`loadMore`). `reload` перечитывает всё показанное, чтобы + * после удаления, загрузки и опроса список не сворачивался к началу. Пока панель открыта и среди показанных есть + * UPLOADED/PROCESSING — перечитываем раз в 5 с. + */ +export function useLegalEntityFiles(legalEntityId: string | null, consolidationId: string | null, open: boolean) { + const [files, setFiles] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const requestRef = useRef(0); + const inFlightRef = useRef(false); + const errorShownRef = useRef(false); + + const loadedRef = useRef(0); + + const load = useCallback( + async (from: number, count: number) => { + if (!legalEntityId) return; + const append = from > 0; + const setBusy = append ? setLoadingMore : setLoading; + const requestId = ++requestRef.current; + inFlightRef.current = true; + setBusy(true); + try { + const { items, total } = await listLegalEntityFilesFrom(legalEntityId, consolidationId, from, count); + if (requestId !== requestRef.current) return; + setFiles((prev) => { + const seen = new Set(prev.map((f) => f.id)); + const next = append ? [...prev, ...items.filter((f) => !seen.has(f.id))] : items; + loadedRef.current = next.length; + return next; + }); + setTotal(total); + errorShownRef.current = false; + } catch (e: unknown) { + // при опросе показываем ошибку один раз на серию; флаг сбрасывается после первого успешного ответа + if (requestId === requestRef.current && !errorShownRef.current) { + errorShownRef.current = true; + message.error(e instanceof ApiError ? e.message : 'Не удалось получить файлы'); + } + } finally { + if (requestId === requestRef.current) { + inFlightRef.current = false; + setBusy(false); + } + } + }, + [legalEntityId, consolidationId], + ); + + const reload = useCallback(() => load(0, loadedRef.current), [load]); + + const hasMore = files.length < total; + const loadMore = useCallback(async () => { + // не догружаем поверх идущего запроса: ответ перечитывания всё равно заменит список + if (hasMore && !inFlightRef.current) await load(loadedRef.current, 1); + }, [load, hasMore]); + + // смена режима (consolidationId) приходит через reload: он пересоздаётся и перезапускает этот эффект + useEffect(() => { + // при смене ЮЛ, режима (консолидация/все) или закрытии панели: старые ответы не применяем, прежние файлы очищаем + requestRef.current++; + inFlightRef.current = false; + errorShownRef.current = false; + loadedRef.current = 0; + setFiles([]); + setTotal(0); + setLoadingMore(false); + if (open && legalEntityId) void reload(); + }, [open, legalEntityId, reload]); + + const pending = hasPendingFiles(files); + useEffect(() => { + if (!open || !pending) return; + const timer = window.setInterval(() => { + if (!inFlightRef.current) void reload(); // не копим параллельные запросы при медленном ответе + }, POLL_MS); + return () => window.clearInterval(timer); + }, [open, pending, reload]); + + return { files, total, loading, loadingMore, reload, loadMore }; +} diff --git a/web/src/hooks/useScrollSentinel.ts b/web/src/hooks/useScrollSentinel.ts new file mode 100644 index 0000000..500d62c --- /dev/null +++ b/web/src/hooks/useScrollSentinel.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef } from 'react'; + +/** + * Подгрузка при прокрутке: вызывает `onReach`, когда метка (`ref`) показалась на экране. Метку ставят под списком, + * который прокручивается вместе со страницей или панелью (например, таблица в Drawer «Первичные документы»). + */ +export function useScrollSentinel(enabled: boolean, trigger: unknown, onReach: () => void) { + const ref = useRef(null); + const onReachRef = useRef(onReach); + onReachRef.current = onReach; + useEffect(() => { + const node = ref.current; + if (!enabled || !node) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) onReachRef.current(); + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [enabled, trigger]); + return ref; +} diff --git a/web/src/layouts/DocumentPage.tsx b/web/src/layouts/DocumentPage.tsx index 0235f81..e27bcca 100644 --- a/web/src/layouts/DocumentPage.tsx +++ b/web/src/layouts/DocumentPage.tsx @@ -1,15 +1,16 @@ import { CommentOutlined } from '@ant-design/icons'; import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; import { TableTag } from '../components/TableTag/TableTag'; import { TAG_PRESETS } from '../components/TableTag/presets'; +import { useLegalEntityFiles } from '../hooks/useLegalEntityFiles'; import { fetchLegalEntity } from '../services/api/directories/company_groups'; import { getDocument } from '../services/api/documents'; -import { getFile, listLegalEntityFiles } from '../services/api/files'; -import type { DocumentOut } from '../types/api'; +import { getFile } from '../services/api/files'; +import type { DocumentOut, FileOut } from '../types/api'; import { documentTypeLabel, periodLabel } from '../utils/labels'; import { DocumentCommentsDrawer } from '../widgets/DocumentComments/DocumentCommentsDrawer'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; @@ -20,6 +21,16 @@ import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/docu type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow }; +const toOption = (row: DocumentRow): DocumentOption => ({ + value: row.documentId ?? row.fileId, + label: row.name, + disabled: !row.linkable, + row, +}); + +/** До конца выпадающего списка осталось меньше этого (px) — догружаем следующую страницу. */ +const POPUP_LOAD_MORE_PX = 50; + export function DocumentPage() { const { document_id } = useParams(); const navigate = useNavigate(); @@ -27,7 +38,7 @@ export function DocumentPage() { const [entityName, setEntityName] = useState(''); const [subtitle, setSubtitle] = useState(null); const [error, setError] = useState(null); - const [documentOptions, setDocumentOptions] = useState([]); + const [file, setFile] = useState(null); const [commentOpen, setCommentOpen] = useState(false); const load = useCallback(async () => { @@ -42,21 +53,12 @@ export function DocumentPage() { getFile(d.file_id) .then((f) => { setSubtitle(documentSubtitle(f)); - // переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ) - listLegalEntityFiles(d.legal_entity_id, f.consolidation_id) - .then((files) => - setDocumentOptions( - buildDocumentRows(files).map((row) => ({ - value: row.documentId ?? row.fileId, - label: row.name, - disabled: !row.linkable, - row, - })), - ), - ) - .catch(() => setDocumentOptions([])); + setFile(f); }) - .catch(() => setSubtitle(null)); + .catch(() => { + setSubtitle(null); + setFile(null); + }); } catch (e: unknown) { setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); } @@ -66,6 +68,29 @@ export function DocumentPage() { void load(); }, [load]); + const { + files, + loadingMore, + reload: reloadFiles, + loadMore, + } = useLegalEntityFiles(file ? file.legal_entity_id : null, file?.consolidation_id ?? null, file !== null); + + const documentOptions = useMemo(() => { + const options = buildDocumentRows(files).map(toOption); + // открытый документ может быть дальше загруженных страниц — без своей опции Select показал бы вместо имени id + if (doc && file && !options.some((o) => o.value === doc.id)) { + const own = buildDocumentRows([file]).find((row) => row.documentId === doc.id); + if (own) options.unshift(toOption(own)); + } + return options; + }, [files, doc, file]); + + // после правки или подтверждения документа обновляем и карточку, и статусы в переключателе + const onChanged = useCallback(() => { + void load(); + void reloadFiles(); + }, [load, reloadFiles]); + if (error) { return navigate(-1)}>Назад} />; } @@ -95,6 +120,20 @@ export function DocumentPage() { value={doc.id} options={documentOptions} onChange={(value) => navigate(`/documents/${value}`, { replace: true })} + onPopupScroll={(e) => { + const list = e.target as HTMLElement; + if (list.scrollTop + list.clientHeight >= list.scrollHeight - POPUP_LOAD_MORE_PX) void loadMore(); + }} + popupRender={(menu) => ( + <> + {menu} + {loadingMore && ( +
+ +
+ )} + + )} optionRender={(option) => (
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */} @@ -129,8 +168,8 @@ export function DocumentPage() { overflow: 'auto', }}> {broken && } - {!broken && kind === 'osv' && } - {!broken && kind === 'statement' && } + {!broken && kind === 'osv' && } + {!broken && kind === 'statement' && } {kind === 'none' && } {/* key: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */} diff --git a/web/src/services/api/files.test.ts b/web/src/services/api/files.test.ts index 07fce57..4f95b5b 100644 --- a/web/src/services/api/files.test.ts +++ b/web/src/services/api/files.test.ts @@ -1,7 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError } from '../../api/client'; import type { FileOut, UploadResult } from '../../types/api'; -import { filesPath, hasPendingFiles, isUploadResult, listLegalEntityFiles, summarizeUpload, uploadFiles } from './files'; +import { + FILES_PAGE_LIMIT, + filesPath, + hasPendingFiles, + isUploadResult, + listLegalEntityFiles, + listLegalEntityFilesFrom, + summarizeUpload, + uploadFiles, +} from './files'; const result: UploadResult = { accepted: [{ file_id: 'f1', job_id: 'j1', name: 'osv.xlsx' }], @@ -95,14 +104,79 @@ describe('uploadFiles', () => { }); }); +const L = FILES_PAGE_LIMIT; +const file = (id: string) => ({ id }) as unknown as FileOut; +const ids = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => `f${from + i}`); + +/** Фальшивый бэк: `total` файлов f0, f1, …; страница — по `offset`/`limit` из адреса запроса. */ +async function fakeServer(total: number) { + const client = await import('../../api/client'); + return vi.spyOn(client, 'apiFetch').mockImplementation((path: string) => { + const query = new URLSearchParams(path.split('?')[1]); + const offset = Number(query.get('offset')); + const limit = Number(query.get('limit')); + return Promise.resolve({ items: ids(offset, Math.min(offset + limit, total)).map(file), total, limit, offset }); + }); +} +const offsets = (spy: Awaited>) => + spy.mock.calls.map(([path]) => Number(new URLSearchParams(String(path).split('?')[1]).get('offset'))); + describe('listLegalEntityFiles', () => { afterEach(() => vi.restoreAllMocks()); - it('стол консолидации и список ЮЛ', async () => { - const client = await import('../../api/client'); - const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + it('стол консолидации и список ЮЛ — одна страница', async () => { + const spy = await fakeServer(0); await listLegalEntityFiles('le', 'c1'); - await listLegalEntityFiles('le', null); - expect(spy.mock.calls[0][0]).toBe('/consolidations/c1/legal-entities/le/files?limit=100'); - expect(spy.mock.calls[1][0]).toBe('/legal-entities/le/files?limit=100'); + await listLegalEntityFiles('le', null, L); + expect(spy.mock.calls[0][0]).toBe(`/consolidations/c1/legal-entities/le/files?limit=${L}&offset=0`); + expect(spy.mock.calls[1][0]).toBe(`/legal-entities/le/files?limit=${L}&offset=${L}`); + }); +}); + +describe('listLegalEntityFilesFrom', () => { + afterEach(() => vi.restoreAllMocks()); + + it('перечитывание трёх показанных страниц — три запроса подряд', async () => { + const spy = await fakeServer(5 * L); + const res = await listLegalEntityFilesFrom('le', null, 0, 3 * L); + expect(offsets(spy)).toEqual([0, L, 2 * L]); + expect(res.items.map((f) => f.id)).toEqual(ids(0, 3 * L)); + expect(res.total).toBe(5 * L); + }); + + it('ничего не показано (count = 0) — всё равно один запрос', async () => { + const spy = await fakeServer(5 * L); + const res = await listLegalEntityFilesFrom('le', null, 0, 0); + expect(offsets(spy)).toEqual([0]); + expect(res.items).toHaveLength(L); + }); + + it('догрузка при прокрутке — одна страница с позиции from', async () => { + const spy = await fakeServer(5 * L); + const res = await listLegalEntityFilesFrom('le', null, L, 1); + expect(offsets(spy)).toEqual([L]); + expect(res.items.map((f) => f.id)).toEqual(ids(L, 2 * L)); + }); + + it('файлы кончились раньше count — останавливается на total', async () => { + const spy = await fakeServer(L + 1); + const res = await listLegalEntityFilesFrom('le', null, 0, 5 * L); + expect(offsets(spy)).toEqual([0, L]); + expect(res.items).toHaveLength(L + 1); + expect(res.total).toBe(L + 1); + }); + + it('догрузка последнего неполного куска — один запрос', async () => { + const spy = await fakeServer(2 * L + 1); + const res = await listLegalEntityFilesFrom('le', null, 2 * L, 1); + expect(offsets(spy)).toEqual([2 * L]); + expect(res.items.map((f) => f.id)).toEqual([`f${2 * L}`]); + }); + + it('пустая страница раньше total (файлы удалили между запросами) — останавливается', async () => { + const client = await import('../../api/client'); + const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue({ items: [], total: 10 * L, limit: L, offset: 0 }); + const res = await listLegalEntityFilesFrom('le', null, 0, 5 * L); + expect(spy).toHaveBeenCalledTimes(1); + expect(res).toEqual({ items: [], total: 10 * L }); }); }); diff --git a/web/src/services/api/files.ts b/web/src/services/api/files.ts index 9b419b0..62918eb 100644 --- a/web/src/services/api/files.ts +++ b/web/src/services/api/files.ts @@ -40,9 +40,34 @@ export function summarizeUpload(result: UploadResult): { accepted: string[]; rej }; } -export async function listLegalEntityFiles(legalEntityId: string, consolidationId: string | null): Promise { - const page = await apiFetch>(`${filesPath(legalEntityId, consolidationId)}?limit=100`); - return page.items; +/** Размер страницы: столько файлов панель загружает сразу и при каждой прокрутке до конца списка. Не больше 200 — `MAX_PAGE_LIMIT` бэка. */ +export const FILES_PAGE_LIMIT = 200; + +/** Страница файлов ЮЛ одним запросом. Бэк сортирует по дате загрузки и `id` — порядок стабилен. */ +export function listLegalEntityFiles(le: string, cons: string | null, offset = 0, limit = FILES_PAGE_LIMIT) { + return apiFetch>(`${filesPath(le, cons)}?limit=${limit}&offset=${offset}`); +} + +/** + * Файлы ЮЛ начиная с номера `from` запросами по `FILES_PAGE_LIMIT`, пока не наберём `count` + * или файлы не кончатся. Пустая страница раньше `total` — файлы удалили между запросами, дальше не идём. + */ +export async function listLegalEntityFilesFrom( + le: string, + cons: string | null, + from: number, + count: number, +): Promise<{ items: FileOut[]; total: number }> { + const items: FileOut[] = []; + let total = 0; + let lastPageEmpty = false; + do { + const page = await listLegalEntityFiles(le, cons, from + items.length); + items.push(...page.items); + total = page.total; + lastPageEmpty = page.items.length === 0; + } while (items.length < count && from + items.length < total && !lastPageEmpty); + return { items, total }; } export function hasPendingFiles(files: FileOut[]): boolean { diff --git a/web/src/widgets/FilesDrawer/FilesDrawer.tsx b/web/src/widgets/FilesDrawer/FilesDrawer.tsx index 7d18a64..2bb2aaa 100644 --- a/web/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/web/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -2,19 +2,20 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd'; import { useEffect, useState } from 'react'; import { ApiError } from '../../api/client'; +import { useLegalEntityFiles } from '../../hooks/useLegalEntityFiles'; import { fetchLegalEntity } from '../../services/api/directories/company_groups'; import { postComment } from '../../services/api/documents'; import { summarizeUpload, uploadFiles } from '../../services/api/files'; import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors'; import { useAppSelector } from '../../store/store'; import type { FileOut, LegalEntityBrief } from '../../types/api'; +import { pluralRu } from '../../utils/labels'; import DeleteReasonModal from './DeleteModal'; import { FilesList } from './FilesList'; import ReplaceDocModal from './ReplaceDocModal'; import { UploadDocumentsModal } from './UploadDocumentsModal'; import { consolidationLabel } from './consolidationLabel'; import type { DocumentRow } from './documentRows'; -import { useLegalEntityFiles } from './useLegalEntityFiles'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -27,7 +28,7 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () // консолидация не определена (версии нет в списке ГК) — режим «все» без переключателя (design D-3) const desk = current !== null && !showAll; const consolidationId = desk ? current.consolidation_id : null; - const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open); + const { files, total, loading, loadingMore, reload, loadMore } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open); const [targetRow, setTargetRow] = useState(null); const [removalOpen, setRemovalOpen] = useState(false); const canUpload = current !== null && isInWork(current.version_status); @@ -154,8 +155,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () Все документы организации )} + {!(loading && files.length === 0) && ( + + Всего {total} {pluralRu(total, ['файл', 'файла', 'файлов'])} + + )}
- + void loadMore()} + labelFor={labelFor} + onDeleteClick={handleRowDelete} + /> {current_legal_entity_id && ( {text}; export function FilesList({ files, loading, + loadingMore, + hasMore, + onLoadMore, labelFor, onDeleteClick, }: { files: FileOut[]; loading: boolean; + loadingMore: boolean; + hasMore: boolean; + onLoadMore: () => void; labelFor?: (f: FileOut) => string | null; onDeleteClick: (row: DocumentRow) => void; }) { const navigate = useNavigate(); const rows = buildDocumentRows(files, labelFor); + const sentinelRef = useScrollSentinel(hasMore, files, onLoadMore); const columns: ColumnsType = [ { @@ -164,20 +172,27 @@ export function FilesList({ } return ( - - size='small' - rowKey='key' - pagination={false} - columns={columns} - dataSource={rows} - locale={{ emptyText: 'Документов нет' }} - onRow={(r) => ({ - onClick: - r.linkable && r.documentId - ? () => navigate(`/documents/${r.documentId}`) - : () => message.warning('Просмотр данного документа недоступен'), - style: r.linkable ? { cursor: 'pointer' } : undefined, - })} - /> + <> + + size='small' + rowKey='key' + pagination={false} + columns={columns} + dataSource={rows} + locale={{ emptyText: 'Документов нет' }} + onRow={(r) => ({ + onClick: + r.linkable && r.documentId + ? () => navigate(`/documents/${r.documentId}`) + : () => message.warning('Просмотр данного документа недоступен'), + style: r.linkable ? { cursor: 'pointer' } : undefined, + })} + /> + {hasMore && ( +
+ {loadingMore && } +
+ )} + ); } diff --git a/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts b/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts deleted file mode 100644 index c877f8e..0000000 --- a/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { message } from 'antd'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ApiError } from '../../api/client'; -import { hasPendingFiles, listLegalEntityFiles } from '../../services/api/files'; -import type { FileOut } from '../../types/api'; - -const POLL_MS = 5000; - -/** - * Файлы рабочего стола консолидации (`consolidationId`) или все файлы ЮЛ (`null`); - * пока панель открыта и есть UPLOADED/PROCESSING — перечитываем раз в 5 с. - */ -export function useLegalEntityFiles(legalEntityId: string | null, consolidationId: string | null, open: boolean) { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - const requestRef = useRef(0); - const inFlightRef = useRef(false); - const errorShownRef = useRef(false); - - const reload = useCallback(async () => { - if (!legalEntityId) return; - const requestId = ++requestRef.current; - inFlightRef.current = true; - setLoading(true); - try { - const items = await listLegalEntityFiles(legalEntityId, consolidationId); - if (requestId === requestRef.current) { - setFiles(items); - errorShownRef.current = false; - } - } catch (e: unknown) { - // при опросе показываем ошибку один раз на серию; флаг сбрасывается после первого успешного ответа - if (requestId === requestRef.current && !errorShownRef.current) { - errorShownRef.current = true; - message.error(e instanceof ApiError ? e.message : 'Не удалось получить файлы'); - } - } finally { - if (requestId === requestRef.current) { - inFlightRef.current = false; - setLoading(false); - } - } - }, [legalEntityId, consolidationId]); - - // смена режима (consolidationId) приходит через reload: он пересоздаётся и перезапускает этот эффект - useEffect(() => { - // при смене ЮЛ, режима (консолидация/все) или закрытии панели: старые ответы не применяем, прежние файлы очищаем - requestRef.current++; - inFlightRef.current = false; - errorShownRef.current = false; - setFiles([]); - if (open && legalEntityId) void reload(); - }, [open, legalEntityId, reload]); - - const pending = hasPendingFiles(files); - useEffect(() => { - if (!open || !pending) return; - const timer = window.setInterval(() => { - if (!inFlightRef.current) void reload(); // не копим параллельные запросы при медленном ответе - }, POLL_MS); - return () => window.clearInterval(timer); - }, [open, pending, reload]); - - return { files, loading, reload }; -} From 3cc7b829693c1532938823a8a8dce10e60e86de0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 13:02:47 +0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(web):=20=D0=BF=D0=B5=D1=80=D0=B5=D1=87?= =?UTF-8?q?=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=B3=D0=B0=D1=81=D0=B8=D1=82=20?= =?UTF-8?q?=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8,=20OpenSpec=20files-?= =?UTF-8?q?pagination=20(AURORA-1816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #242: перечитывание (опрос, удаление, загрузка, подтверждение) во время догрузки отбрасывало её ответ, а loadingMore оставался true — спиннер под списком и в переключателе документа не гас. Теперь перечитывание сразу сбрасывает флаг догрузки. useScrollSentinel: biome-ignore для trigger с причиной (переподписка после новой порции файлов). OpenSpec files-pagination: «Догрузка списка файлов» (primary-files-ui), опрос перечитывает все показанные страницы, переключатель документа догружает список при прокрутке (document-review-ui). Co-Authored-By: Claude Opus 5.5 --- openspec/changes/files-pagination/proposal.md | 36 +++++++++++++++++++ .../specs/document-review-ui/spec.md | 22 ++++++++++++ .../specs/primary-files-ui/spec.md | 29 +++++++++++++++ openspec/changes/files-pagination/tasks.md | 10 ++++++ web/src/hooks/useLegalEntityFiles.ts | 2 ++ web/src/hooks/useScrollSentinel.ts | 1 + 6 files changed, 100 insertions(+) create mode 100644 openspec/changes/files-pagination/proposal.md create mode 100644 openspec/changes/files-pagination/specs/document-review-ui/spec.md create mode 100644 openspec/changes/files-pagination/specs/primary-files-ui/spec.md create mode 100644 openspec/changes/files-pagination/tasks.md diff --git a/openspec/changes/files-pagination/proposal.md b/openspec/changes/files-pagination/proposal.md new file mode 100644 index 0000000..34134bf --- /dev/null +++ b/openspec/changes/files-pagination/proposal.md @@ -0,0 +1,36 @@ +# Proposal + +## Why + +AURORA-1816 (PR arm-core #242). Панель «Первичные документы» запрашивала файлы ЮЛ с `limit=100` и не догружала +остальные: в режиме «Все документы организации» у ЮЛ с сотней файлов и больше лишние не видны ни в панели, ни в +переключателе документа на экране проверки, и пользователь не знает, что список неполный. Сервер пагинацию уже +поддерживает (`total`, `offset`, `limit` до 200). + +## What Changes + +- Панель «Первичные документы»: над списком «Всего N файлов» (`total` ответа); файлы страницами по 200, следующая — + при прокрутке до конца списка. +- Опрос раз в 5 секунд и перечитывание после удаления, загрузки и подтверждения перечитывают все показанные страницы, + список не сворачивается к первой. +- Переключатель «Документ» на экране проверки берёт тот же список: догрузка при прокрутке выпадающего списка, открытый + документ есть в списке, даже если он дальше загруженных страниц. + +## Capabilities + +### Modified Capabilities + +- `primary-files-ui` — новое требование «Догрузка списка файлов»; «Опрос до окончания распознавания» перечитывает все + показанные страницы. +- `document-review-ui` — «Переключатель документа»: догрузка и открытый документ в списке. + +## Impact + +Только фронт arm-core: `web/src/hooks/useLegalEntityFiles.ts` (перенесён из `widgets/FilesDrawer/`), +`web/src/hooks/useScrollSentinel.ts`, `web/src/services/api/files.ts`, `web/src/widgets/FilesDrawer/*`, +`web/src/layouts/DocumentPage.tsx`. API, DBML, правила `05-domain-rules.md` не меняются. + +## Non-goals + +- Кнопка «Показать ещё» — выбрана подгрузка при прокрутке (задача допускала оба варианта). +- Серверный поиск и фильтры по файлам. diff --git a/openspec/changes/files-pagination/specs/document-review-ui/spec.md b/openspec/changes/files-pagination/specs/document-review-ui/spec.md new file mode 100644 index 0000000..03f2998 --- /dev/null +++ b/openspec/changes/files-pagination/specs/document-review-ui/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: Переключатель документа +В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации, +что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат · +дата) и статус; пункты, которые нельзя открыть, неактивны. Список читается теми же страницами, что панель «Первичные документы» +(требование «Догрузка списка файлов» `primary-files-ui`): следующая — при прокрутке выпадающего списка до конца; +открытый документ SHALL быть в списке и показываться по имени, даже если его файл дальше загруженных страниц. Выбор пункта SHALL открывать `/documents/:document_id` +выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера +возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному. Если первый документ открыт из +панели «Первичные документы» консолидации, «Назад» SHALL открывать экран консолидации с этой панелью той же организации. + +#### Scenario: Назад после перебора документов +- **WHEN** аналитик открыл документ из панели «Первичные документы» организации в карточке консолидации, выбрал в списке + «Документ» подряд ещё два документа и нажал «Назад» +- **THEN** открыт экран консолидации с той же выбранной версией и открытой панелью «Первичные документы» той же + организации + +#### Scenario: Документ открыт прямой ссылкой +- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал + «Назад» экрана +- **THEN** открыт список групп компаний `/groups` diff --git a/openspec/changes/files-pagination/specs/primary-files-ui/spec.md b/openspec/changes/files-pagination/specs/primary-files-ui/spec.md new file mode 100644 index 0000000..ac07699 --- /dev/null +++ b/openspec/changes/files-pagination/specs/primary-files-ui/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Догрузка списка файлов +Панель SHALL читать список файлов активного режима (стол или все документы) страницами по 200 (`limit=200&offset=…`, +`MAX_PAGE_LIMIT` бэка): первую — при открытии, следующую — когда аналитик прокрутил список до конца, пока показано +меньше `total`. Над списком SHALL стоять «Всего N файлов» с `total` ответа. Опрос, удаление, загрузка и подтверждение +документа SHALL перечитывать все показанные файлы (столько страниц, сколько было показано), а не только первую. +Перечитывание отменяет идущую догрузку: её ответ не применяется, индикатор догрузки гаснет. + +#### Scenario: 450 файлов в режиме «все» +- **WHEN** у ЮЛ 450 файлов, панель открыта в режиме «Все документы организации» +- **THEN** над списком «Всего 450 файлов», показаны первые 200; прокрутка до конца догружает 201–400, ещё одна — + 401–450, дальше запросов нет + +#### Scenario: Опрос не сворачивает список +- **WHEN** показаны 400 файлов и среди них есть файл в `PROCESSING` +- **THEN** раз в 5 секунд перечитываются обе показанные страницы, после ответа в списке по-прежнему 400 файлов + +## MODIFIED Requirements + +### Requirement: Опрос до окончания распознавания +Пока панель открыта и среди файлов активного списка (стол или все документы) есть `UPLOADED` или `PROCESSING`, панель +SHALL перечитывать этот список раз в 5 секунд — все показанные страницы, не сворачивая список к первой (требование «Догрузка списка файлов»), и SHALL прекратить опрос, когда таких файлов нет или панель закрыта. +Переключение режима или смена ЮЛ SHALL сбрасывать список и перечитывать его. При закрытии панели карточка версии SHALL +перечитываться (обновить ячейки чек-листа). + +#### Scenario: Опрос останавливается +- **WHEN** все файлы активного списка перешли в `PROCESSED` или `ERROR` +- **THEN** запросы списка файлов прекращаются diff --git a/openspec/changes/files-pagination/tasks.md b/openspec/changes/files-pagination/tasks.md new file mode 100644 index 0000000..ea7feb0 --- /dev/null +++ b/openspec/changes/files-pagination/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +- [x] 1.1 arm-core `web/src/services/api/files.test.ts` — несколько страниц: перечитывание показанного, догрузка с + позиции, конец по `total`, пустая страница раньше `total` +- [x] 1.2 arm-core `web/src/services/api/files.ts` — `listLegalEntityFiles(offset, limit)`, `listLegalEntityFilesFrom`, + `FILES_PAGE_LIMIT = 200` +- [x] 1.3 arm-core `web/` — хук `useLegalEntityFiles` со страницами, `useScrollSentinel`, «Всего N файлов» в панели, + переключатель документа на экране проверки (PR #242, автор) +- [x] 1.4 Ревью: перечитывание во время догрузки сбрасывает `loadingMore` (иначе спиннер под списком не гаснет); + `biome-ignore` для `trigger` в `useScrollSentinel` с причиной diff --git a/web/src/hooks/useLegalEntityFiles.ts b/web/src/hooks/useLegalEntityFiles.ts index 60c7e8d..41763fe 100644 --- a/web/src/hooks/useLegalEntityFiles.ts +++ b/web/src/hooks/useLegalEntityFiles.ts @@ -30,6 +30,8 @@ export function useLegalEntityFiles(legalEntityId: string | null, consolidationI const setBusy = append ? setLoadingMore : setLoading; const requestId = ++requestRef.current; inFlightRef.current = true; + // перечитывание отменяет идущую догрузку: её ответ отбросится, а finally не сбросит флаг чужого запроса + if (!append) setLoadingMore(false); setBusy(true); try { const { items, total } = await listLegalEntityFilesFrom(legalEntityId, consolidationId, from, count); diff --git a/web/src/hooks/useScrollSentinel.ts b/web/src/hooks/useScrollSentinel.ts index 500d62c..20af470 100644 --- a/web/src/hooks/useScrollSentinel.ts +++ b/web/src/hooks/useScrollSentinel.ts @@ -8,6 +8,7 @@ export function useScrollSentinel(enabled: boolean, trigger: unknown, onReach: ( const ref = useRef(null); const onReachRef = useRef(onReach); onReachRef.current = onReach; + // biome-ignore lint/correctness/useExhaustiveDependencies: trigger — переподписка после новой порции: если метка всё ещё на экране, наблюдатель сразу вызовет onReach useEffect(() => { const node = ref.current; if (!enabled || !node) return; From effa4c788ec75eb17965d7cf76e34e670fa42ba4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 13:02:54 +0400 Subject: [PATCH 3/3] =?UTF-8?q?style(web):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=86=D0=B5=20CatalogueCompanyGroup?= =?UTF-8?q?Tabs.css=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20PR=20#219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/widgets/CatalogueCompanyGroup/CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs.css b/web/src/widgets/CatalogueCompanyGroup/CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs.css index fb2568b..6f6d287 100644 --- a/web/src/widgets/CatalogueCompanyGroup/CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs.css +++ b/web/src/widgets/CatalogueCompanyGroup/CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs.css @@ -64,4 +64,4 @@ background-color: transparent; color: #166534; font-weight: 600; -} \ No newline at end of file +}