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

- В панели отображается общее число файлов и догружаются следующие страницы (при прокрутке).
- Опрос статусов обработки файлов (раз в 5 секунд) не сбрасывает уже подгруженные страницы.
- Тот же список используется на экране проверки распознавания
This commit is contained in:
Veronika Tsygankova 2026-11-10 17:43:01 +03:00
parent 0e4af260aa
commit ca19d654da
8 changed files with 331 additions and 113 deletions

View File

@ -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<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;
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,24 @@
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;
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 { CommentOutlined } from '@ant-design/icons';
import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd'; 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 { useNavigate, useParams } from 'react-router-dom';
import { ApiError } from '../api/client'; import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle'; import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag'; import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets'; import { TAG_PRESETS } from '../components/TableTag/presets';
import { useLegalEntityFiles } from '../hooks/useLegalEntityFiles';
import { fetchLegalEntity } from '../services/api/directories/company_groups'; import { fetchLegalEntity } from '../services/api/directories/company_groups';
import { getDocument } from '../services/api/documents'; import { getDocument } from '../services/api/documents';
import { getFile, listLegalEntityFiles } from '../services/api/files'; import { getFile } from '../services/api/files';
import type { DocumentOut } from '../types/api'; import type { DocumentOut, FileOut } from '../types/api';
import { documentTypeLabel, periodLabel } from '../utils/labels'; import { documentTypeLabel, periodLabel } from '../utils/labels';
import { DocumentCommentsDrawer } from '../widgets/DocumentComments/DocumentCommentsDrawer'; import { DocumentCommentsDrawer } from '../widgets/DocumentComments/DocumentCommentsDrawer';
import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; 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 }; 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() { export function DocumentPage() {
const { document_id } = useParams(); const { document_id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -27,7 +38,7 @@ export function DocumentPage() {
const [entityName, setEntityName] = useState(''); const [entityName, setEntityName] = useState('');
const [subtitle, setSubtitle] = useState<string | null>(null); const [subtitle, setSubtitle] = useState<string | null>(null);
const [error, setError] = 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 [commentOpen, setCommentOpen] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
@ -42,21 +53,12 @@ export function DocumentPage() {
getFile(d.file_id) getFile(d.file_id)
.then((f) => { .then((f) => {
setSubtitle(documentSubtitle(f)); setSubtitle(documentSubtitle(f));
// переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ) setFile(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([]));
}) })
.catch(() => setSubtitle(null)); .catch(() => {
setSubtitle(null);
setFile(null);
});
} catch (e: unknown) { } catch (e: unknown) {
setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ');
} }
@ -66,6 +68,29 @@ export function DocumentPage() {
void load(); void load();
}, [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) { if (error) {
return <Result status='warning' title={error} extra={<Button onClick={() => navigate(-1)}>Назад</Button>} />; return <Result status='warning' title={error} extra={<Button onClick={() => navigate(-1)}>Назад</Button>} />;
} }
@ -95,6 +120,20 @@ export function DocumentPage() {
value={doc.id} value={doc.id}
options={documentOptions} options={documentOptions}
onChange={(value) => navigate(`/documents/${value}`, { replace: true })} 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) => ( optionRender={(option) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */} {/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */}
@ -129,8 +168,8 @@ export function DocumentPage() {
overflow: 'auto', overflow: 'auto',
}}> }}>
{broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />} {broken && <Empty description='Документ не удалось распознать — данных для проверки нет' />}
{!broken && kind === 'osv' && <OsvDocument 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={load} />} {!broken && kind === 'statement' && <StatementReview key={doc.id} document={doc} onChanged={onChanged} />}
{kind === 'none' && <Empty description='Документ без данных для проверки' />} {kind === 'none' && <Empty description='Документ без данных для проверки' />}
</main> </main>
{/* key: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */} {/* key: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */}

View File

@ -1,7 +1,16 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import type { FileOut, UploadResult } from '../../types/api'; 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 = { const result: UploadResult = {
accepted: [{ file_id: 'f1', job_id: 'j1', name: 'osv.xlsx' }], 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', () => { describe('listLegalEntityFiles', () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it('стол консолидации и список ЮЛ', async () => { it('стол консолидации и список ЮЛ — одна страница', async () => {
const client = await import('../../api/client'); const spy = await fakeServer(0);
const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
await listLegalEntityFiles('le', 'c1'); await listLegalEntityFiles('le', 'c1');
await listLegalEntityFiles('le', null); await listLegalEntityFiles('le', null, L);
expect(spy.mock.calls[0][0]).toBe('/consolidations/c1/legal-entities/le/files?limit=100'); 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=100'); 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[]> { /** Размер страницы: столько файлов панель загружает сразу и при каждой прокрутке до конца списка. Не больше 200 — `MAX_PAGE_LIMIT` бэка. */
const page = await apiFetch<Page<FileOut>>(`${filesPath(legalEntityId, consolidationId)}?limit=100`); export const FILES_PAGE_LIMIT = 200;
return page.items;
/** Страница файлов ЮЛ одним запросом. Бэк сортирует по дате загрузки и `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 { export function hasPendingFiles(files: FileOut[]): boolean {

View File

@ -2,19 +2,20 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons';
import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd'; import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { useLegalEntityFiles } from '../../hooks/useLegalEntityFiles';
import { fetchLegalEntity } from '../../services/api/directories/company_groups'; import { fetchLegalEntity } from '../../services/api/directories/company_groups';
import { postComment } from '../../services/api/documents'; import { postComment } from '../../services/api/documents';
import { summarizeUpload, uploadFiles } from '../../services/api/files'; import { summarizeUpload, uploadFiles } from '../../services/api/files';
import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors'; import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors';
import { useAppSelector } from '../../store/store'; import { useAppSelector } from '../../store/store';
import type { FileOut, LegalEntityBrief } from '../../types/api'; import type { FileOut, LegalEntityBrief } from '../../types/api';
import { pluralRu } from '../../utils/labels';
import DeleteReasonModal from './DeleteModal'; import DeleteReasonModal from './DeleteModal';
import { FilesList } from './FilesList'; import { FilesList } from './FilesList';
import ReplaceDocModal from './ReplaceDocModal'; import ReplaceDocModal from './ReplaceDocModal';
import { UploadDocumentsModal } from './UploadDocumentsModal'; import { UploadDocumentsModal } from './UploadDocumentsModal';
import { consolidationLabel } from './consolidationLabel'; import { consolidationLabel } from './consolidationLabel';
import type { DocumentRow } from './documentRows'; import type { DocumentRow } from './documentRows';
import { useLegalEntityFiles } from './useLegalEntityFiles';
const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе';
@ -27,7 +28,7 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
// консолидация не определена (версии нет в списке ГК) — режим «все» без переключателя (design D-3) // консолидация не определена (версии нет в списке ГК) — режим «все» без переключателя (design D-3)
const desk = current !== null && !showAll; const desk = current !== null && !showAll;
const consolidationId = desk ? current.consolidation_id : null; 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 [targetRow, setTargetRow] = useState<DocumentRow | null>(null);
const [removalOpen, setRemovalOpen] = useState(false); const [removalOpen, setRemovalOpen] = useState(false);
const canUpload = current !== null && isInWork(current.version_status); const canUpload = current !== null && isInWork(current.version_status);
@ -154,8 +155,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: ()
<Typography.Text>Все документы организации</Typography.Text> <Typography.Text>Все документы организации</Typography.Text>
</span> </span>
)} )}
{!(loading && files.length === 0) && (
<Typography.Text type='secondary' style={{ marginLeft: 'auto' }}>
Всего {total} {pluralRu(total, ['файл', 'файла', 'файлов'])}
</Typography.Text>
)}
</div> </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 && ( {current_legal_entity_id && (
<UploadDocumentsModal <UploadDocumentsModal
legalEntityId={current_legal_entity_id} 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 { ColumnsType } from 'antd/es/table';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useScrollSentinel } from '../../hooks/useScrollSentinel';
import type { CheckResult, FileOut, FileType } from '../../types/api'; import type { CheckResult, FileOut, FileType } from '../../types/api';
import { CommentsPopover } from '../DocumentComments/CommentsPopover'; import { CommentsPopover } from '../DocumentComments/CommentsPopover';
import { FileWarningsPopover } from './FileWarningsPopover'; import { FileWarningsPopover } from './FileWarningsPopover';
@ -67,16 +68,23 @@ const head = (text: string) => <span style={HEADER}>{text}</span>;
export function FilesList({ export function FilesList({
files, files,
loading, loading,
loadingMore,
hasMore,
onLoadMore,
labelFor, labelFor,
onDeleteClick, onDeleteClick,
}: { }: {
files: FileOut[]; files: FileOut[];
loading: boolean; loading: boolean;
loadingMore: boolean;
hasMore: boolean;
onLoadMore: () => void;
labelFor?: (f: FileOut) => string | null; labelFor?: (f: FileOut) => string | null;
onDeleteClick: (row: DocumentRow) => void; onDeleteClick: (row: DocumentRow) => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const rows = buildDocumentRows(files, labelFor); const rows = buildDocumentRows(files, labelFor);
const sentinelRef = useScrollSentinel(hasMore, files, onLoadMore);
const columns: ColumnsType<DocumentRow> = [ const columns: ColumnsType<DocumentRow> = [
{ {
@ -164,20 +172,27 @@ export function FilesList({
} }
return ( return (
<Table<DocumentRow> <>
size='small' <Table<DocumentRow>
rowKey='key' size='small'
pagination={false} rowKey='key'
columns={columns} pagination={false}
dataSource={rows} columns={columns}
locale={{ emptyText: 'Документов нет' }} dataSource={rows}
onRow={(r) => ({ locale={{ emptyText: 'Документов нет' }}
onClick: onRow={(r) => ({
r.linkable && r.documentId onClick:
? () => navigate(`/documents/${r.documentId}`) r.linkable && r.documentId
: () => message.warning('Просмотр данного документа недоступен'), ? () => navigate(`/documents/${r.documentId}`)
style: r.linkable ? { cursor: 'pointer' } : undefined, : () => 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 };
}