[AURORA-1916] - Панель «Первичные документы»: файлы сверх первой сотни не отображаются:
- В панели отображается общее число файлов и догружаются следующие страницы (при прокрутке). - Опрос статусов обработки файлов (раз в 5 секунд) не сбрасывает уже подгруженные страницы. - Тот же список используется на экране проверки распознавания
This commit is contained in:
parent
0e4af260aa
commit
ca19d654da
92
web/src/hooks/useLegalEntityFiles.ts
Normal file
92
web/src/hooks/useLegalEntityFiles.ts
Normal 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 };
|
||||
}
|
||||
24
web/src/hooks/useScrollSentinel.ts
Normal file
24
web/src/hooks/useScrollSentinel.ts
Normal 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;
|
||||
}
|
||||
@ -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: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */}
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user