From 0fdbd80fbd6db97edd7ffb958faa8b6023d84f6e Mon Sep 17 00:00:00 2001 From: maxim Date: Mon, 19 Oct 2026 11:26:41 +0300 Subject: [PATCH 1/3] feat: add comments frontend, checklist modal, files delete, select options in document review --- src/App.tsx | 2 +- src/components/PageTitle.tsx | 24 ++- src/index.tsx | 6 + src/layouts/DocumentPage.tsx | 116 +++++++++++++-- src/services/api/consolidations.ts | 1 + src/services/api/documents.ts | 5 + src/services/api/files.ts | 6 +- .../consolidations/consolidationsSlice.ts | 8 +- src/store/slices/consolidations/types.ts | 1 + src/types/consolidations/version_members.ts | 18 +++ .../ConsoleLegalEntities/ChecklistModal.tsx | 81 ++++++++++ .../ConsoleLegalEntities.tsx | 140 +++++++++++------- .../ConsoleLegalEntities/checklist_utils.tsx | 94 ++++++++++++ src/widgets/DocumentReview/CommentModal.tsx | 43 ++++++ .../DocumentReview/StatementReview.tsx | 5 + src/widgets/DocumentReview/reviewView.ts | 14 ++ src/widgets/DocumentReview/statementGrid.ts | 36 ++++- src/widgets/FilesDrawer/DeleteModal.tsx | 77 ++++++++++ src/widgets/FilesDrawer/FilesDrawer.tsx | 72 ++++++++- src/widgets/FilesDrawer/FilesList.tsx | 62 +++++++- src/widgets/FilesDrawer/ReplaceDocModal.tsx | 75 ++++++++++ src/widgets/FilesDrawer/documentRows.ts | 7 + 22 files changed, 807 insertions(+), 86 deletions(-) create mode 100644 src/widgets/ConsoleLegalEntities/ChecklistModal.tsx create mode 100644 src/widgets/ConsoleLegalEntities/checklist_utils.tsx create mode 100644 src/widgets/DocumentReview/CommentModal.tsx create mode 100644 src/widgets/FilesDrawer/DeleteModal.tsx create mode 100644 src/widgets/FilesDrawer/ReplaceDocModal.tsx diff --git a/src/App.tsx b/src/App.tsx index 24faf7a..736da23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,7 +43,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx index c79a8cf..a7f5688 100644 --- a/src/components/PageTitle.tsx +++ b/src/components/PageTitle.tsx @@ -19,8 +19,16 @@ const crumbs = (location: string | string[]) => export default function PageTitle({ location, header, description, backable = false, extra }: PageTitleProps) { const navigate = useNavigate(); return ( -
-
+
+ {/*
*/} +
{backable && (
- {extra &&
{extra}
} + {extra && ( +
+ {extra} +
+ )}
); } diff --git a/src/index.tsx b/src/index.tsx index 207a715..dd1f6db 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -29,7 +29,9 @@ createRoot(rootElement).render( components: { Layout: { siderBg: '#fff', + fontSize: 13, }, + Button: { defaultBorderColor: '#c7cdd5', defaultHoverBg: '#f8fafc', @@ -46,6 +48,10 @@ createRoot(rootElement).render( headerColor: '#667085', headerBg: 'rgb(248, 250, 252)', }, + Form: { + labelFontSize: 12, + labelColor: '#665087', + }, }, }}> diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx index 676f51e..10f4e41 100644 --- a/src/layouts/DocumentPage.tsx +++ b/src/layouts/DocumentPage.tsx @@ -1,30 +1,51 @@ -import { Button, Empty, Result, Spin, Tag } from 'antd'; +import { Button, Empty, Form, message, Result, Select, Spin, Tooltip } from 'antd'; import { useCallback, useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; import { fetchLegalEntity } from '../services/api/directories/company_groups'; import { getDocument } from '../services/api/documents'; -import { getFile } from '../services/api/files'; +import { getFile, listLegalEntityFiles } from '../services/api/files'; import type { DocumentOut } from '../types/api'; import { documentTypeLabel, periodLabel } from '../utils/labels'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; import { StatementReview } from '../widgets/DocumentReview/StatementReview'; -import { documentPageTitle, documentSubtitle, statusBadge } from '../widgets/DocumentReview/reviewView'; +import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView'; import { documentViewKind } from '../widgets/DocumentReview/viewKind'; +import { buildDocumentRows } from '../widgets/FilesDrawer/documentRows'; +import { TableTag } from '../components/TableTag/TableTag'; +import { TAG_PRESETS } from '../components/TableTag/presets'; +import { CommentOutlined } from '@ant-design/icons'; +import CommentModal from '../widgets/DocumentReview/CommentModal'; export function DocumentPage() { - const { document_id } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const document_id = searchParams.get('document_id'); + const legal_entity_id = searchParams.get('legal_entity_id'); + const consolidation_id = searchParams.get('consolidation_id'); const navigate = useNavigate(); const [doc, setDoc] = useState(null); const [entityName, setEntityName] = useState(''); const [subtitle, setSubtitle] = useState(null); const [error, setError] = useState(null); + const [filesOptions, setFilesOptions] = useState([]); + const [commentOpen, setCommentOpen] = useState(false); const load = useCallback(async () => { if (!document_id) return; try { const d = await getDocument(document_id); + const filesJson = await listLegalEntityFiles(legal_entity_id, consolidation_id); + const documentOptions = buildDocumentRows(filesJson); + + const options = documentOptions.map((d) => ({ + disabled: d.documentId === null, + label: d.name, + value: d.documentId, + ...d, + title: '', + })); + setFilesOptions(options); setDoc(d); setError(null); fetchLegalEntity(d.legal_entity_id) @@ -36,7 +57,7 @@ export function DocumentPage() { } catch (e: unknown) { setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); } - }, [document_id]); + }, [document_id, legal_entity_id, consolidation_id]); useEffect(() => { void load(); @@ -53,7 +74,14 @@ export function DocumentPage() { // Документ в статусе ERROR — данных формы/строк нет: показываем карточку и понятный текст, а не пустой экран. const broken = doc.status === 'ERROR' && kind !== 'none'; return ( -
+
- {badge.label} - +
+
+ + {document_id && ( + + )} + +
+
+ +
+
} /> + {true && ( +
+ +
+ )}
} {kind === 'none' && }
+ { + setCommentOpen(false); + }} + />
); } diff --git a/src/services/api/consolidations.ts b/src/services/api/consolidations.ts index e794227..e20dcf8 100644 --- a/src/services/api/consolidations.ts +++ b/src/services/api/consolidations.ts @@ -8,5 +8,6 @@ export const getConsolidations = (groupId: string) => api.get( export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); export const createConsolidation = (body: ConsolidationCreate) => api.post('/consolidations', body); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); +export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ export const lockVersion = (versionId: string) => api.post(`/consolidations/versions/${versionId}/lock`); diff --git a/src/services/api/documents.ts b/src/services/api/documents.ts index fa4547e..30de97c 100644 --- a/src/services/api/documents.ts +++ b/src/services/api/documents.ts @@ -21,3 +21,8 @@ export async function getAllEntries(id: string): Promise { } } } + +export function postComment(document_id: string, comment: string) { + const body = { text: comment }; + return api.post(`/documents/${document_id}/comments/`, body); +} diff --git a/src/services/api/files.ts b/src/services/api/files.ts index 921f864..5d0b9d2 100644 --- a/src/services/api/files.ts +++ b/src/services/api/files.ts @@ -1,4 +1,4 @@ -import { ApiError, apiFetch, apiFetchBlob } from '../../api/client'; +import { api, ApiError, apiFetch, apiFetchBlob } from '../../api/client'; import type { FileOut, Page, UploadResult } from '../../types/api'; export function isUploadResult(body: unknown): body is UploadResult { @@ -56,3 +56,7 @@ export function fetchFileContent(fileId: string): Promise<{ blob: Blob; filename export function getFile(id: string): Promise { return apiFetch(`/files/${id}`); } + +export function removeFile(fileId: string) { + return api.del(`/files/${fileId}`); +} diff --git a/src/store/slices/consolidations/consolidationsSlice.ts b/src/store/slices/consolidations/consolidationsSlice.ts index 1beef35..4f873c2 100644 --- a/src/store/slices/consolidations/consolidationsSlice.ts +++ b/src/store/slices/consolidations/consolidationsSlice.ts @@ -6,6 +6,7 @@ const initialState: ConsolidationsSliceType = { consolidations: [], current_version_id: null, current_legal_entity_id: null, + current_legal_entity_name: null, loading: false, error: null, }; @@ -17,9 +18,12 @@ const consolidationsSlice = createSlice({ setSelectedVersion: (state, action: PayloadAction) => { state.current_version_id = action.payload; }, - setSelectedLegalEntity: (state, action: PayloadAction) => { + setSelectedLegalEntityId: (state, action: PayloadAction) => { state.current_legal_entity_id = action.payload; }, + setSelectedLegalEntityName: (state, action: PayloadAction) => { + state.current_legal_entity_name = action.payload; + }, }, extraReducers: (builder) => { builder @@ -39,4 +43,4 @@ const consolidationsSlice = createSlice({ }); export default consolidationsSlice.reducer; -export const { setSelectedVersion, setSelectedLegalEntity } = consolidationsSlice.actions; +export const { setSelectedVersion, setSelectedLegalEntityId, setSelectedLegalEntityName } = consolidationsSlice.actions; diff --git a/src/store/slices/consolidations/types.ts b/src/store/slices/consolidations/types.ts index 3a4ef02..ab6a346 100644 --- a/src/store/slices/consolidations/types.ts +++ b/src/store/slices/consolidations/types.ts @@ -6,4 +6,5 @@ export type ConsolidationsSliceType = { current_version_id: string | null; error: string | null; current_legal_entity_id: string | null; + current_legal_entity_name: string | null; }; diff --git a/src/types/consolidations/version_members.ts b/src/types/consolidations/version_members.ts index 1834baf..18a559a 100644 --- a/src/types/consolidations/version_members.ts +++ b/src/types/consolidations/version_members.ts @@ -35,6 +35,24 @@ type ChecklistItem = { account?: string | null; }; +type ChecklistRow = { + cells: ChecklistItem[]; + legal_entity_id: string; + legal_entity_name: string; + legal_entity_inn: string; +}; + +type ChecklistRule = { + document_type: DocumentType; + account: string; + is_mandatory: boolean; +}; + +export type ChecklistBackendShape = { + rows: ChecklistRow[]; + rules: ChecklistRule[]; +}; + type VersionMember = { id: string; name: string; diff --git a/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx b/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx new file mode 100644 index 0000000..63724c6 --- /dev/null +++ b/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx @@ -0,0 +1,81 @@ +import { useEffect, useMemo, useState } from 'react'; +import { getChecklist } from '../../services/api/consolidations'; +import { message, Modal, Table, TableColumnsType } from 'antd'; +import { ChecklistBackendShape, type IVersionCard } from '../../types/consolidations/version_members'; +import { DownloadOutlined } from '@ant-design/icons'; +import { buildChecklistColumns, getMarksItems } from './checklist_utils'; +// import { useMe } from '../../auth/useMe'; + +type ChecklistProps = { + open: boolean; + onDownload: () => void; + consolidationData: IVersionCard; + onClose: () => void; +}; + +export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) { + const [checklistData, setChecklistData] = useState(null); + + const displayedData = useMemo(() => { + if (!checklistData) return; + const result = []; + for (const row of checklistData.rows) { + const rowObj = { + name: row.legal_entity_name, + inn: row.legal_entity_inn, + ofr: 'MISSING', + balance: 'MISSING', + }; + for (const cell of row.cells) { + if (cell.document_type === 'OSV') { + const code = cell.account; + const key = `osv_${code}`; + rowObj[key] = cell.status; + } else if (cell.document_type === 'BALANCE') { + rowObj.balance = cell.status; + } else if (cell.document_type === 'OFR') { + rowObj.ofr = cell.status; + } + } + result.push(rowObj); + } + return result; + }, [checklistData]); + const columns: TableColumnsType = useMemo(() => { + if (!checklistData) return []; + return buildChecklistColumns(checklistData); + }, [checklistData]); + + useEffect(() => { + async function fetchChecklist() { + if (!consolidationData.id || !open) return; + try { + const res = await getChecklist(consolidationData.id); + setChecklistData(res); + } catch (e) { + message.error(e.message); + } + } + fetchChecklist(); + }, [open, consolidationData.id]); + return ( + { + onDownload(); + onClose(); + }} + okText='Скачать чек-лист' + okButtonProps={{ icon: }}> +
+ {getMarksItems()} + + + + ); +} diff --git a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index d21cf2f..bc59727 100644 --- a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -6,18 +6,24 @@ import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '. import { useTableFilters } from '../../components/GenericTable/useTableFilters'; import { downloadChecklistCsv } from '../../services/api/consolidations'; import { getVersionCard } from '../../services/api/consolidations'; -import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice'; +import { + setSelectedLegalEntityId, + setSelectedLegalEntityName, + setSelectedVersion, +} from '../../store/slices/consolidations/consolidationsSlice'; import { useAppDispatch, useAppSelector } from '../../store/store'; import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members'; import { saveBlob } from '../../utils/download'; import { membersConfig } from './config'; import { normalizeData } from './normalize'; import { checklistFilename, responsibleName } from './panelInfo'; +import ChecklistModal from './ChecklistModal'; 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); + const [checklistOpen, setChecklistOpen] = useState(false); const [consolidationData, setConsolidationData] = useState(null); @@ -47,6 +53,10 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe message.error(e instanceof ApiError ? e.message : 'Не удалось скачать чек-лист'); } }; + const viewChecklist = () => { + if (!consolidationData) return; + setChecklistOpen(true); + }; const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig); const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters(); // single filtered array — title count and table share it @@ -54,67 +64,85 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe const onRow = (row: MemberRow) => ({ onClick: () => { dispatch(setSelectedVersion(row.version_id)); - dispatch(setSelectedLegalEntity(row.id)); + dispatch(setSelectedLegalEntityId(row.id)); + dispatch(setSelectedLegalEntityName(row.name)); onDrawerOpen(); }, }); return ( -
- -
-

- Организации -

- {rawRows.length} шт. - {consolidationData && ( - // «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7 - - Ответственный: {responsibleName(consolidationData)}   Утверждён в РФ: —   Верифицирован в ГО: — - - )} -
-
- {hasActiveFilters && ( - + )} + + - )} - - -
-
- -
+ + + + + {consolidationData && ( + { + setChecklistOpen(false); + }} + onDownload={downloadChecklist} + consolidationData={consolidationData} + /> + )} + ); } diff --git a/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/src/widgets/ConsoleLegalEntities/checklist_utils.tsx new file mode 100644 index 0000000..2314758 --- /dev/null +++ b/src/widgets/ConsoleLegalEntities/checklist_utils.tsx @@ -0,0 +1,94 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + EllipsisOutlined, + ExclamationCircleOutlined, + EyeOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; +import { TAG_PRESETS } from '../../components/TableTag/presets'; +import { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; +import { Avatar, TableColumnsType } from 'antd'; +import { statusMapping } from './consts'; + +export const mapStatus = (status: FileStatus) => { + const presetMapping = { + MISSING: TAG_PRESETS['default'], + CONFIRMED: TAG_PRESETS['green'], + NEEDS_CHECK: TAG_PRESETS['orange'], + RECOGNIZED: TAG_PRESETS['gold'], + ERROR: TAG_PRESETS['red'], + PROCESSING: TAG_PRESETS['geekblue'], + }; + const preset = presetMapping[status]; + + const iconMapping = { + MISSING: , + NEEDS_CHECK: , + ERROR: , + CONFIRMED: , + RECOGNIZED: , + PROCESSING: , + }; + return ( + + ); +}; +export function getMarksItems() { + return ( +
+ Условные обозначения + {Object.entries(statusMapping).map((item) => { + const avatar = mapStatus(item[0] as FileStatus); + return ( +
+ {avatar} {item[1]} +
+ ); + })} +
+ ); +} +export function buildChecklistColumns(checklistData: ChecklistBackendShape) { + const osv_children = []; + const osv_codes = new Set(); + for (const row of checklistData.rows) { + for (const cell of row.cells) { + if (cell.document_type === 'OSV') { + const code = cell.account; + if (!osv_codes.has(code)) { + osv_children.push({ key: `osv_${code}`, dataIndex: `osv_${code}`, title: code, render: (v) => mapStatus(v) }); + osv_codes.add(code); + } + } + } + } + const result: TableColumnsType = [ + { + key: 'name', + dataIndex: 'leName', + title: 'ОРГАНИЗАЦИЯ', + render: (v, r) => ( +
+
+ {r.name}: {r.inn} +
+
+ ), + }, + { key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v) => mapStatus(v) }, + { key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v) => mapStatus(v) }, + { title: 'ОСВ', children: osv_children, render: (v) => mapStatus(v) }, + ]; + + return result; +} diff --git a/src/widgets/DocumentReview/CommentModal.tsx b/src/widgets/DocumentReview/CommentModal.tsx new file mode 100644 index 0000000..48c5af5 --- /dev/null +++ b/src/widgets/DocumentReview/CommentModal.tsx @@ -0,0 +1,43 @@ +import { Form, Input, message, Modal } from 'antd'; +import { useState } from 'react'; +import { postComment } from '../../services/api/documents'; + +export default function CommentModal({ + document_id, // () => void + open, + onClose, +}) { + const [comment, setComment] = useState(''); + const [isLoading, setIsloading] = useState(false); + const handleOk = async () => { + setIsloading(true); + try { + const res = await postComment(document_id, comment); + } catch (e) { + message.error(e.message); + } finally { + setIsloading(false); + onClose(); + } + }; + return ( + Добавить комментарий} + okText='Добавить' + onOk={handleOk} + okButtonProps={{ disabled: comment.length === 0 }} + open={open} + cancelText='Отмена' + onCancel={onClose} + destroyOnHidden> +
+ Комментарий}> + setComment(e.target.value)} + placeholder='Опишите результат проверки или внесенные правки'> + + +
+ ); +} diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 5ac0ffd..893e21a 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -295,6 +295,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut )} {serverErrorOffScreen && } + {grid.changedValues !== null && ( +

+ Измененных значений: {grid.changedValues}. Значений, требующих проверки: {grid.unrecognizedValues} +

+ )} ): string { return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · '); diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index a80c53d..4c232f3 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -28,7 +28,39 @@ export type GridRow = { /** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */ const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`; -export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } { +export function buildStatementGrid(statement: StatementOut): { + columns: ColumnOut[]; + rows: GridRow[]; + changedValues: number | null; + unrecognizedValues: number | null; +} { + let changedValues: number | null = 0; + let unrecognizedValues: number | null = 0; + if (statement.document.status === 'NEEDS_CHECK') { + for (const key of ['inn', 'report_date', 'units']) { + const val = statement.header[key]; + if (!val['verified'] && !val['modified']) { + unrecognizedValues++; + } + if (val['modified']) { + changedValues++; + } + } + for (const l of statement.lines) { + for (const c of l.values) { + if (!c.is_recognized && !c.is_modified) { + unrecognizedValues++; + } + if (c.is_modified) { + changedValues++; + } + } + } + } else { + unrecognizedValues = null; + changedValues = null; + } + const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index); const rows = statement.lines.map((line) => { const cells: Record = {}; @@ -54,7 +86,7 @@ export function buildStatementGrid(statement: StatementOut): { columns: ColumnOu cells, }; }); - return { columns, rows }; + return { columns, rows, changedValues, unrecognizedValues }; } /** diff --git a/src/widgets/FilesDrawer/DeleteModal.tsx b/src/widgets/FilesDrawer/DeleteModal.tsx new file mode 100644 index 0000000..1d81742 --- /dev/null +++ b/src/widgets/FilesDrawer/DeleteModal.tsx @@ -0,0 +1,77 @@ +import React, { useCallback, useState } from 'react'; +import { Modal, Button, Typography, theme, message } from 'antd'; +import { removeFile } from '../../services/api/files'; + +const { Text } = Typography; + +const OptionCard = ({ title, detail, action }) => { + const { token } = theme.useToken(); + return ( +
+
+ {title} + {detail} +
+ {action} +
+ ); +}; + +export default function DeleteReasonModal({ + targetRow, + open, + onCancel, + onDelete, // () => void + onReplace, // () => void +}) { + const [isDeleting, setIsDeleting] = useState(false); + const handleDelete = useCallback(async () => { + setIsDeleting(true); + try { + await removeFile(targetRow.key); + message.success('Файл удалён'); + } catch (e) { + message.error(e.message); + } finally { + setIsDeleting(false); + onDelete(); + } + }, [targetRow.key, onDelete]); + return ( + +
+

Выберите причину удаления документа «{targetRow.name}»

+

Будет удален/заменён сам файл и все его документы

+ + + Удалить + + } + /> + + + Заменить + + } + /> +
+
+ ); +} diff --git a/src/widgets/FilesDrawer/FilesDrawer.tsx b/src/widgets/FilesDrawer/FilesDrawer.tsx index 3026379..789da12 100644 --- a/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,5 +1,5 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; -import { Button, Drawer, Switch, Tooltip, Typography } from 'antd'; +import { Button, Drawer, message, Switch, Tooltip, Typography } from 'antd'; import { useEffect, useState } from 'react'; import { fetchLegalEntity } from '../../services/api/directories/company_groups'; import { isInWork, useCurrentConsolidation } from '../../store/slices/consolidations/selectors'; @@ -9,6 +9,9 @@ import { FilesList } from './FilesList'; import { UploadDocumentsModal } from './UploadDocumentsModal'; import { consolidationLabel } from './consolidationLabel'; import { useLegalEntityFiles } from './useLegalEntityFiles'; +import { DocumentRow } from './documentRows'; +import DeleteReasonModal from './DeleteModal'; +import ReplaceDocModal from './ReplaceDocModal'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -22,7 +25,21 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () const desk = current !== null && !showAll; const consolidationId = desk ? current.consolidation_id : null; const { files, loading, reload } = 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); + const [replaceOpen, setReplaceOpen] = useState(false); + const [replacing, setReplacing] = useState(false); + + const onDelete = () => { + void reload(); + setRemovalOpen(false); + }; + + const handleReplaceClick = () => { + setRemovalOpen(false); + setReplaceOpen(true); + }; const labelFor = desk ? undefined : (f: FileOut) => consolidationLabel(f.consolidation_id, consolidations, current?.consolidation_id ?? null); @@ -44,6 +61,23 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () }; }, [open, current_legal_entity_id]); + const handleRowDelete = (row: DocumentRow) => { + setTargetRow(row); + setRemovalOpen(true); + }; + const handleReplaceSubmit = ({ file, comment }) => { + try { + setReplacing(true); + message.success('Документ заменен'); + setReplaceOpen(false); + reload(); + } catch (e) { + message.error(e.message); + } finally { + setReplacing(false); + } + }; + const uploadButton = ( + + + + ); +} diff --git a/src/widgets/FilesDrawer/documentRows.ts b/src/widgets/FilesDrawer/documentRows.ts index d5873ee..369e959 100644 --- a/src/widgets/FilesDrawer/documentRows.ts +++ b/src/widgets/FilesDrawer/documentRows.ts @@ -20,6 +20,8 @@ export interface DocumentRow { statusLabel: string; errorReason: string | null; linkable: boolean; + consolidationId: string | null; + legalEntityId: string | null; /** предупреждения разбора файла (общие для всех документов файла) */ fileWarnings: FileWarningOut[]; mappingWarnings: string[]; @@ -72,6 +74,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s statusLabel: failed ? 'Ошибка' : 'Обработка', errorReason: errorReason(f), linkable: false, + consolidationId: f.consolidation_id, + legalEntityId: f.legal_entity_id, fileWarnings, mappingWarnings, issueCounts: { error: 0, info: 0 }, @@ -86,7 +90,10 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s const status = documentRowStatus(d.status); return { key: d.id, + consolidationId: f.consolidation_id, documentId: d.id, + file_id: f.id, + legalEntityId: f.legal_entity_id, name, fileType: f.file_type, subtitle: join([typeText, f.file_type, date, extra]), -- 2.47.2 From 0242486eacf2e52f2abc1e6aeb00563a681a1d3f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 13:14:09 +0400 Subject: [PATCH 2/3] =?UTF-8?q?fix(#49):=20=D0=B7=D0=B5=D0=BB=D1=91=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20make=20check=20=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(AURORA-144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - маршрут /documents/:document_id возвращён: старые ссылки («Открыть подтверждённую форму») снова открывают документ; ЮЛ и консолидация для переключателя документов берутся из документа и его файла, а не из адреса; в списке переключателя — вторая строка (тип · формат · дата), ББ и ОФР одного PDF различимы - чек-лист: ОФР — ячейка INCOME (было 'OFR' → всегда «Отсутствует»), сборка строк — buildChecklistRows с тестами, rowKey по ЮЛ, типы ответа GET …/checklist - удаление файла по id файла (fileId строки), а не по id документа (было 404); ошибка — без закрытия окна - замена: настоящая загрузка нового файла в ту же консолидацию (автозамена ядра, 05-domain-rules §9.5 п. 3), причина — комментарием к заменяемому документу; кнопка доступна только в версии в работе - комментарий: путь без хвостового «/», типы, закрытие только после успеха, лимит 4000 - счётчики над таблицей формы — модуль reviewCounters (по needs_review, как подсветка) с тестами; buildStatementGrid возвращён к test (падали 3 теста) - PageTitle и store возвращены к test (ширина переключателя — внутри DocumentPage; имя ЮЛ — пропсом) - цвет подписей полей в теме: #667085 (опечатка #665087) - типы во всех новых компонентах: tsc 0 ошибок, ESLint/biome без ошибок, vitest 331/331, сборка Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- src/App.tsx | 2 +- src/components/PageTitle.tsx | 24 +-- src/index.tsx | 2 +- src/layouts/DocumentPage.tsx | 156 +++++++----------- src/services/api/consolidations.ts | 4 +- src/services/api/documents.ts | 6 +- src/services/api/files.ts | 2 +- .../consolidations/consolidationsSlice.ts | 8 +- src/store/slices/consolidations/types.ts | 1 - src/types/consolidations/version_members.ts | 2 +- .../ConsoleLegalEntities/ChecklistModal.tsx | 60 ++----- .../ConsoleLegalEntities.tsx | 11 +- .../checklist_utils.test.ts | 39 +++++ .../ConsoleLegalEntities/checklist_utils.tsx | 135 +++++++++------ src/widgets/DocumentReview/CommentModal.tsx | 45 +++-- .../DocumentReview/StatementReview.tsx | 10 +- .../DocumentReview/reviewCounters.test.ts | 67 ++++++++ src/widgets/DocumentReview/reviewCounters.ts | 31 ++++ src/widgets/DocumentReview/reviewView.test.ts | 20 ++- src/widgets/DocumentReview/reviewView.ts | 23 ++- src/widgets/DocumentReview/statementGrid.ts | 36 +--- src/widgets/FilesDrawer/DeleteModal.tsx | 49 +++--- src/widgets/FilesDrawer/FilesDrawer.tsx | 40 +++-- src/widgets/FilesDrawer/FilesList.tsx | 13 +- src/widgets/FilesDrawer/ReplaceDocModal.tsx | 54 +++--- src/widgets/FilesDrawer/documentRows.test.ts | 7 + src/widgets/FilesDrawer/documentRows.ts | 11 +- 28 files changed, 479 insertions(+), 382 deletions(-) create mode 100644 src/widgets/ConsoleLegalEntities/checklist_utils.test.ts create mode 100644 src/widgets/DocumentReview/reviewCounters.test.ts create mode 100644 src/widgets/DocumentReview/reviewCounters.ts diff --git a/CLAUDE.md b/CLAUDE.md index bbe34c0..4c404f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ SPA кредитного аналитика АРМ КА: справочники список для панели и `collapseIssues`; `statementDraft` — весь черновик правки и сборка тела `PATCH` (суммы, подтверждения, коды, даты, шапка); `cellView` — цвета, подсказки, текст ячейки суммы и кода; `codeSuggestions` — подсказки кодов по справочнику; `lineMenu` — меню строки («Это расшифровка», «Назначить код»); `editRules` — что можно править и какая кнопка подтверждения нужна ячейке/колонке; - `confirmBlockers` — строки без кода, блокирующие подтверждение; `focusTarget` — к какой строке прокрутить по «Показать». + `confirmBlockers` — строки без кода, блокирующие подтверждение; `focusTarget` — к какой строке прокрутить по «Показать»; + `reviewCounters` — счётчики «изменённых» и «требующих проверки» над таблицей (по `needs_review`, как подсветка). `src/widgets/FilesDrawer/fileWarnings` и `documentRows` — предупреждения файла и документа в панели файлов; `src/utils/percent` — формат уверенности. diff --git a/src/App.tsx b/src/App.tsx index 736da23..24faf7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,7 +43,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx index a7f5688..c79a8cf 100644 --- a/src/components/PageTitle.tsx +++ b/src/components/PageTitle.tsx @@ -19,16 +19,8 @@ const crumbs = (location: string | string[]) => export default function PageTitle({ location, header, description, backable = false, extra }: PageTitleProps) { const navigate = useNavigate(); return ( -
- {/*
*/} -
+
+
{backable && (
- {extra && ( -
- {extra} -
- )} + {extra &&
{extra}
}
); } diff --git a/src/index.tsx b/src/index.tsx index dd1f6db..83b74ef 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -50,7 +50,7 @@ createRoot(rootElement).render( }, Form: { labelFontSize: 12, - labelColor: '#665087', + labelColor: '#667085', }, }, }}> diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx index 10f4e41..661a6d1 100644 --- a/src/layouts/DocumentPage.tsx +++ b/src/layouts/DocumentPage.tsx @@ -1,63 +1,66 @@ -import { Button, Empty, Form, message, Result, Select, Spin, Tooltip } from 'antd'; +import { CommentOutlined } from '@ant-design/icons'; +import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd'; import { useCallback, useEffect, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +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 { 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 { documentTypeLabel, periodLabel } from '../utils/labels'; +import CommentModal from '../widgets/DocumentReview/CommentModal'; import { OsvDocument } from '../widgets/DocumentReview/OsvDocument'; import { StatementReview } from '../widgets/DocumentReview/StatementReview'; import { documentPageTitle, documentSubtitle, optionStatusBadge, statusBadge } from '../widgets/DocumentReview/reviewView'; import { documentViewKind } from '../widgets/DocumentReview/viewKind'; -import { buildDocumentRows } from '../widgets/FilesDrawer/documentRows'; -import { TableTag } from '../components/TableTag/TableTag'; -import { TAG_PRESETS } from '../components/TableTag/presets'; -import { CommentOutlined } from '@ant-design/icons'; -import CommentModal from '../widgets/DocumentReview/CommentModal'; +import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/documentRows'; + +type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow }; export function DocumentPage() { - const [searchParams, setSearchParams] = useSearchParams(); - const document_id = searchParams.get('document_id'); - const legal_entity_id = searchParams.get('legal_entity_id'); - const consolidation_id = searchParams.get('consolidation_id'); + const { document_id } = useParams(); const navigate = useNavigate(); const [doc, setDoc] = useState(null); const [entityName, setEntityName] = useState(''); const [subtitle, setSubtitle] = useState(null); const [error, setError] = useState(null); - const [filesOptions, setFilesOptions] = useState([]); + const [documentOptions, setDocumentOptions] = useState([]); const [commentOpen, setCommentOpen] = useState(false); const load = useCallback(async () => { if (!document_id) return; try { const d = await getDocument(document_id); - const filesJson = await listLegalEntityFiles(legal_entity_id, consolidation_id); - const documentOptions = buildDocumentRows(filesJson); - - const options = documentOptions.map((d) => ({ - disabled: d.documentId === null, - label: d.name, - value: d.documentId, - ...d, - title: '', - })); - setFilesOptions(options); setDoc(d); setError(null); fetchLegalEntity(d.legal_entity_id) .then((le) => setEntityName(le.name)) .catch(() => setEntityName('')); getFile(d.file_id) - .then((f) => setSubtitle(documentSubtitle(f))) + .then((f) => { + setSubtitle(documentSubtitle(f)); + // переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ) + listLegalEntityFiles(d.legal_entity_id, f.consolidation_id) + .then((files) => + setDocumentOptions( + buildDocumentRows(files).map((row) => ({ + value: row.documentId, + label: row.name, + disabled: !row.linkable, + row, + })), + ), + ) + .catch(() => setDocumentOptions([])); + }) .catch(() => setSubtitle(null)); } catch (e: unknown) { setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); } - }, [document_id, legal_entity_id, consolidation_id]); + }, [document_id]); useEffect(() => { void load(); @@ -74,14 +77,7 @@ export function DocumentPage() { // Документ в статусе ERROR — данных формы/строк нет: показываем карточку и понятный текст, а не пустой экран. const broken = doc.status === 'ERROR' && kind !== 'none'; return ( -
+
-
- - {document_id && ( - - )} + + +
+ )} + /> -
- -
+
} /> - {true && ( -
- -
- )} +
+ +
} {kind === 'none' && }
- { - setCommentOpen(false); - }} - /> + setCommentOpen(false)} />
); } diff --git a/src/services/api/consolidations.ts b/src/services/api/consolidations.ts index e20dcf8..28e1220 100644 --- a/src/services/api/consolidations.ts +++ b/src/services/api/consolidations.ts @@ -2,12 +2,12 @@ import { api, apiFetchBlob } from '../../api/client'; import type { ConsolidationCreate } from '../../types/api'; import type { LockOut } from '../../types/consolidations/common_types'; import type { IConsolidation } from '../../types/consolidations/consolidations'; -import type { VersionCard } from '../../types/consolidations/version_members'; +import type { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members'; export const getConsolidations = (groupId: string) => api.get(`/company-groups/${groupId}/consolidations`); export const getVersionCard = (versionId: string) => api.get(`/consolidations/versions/${versionId}`); export const createConsolidation = (body: ConsolidationCreate) => api.post('/consolidations', body); export const downloadChecklistCsv = (versionId: string) => apiFetchBlob(`/consolidations/versions/${versionId}/checklist?format=csv`); -export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); +export const getChecklist = (versionId: string) => api.get(`/consolidations/versions/${versionId}/checklist`); /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ export const lockVersion = (versionId: string) => api.post(`/consolidations/versions/${versionId}/lock`); diff --git a/src/services/api/documents.ts b/src/services/api/documents.ts index 30de97c..64bd52d 100644 --- a/src/services/api/documents.ts +++ b/src/services/api/documents.ts @@ -22,7 +22,5 @@ export async function getAllEntries(id: string): Promise { } } -export function postComment(document_id: string, comment: string) { - const body = { text: comment }; - return api.post(`/documents/${document_id}/comments/`, body); -} +/** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */ +export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text }); diff --git a/src/services/api/files.ts b/src/services/api/files.ts index 5d0b9d2..9b419b0 100644 --- a/src/services/api/files.ts +++ b/src/services/api/files.ts @@ -1,4 +1,4 @@ -import { api, ApiError, apiFetch, apiFetchBlob } from '../../api/client'; +import { ApiError, api, apiFetch, apiFetchBlob } from '../../api/client'; import type { FileOut, Page, UploadResult } from '../../types/api'; export function isUploadResult(body: unknown): body is UploadResult { diff --git a/src/store/slices/consolidations/consolidationsSlice.ts b/src/store/slices/consolidations/consolidationsSlice.ts index 4f873c2..1beef35 100644 --- a/src/store/slices/consolidations/consolidationsSlice.ts +++ b/src/store/slices/consolidations/consolidationsSlice.ts @@ -6,7 +6,6 @@ const initialState: ConsolidationsSliceType = { consolidations: [], current_version_id: null, current_legal_entity_id: null, - current_legal_entity_name: null, loading: false, error: null, }; @@ -18,12 +17,9 @@ const consolidationsSlice = createSlice({ setSelectedVersion: (state, action: PayloadAction) => { state.current_version_id = action.payload; }, - setSelectedLegalEntityId: (state, action: PayloadAction) => { + setSelectedLegalEntity: (state, action: PayloadAction) => { state.current_legal_entity_id = action.payload; }, - setSelectedLegalEntityName: (state, action: PayloadAction) => { - state.current_legal_entity_name = action.payload; - }, }, extraReducers: (builder) => { builder @@ -43,4 +39,4 @@ const consolidationsSlice = createSlice({ }); export default consolidationsSlice.reducer; -export const { setSelectedVersion, setSelectedLegalEntityId, setSelectedLegalEntityName } = consolidationsSlice.actions; +export const { setSelectedVersion, setSelectedLegalEntity } = consolidationsSlice.actions; diff --git a/src/store/slices/consolidations/types.ts b/src/store/slices/consolidations/types.ts index ab6a346..3a4ef02 100644 --- a/src/store/slices/consolidations/types.ts +++ b/src/store/slices/consolidations/types.ts @@ -6,5 +6,4 @@ export type ConsolidationsSliceType = { current_version_id: string | null; error: string | null; current_legal_entity_id: string | null; - current_legal_entity_name: string | null; }; diff --git a/src/types/consolidations/version_members.ts b/src/types/consolidations/version_members.ts index 18a559a..a8212ac 100644 --- a/src/types/consolidations/version_members.ts +++ b/src/types/consolidations/version_members.ts @@ -44,7 +44,7 @@ type ChecklistRow = { type ChecklistRule = { document_type: DocumentType; - account: string; + account: string | null; is_mandatory: boolean; }; diff --git a/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx b/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx index 63724c6..43e0db7 100644 --- a/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx +++ b/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useState } from 'react'; -import { getChecklist } from '../../services/api/consolidations'; -import { message, Modal, Table, TableColumnsType } from 'antd'; -import { ChecklistBackendShape, type IVersionCard } from '../../types/consolidations/version_members'; import { DownloadOutlined } from '@ant-design/icons'; -import { buildChecklistColumns, getMarksItems } from './checklist_utils'; -// import { useMe } from '../../auth/useMe'; +import { Modal, Table, message } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { ApiError } from '../../api/client'; +import { getChecklist } from '../../services/api/consolidations'; +import type { ChecklistBackendShape, IVersionCard } from '../../types/consolidations/version_members'; +import { buildChecklistColumns, buildChecklistRows, getMarksItems } from './checklist_utils'; type ChecklistProps = { open: boolean; @@ -16,48 +16,16 @@ type ChecklistProps = { export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) { const [checklistData, setChecklistData] = useState(null); - const displayedData = useMemo(() => { - if (!checklistData) return; - const result = []; - for (const row of checklistData.rows) { - const rowObj = { - name: row.legal_entity_name, - inn: row.legal_entity_inn, - ofr: 'MISSING', - balance: 'MISSING', - }; - for (const cell of row.cells) { - if (cell.document_type === 'OSV') { - const code = cell.account; - const key = `osv_${code}`; - rowObj[key] = cell.status; - } else if (cell.document_type === 'BALANCE') { - rowObj.balance = cell.status; - } else if (cell.document_type === 'OFR') { - rowObj.ofr = cell.status; - } - } - result.push(rowObj); - } - return result; - }, [checklistData]); - const columns: TableColumnsType = useMemo(() => { - if (!checklistData) return []; - return buildChecklistColumns(checklistData); - }, [checklistData]); + const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]); + const columns = useMemo(() => (checklistData ? buildChecklistColumns(checklistData) : []), [checklistData]); useEffect(() => { - async function fetchChecklist() { - if (!consolidationData.id || !open) return; - try { - const res = await getChecklist(consolidationData.id); - setChecklistData(res); - } catch (e) { - message.error(e.message); - } - } - fetchChecklist(); + if (!consolidationData.id || !open) return; + getChecklist(consolidationData.id) + .then(setChecklistData) + .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист')); }, [open, consolidationData.id]); + return ( }}>
{getMarksItems()} -
+
); diff --git a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index bc59727..44aa8fa 100644 --- a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -6,18 +6,14 @@ import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '. import { useTableFilters } from '../../components/GenericTable/useTableFilters'; import { downloadChecklistCsv } from '../../services/api/consolidations'; import { getVersionCard } from '../../services/api/consolidations'; -import { - setSelectedLegalEntityId, - setSelectedLegalEntityName, - setSelectedVersion, -} from '../../store/slices/consolidations/consolidationsSlice'; +import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice'; import { useAppDispatch, useAppSelector } from '../../store/store'; import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members'; import { saveBlob } from '../../utils/download'; +import ChecklistModal from './ChecklistModal'; import { membersConfig } from './config'; import { normalizeData } from './normalize'; import { checklistFilename, responsibleName } from './panelInfo'; -import ChecklistModal from './ChecklistModal'; export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawerOpen: () => void; reloadKey: number }) { const dispatch = useAppDispatch(); @@ -64,8 +60,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe const onRow = (row: MemberRow) => ({ onClick: () => { dispatch(setSelectedVersion(row.version_id)); - dispatch(setSelectedLegalEntityId(row.id)); - dispatch(setSelectedLegalEntityName(row.name)); + dispatch(setSelectedLegalEntity(row.id)); onDrawerOpen(); }, }); diff --git a/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts b/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts new file mode 100644 index 0000000..fc67fd6 --- /dev/null +++ b/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { ChecklistBackendShape } from '../../types/consolidations/version_members'; +import { buildChecklistRows } from './checklist_utils'; + +const checklist: ChecklistBackendShape = { + rules: [], + rows: [ + { + legal_entity_id: 'le1', + legal_entity_name: 'РУССКИЙ БИСКВИТ, АО', + legal_entity_inn: '3528052718', + cells: [ + { document_type: 'BALANCE', account: null, status: 'NEEDS_CHECK', missing_quarters: [], document_ids: ['d1'] }, + { document_type: 'INCOME', account: null, status: 'CONFIRMED', missing_quarters: [], document_ids: ['d2'] }, + { document_type: 'OSV', account: '60', status: 'ERROR', missing_quarters: [], document_ids: ['d3'] }, + ], + }, + ], +}; + +describe('buildChecklistRows', () => { + it('ОФР — ячейка INCOME, ББ — BALANCE, ОСВ — по счёту; ключ строки — ЮЛ', () => { + expect(buildChecklistRows(checklist)).toEqual([ + { + key: 'le1', + name: 'РУССКИЙ БИСКВИТ, АО', + inn: '3528052718', + balance: 'NEEDS_CHECK', + ofr: 'CONFIRMED', + osv: { '60': 'ERROR' }, + }, + ]); + }); + + it('нет ячеек ББ/ОФР — «Отсутствует»', () => { + const rows = buildChecklistRows({ rules: [], rows: [{ ...checklist.rows[0], cells: [] }] }); + expect(rows[0]).toMatchObject({ balance: 'MISSING', ofr: 'MISSING', osv: {} }); + }); +}); diff --git a/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/src/widgets/ConsoleLegalEntities/checklist_utils.tsx index 2314758..606d72b 100644 --- a/src/widgets/ConsoleLegalEntities/checklist_utils.tsx +++ b/src/widgets/ConsoleLegalEntities/checklist_utils.tsx @@ -6,34 +6,47 @@ import { EyeOutlined, MinusCircleOutlined, } from '@ant-design/icons'; -import { TAG_PRESETS } from '../../components/TableTag/presets'; -import { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; -import { Avatar, TableColumnsType } from 'antd'; +import { Avatar, type TableColumnsType } from 'antd'; +import type { ReactNode } from 'react'; +import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; +import type { ChecklistBackendShape, FileStatus } from '../../types/consolidations/version_members'; import { statusMapping } from './consts'; -export const mapStatus = (status: FileStatus) => { - const presetMapping = { - MISSING: TAG_PRESETS['default'], - CONFIRMED: TAG_PRESETS['green'], - NEEDS_CHECK: TAG_PRESETS['orange'], - RECOGNIZED: TAG_PRESETS['gold'], - ERROR: TAG_PRESETS['red'], - PROCESSING: TAG_PRESETS['geekblue'], - }; - const preset = presetMapping[status]; +/** Строка таблицы чек-листа: ЮЛ и статусы ББ, ОФР и ОСВ по счетам. */ +export type ChecklistTableRow = { + key: string; + name: string; + inn: string; + balance: FileStatus; + ofr: FileStatus; + osv: Record; +}; - const iconMapping = { - MISSING: , - NEEDS_CHECK: , - ERROR: , - CONFIRMED: , - RECOGNIZED: , - PROCESSING: , - }; +const presetMapping: Record = { + MISSING: TAG_PRESETS.default, + CONFIRMED: TAG_PRESETS.green, + NEEDS_CHECK: TAG_PRESETS.orange, + RECOGNIZED: TAG_PRESETS.gold, + ERROR: TAG_PRESETS.red, + PROCESSING: TAG_PRESETS.geekblue, +}; + +const iconMapping: Record = { + MISSING: , + NEEDS_CHECK: , + ERROR: , + CONFIRMED: , + RECOGNIZED: , + PROCESSING: , +}; + +export const mapStatus = (status: FileStatus) => { + const preset = presetMapping[status]; return ( ); }; + export function getMarksItems() { return (
Условные обозначения - {Object.entries(statusMapping).map((item) => { - const avatar = mapStatus(item[0] as FileStatus); - return ( -
- {avatar} {item[1]} -
- ); - })} + {(Object.entries(statusMapping) as [FileStatus, string][]).map(([status, label]) => ( +
+ {mapStatus(status)} {label} +
+ ))}
); } -export function buildChecklistColumns(checklistData: ChecklistBackendShape) { - const osv_children = []; - const osv_codes = new Set(); + +/** Строки таблицы из ответа `GET …/checklist`: ОФР — ячейка `INCOME`; нет ячейки ББ/ОФР — «Отсутствует». */ +export function buildChecklistRows(checklistData: ChecklistBackendShape): ChecklistTableRow[] { + return checklistData.rows.map((row) => { + const result: ChecklistTableRow = { + key: row.legal_entity_id, + name: row.legal_entity_name, + inn: row.legal_entity_inn, + balance: 'MISSING', + ofr: 'MISSING', + osv: {}, + }; + for (const cell of row.cells) { + if (cell.document_type === 'OSV' && cell.account) { + result.osv[cell.account] = cell.status; + } else if (cell.document_type === 'BALANCE') { + result.balance = cell.status; + } else if (cell.document_type === 'INCOME') { + result.ofr = cell.status; + } + } + return result; + }); +} + +export function buildChecklistColumns(checklistData: ChecklistBackendShape): TableColumnsType { + const osvCodes: string[] = []; for (const row of checklistData.rows) { for (const cell of row.cells) { - if (cell.document_type === 'OSV') { - const code = cell.account; - if (!osv_codes.has(code)) { - osv_children.push({ key: `osv_${code}`, dataIndex: `osv_${code}`, title: code, render: (v) => mapStatus(v) }); - osv_codes.add(code); - } + if (cell.document_type === 'OSV' && cell.account && !osvCodes.includes(cell.account)) { + osvCodes.push(cell.account); } } } - const result: TableColumnsType = [ + return [ { key: 'name', - dataIndex: 'leName', title: 'ОРГАНИЗАЦИЯ', - render: (v, r) => ( -
-
- {r.name}: {r.inn} -
-
+ render: (_, r) => ( +
+ {r.name}: {r.inn} +
), }, - { key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v) => mapStatus(v) }, - { key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v) => mapStatus(v) }, - { title: 'ОСВ', children: osv_children, render: (v) => mapStatus(v) }, + { key: 'balance', dataIndex: 'balance', title: 'БУХ.БАЛАНС', render: (v: FileStatus) => mapStatus(v) }, + { key: 'ofr', dataIndex: 'ofr', title: 'ОФР', render: (v: FileStatus) => mapStatus(v) }, + { + key: 'osv', + title: 'ОСВ', + children: osvCodes.map((code) => ({ + key: `osv_${code}`, + title: code, + render: (_: unknown, r: ChecklistTableRow) => mapStatus(r.osv[code] ?? 'MISSING'), + })), + }, ]; - - return result; } diff --git a/src/widgets/DocumentReview/CommentModal.tsx b/src/widgets/DocumentReview/CommentModal.tsx index 48c5af5..bfc491c 100644 --- a/src/widgets/DocumentReview/CommentModal.tsx +++ b/src/widgets/DocumentReview/CommentModal.tsx @@ -1,41 +1,50 @@ -import { Form, Input, message, Modal } from 'antd'; +import { Form, Input, Modal, message } from 'antd'; import { useState } from 'react'; +import { ApiError } from '../../api/client'; import { postComment } from '../../services/api/documents'; -export default function CommentModal({ - document_id, // () => void - open, - onClose, -}) { +type CommentModalProps = { documentId: string; open: boolean; onClose: () => void }; + +export default function CommentModal({ documentId, open, onClose }: CommentModalProps) { const [comment, setComment] = useState(''); - const [isLoading, setIsloading] = useState(false); + const [saving, setSaving] = useState(false); + const close = () => { + setComment(''); + onClose(); + }; const handleOk = async () => { - setIsloading(true); + setSaving(true); try { - const res = await postComment(document_id, comment); - } catch (e) { - message.error(e.message); + await postComment(documentId, comment.trim()); + message.success('Комментарий добавлен'); + close(); + } catch (e: unknown) { + // окно не закрываем: текст не теряется, можно повторить + message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий'); } finally { - setIsloading(false); - onClose(); + setSaving(false); } }; return ( Добавить комментарий} + title='Добавить комментарий' okText='Добавить' onOk={handleOk} - okButtonProps={{ disabled: comment.length === 0 }} + okButtonProps={{ disabled: comment.trim().length === 0 }} + confirmLoading={saving} open={open} cancelText='Отмена' - onCancel={onClose} + onCancel={close} destroyOnHidden>
Комментарий}> - setComment(e.target.value)} - placeholder='Опишите результат проверки или внесенные правки'> + autoSize={{ minRows: 3 }} + maxLength={4000} + placeholder='Опишите результат проверки или внесенные правки' + />
diff --git a/src/widgets/DocumentReview/StatementReview.tsx b/src/widgets/DocumentReview/StatementReview.tsx index 893e21a..349b107 100644 --- a/src/widgets/DocumentReview/StatementReview.tsx +++ b/src/widgets/DocumentReview/StatementReview.tsx @@ -20,6 +20,7 @@ import { switchesFor } from './focusTarget'; import { detailCount, filterLayout, formLayout } from './formLayout'; import { issueListItems, issuePlaces } from './issuePlaces'; import { applyLineMenu, showsCodeInput } from './lineMenu'; +import { countReviewValues } from './reviewCounters'; import { reviewActions } from './reviewView'; import { type DraftError, type StatementDraft, buildStatementPatch, emptyDraft, isDraftEmpty } from './statementDraft'; import { applyDraftInput, buildStatementGrid, emptySublineKeys } from './statementGrid'; @@ -68,6 +69,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut }, [load]); const grid = useMemo(() => (statement ? buildStatementGrid(statement) : null), [statement]); + // счётчики над таблицей — только пока форма на проверке + const counters = useMemo( + () => (statement && statement.document.status === 'NEEDS_CHECK' ? countReviewValues(statement) : null), + [statement], + ); // пустые строки детализации скрыты по умолчанию (только показ: PATCH и черновик работают по всем строкам формы) const emptySublines = useMemo(() => (grid ? emptySublineKeys(grid.rows, draft.amounts, draft.lines) : new Set()), [grid, draft]); const fullLayout = useMemo(() => (grid && statement ? formLayout(statement.document.document_type, grid.rows) : []), [grid, statement]); @@ -295,9 +301,9 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut )} {serverErrorOffScreen && } - {grid.changedValues !== null && ( + {counters && (

- Измененных значений: {grid.changedValues}. Значений, требующих проверки: {grid.unrecognizedValues} + Изменённых значений: {counters.changed}. Значений, требующих проверки: {counters.needsReview}

)} = {}): ValueOut => ({ + id: 'v', + column_id: 'c1', + column_index: 0, + amount: '1.00', + string_value: null, + text: null, + confidence: '100', + is_recognized: true, + is_modified: false, + original_amount: null, + needs_review: false, + is_confirmed: false, + ...extra, +}); + +const field = (extra: Partial = {}): HeaderFieldOut => ({ + value: 'x', + original_value: 'x', + verified: null, + modified: null, + ...extra, +}); + +const statement = (values: ValueOut[], header: Partial = {}) => + ({ + header: { + inn: field(), + report_date: field(), + units: field(), + organization_name_modified: false, + needs_review_fields: [], + ...header, + }, + lines: [{ id: 'l1', values }], + }) as unknown as StatementOut; + +describe('countReviewValues', () => { + it('чистая форма — нули', () => { + expect(countReviewValues(statement([v(), v()]))).toEqual({ changed: 0, needsReview: 0 }); + }); + + it('на проверку — needs_review без подтверждения и без правки (как подсветка ячейки)', () => { + const values = [ + v({ needs_review: true }), + v({ needs_review: true, is_confirmed: true }), + v({ needs_review: true, is_modified: true }), + // достроенная сервером ячейка: is_recognized = null, на проверку не считается + v({ id: null, is_recognized: null, amount: null }), + ]; + expect(countReviewValues(statement(values))).toEqual({ changed: 1, needsReview: 1 }); + }); + + it('шапка: правки полей и наименования, поля из needs_review_fields без подтверждения', () => { + const s = statement([], { + inn: field({ modified: true }), + report_date: field({ verified: true }), + organization_name_modified: true, + needs_review_fields: ['report_date', 'units'], + }); + expect(countReviewValues(s)).toEqual({ changed: 2, needsReview: 1 }); + }); +}); diff --git a/src/widgets/DocumentReview/reviewCounters.ts b/src/widgets/DocumentReview/reviewCounters.ts new file mode 100644 index 0000000..7dfb197 --- /dev/null +++ b/src/widgets/DocumentReview/reviewCounters.ts @@ -0,0 +1,31 @@ +import type { HeaderFieldOut, StatementOut } from '../../types/api'; + +const HEADER_FIELDS = ['inn', 'report_date', 'units'] as const; + +/** + * Счётчики над таблицей формы: «изменённых» — правки аналитика (ячейки, поля шапки, наименование организации); + * «требующих проверки» — то же, что подсвечено на экране: ячейка с `needs_review` и поле из `needs_review_fields` + * (решает ядро), ещё не подтверждённые и не исправленные. Достроенные сервером пустые ячейки не считаются. + */ +export function countReviewValues(statement: StatementOut): { changed: number; needsReview: number } { + let changed = 0; + let needsReview = 0; + for (const line of statement.lines) { + for (const c of line.values) { + if (c.is_modified) changed++; + else if (c.needs_review && !c.is_confirmed) needsReview++; + } + } + const header = statement.header; + for (const key of HEADER_FIELDS) { + if (header[key].modified) changed++; + } + if (header.organization_name_modified) changed++; + for (const name of header.needs_review_fields ?? []) { + const field: HeaderFieldOut | undefined = (HEADER_FIELDS as readonly string[]).includes(name) + ? header[name as (typeof HEADER_FIELDS)[number]] + : undefined; + if (!field?.modified && !field?.verified) needsReview++; + } + return { changed, needsReview }; +} diff --git a/src/widgets/DocumentReview/reviewView.test.ts b/src/widgets/DocumentReview/reviewView.test.ts index 108cae4..8ec4a94 100644 --- a/src/widgets/DocumentReview/reviewView.test.ts +++ b/src/widgets/DocumentReview/reviewView.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { columnTitle, documentPageTitle, documentSubtitle, formTitle, formatReportDate, reviewActions, statusBadge } from './reviewView'; +import { + columnTitle, + documentPageTitle, + documentSubtitle, + formTitle, + formatReportDate, + optionStatusBadge, + reviewActions, + statusBadge, +} from './reviewView'; describe('formatReportDate', () => { it('ISO → дд.мм.гггг', () => { @@ -69,3 +78,12 @@ describe('columnTitle', () => { expect(columnTitle(col(null, null))).toBe('Колонка 2'); }); }); + +describe('optionStatusBadge', () => { + it('текст значка по статусу строки документа', () => { + expect(optionStatusBadge('success').text).toBe('Загружен'); + expect(optionStatusBadge('warning').text).toBe('Требуется проверка'); + expect(optionStatusBadge('processing').text).toBe('Обработка'); + expect(optionStatusBadge('error').text).toBe('Ошибка'); + }); +}); diff --git a/src/widgets/DocumentReview/reviewView.ts b/src/widgets/DocumentReview/reviewView.ts index d499e00..9007893 100644 --- a/src/widgets/DocumentReview/reviewView.ts +++ b/src/widgets/DocumentReview/reviewView.ts @@ -1,8 +1,8 @@ -import { Preset, TAG_PRESETS } from '../../components/TableTag/presets'; +import { type Preset, TAG_PRESETS } from '../../components/TableTag/presets'; import type { ColumnOut, DocumentStatus, DocumentType, FileOut } from '../../types/api'; import { formatMoscowDate } from '../../utils/dates'; import { documentStatusLabel } from '../../utils/labels'; -import { RowStatus } from '../FilesDrawer/documentRows'; +import type { RowStatus } from '../FilesDrawer/documentRows'; /** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */ export function formatReportDate(value: string | null | undefined): string | null { @@ -34,17 +34,16 @@ export function statusBadge(status: DocumentStatus): { label: string; color: 'gr } return { label, color: status === 'ERROR' ? 'red' : 'orange' }; } +const OPTION_BADGES: Record = { + success: { text: 'Загружен', ...TAG_PRESETS.green }, + warning: { text: 'Требуется проверка', ...TAG_PRESETS.orange }, + processing: { text: 'Обработка', ...TAG_PRESETS.geekblue }, + error: { text: 'Ошибка', ...TAG_PRESETS.red }, +}; + +/** Значок статуса документа в переключателе документов на экране проверки. */ export function optionStatusBadge(status: RowStatus): { text: string } & Preset { - if (status === 'success') { - const preset = TAG_PRESETS.green; - const text = 'Загружен'; - return { text, ...preset }; - } else if (status === 'warning') { - return { text: 'Требуется проверка', ...TAG_PRESETS.orange }; - } else if (status === 'processing') { - return { text: 'Обработка', ...TAG_PRESETS.geekblue }; - } - return { text: 'Ошибка', ...TAG_PRESETS.red }; + return OPTION_BADGES[status]; } export function documentSubtitle(file: Pick): string { diff --git a/src/widgets/DocumentReview/statementGrid.ts b/src/widgets/DocumentReview/statementGrid.ts index 4c232f3..a80c53d 100644 --- a/src/widgets/DocumentReview/statementGrid.ts +++ b/src/widgets/DocumentReview/statementGrid.ts @@ -28,39 +28,7 @@ export type GridRow = { /** Ключ строки: id из БД, у достроенной строки (id = null) — код; двоеточия в ключе нет (разделитель cellKey). */ const rowKey = (id: string | null, code: string | null) => id ?? `code-${code ?? ''}`; -export function buildStatementGrid(statement: StatementOut): { - columns: ColumnOut[]; - rows: GridRow[]; - changedValues: number | null; - unrecognizedValues: number | null; -} { - let changedValues: number | null = 0; - let unrecognizedValues: number | null = 0; - if (statement.document.status === 'NEEDS_CHECK') { - for (const key of ['inn', 'report_date', 'units']) { - const val = statement.header[key]; - if (!val['verified'] && !val['modified']) { - unrecognizedValues++; - } - if (val['modified']) { - changedValues++; - } - } - for (const l of statement.lines) { - for (const c of l.values) { - if (!c.is_recognized && !c.is_modified) { - unrecognizedValues++; - } - if (c.is_modified) { - changedValues++; - } - } - } - } else { - unrecognizedValues = null; - changedValues = null; - } - +export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] } { const columns = [...statement.columns].sort((a, b) => a.column_index - b.column_index); const rows = statement.lines.map((line) => { const cells: Record = {}; @@ -86,7 +54,7 @@ export function buildStatementGrid(statement: StatementOut): { cells, }; }); - return { columns, rows, changedValues, unrecognizedValues }; + return { columns, rows }; } /** diff --git a/src/widgets/FilesDrawer/DeleteModal.tsx b/src/widgets/FilesDrawer/DeleteModal.tsx index 1d81742..c01ac60 100644 --- a/src/widgets/FilesDrawer/DeleteModal.tsx +++ b/src/widgets/FilesDrawer/DeleteModal.tsx @@ -1,10 +1,12 @@ -import React, { useCallback, useState } from 'react'; -import { Modal, Button, Typography, theme, message } from 'antd'; +import { Button, Modal, Tooltip, Typography, message, theme } from 'antd'; +import { type ReactNode, useState } from 'react'; +import { ApiError } from '../../api/client'; import { removeFile } from '../../services/api/files'; +import type { DocumentRow } from './documentRows'; const { Text } = Typography; -const OptionCard = ({ title, detail, action }) => { +const OptionCard = ({ title, detail, action }: { title: string; detail: string; action: ReactNode }) => { const { token } = theme.useToken(); return (
{ ); }; -export default function DeleteReasonModal({ - targetRow, - open, - onCancel, - onDelete, // () => void - onReplace, // () => void -}) { +type DeleteReasonModalProps = { + targetRow: DocumentRow; + open: boolean; + onCancel: () => void; + /** файл удалён — обновить список */ + onDelete: () => void; + onReplace: () => void; + /** замена = загрузка нового файла: только в версии в работе, как кнопка «Загрузить документы» */ + canReplace: boolean; +}; + +/** Удаление или замена — файлом целиком (`DELETE /files/{file_id}`): уходят все документы файла. */ +export default function DeleteReasonModal({ targetRow, open, onCancel, onDelete, onReplace, canReplace }: DeleteReasonModalProps) { const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = useCallback(async () => { + const handleDelete = async () => { setIsDeleting(true); try { - await removeFile(targetRow.key); + await removeFile(targetRow.fileId); message.success('Файл удалён'); - } catch (e) { - message.error(e.message); + onDelete(); + } catch (e: unknown) { + // 409 — например, в файле подтверждённый документ; сервер объясняет причину + message.error(e instanceof ApiError ? e.message : 'Не удалось удалить файл'); } finally { setIsDeleting(false); - onDelete(); } - }, [targetRow.key, onDelete]); + }; return (
@@ -66,9 +75,11 @@ export default function DeleteReasonModal({ title='Замена документа' detail='Загрузите актуальный файл и укажите причину замены.' action={ - + + + } />
diff --git a/src/widgets/FilesDrawer/FilesDrawer.tsx b/src/widgets/FilesDrawer/FilesDrawer.tsx index 789da12..7d18a64 100644 --- a/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,17 +1,20 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; -import { Button, Drawer, message, Switch, Tooltip, Typography } from 'antd'; +import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd'; import { useEffect, useState } from 'react'; +import { ApiError } from '../../api/client'; 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 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'; -import { DocumentRow } from './documentRows'; -import DeleteReasonModal from './DeleteModal'; -import ReplaceDocModal from './ReplaceDocModal'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -65,14 +68,27 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () setTargetRow(row); setRemovalOpen(true); }; - const handleReplaceSubmit = ({ file, comment }) => { + // Замена = загрузка нового файла в ту же консолидацию: прежний ядро переводит в SUPERSEDED после разбора, + // если совпали тип, счёт и период (05-domain-rules §9.5 п. 3). Причина — комментарием к заменяемому документу. + const handleReplaceSubmit = async (file: File, reason: string) => { + if (!current_legal_entity_id || !targetRow) return; + setReplacing(true); try { - setReplacing(true); - message.success('Документ заменен'); + const result = summarizeUpload(await uploadFiles(current_legal_entity_id, [file], current?.consolidation_id ?? null)); + if (result.accepted.length === 0) { + message.error(result.rejected[0]?.reason ?? 'Файл не принят'); + return; + } + if (targetRow.documentId) { + await postComment(targetRow.documentId, `Замена документа: ${reason}`).catch(() => + message.warning('Файл загружен, но причину замены сохранить не удалось'), + ); + } + message.success('Файл загружен: прежний будет заменён после разбора, если совпадут тип, счёт и период'); setReplaceOpen(false); - reload(); - } catch (e) { - message.error(e.message); + void reload(); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файл'); } finally { setReplacing(false); } @@ -158,10 +174,12 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () onCancel={() => setRemovalOpen(false)} onDelete={onDelete} onReplace={handleReplaceClick} + canReplace={canUpload} /> setReplaceOpen(false)} onSubmit={handleReplaceSubmit} diff --git a/src/widgets/FilesDrawer/FilesList.tsx b/src/widgets/FilesDrawer/FilesList.tsx index 26b4774..7c74d7c 100644 --- a/src/widgets/FilesDrawer/FilesList.tsx +++ b/src/widgets/FilesDrawer/FilesList.tsx @@ -1,5 +1,5 @@ import { CheckOutlined, CloseOutlined, DeleteOutlined, FileExcelOutlined, FileImageOutlined, FilePdfOutlined } from '@ant-design/icons'; -import { Button, message, Spin, Table, Tooltip, Typography } from 'antd'; +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'; @@ -140,6 +140,7 @@ export function FilesList({ render: (_, r) => ( diff --git a/src/widgets/FilesDrawer/documentRows.test.ts b/src/widgets/FilesDrawer/documentRows.test.ts index 88683a7..d99e3fc 100644 --- a/src/widgets/FilesDrawer/documentRows.test.ts +++ b/src/widgets/FilesDrawer/documentRows.test.ts @@ -145,6 +145,13 @@ describe('buildDocumentRows: предупреждения', () => { expect(rows[0].issueCounts).toEqual({ error: 0, info: 0 }); }); + it('fileId — id файла и у строки документа, и у строки файла без документов (удаление идёт по файлу)', () => { + const withDoc = buildDocumentRows([file({ id: 'f7', documents: [doc({ id: 'd7' })] })]); + expect(withDoc[0]).toMatchObject({ key: 'd7', documentId: 'd7', fileId: 'f7' }); + const failed = buildDocumentRows([file({ id: 'f8', status: 'ERROR' })]); + expect(failed[0]).toMatchObject({ key: 'f8', documentId: null, fileId: 'f8' }); + }); + it('job null — пустые списки; старый формат строк не ломает', () => { expect(buildDocumentRows([file({ documents: [doc()] })])[0]).toMatchObject({ fileWarnings: [], mappingWarnings: [] }); const legacy = buildDocumentRows([file({ job: job({ warnings: ['старое'] as never }), documents: [doc()] })]); diff --git a/src/widgets/FilesDrawer/documentRows.ts b/src/widgets/FilesDrawer/documentRows.ts index 369e959..2a62265 100644 --- a/src/widgets/FilesDrawer/documentRows.ts +++ b/src/widgets/FilesDrawer/documentRows.ts @@ -10,6 +10,8 @@ export interface DocumentRow { key: string; /** id документа; null — строка файла без документов */ documentId: string | null; + /** id файла: удаление и замена — файлом целиком */ + fileId: string; name: string; fileType: FileType; subtitle: string; @@ -20,8 +22,6 @@ export interface DocumentRow { statusLabel: string; errorReason: string | null; linkable: boolean; - consolidationId: string | null; - legalEntityId: string | null; /** предупреждения разбора файла (общие для всех документов файла) */ fileWarnings: FileWarningOut[]; mappingWarnings: string[]; @@ -66,6 +66,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s { key: f.id, documentId: null, + fileId: f.id, name, fileType: f.file_type, subtitle: join([f.file_type, date, extra]), @@ -74,8 +75,6 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s statusLabel: failed ? 'Ошибка' : 'Обработка', errorReason: errorReason(f), linkable: false, - consolidationId: f.consolidation_id, - legalEntityId: f.legal_entity_id, fileWarnings, mappingWarnings, issueCounts: { error: 0, info: 0 }, @@ -90,10 +89,8 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s const status = documentRowStatus(d.status); return { key: d.id, - consolidationId: f.consolidation_id, documentId: d.id, - file_id: f.id, - legalEntityId: f.legal_entity_id, + fileId: f.id, name, fileType: f.file_type, subtitle: join([typeText, f.file_type, date, extra]), -- 2.47.2 From ff1e45c069a3ffa8fdb6022af84e04dec67acdc7 Mon Sep 17 00:00:00 2001 From: maxim Date: Mon, 19 Oct 2026 12:56:42 +0300 Subject: [PATCH 3/3] file id as option value for react rendering --- src/layouts/DocumentPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/layouts/DocumentPage.tsx b/src/layouts/DocumentPage.tsx index 661a6d1..0b2846d 100644 --- a/src/layouts/DocumentPage.tsx +++ b/src/layouts/DocumentPage.tsx @@ -47,7 +47,7 @@ export function DocumentPage() { .then((files) => setDocumentOptions( buildDocumentRows(files).map((row) => ({ - value: row.documentId, + value: row.documentId ?? row.fileId, label: row.name, disabled: !row.linkable, row, -- 2.47.2