[AURORA-1816] - Панель «Первичные документы»: файлы сверх первой сотни не отображаются #242

Merged
aep merged 4 commits from fix/1916-files-pagination into main 2026-11-11 12:07:24 +03:00
13 changed files with 432 additions and 114 deletions

View File

@ -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
- Кнопка «Показать ещё» — выбрана подгрузка при прокрутке (задача допускала оба варианта).
- Серверный поиск и фильтры по файлам.

View File

@ -0,0 +1,22 @@
## MODIFIED Requirements
### Requirement: Переключатель документа
В шапке экрана документа SHALL быть выпадающий список «Документ» с документами файлов того же ЮЛ в той же консолидации,
что и файл открытого документа (файл вне консолидации — все файлы ЮЛ): у пункта имя файла, вторая строка (тип · формат ·
дата) и статус; пункты, которые нельзя открыть, неактивны. Список читается теми же страницами, что панель «Первичные документы»
(требование «Догрузка списка файлов» `primary-files-ui`): следующая — при прокрутке выпадающего списка до конца;
открытый документ SHALL быть в списке и показываться по имени, даже если его файл дальше загруженных страниц. Выбор пункта SHALL открывать `/documents/:document_id`
выбранного документа заменой текущей записи истории браузера, без новой записи: кнопка «Назад» экрана и браузера
возвращает на экран, с которого открыт первый документ, а не к предыдущему выбранному. Если первый документ открыт из
панели «Первичные документы» консолидации, «Назад» SHALL открывать экран консолидации с этой панелью той же организации.
#### Scenario: Назад после перебора документов
- **WHEN** аналитик открыл документ из панели «Первичные документы» организации в карточке консолидации, выбрал в списке
«Документ» подряд ещё два документа и нажал «Назад»
- **THEN** открыт экран консолидации с той же выбранной версией и открытой панелью «Первичные документы» той же
организации
#### Scenario: Документ открыт прямой ссылкой
- **WHEN** документ открыт прямой ссылкой в новой вкладке, аналитик выбрал в списке «Документ» другой документ и нажал
«Назад» экрана
- **THEN** открыт список групп компаний `/groups`

View File

@ -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** запросы списка файлов прекращаются

View File

@ -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` с причиной

View File

@ -0,0 +1,94 @@
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<FileOut[]>([]);
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;
// перечитывание отменяет идущую догрузку: её ответ отбросится, а finally не сбросит флаг чужого запроса
if (!append) setLoadingMore(false);
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 };
}

View File

@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';
/**
* Подгрузка при прокрутке: вызывает `onReach`, когда метка (`ref`) показалась на экране. Метку ставят под списком,
* который прокручивается вместе со страницей или панелью (например, таблица в Drawer «Первичные документы»).
*/
export function useScrollSentinel(enabled: boolean, trigger: unknown, onReach: () => void) {
const ref = useRef<HTMLDivElement | null>(null);
const onReachRef = useRef(onReach);
onReachRef.current = onReach;
// biome-ignore lint/correctness/useExhaustiveDependencies: trigger — переподписка после новой порции: если метка всё ещё на экране, наблюдатель сразу вызовет 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;
}

View File

@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [documentOptions, setDocumentOptions] = useState<DocumentOption[]>([]);
const [file, setFile] = useState<FileOut | null>(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 <Result status='warning' title={error} extra={<Button onClick={() => navigate(-1)}>Назад</Button>} />;
}
@ -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 && (
<div style={{ textAlign: 'center', padding: 8 }}>
<Spin size='small' />
</div>
)}
</>
)}
optionRender={(option) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}
@ -129,8 +168,8 @@ export function DocumentPage() {
overflow: 'auto',
}}>
{broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />}
{!broken && kind === 'osv' && <OsvDocument key={doc.id} document={doc} onChanged={load} />}
{!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={load} />}
{!broken && kind === 'osv' && <OsvDocument key={doc.id} document={doc} onChanged={onChanged} />}
{!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={onChanged} />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main>
{/* key: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */}

View File

@ -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<ReturnType<typeof fakeServer>>) =>
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 });
});
});

View File

@ -40,9 +40,34 @@ export function summarizeUpload(result: UploadResult): { accepted: string[]; rej
};
}
export async function listLegalEntityFiles(legalEntityId: string, consolidationId: string | null): Promise<FileOut[]> {
const page = await apiFetch<Page<FileOut>>(`${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<Page<FileOut>>(`${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 {

View File

@ -64,4 +64,4 @@
background-color: transparent;
color: #166534;
font-weight: 600;
}
}

View File

@ -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<DocumentRow | null>(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: ()
<Typography.Text>Все документы организации</Typography.Text>
</span>
)}
{!(loading && files.length === 0) && (
<Typography.Text type='secondary' style={{ marginLeft: 'auto' }}>
Всего {total} {pluralRu(total, ['файл', 'файла', 'файлов'])}
</Typography.Text>
)}
</div>
<FilesList files={files} loading={loading} labelFor={labelFor} onDeleteClick={handleRowDelete} />
<FilesList
files={files}
loading={loading}
loadingMore={loadingMore}
hasMore={files.length < total}
onLoadMore={() => void loadMore()}
labelFor={labelFor}
onDeleteClick={handleRowDelete}
/>
{current_legal_entity_id && (
<UploadDocumentsModal
legalEntityId={current_legal_entity_id}

View File

@ -3,6 +3,7 @@ import { Button, Spin, Table, Tooltip, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useScrollSentinel } from '../../hooks/useScrollSentinel';
import type { CheckResult, FileOut, FileType } from '../../types/api';
import { CommentsPopover } from '../DocumentComments/CommentsPopover';
import { FileWarningsPopover } from './FileWarningsPopover';
@ -67,16 +68,23 @@ const head = (text: string) => <span style={HEADER}>{text}</span>;
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<DocumentRow> = [
{
@ -164,20 +172,27 @@ export function FilesList({
}
return (
<Table<DocumentRow>
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,
})}
/>
<>
<Table<DocumentRow>
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 && (
<div ref={sentinelRef} style={{ textAlign: 'center', padding: 16 }}>
{loadingMore && <Spin size='small' />}
</div>
)}
</>
);
}

View File

@ -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<FileOut[]>([]);
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 };
}