[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 { 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: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */}
|
||||||
|
|||||||
@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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