From c7867277d81783b93797937b5b58819c534d0c45 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 01:09:34 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=AE=D0=9B=20=E2=80=94?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=B3=D0=BE=D0=BC,=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D1=8B,=20=D0=BE=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=81=20=D0=B4=D0=BE=20=D0=BE=D0=BA=D0=BE=D0=BD=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE=D0=B7?= =?UTF-8?q?=D0=BD=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- openspec/changes/demo-prototype/design.md | 1 + src/layouts/ConsolidationsList.tsx | 3 + src/store/slices/files/fileSlice.ts | 45 ------ src/store/slices/files/thunks.ts | 14 -- src/store/store.ts | 2 - .../ConsoleLegalEntities.tsx | 17 ++- src/widgets/FilesDrawer/FilesDrawer.tsx | 57 +++----- src/widgets/FilesDrawer/FilesList.tsx | 47 +++++++ .../FilesDrawer/UploadDocumentsModal.tsx | 133 ++++++++++-------- .../FilesDrawer/useDocumentsPolling.ts | 27 ---- .../FilesDrawer/useLegalEntityFiles.ts | 54 +++++++ 11 files changed, 208 insertions(+), 192 deletions(-) delete mode 100644 src/store/slices/files/fileSlice.ts delete mode 100644 src/store/slices/files/thunks.ts create mode 100644 src/widgets/FilesDrawer/FilesList.tsx delete mode 100644 src/widgets/FilesDrawer/useDocumentsPolling.ts create mode 100644 src/widgets/FilesDrawer/useLegalEntityFiles.ts diff --git a/openspec/changes/demo-prototype/design.md b/openspec/changes/demo-prototype/design.md index 8e1d2a4..8f3d2c3 100644 --- a/openspec/changes/demo-prototype/design.md +++ b/openspec/changes/demo-prototype/design.md @@ -122,3 +122,4 @@ docs#58 O-1 на фронте обходится). В контуре SPA отд - 08.10.2026 — D-6 worker pdf.js из пакета, без CDN. Обоснование: в контуре нет внешнего интернета. Цена ошибки: низкая. - 08.10.2026 — D-7 правка ячеек пакетом по «Сохранить», а не по каждой ячейке. Обоснование: меньше запросов, сервер принимает до 5000 ячеек и пересчитывает проверки один раз. Цена ошибки: низкая — поведение меняется в одном хуке. +- 08.10.2026 — D-8: слайс Redux `files` заменён локальным хуком `useLegalEntityFiles` (список нужен только открытой панели). Цена ошибки: низкая. diff --git a/src/layouts/ConsolidationsList.tsx b/src/layouts/ConsolidationsList.tsx index a641d46..fdbab42 100644 --- a/src/layouts/ConsolidationsList.tsx +++ b/src/layouts/ConsolidationsList.tsx @@ -13,6 +13,7 @@ import { DocumentsDrawer } from '../widgets/FilesDrawer/FilesDrawer'; export function ConsolidationPage() { const [drawerOpen, setDrawerOpen] = useState(false); + const [reloadKey, setReloadKey] = useState(0); const { group_id } = useParams(); const [searchParams] = useSearchParams(); const versionParam = searchParams.get('version'); @@ -84,6 +85,7 @@ export function ConsolidationPage() { minHeight: 0, }}> { setDrawerOpen(true); }} @@ -94,6 +96,7 @@ export function ConsolidationPage() { open={drawerOpen} onClose={() => { setDrawerOpen(false); + setReloadKey((k) => k + 1); }} /> diff --git a/src/store/slices/files/fileSlice.ts b/src/store/slices/files/fileSlice.ts deleted file mode 100644 index feed2e9..0000000 --- a/src/store/slices/files/fileSlice.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; -import type { PayloadAction } from '@reduxjs/toolkit'; -import type { FileOut } from '../../../types/api'; -import { getFilesThunk } from './thunks'; - -interface FilesState { - currentOpenFiles: FileOut[]; - loading: boolean; - error: string | null; -} - -const initialState: FilesState = { - currentOpenFiles: [], - loading: false, - error: null, -}; - -const filesSlice = createSlice({ - name: 'files', - initialState, - reducers: { - setCurrentFiles: (state, action: PayloadAction) => { - state.currentOpenFiles = action.payload; - }, - }, - extraReducers(builder) { - builder - .addCase(getFilesThunk.pending, (state) => { - state.loading = true; - state.error = null; - }) - .addCase(getFilesThunk.fulfilled, (state, action) => { - state.currentOpenFiles = action.payload; - state.loading = false; - }) - .addCase(getFilesThunk.rejected, (state, action) => { - state.error = action.payload ?? 'Неизвестная ошибка'; - state.currentOpenFiles = []; - state.loading = false; - }); - }, -}); - -export const { setCurrentFiles } = filesSlice.actions; -export default filesSlice.reducer; diff --git a/src/store/slices/files/thunks.ts b/src/store/slices/files/thunks.ts deleted file mode 100644 index 66d2f30..0000000 --- a/src/store/slices/files/thunks.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { listLegalEntityFiles } from '../../../services/api/files'; -import type { FileOut } from '../../../types/api'; - -export const getFilesThunk = createAsyncThunk( - 'files/getCurrentFiles', - async (legalEntityId, thunkApi) => { - try { - return await listLegalEntityFiles(legalEntityId); - } catch (e) { - return thunkApi.rejectWithValue(e instanceof Error ? e.message : 'Ошибка'); - } - }, -); diff --git a/src/store/store.ts b/src/store/store.ts index 4ab0967..1451d6d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -2,11 +2,9 @@ import { configureStore } from '@reduxjs/toolkit'; import { useDispatch, useSelector } from 'react-redux'; import comGroupsReducer from './slices/companyGroupsSlice/companyGroupsSlice'; import consolidationsReducer from './slices/consolidations/consolidationsSlice'; -import filesReducer from './slices/files/fileSlice'; export const store = configureStore({ reducer: { - files: filesReducer, company_groups: comGroupsReducer, consolidations: consolidationsReducer, }, diff --git a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index 934bc0d..89fb0d6 100644 --- a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -1,5 +1,6 @@ -import { Button, Space } from 'antd'; +import { Button, Space, message } from 'antd'; import { useEffect, useMemo, useState } from 'react'; +import { ApiError } from '../../api/client'; import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '../../components/GenericTable'; import { useTableFilters } from '../../components/GenericTable/useTableFilters'; import { getVersionCard } from '../../services/api/consolidations'; @@ -9,7 +10,7 @@ import type { IVersionCard, MemberRow } from '../../types/consolidations/version import { membersConfig } from './config'; import { normalizeData } from './normalize'; -export function ConsoleLegalEntitiesTable({ onDrawerOpen }: { onDrawerOpen: () => void }) { +export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawerOpen: () => void; reloadKey: number }) { const dispatch = useAppDispatch(); const [loading, setLoading] = useState(false); const consolidation_id = useAppSelector((s) => s.consolidations.current_version_id); @@ -20,12 +21,16 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen }: { onDrawerOpen: () = async function getLegalEntities() { if (!consolidation_id) return; setLoading(true); - const res = await getVersionCard(consolidation_id); - setConsolidationData(res); - setLoading(false); + try { + setConsolidationData(await getVersionCard(consolidation_id)); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить карточку версии'); + } finally { + setLoading(false); + } } getLegalEntities(); - }, [consolidation_id]); + }, [consolidation_id, reloadKey]); const rawRows = useMemo(() => normalizeData(consolidationData), [consolidationData]); const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig); const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters(); diff --git a/src/widgets/FilesDrawer/FilesDrawer.tsx b/src/widgets/FilesDrawer/FilesDrawer.tsx index d53223d..c64cab3 100644 --- a/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,57 +1,36 @@ -// DocumentsDrawer.tsx -import { Button, Drawer, List } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import { Button, Drawer } from 'antd'; import { useState } from 'react'; -import { getFilesThunk } from '../../store/slices/files/thunks'; -import { useAppDispatch, useAppSelector } from '../../store/store'; +import { useAppSelector } from '../../store/store'; +import { FilesList } from './FilesList'; import { UploadDocumentsModal } from './UploadDocumentsModal'; -import { useDocumentsPolling } from './useDocumentsPolling'; +import { useLegalEntityFiles } from './useLegalEntityFiles'; -export function DocumentsDrawer({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) { - const dispatch = useAppDispatch(); +export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { const [uploadOpen, setUploadOpen] = useState(false); - const { current_version_id, current_legal_entity_id } = useAppSelector((state) => state.consolidations); - - const { data, loading } = useDocumentsPolling(current_legal_entity_id, open); - const handleClose = () => { - // optional: clear on close so next open doesn't flash stale list - // dispatch(clearDocuments()); - onClose(); - }; + const { current_version_id, current_legal_entity_id } = useAppSelector((s) => s.consolidations); + const { files, loading, reload } = useLegalEntityFiles(current_legal_entity_id, open); return ( setUploadOpen(true)}> - Upload documents + }> - ( - - {item.original_name} - {item.file_type} - {item.status} - - )} - /> - + {current_legal_entity_id && ( setUploadOpen(false)} - onUploaded={() => dispatch(getFilesThunk(current_legal_entity_id))} // instant refresh, polling covers steady state + onUploaded={() => void reload()} /> )} diff --git a/src/widgets/FilesDrawer/FilesList.tsx b/src/widgets/FilesDrawer/FilesList.tsx new file mode 100644 index 0000000..104f571 --- /dev/null +++ b/src/widgets/FilesDrawer/FilesList.tsx @@ -0,0 +1,47 @@ +import { List, Space, Tag, Typography } from 'antd'; +import { Link } from 'react-router-dom'; +import type { DocumentBrief, FileOut } from '../../types/api'; +import { documentStatusLabel, documentTypeLabel, fileStatusLabel, periodLabel } from '../../utils/labels'; + +const LINKABLE = new Set(['OSV', 'BALANCE', 'INCOME']); + +const FILE_COLORS: Record = { ERROR: 'red', PROCESSED: 'default' }; +const DOC_COLORS: Record = { CONFIRMED: 'green', ERROR: 'red' }; + +const fileColor = (f: FileOut) => FILE_COLORS[f.status] ?? 'processing'; +const docColor = (d: DocumentBrief) => DOC_COLORS[d.status] ?? 'orange'; + +function errorReason(f: FileOut): string | null { + if (f.status !== 'ERROR' || !f.job) return null; + return f.job.mapping_error ?? f.job.error_message ?? f.job.error_code ?? f.job.status; +} + +export function FilesList({ files, loading }: { files: FileOut[]; loading: boolean }) { + return ( + ( + + + + {f.original_name ?? 'без имени'} + {fileStatusLabel(f.status)} + + {errorReason(f) && {errorReason(f)}} + {f.documents.map((d) => { + const title = `${documentTypeLabel(d.document_type, d.osv_account_code)} · ${periodLabel(d.period_year, d.period_quarter, d.is_cumulative)}`; + return ( + + {LINKABLE.has(d.document_type) ? {title} : {title}} + {documentStatusLabel(d.status)} + + ); + })} + + + )} + /> + ); +} diff --git a/src/widgets/FilesDrawer/UploadDocumentsModal.tsx b/src/widgets/FilesDrawer/UploadDocumentsModal.tsx index e7c2a87..0be0398 100644 --- a/src/widgets/FilesDrawer/UploadDocumentsModal.tsx +++ b/src/widgets/FilesDrawer/UploadDocumentsModal.tsx @@ -1,91 +1,106 @@ import { InboxOutlined } from '@ant-design/icons'; -// UploadDocumentsModal.tsx -import { Modal, Upload, message } from 'antd'; -import type { RcFile, UploadFile } from 'antd/es/upload/interface'; +import { Alert, Modal, Upload, message } from 'antd'; +import type { UploadFile } from 'antd/es/upload/interface'; import { useState } from 'react'; +import { ApiError } from '../../api/client'; import { summarizeUpload, uploadFiles } from '../../services/api/files'; type Props = { - entityId: string; + legalEntityId: string; + versionId: string | null; open: boolean; - consolidation_version_id: string | null; onClose: () => void; - onUploaded?: () => void; // trigger refetch after done + onUploaded: () => void; }; -export function UploadDocumentsModal({ entityId, open, onClose, onUploaded, consolidation_version_id = null }: Props) { +type Summary = ReturnType; + +export function UploadDocumentsModal({ legalEntityId, versionId, open, onClose, onUploaded }: Props) { const [fileList, setFileList] = useState([]); const [uploading, setUploading] = useState(false); + const [summary, setSummary] = useState(null); + + const close = () => { + if (uploading) return; + setFileList([]); + setSummary(null); + onClose(); + }; const handleOk = async () => { - if (!fileList.length) { - message.warning('No files selected'); + if (summary) { + close(); return; } - + const files = fileList.map((f) => f.originFileObj).filter((f): f is NonNullable => Boolean(f)); setUploading(true); - - const files = fileList.map((f) => (f.originFileObj ?? f) as RcFile); try { - const result = await uploadFiles(entityId, files, consolidation_version_id); - const { accepted, rejected } = summarizeUpload(result); - - if (rejected.length) { - const rejectedMsg = rejected.map((r) => `${r.name}: ${r.reason}`).join('; '); - message.warning(`Загружено: ${accepted.length}, отклонено: ${rejectedMsg}`); - setFileList((prev) => prev.filter((f) => rejected.some((r) => r.name === f.name))); - setUploading(false); - return; - } - - message.success('Все документы загружены'); - setFileList([]); - onUploaded?.(); - onClose(); - } catch (e) { - message.error(e instanceof Error ? e.message : 'Ошибка загрузки'); + const result = summarizeUpload(await uploadFiles(legalEntityId, files, versionId)); + setSummary(result); + if (result.accepted.length > 0) onUploaded(); + else message.error('Ни один файл не принят'); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файлы'); } finally { setUploading(false); } }; - const handleCancel = () => { - if (uploading) return; // block closing mid-upload - setFileList([]); - onClose(); - }; - return ( - { - // dedupe by uid protects from React StrictMode double invoke - setFileList((prev) => (prev.some((f) => f.uid === file.uid) ? prev : [...prev, file as UploadFile])); - return false; // prevent antd auto-upload - }} - onRemove={(file) => { - setFileList((prev) => prev.filter((f) => f.uid !== file.uid)); - }} - // we manage fileList ourselves, so disable antd status updates - showUploadList={{ showRemoveIcon: !uploading }} - > -

- -

-

Выберите файлы

-
+ {summary ? ( + <> + {summary.accepted.length > 0 && ( + + )} + {summary.rejected.length > 0 && ( + + {summary.rejected.map((r) => ( +
  • + {r.name} — {r.reason} +
  • + ))} + + } + /> + )} + + ) : ( + { + setFileList((prev) => + prev.some((f) => f.uid === file.uid) ? prev : [...prev, { uid: file.uid, name: file.name, originFileObj: file }], + ); + return false; + }} + onRemove={(file) => setFileList((prev) => prev.filter((f) => f.uid !== file.uid))} + showUploadList={{ showRemoveIcon: !uploading }}> +

    + +

    +

    Перетащите файлы или нажмите, чтобы выбрать

    +

    ОСВ, ББ, ОФР: xls, xlsx, xlsb, pdf, png, jpg — тип и период определятся автоматически

    +
    + )}
    ); } diff --git a/src/widgets/FilesDrawer/useDocumentsPolling.ts b/src/widgets/FilesDrawer/useDocumentsPolling.ts deleted file mode 100644 index 731f489..0000000 --- a/src/widgets/FilesDrawer/useDocumentsPolling.ts +++ /dev/null @@ -1,27 +0,0 @@ -// useDocumentsPolling.ts -import { useEffect } from 'react'; -import { getFilesThunk } from '../../store/slices/files/thunks'; -import { useAppDispatch, useAppSelector } from '../../store/store'; - -const POLL_INTERVAL = 5000; - -export function useDocumentsPolling(rowId: string | null, enabled: boolean) { - const dispatch = useAppDispatch(); - const data = useAppSelector((s) => s.files.currentOpenFiles); - const loading = useAppSelector((s) => s.files.loading); - - useEffect(() => { - if (!enabled || !rowId) return; - - // immediate fetch - dispatch(getFilesThunk(rowId)); - - const id = window.setInterval(() => { - dispatch(getFilesThunk(rowId)); - }, POLL_INTERVAL); - - return () => clearInterval(id); - }, [dispatch, rowId, enabled]); - - return { data, loading }; -} diff --git a/src/widgets/FilesDrawer/useLegalEntityFiles.ts b/src/widgets/FilesDrawer/useLegalEntityFiles.ts new file mode 100644 index 0000000..e01de93 --- /dev/null +++ b/src/widgets/FilesDrawer/useLegalEntityFiles.ts @@ -0,0 +1,54 @@ +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; + +/** Файлы ЮЛ; пока панель открыта и есть UPLOADED/PROCESSING — перечитываем раз в 5 с. */ +export function useLegalEntityFiles(legalEntityId: string | null, open: boolean) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + const requestRef = useRef(0); + const inFlightRef = useRef(false); + + const reload = useCallback(async () => { + if (!legalEntityId) return; + const requestId = ++requestRef.current; + inFlightRef.current = true; + setLoading(true); + try { + const items = await listLegalEntityFiles(legalEntityId); + if (requestId === requestRef.current) setFiles(items); + } catch (e: unknown) { + if (requestId === requestRef.current) message.error(e instanceof ApiError ? e.message : 'Не удалось получить файлы'); + } finally { + if (requestId === requestRef.current) { + inFlightRef.current = false; + setLoading(false); + } + } + }, [legalEntityId]); + + useEffect(() => { + if (open) { + void reload(); + } else { + requestRef.current++; // ответы после закрытия не применяем + inFlightRef.current = false; + setFiles([]); + } + }, [open, 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 }; +}