diff --git a/web/src/hooks/useLegalEntityFiles.ts b/web/src/hooks/useLegalEntityFiles.ts new file mode 100644 index 0000000..60c7e8d --- /dev/null +++ b/web/src/hooks/useLegalEntityFiles.ts @@ -0,0 +1,92 @@ +import { message } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError } from '../api/client'; +import { hasPendingFiles, listLegalEntityFilesFrom } from '../services/api/files'; +import type { FileOut } from '../types/api'; + +const POLL_MS = 5000; + +/** + * Файлы рабочего стола консолидации (`consolidationId`) или все файлы ЮЛ (`null`) — страницами по `FILES_PAGE_LIMIT`: + * первая сразу, следующие при прокрутке до конца списка (`loadMore`). `reload` перечитывает всё показанное, чтобы + * после удаления, загрузки и опроса список не сворачивался к началу. Пока панель открыта и среди показанных есть + * UPLOADED/PROCESSING — перечитываем раз в 5 с. + */ +export function useLegalEntityFiles(legalEntityId: string | null, consolidationId: string | null, open: boolean) { + const [files, setFiles] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const requestRef = useRef(0); + const inFlightRef = useRef(false); + const errorShownRef = useRef(false); + + const loadedRef = useRef(0); + + const load = useCallback( + async (from: number, count: number) => { + if (!legalEntityId) return; + const append = from > 0; + const setBusy = append ? setLoadingMore : setLoading; + const requestId = ++requestRef.current; + inFlightRef.current = true; + setBusy(true); + try { + const { items, total } = await listLegalEntityFilesFrom(legalEntityId, consolidationId, from, count); + if (requestId !== requestRef.current) return; + setFiles((prev) => { + const seen = new Set(prev.map((f) => f.id)); + const next = append ? [...prev, ...items.filter((f) => !seen.has(f.id))] : items; + loadedRef.current = next.length; + return next; + }); + setTotal(total); + errorShownRef.current = false; + } catch (e: unknown) { + // при опросе показываем ошибку один раз на серию; флаг сбрасывается после первого успешного ответа + if (requestId === requestRef.current && !errorShownRef.current) { + errorShownRef.current = true; + message.error(e instanceof ApiError ? e.message : 'Не удалось получить файлы'); + } + } finally { + if (requestId === requestRef.current) { + inFlightRef.current = false; + setBusy(false); + } + } + }, + [legalEntityId, consolidationId], + ); + + const reload = useCallback(() => load(0, loadedRef.current), [load]); + + const hasMore = files.length < total; + const loadMore = useCallback(async () => { + // не догружаем поверх идущего запроса: ответ перечитывания всё равно заменит список + if (hasMore && !inFlightRef.current) await load(loadedRef.current, 1); + }, [load, hasMore]); + + // смена режима (consolidationId) приходит через reload: он пересоздаётся и перезапускает этот эффект + useEffect(() => { + // при смене ЮЛ, режима (консолидация/все) или закрытии панели: старые ответы не применяем, прежние файлы очищаем + requestRef.current++; + inFlightRef.current = false; + errorShownRef.current = false; + loadedRef.current = 0; + setFiles([]); + setTotal(0); + setLoadingMore(false); + if (open && legalEntityId) void reload(); + }, [open, legalEntityId, reload]); + + const pending = hasPendingFiles(files); + useEffect(() => { + if (!open || !pending) return; + const timer = window.setInterval(() => { + if (!inFlightRef.current) void reload(); // не копим параллельные запросы при медленном ответе + }, POLL_MS); + return () => window.clearInterval(timer); + }, [open, pending, reload]); + + return { files, total, loading, loadingMore, reload, loadMore }; +} diff --git a/web/src/hooks/useScrollSentinel.ts b/web/src/hooks/useScrollSentinel.ts new file mode 100644 index 0000000..500d62c --- /dev/null +++ b/web/src/hooks/useScrollSentinel.ts @@ -0,0 +1,24 @@ +import { useEffect, useRef } from 'react'; + +/** + * Подгрузка при прокрутке: вызывает `onReach`, когда метка (`ref`) показалась на экране. Метку ставят под списком, + * который прокручивается вместе со страницей или панелью (например, таблица в Drawer «Первичные документы»). + */ +export function useScrollSentinel(enabled: boolean, trigger: unknown, onReach: () => void) { + const ref = useRef(null); + const onReachRef = useRef(onReach); + onReachRef.current = onReach; + useEffect(() => { + const node = ref.current; + if (!enabled || !node) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) onReachRef.current(); + }, + { rootMargin: '200px' }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [enabled, trigger]); + return ref; +} diff --git a/web/src/layouts/DocumentPage.tsx b/web/src/layouts/DocumentPage.tsx index 0235f81..e27bcca 100644 --- a/web/src/layouts/DocumentPage.tsx +++ b/web/src/layouts/DocumentPage.tsx @@ -1,15 +1,16 @@ import { CommentOutlined } from '@ant-design/icons'; import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; import { TableTag } from '../components/TableTag/TableTag'; import { TAG_PRESETS } from '../components/TableTag/presets'; +import { useLegalEntityFiles } from '../hooks/useLegalEntityFiles'; import { fetchLegalEntity } from '../services/api/directories/company_groups'; import { getDocument } from '../services/api/documents'; -import { getFile, listLegalEntityFiles } from '../services/api/files'; -import type { DocumentOut } from '../types/api'; +import { getFile } from '../services/api/files'; +import type { DocumentOut, FileOut } from '../types/api'; import { documentTypeLabel, periodLabel } from '../utils/labels'; import { DocumentCommentsDrawer } from '../widgets/DocumentComments/DocumentCommentsDrawer'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; @@ -20,6 +21,16 @@ import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/docu type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow }; +const toOption = (row: DocumentRow): DocumentOption => ({ + value: row.documentId ?? row.fileId, + label: row.name, + disabled: !row.linkable, + row, +}); + +/** До конца выпадающего списка осталось меньше этого (px) — догружаем следующую страницу. */ +const POPUP_LOAD_MORE_PX = 50; + export function DocumentPage() { const { document_id } = useParams(); const navigate = useNavigate(); @@ -27,7 +38,7 @@ export function DocumentPage() { const [entityName, setEntityName] = useState(''); const [subtitle, setSubtitle] = useState(null); const [error, setError] = useState(null); - const [documentOptions, setDocumentOptions] = useState([]); + const [file, setFile] = useState(null); const [commentOpen, setCommentOpen] = useState(false); const load = useCallback(async () => { @@ -42,21 +53,12 @@ export function DocumentPage() { getFile(d.file_id) .then((f) => { setSubtitle(documentSubtitle(f)); - // переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ) - listLegalEntityFiles(d.legal_entity_id, f.consolidation_id) - .then((files) => - setDocumentOptions( - buildDocumentRows(files).map((row) => ({ - value: row.documentId ?? row.fileId, - label: row.name, - disabled: !row.linkable, - row, - })), - ), - ) - .catch(() => setDocumentOptions([])); + setFile(f); }) - .catch(() => setSubtitle(null)); + .catch(() => { + setSubtitle(null); + setFile(null); + }); } catch (e: unknown) { setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); } @@ -66,6 +68,29 @@ export function DocumentPage() { void load(); }, [load]); + const { + files, + loadingMore, + reload: reloadFiles, + loadMore, + } = useLegalEntityFiles(file ? file.legal_entity_id : null, file?.consolidation_id ?? null, file !== null); + + const documentOptions = useMemo(() => { + const options = buildDocumentRows(files).map(toOption); + // открытый документ может быть дальше загруженных страниц — без своей опции Select показал бы вместо имени id + if (doc && file && !options.some((o) => o.value === doc.id)) { + const own = buildDocumentRows([file]).find((row) => row.documentId === doc.id); + if (own) options.unshift(toOption(own)); + } + return options; + }, [files, doc, file]); + + // после правки или подтверждения документа обновляем и карточку, и статусы в переключателе + const onChanged = useCallback(() => { + void load(); + void reloadFiles(); + }, [load, reloadFiles]); + if (error) { return navigate(-1)}>Назад} />; } @@ -95,6 +120,20 @@ export function DocumentPage() { value={doc.id} options={documentOptions} onChange={(value) => navigate(`/documents/${value}`, { replace: true })} + onPopupScroll={(e) => { + const list = e.target as HTMLElement; + if (list.scrollTop + list.clientHeight >= list.scrollHeight - POPUP_LOAD_MORE_PX) void loadMore(); + }} + popupRender={(menu) => ( + <> + {menu} + {loadingMore && ( +
+ +
+ )} + + )} optionRender={(option) => (
{/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */} @@ -129,8 +168,8 @@ export function DocumentPage() { overflow: 'auto', }}> {broken && } - {!broken && kind === 'osv' && } - {!broken && kind === 'statement' && } + {!broken && kind === 'osv' && } + {!broken && kind === 'statement' && } {kind === 'none' && } {/* key: при смене документа панель пересоздаётся — черновик и список прежнего документа не переходят на новый */} diff --git a/web/src/services/api/files.test.ts b/web/src/services/api/files.test.ts index 07fce57..4f95b5b 100644 --- a/web/src/services/api/files.test.ts +++ b/web/src/services/api/files.test.ts @@ -1,7 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiError } from '../../api/client'; import type { FileOut, UploadResult } from '../../types/api'; -import { filesPath, hasPendingFiles, isUploadResult, listLegalEntityFiles, summarizeUpload, uploadFiles } from './files'; +import { + FILES_PAGE_LIMIT, + filesPath, + hasPendingFiles, + isUploadResult, + listLegalEntityFiles, + listLegalEntityFilesFrom, + summarizeUpload, + uploadFiles, +} from './files'; const result: UploadResult = { accepted: [{ file_id: 'f1', job_id: 'j1', name: 'osv.xlsx' }], @@ -95,14 +104,79 @@ describe('uploadFiles', () => { }); }); +const L = FILES_PAGE_LIMIT; +const file = (id: string) => ({ id }) as unknown as FileOut; +const ids = (from: number, to: number) => Array.from({ length: to - from }, (_, i) => `f${from + i}`); + +/** Фальшивый бэк: `total` файлов f0, f1, …; страница — по `offset`/`limit` из адреса запроса. */ +async function fakeServer(total: number) { + const client = await import('../../api/client'); + return vi.spyOn(client, 'apiFetch').mockImplementation((path: string) => { + const query = new URLSearchParams(path.split('?')[1]); + const offset = Number(query.get('offset')); + const limit = Number(query.get('limit')); + return Promise.resolve({ items: ids(offset, Math.min(offset + limit, total)).map(file), total, limit, offset }); + }); +} +const offsets = (spy: Awaited>) => + spy.mock.calls.map(([path]) => Number(new URLSearchParams(String(path).split('?')[1]).get('offset'))); + describe('listLegalEntityFiles', () => { afterEach(() => vi.restoreAllMocks()); - it('стол консолидации и список ЮЛ', async () => { - const client = await import('../../api/client'); - const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 }); + it('стол консолидации и список ЮЛ — одна страница', async () => { + const spy = await fakeServer(0); await listLegalEntityFiles('le', 'c1'); - await listLegalEntityFiles('le', null); - expect(spy.mock.calls[0][0]).toBe('/consolidations/c1/legal-entities/le/files?limit=100'); - expect(spy.mock.calls[1][0]).toBe('/legal-entities/le/files?limit=100'); + await listLegalEntityFiles('le', null, L); + expect(spy.mock.calls[0][0]).toBe(`/consolidations/c1/legal-entities/le/files?limit=${L}&offset=0`); + expect(spy.mock.calls[1][0]).toBe(`/legal-entities/le/files?limit=${L}&offset=${L}`); + }); +}); + +describe('listLegalEntityFilesFrom', () => { + afterEach(() => vi.restoreAllMocks()); + + it('перечитывание трёх показанных страниц — три запроса подряд', async () => { + const spy = await fakeServer(5 * L); + const res = await listLegalEntityFilesFrom('le', null, 0, 3 * L); + expect(offsets(spy)).toEqual([0, L, 2 * L]); + expect(res.items.map((f) => f.id)).toEqual(ids(0, 3 * L)); + expect(res.total).toBe(5 * L); + }); + + it('ничего не показано (count = 0) — всё равно один запрос', async () => { + const spy = await fakeServer(5 * L); + const res = await listLegalEntityFilesFrom('le', null, 0, 0); + expect(offsets(spy)).toEqual([0]); + expect(res.items).toHaveLength(L); + }); + + it('догрузка при прокрутке — одна страница с позиции from', async () => { + const spy = await fakeServer(5 * L); + const res = await listLegalEntityFilesFrom('le', null, L, 1); + expect(offsets(spy)).toEqual([L]); + expect(res.items.map((f) => f.id)).toEqual(ids(L, 2 * L)); + }); + + it('файлы кончились раньше count — останавливается на total', async () => { + const spy = await fakeServer(L + 1); + const res = await listLegalEntityFilesFrom('le', null, 0, 5 * L); + expect(offsets(spy)).toEqual([0, L]); + expect(res.items).toHaveLength(L + 1); + expect(res.total).toBe(L + 1); + }); + + it('догрузка последнего неполного куска — один запрос', async () => { + const spy = await fakeServer(2 * L + 1); + const res = await listLegalEntityFilesFrom('le', null, 2 * L, 1); + expect(offsets(spy)).toEqual([2 * L]); + expect(res.items.map((f) => f.id)).toEqual([`f${2 * L}`]); + }); + + it('пустая страница раньше total (файлы удалили между запросами) — останавливается', async () => { + const client = await import('../../api/client'); + const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue({ items: [], total: 10 * L, limit: L, offset: 0 }); + const res = await listLegalEntityFilesFrom('le', null, 0, 5 * L); + expect(spy).toHaveBeenCalledTimes(1); + expect(res).toEqual({ items: [], total: 10 * L }); }); }); diff --git a/web/src/services/api/files.ts b/web/src/services/api/files.ts index 9b419b0..62918eb 100644 --- a/web/src/services/api/files.ts +++ b/web/src/services/api/files.ts @@ -40,9 +40,34 @@ export function summarizeUpload(result: UploadResult): { accepted: string[]; rej }; } -export async function listLegalEntityFiles(legalEntityId: string, consolidationId: string | null): Promise { - const page = await apiFetch>(`${filesPath(legalEntityId, consolidationId)}?limit=100`); - return page.items; +/** Размер страницы: столько файлов панель загружает сразу и при каждой прокрутке до конца списка. Не больше 200 — `MAX_PAGE_LIMIT` бэка. */ +export const FILES_PAGE_LIMIT = 200; + +/** Страница файлов ЮЛ одним запросом. Бэк сортирует по дате загрузки и `id` — порядок стабилен. */ +export function listLegalEntityFiles(le: string, cons: string | null, offset = 0, limit = FILES_PAGE_LIMIT) { + return apiFetch>(`${filesPath(le, cons)}?limit=${limit}&offset=${offset}`); +} + +/** + * Файлы ЮЛ начиная с номера `from` запросами по `FILES_PAGE_LIMIT`, пока не наберём `count` + * или файлы не кончатся. Пустая страница раньше `total` — файлы удалили между запросами, дальше не идём. + */ +export async function listLegalEntityFilesFrom( + le: string, + cons: string | null, + from: number, + count: number, +): Promise<{ items: FileOut[]; total: number }> { + const items: FileOut[] = []; + let total = 0; + let lastPageEmpty = false; + do { + const page = await listLegalEntityFiles(le, cons, from + items.length); + items.push(...page.items); + total = page.total; + lastPageEmpty = page.items.length === 0; + } while (items.length < count && from + items.length < total && !lastPageEmpty); + return { items, total }; } export function hasPendingFiles(files: FileOut[]): boolean { diff --git a/web/src/widgets/FilesDrawer/FilesDrawer.tsx b/web/src/widgets/FilesDrawer/FilesDrawer.tsx index 7d18a64..2bb2aaa 100644 --- a/web/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/web/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -2,19 +2,20 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd'; import { useEffect, useState } from 'react'; import { ApiError } from '../../api/client'; +import { useLegalEntityFiles } from '../../hooks/useLegalEntityFiles'; import { fetchLegalEntity } from '../../services/api/directories/company_groups'; import { postComment } from '../../services/api/documents'; import { summarizeUpload, uploadFiles } from '../../services/api/files'; import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors'; import { useAppSelector } from '../../store/store'; import type { FileOut, LegalEntityBrief } from '../../types/api'; +import { pluralRu } from '../../utils/labels'; import DeleteReasonModal from './DeleteModal'; import { FilesList } from './FilesList'; import ReplaceDocModal from './ReplaceDocModal'; import { UploadDocumentsModal } from './UploadDocumentsModal'; import { consolidationLabel } from './consolidationLabel'; import type { DocumentRow } from './documentRows'; -import { useLegalEntityFiles } from './useLegalEntityFiles'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -27,7 +28,7 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () // консолидация не определена (версии нет в списке ГК) — режим «все» без переключателя (design D-3) const desk = current !== null && !showAll; const consolidationId = desk ? current.consolidation_id : null; - const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open); + const { files, total, loading, loadingMore, reload, loadMore } = useLegalEntityFiles(current_legal_entity_id, consolidationId, open); const [targetRow, setTargetRow] = useState(null); const [removalOpen, setRemovalOpen] = useState(false); const canUpload = current !== null && isInWork(current.version_status); @@ -154,8 +155,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () Все документы организации )} + {!(loading && files.length === 0) && ( + + Всего {total} {pluralRu(total, ['файл', 'файла', 'файлов'])} + + )}
- + void loadMore()} + labelFor={labelFor} + onDeleteClick={handleRowDelete} + /> {current_legal_entity_id && ( {text}; export function FilesList({ files, loading, + loadingMore, + hasMore, + onLoadMore, labelFor, onDeleteClick, }: { files: FileOut[]; loading: boolean; + loadingMore: boolean; + hasMore: boolean; + onLoadMore: () => void; labelFor?: (f: FileOut) => string | null; onDeleteClick: (row: DocumentRow) => void; }) { const navigate = useNavigate(); const rows = buildDocumentRows(files, labelFor); + const sentinelRef = useScrollSentinel(hasMore, files, onLoadMore); const columns: ColumnsType = [ { @@ -164,20 +172,27 @@ export function FilesList({ } return ( - - size='small' - rowKey='key' - pagination={false} - columns={columns} - dataSource={rows} - locale={{ emptyText: 'Документов нет' }} - onRow={(r) => ({ - onClick: - r.linkable && r.documentId - ? () => navigate(`/documents/${r.documentId}`) - : () => message.warning('Просмотр данного документа недоступен'), - style: r.linkable ? { cursor: 'pointer' } : undefined, - })} - /> + <> + + size='small' + rowKey='key' + pagination={false} + columns={columns} + dataSource={rows} + locale={{ emptyText: 'Документов нет' }} + onRow={(r) => ({ + onClick: + r.linkable && r.documentId + ? () => navigate(`/documents/${r.documentId}`) + : () => message.warning('Просмотр данного документа недоступен'), + style: r.linkable ? { cursor: 'pointer' } : undefined, + })} + /> + {hasMore && ( +
+ {loadingMore && } +
+ )} + ); } diff --git a/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts b/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts deleted file mode 100644 index c877f8e..0000000 --- a/web/src/widgets/FilesDrawer/useLegalEntityFiles.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { message } from 'antd'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ApiError } from '../../api/client'; -import { hasPendingFiles, listLegalEntityFiles } from '../../services/api/files'; -import type { FileOut } from '../../types/api'; - -const POLL_MS = 5000; - -/** - * Файлы рабочего стола консолидации (`consolidationId`) или все файлы ЮЛ (`null`); - * пока панель открыта и есть UPLOADED/PROCESSING — перечитываем раз в 5 с. - */ -export function useLegalEntityFiles(legalEntityId: string | null, consolidationId: string | null, open: boolean) { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - const requestRef = useRef(0); - const inFlightRef = useRef(false); - const errorShownRef = useRef(false); - - const reload = useCallback(async () => { - if (!legalEntityId) return; - const requestId = ++requestRef.current; - inFlightRef.current = true; - setLoading(true); - try { - const items = await listLegalEntityFiles(legalEntityId, consolidationId); - if (requestId === requestRef.current) { - setFiles(items); - errorShownRef.current = false; - } - } catch (e: unknown) { - // при опросе показываем ошибку один раз на серию; флаг сбрасывается после первого успешного ответа - if (requestId === requestRef.current && !errorShownRef.current) { - errorShownRef.current = true; - message.error(e instanceof ApiError ? e.message : 'Не удалось получить файлы'); - } - } finally { - if (requestId === requestRef.current) { - inFlightRef.current = false; - setLoading(false); - } - } - }, [legalEntityId, consolidationId]); - - // смена режима (consolidationId) приходит через reload: он пересоздаётся и перезапускает этот эффект - useEffect(() => { - // при смене ЮЛ, режима (консолидация/все) или закрытии панели: старые ответы не применяем, прежние файлы очищаем - requestRef.current++; - inFlightRef.current = false; - errorShownRef.current = false; - setFiles([]); - if (open && legalEntityId) void reload(); - }, [open, legalEntityId, reload]); - - const pending = hasPendingFiles(files); - useEffect(() => { - if (!open || !pending) return; - const timer = window.setInterval(() => { - if (!inFlightRef.current) void reload(); // не копим параллельные запросы при медленном ответе - }, POLL_MS); - return () => window.clearInterval(timer); - }, [open, pending, reload]); - - return { files, loading, reload }; -}