From 7891430fce5ca53086314096950a8f6e198a0477 Mon Sep 17 00:00:00 2001 From: Grog Date: Mon, 19 Oct 2026 17:39:34 +0300 Subject: [PATCH 01/21] added chess table consolidation --- web/src/App.tsx | 4 +- web/src/components/TableTag/presets.tsx | 6 +- web/src/hooks/useVirtualList.tsx | 71 +++++----- web/src/index.tsx | 6 - web/src/layouts/ConsolidatioReportPage.tsx | 69 +++++++++ web/src/layouts/DocumentPage.tsx | 66 +-------- web/src/layouts/index.ts | 2 +- web/src/services/api/consolidations.ts | 3 +- .../services/api/directories/catalogue_jur.ts | 2 +- web/src/services/api/documents.ts | 3 - web/src/services/api/files.ts | 6 +- web/src/services/consodilation/chess.ts | 4 + web/src/types/chess/chess.ts | 17 +++ .../types/consolidations/version_members.ts | 18 --- .../CatalogueJurForm/CatalogueJurForm.tsx | 2 +- .../CatalogueJurTable/CatalogueJurTable.tsx | 2 +- .../CompanyGroupsList/CompanyGroupsList.tsx | 10 +- .../ConsoleLegalEntities/ChecklistModal.tsx | 49 ------- .../ConsoleLegalEntities.tsx | 131 ++++++++---------- .../checklist_utils.test.ts | 39 ------ .../ConsoleLegalEntities/checklist_utils.tsx | 127 ----------------- .../ConsolidatedChessContent.css | 6 + .../ConsolidatedChessContent.tsx | 119 ++++++++++++++++ web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 85 +++++++++--- .../ConsolidatedOsvContent.tsx} | 92 ++++-------- .../widgets/DocumentReview/CommentModal.tsx | 52 ------- .../DocumentReview/StatementReview.tsx | 11 -- .../DocumentReview/reviewCounters.test.ts | 67 --------- .../widgets/DocumentReview/reviewCounters.ts | 31 ----- .../widgets/DocumentReview/reviewView.test.ts | 20 +-- web/src/widgets/DocumentReview/reviewView.ts | 13 -- web/src/widgets/FilesDrawer/DeleteModal.tsx | 88 ------------ web/src/widgets/FilesDrawer/FilesDrawer.tsx | 90 +----------- web/src/widgets/FilesDrawer/FilesList.tsx | 59 +------- .../widgets/FilesDrawer/ReplaceDocModal.tsx | 77 ---------- .../widgets/FilesDrawer/documentRows.test.ts | 7 - web/src/widgets/FilesDrawer/documentRows.ts | 4 - 37 files changed, 436 insertions(+), 1022 deletions(-) create mode 100644 web/src/layouts/ConsolidatioReportPage.tsx create mode 100644 web/src/services/consodilation/chess.ts create mode 100644 web/src/types/chess/chess.ts delete mode 100644 web/src/widgets/ConsoleLegalEntities/ChecklistModal.tsx delete mode 100644 web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts delete mode 100644 web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx create mode 100644 web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css create mode 100644 web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx rename web/src/{layouts/ConsolidatedOsvPage.tsx => widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx} (50%) delete mode 100644 web/src/widgets/DocumentReview/CommentModal.tsx delete mode 100644 web/src/widgets/DocumentReview/reviewCounters.test.ts delete mode 100644 web/src/widgets/DocumentReview/reviewCounters.ts delete mode 100644 web/src/widgets/FilesDrawer/DeleteModal.tsx delete mode 100644 web/src/widgets/FilesDrawer/ReplaceDocModal.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 24faf7a..044ea1e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,6 @@ import { AuditLogging, CatalogueGroup, CatalogueJur, - ConsolidatedOsvPage, ConsolidationPage, DocStorage, DocumentPage, @@ -20,6 +19,7 @@ import { } from './layouts/'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; +import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage'; // Каркас: экраны по docs/context/10-user-journey.md добавляются срезами. function App() { @@ -37,7 +37,7 @@ function App() { } /> } /> - } /> + } /> } /> } /> diff --git a/web/src/components/TableTag/presets.tsx b/web/src/components/TableTag/presets.tsx index 1846879..3647e0f 100644 --- a/web/src/components/TableTag/presets.tsx +++ b/web/src/components/TableTag/presets.tsx @@ -40,8 +40,8 @@ export const TAG_PRESETS = { color: 'rgb(146, 64, 14)', }, gray: { - color: 'rgb(71, 84, 103)', - bgColor: 'rgb(248, 250, 252)', - borderColor: 'rgba(15, 23, 42, 0.08)', + color: 'rgb(71, 84, 103)', + bgColor: 'rgb(248, 250, 252)', + borderColor: 'rgba(15, 23, 42, 0.08)', }, } as const; diff --git a/web/src/hooks/useVirtualList.tsx b/web/src/hooks/useVirtualList.tsx index d019b08..fdd04b9 100644 --- a/web/src/hooks/useVirtualList.tsx +++ b/web/src/hooks/useVirtualList.tsx @@ -1,46 +1,51 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from "react"; type Options = { - /** высота одного элемента в px (фиксированная) */ - itemHeight: number; - /** высота контейнера в px (видимая область) */ - containerHeight: number; - /** сколько элементов рендерить дополнительно сверху/снизу */ - overscan?: number; + /** высота одного элемента в px (фиксированная) */ + itemHeight: number; + /** высота контейнера в px (видимая область) */ + containerHeight: number; + /** сколько элементов рендерить дополнительно сверху/снизу */ + overscan?: number; }; export function useVirtualList(items: T[], options: Options) { - const { itemHeight, containerHeight, overscan = 5 } = options; + const { itemHeight, containerHeight, overscan = 5 } = options; - const [scrollTop, setScrollTop] = useState(0); - const ref = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const ref = useRef(null); - const totalHeight = items.length * itemHeight; + const totalHeight = items.length * itemHeight; - const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const startIndex = Math.max( + 0, + Math.floor(scrollTop / itemHeight) - overscan, + ); - const endIndex = Math.min(items.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan); + const endIndex = Math.min( + items.length, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan, + ); - const visibleItems = useMemo( - () => - items.slice(startIndex, endIndex).map((item, i) => ({ - item, - index: startIndex + i, - })), - [items, startIndex, endIndex], - ); + const visibleItems = useMemo( + () => items.slice(startIndex, endIndex).map((item, i) => ({ + item, + index: startIndex + i, + })), + [items, startIndex, endIndex], + ); - const offsetY = startIndex * itemHeight; + const offsetY = startIndex * itemHeight; - const onScroll = useCallback((e: React.UIEvent) => { - setScrollTop(e.currentTarget.scrollTop); - }, []); + const onScroll = useCallback((e: React.UIEvent) => { + setScrollTop(e.currentTarget.scrollTop); + }, []); - return { - ref, - onScroll, - visibleItems, - totalHeight, - offsetY, - }; -} + return { + ref, + onScroll, + visibleItems, + totalHeight, + offsetY, + }; +} \ No newline at end of file diff --git a/web/src/index.tsx b/web/src/index.tsx index 83b74ef..207a715 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -29,9 +29,7 @@ createRoot(rootElement).render( components: { Layout: { siderBg: '#fff', - fontSize: 13, }, - Button: { defaultBorderColor: '#c7cdd5', defaultHoverBg: '#f8fafc', @@ -48,10 +46,6 @@ createRoot(rootElement).render( headerColor: '#667085', headerBg: 'rgb(248, 250, 252)', }, - Form: { - labelFontSize: 12, - labelColor: '#667085', - }, }, }}> diff --git a/web/src/layouts/ConsolidatioReportPage.tsx b/web/src/layouts/ConsolidatioReportPage.tsx new file mode 100644 index 0000000..06179ae --- /dev/null +++ b/web/src/layouts/ConsolidatioReportPage.tsx @@ -0,0 +1,69 @@ +import { Space, message } from 'antd'; +import { ReactNode, useEffect, useState } from 'react'; +import { 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 { getVersionCard } from '../services/api/consolidations'; +import type { ConsRussianStatus } from '../types/consolidations/common_types'; +import type { VersionCard } from '../types/consolidations/version_members'; +import { quarterShortLabel } from '../utils/labels'; +import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; +import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; +import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; +import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent'; + +export function ConsolidatioReportPage() { + const [card, setCard] = useState(null); + const { version_id, tab } = useParams<{ tab?: string; version_id: string }>(); + + const TAB_COMPONENTS: Record = { + osv: , + + chess: , + }; + + const ActiveTab = TAB_COMPONENTS[tab as string] ?? <>; + + const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; + const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; + const groupName = card?.company_group.name ?? ''; + const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; + + useEffect(() => { + if (version_id) { + let actual = true; + getVersionCard(version_id) + .then((c) => { + if (actual) setCard(c); + }) + .catch((e: unknown) => { + if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию'); + }); + return () => { + actual = false; + }; + } + }, [version_id]); + return ( +
+ + Статус отчёта + + + ) + } + /> + + {ActiveTab} +
+ ); +} diff --git a/web/src/layouts/DocumentPage.tsx b/web/src/layouts/DocumentPage.tsx index 0b2846d..676f51e 100644 --- a/web/src/layouts/DocumentPage.tsx +++ b/web/src/layouts/DocumentPage.tsx @@ -1,24 +1,17 @@ -import { CommentOutlined } from '@ant-design/icons'; -import { Button, Empty, Form, Result, Select, Spin, Tooltip } from 'antd'; +import { Button, Empty, Result, Spin, Tag } from 'antd'; import { useCallback, useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; -import { TableTag } from '../components/TableTag/TableTag'; -import { TAG_PRESETS } from '../components/TableTag/presets'; import { fetchLegalEntity } from '../services/api/directories/company_groups'; import { getDocument } from '../services/api/documents'; -import { getFile, listLegalEntityFiles } from '../services/api/files'; +import { getFile } from '../services/api/files'; import type { DocumentOut } from '../types/api'; import { 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 { documentPageTitle, documentSubtitle, statusBadge } from '../widgets/DocumentReview/reviewView'; import { documentViewKind } from '../widgets/DocumentReview/viewKind'; -import { type DocumentRow, buildDocumentRows } from '../widgets/FilesDrawer/documentRows'; - -type DocumentOption = { value: string | null; label: string; disabled: boolean; row: DocumentRow }; export function DocumentPage() { const { document_id } = useParams(); @@ -27,8 +20,6 @@ export function DocumentPage() { const [entityName, setEntityName] = useState(''); const [subtitle, setSubtitle] = useState(null); const [error, setError] = useState(null); - const [documentOptions, setDocumentOptions] = useState([]); - const [commentOpen, setCommentOpen] = useState(false); const load = useCallback(async () => { if (!document_id) return; @@ -40,22 +31,7 @@ export function DocumentPage() { .then((le) => setEntityName(le.name)) .catch(() => setEntityName('')); getFile(d.file_id) - .then((f) => { - setSubtitle(documentSubtitle(f)); - // переключатель документа — файлы того же ЮЛ в той же консолидации, что и файл документа (без неё — все файлы ЮЛ) - listLegalEntityFiles(d.legal_entity_id, f.consolidation_id) - .then((files) => - setDocumentOptions( - buildDocumentRows(files).map((row) => ({ - value: row.documentId ?? row.fileId, - label: row.name, - disabled: !row.linkable, - row, - })), - ), - ) - .catch(() => setDocumentOptions([])); - }) + .then((f) => setSubtitle(documentSubtitle(f))) .catch(() => setSubtitle(null)); } catch (e: unknown) { setError(e instanceof ApiError ? e.message : 'Не удалось открыть документ'); @@ -87,38 +63,11 @@ export function DocumentPage() { `${documentTypeLabel(doc.document_type, doc.osv_account_code)} · ${periodLabel(doc.period_year, doc.period_quarter, doc.is_cumulative)}` } extra={ -
-
- - - style={{ width: '100%' }} - value={doc.id} - options={documentOptions} - onChange={(value) => navigate(`/documents/${value}`)} - optionRender={(option) => ( -
- {/* у ББ и ОФР из одного PDF имя файла одно — различаются второй строкой (тип · формат · дата) */} - -
-
{option.data.label}
-
{option.data.row.subtitle}
-
-
- -
- )} - /> -
-
- -
+ + {badge.label} + } /> -
- -
} {kind === 'none' && }
- setCommentOpen(false)} /> ); } diff --git a/web/src/layouts/index.ts b/web/src/layouts/index.ts index b006199..90015f7 100644 --- a/web/src/layouts/index.ts +++ b/web/src/layouts/index.ts @@ -9,4 +9,4 @@ export { GroupsLayout } from './Groups'; export { AuditLogging } from './AuditLogging'; export { Admin } from './Admin'; export { DocumentPage } from './DocumentPage'; -export { ConsolidatedOsvPage } from './ConsolidatedOsvPage'; +export { ConsolidatioReportPage } from './ConsolidatioReportPage'; diff --git a/web/src/services/api/consolidations.ts b/web/src/services/api/consolidations.ts index 28e1220..e794227 100644 --- a/web/src/services/api/consolidations.ts +++ b/web/src/services/api/consolidations.ts @@ -2,12 +2,11 @@ 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 { ChecklistBackendShape, VersionCard } from '../../types/consolidations/version_members'; +import type { 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`); /** «Взять в работу»: 201 — взята, 200 — уже ваша; чужая — 409 `LOCKED_BY_OTHER` (перехват — с `takeover_reason`). */ export const lockVersion = (versionId: string) => api.post(`/consolidations/versions/${versionId}/lock`); diff --git a/web/src/services/api/directories/catalogue_jur.ts b/web/src/services/api/directories/catalogue_jur.ts index 9b1d30a..c84de06 100644 --- a/web/src/services/api/directories/catalogue_jur.ts +++ b/web/src/services/api/directories/catalogue_jur.ts @@ -10,7 +10,7 @@ import type { import { LE_PAGE } from './company_groups'; export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) { - const res = await api.get>('/directories/legal-entities', { limit: LE_PAGE, offset: 0, ...params }); + const res = await api.get>('/directories/legal-entities', {limit:LE_PAGE, offset:0, ...params}); return res; } diff --git a/web/src/services/api/documents.ts b/web/src/services/api/documents.ts index 64bd52d..fa4547e 100644 --- a/web/src/services/api/documents.ts +++ b/web/src/services/api/documents.ts @@ -21,6 +21,3 @@ export async function getAllEntries(id: string): Promise { } } } - -/** Комментарий к документу: текст 1–4000 символов после обрезки пробелов (иначе 422). */ -export const postComment = (documentId: string, text: string) => api.post(`/documents/${documentId}/comments`, { text }); diff --git a/web/src/services/api/files.ts b/web/src/services/api/files.ts index 9b419b0..921f864 100644 --- a/web/src/services/api/files.ts +++ b/web/src/services/api/files.ts @@ -1,4 +1,4 @@ -import { ApiError, api, apiFetch, apiFetchBlob } from '../../api/client'; +import { ApiError, apiFetch, apiFetchBlob } from '../../api/client'; import type { FileOut, Page, UploadResult } from '../../types/api'; export function isUploadResult(body: unknown): body is UploadResult { @@ -56,7 +56,3 @@ 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/web/src/services/consodilation/chess.ts b/web/src/services/consodilation/chess.ts new file mode 100644 index 0000000..7e61aa4 --- /dev/null +++ b/web/src/services/consodilation/chess.ts @@ -0,0 +1,4 @@ +import { api } from "../../api/client"; +import { IntercompanyMatrixResponse } from "../../types/chess/chess"; + +export const fetchChessConsolidations = (id: string) => api.get(`/consolidations/versions/${id}/intercompany-matrix/all`); diff --git a/web/src/types/chess/chess.ts b/web/src/types/chess/chess.ts new file mode 100644 index 0000000..0524ce7 --- /dev/null +++ b/web/src/types/chess/chess.ts @@ -0,0 +1,17 @@ +export interface MatrixCompany { + id: string; + name: string; +} + +export interface MatrixCell { + company_a_id: string; + company_b_id: string; + amount_a: string; + amount_b: string; + variance_amount: string; +} + +export interface IntercompanyMatrixResponse { + companies: MatrixCompany[]; + cells: MatrixCell[]; +} \ No newline at end of file diff --git a/web/src/types/consolidations/version_members.ts b/web/src/types/consolidations/version_members.ts index a8212ac..1834baf 100644 --- a/web/src/types/consolidations/version_members.ts +++ b/web/src/types/consolidations/version_members.ts @@ -35,24 +35,6 @@ 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 | null; - is_mandatory: boolean; -}; - -export type ChecklistBackendShape = { - rows: ChecklistRow[]; - rules: ChecklistRule[]; -}; - type VersionMember = { id: string; name: string; diff --git a/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx b/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx index bd865e0..ccf1364 100644 --- a/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx +++ b/web/src/widgets/CatalogueJurForm/CatalogueJurForm.tsx @@ -54,7 +54,7 @@ export const CatalogueJurForm: React.FC = ({ open, onCancel, onOk, jurId } catch (e) { if (e instanceof ApiError) { notification.error({ description: e.message }); - } + } } finally { setInitialDataLoading(false); } diff --git a/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx b/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx index 9311884..edb5b12 100644 --- a/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx +++ b/web/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx @@ -243,7 +243,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur - {relatedGroupFilter?.id && !outside_group && ( + {(relatedGroupFilter?.id && !outside_group) && (
void; - consolidationData: IVersionCard; - onClose: () => void; -}; - -export default function ChecklistModal({ open, onDownload, consolidationData, onClose }: ChecklistProps) { - const [checklistData, setChecklistData] = useState(null); - - const rows = useMemo(() => (checklistData ? buildChecklistRows(checklistData) : []), [checklistData]); - const columns = useMemo(() => (checklistData ? buildChecklistColumns(checklistData) : []), [checklistData]); - - useEffect(() => { - if (!consolidationData.id || !open) return; - getChecklist(consolidationData.id) - .then(setChecklistData) - .catch((e: unknown) => message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить чек-лист')); - }, [open, consolidationData.id]); - - return ( - { - onDownload(); - onClose(); - }} - okText='Скачать чек-лист' - okButtonProps={{ icon: }}> -
- {getMarksItems()} - - - - ); -} diff --git a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index 44aa8fa..d21cf2f 100644 --- a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -10,7 +10,6 @@ import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/c 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'; @@ -19,7 +18,6 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe 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); @@ -49,10 +47,6 @@ 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 @@ -66,78 +60,61 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe }); return ( - <> -
- -
-

+ +
+

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

+ {rawRows.length} шт. + {consolidationData && ( + // «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7 + + Ответственный: {responsibleName(consolidationData)}   Утверждён в РФ: —   Верифицирован в ГО: — + + )} +
+
+ {hasActiveFilters && ( +

- {rawRows.length} шт. - {consolidationData && ( - // «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7 - - Ответственный: {responsibleName(consolidationData)}   Утверждён в РФ: —   Верифицирован в ГО: — - - )} -
-
- {hasActiveFilters && ( - - )} - - -
-
- -
- {consolidationData && ( - { - setChecklistOpen(false); - }} - onDownload={downloadChecklist} - consolidationData={consolidationData} - /> - )} - + )} + + + + + + ); } diff --git a/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts b/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts deleted file mode 100644 index fc67fd6..0000000 --- a/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -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/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx deleted file mode 100644 index 606d72b..0000000 --- a/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { - CheckCircleOutlined, - ClockCircleOutlined, - EllipsisOutlined, - ExclamationCircleOutlined, - EyeOutlined, - MinusCircleOutlined, -} from '@ant-design/icons'; -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 type ChecklistTableRow = { - key: string; - name: string; - inn: string; - balance: FileStatus; - ofr: FileStatus; - osv: Record; -}; - -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) as [FileStatus, string][]).map(([status, label]) => ( -
- {mapStatus(status)} {label} -
- ))} -
- ); -} - -/** Строки таблицы из ответа `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' && cell.account && !osvCodes.includes(cell.account)) { - osvCodes.push(cell.account); - } - } - } - return [ - { - key: 'name', - title: 'ОРГАНИЗАЦИЯ', - render: (_, r) => ( -
- {r.name}: {r.inn} -
- ), - }, - { 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'), - })), - }, - ]; -} diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css new file mode 100644 index 0000000..d9c6dc3 --- /dev/null +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css @@ -0,0 +1,6 @@ +.empty-cell { + background-color: #f2f4f7; +} +.ant-table-tbody > tr:hover > td.empty-cell { + background-color: #f2f4f7 !important; +} \ No newline at end of file diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx new file mode 100644 index 0000000..768a4ac --- /dev/null +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useParams } from 'react-router-dom'; +import { ApiError } from '../../api/client'; +import { fetchChessConsolidations } from '../../services/consodilation/chess'; +import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess'; +import { useNotificationApi } from '../../utils/useNotification'; + +import './ConsolidatedChessContent.css'; + +const { Text } = Typography; + +export const ChessContent = () => { + const { version_id } = useParams<{ version_id: string }>(); + const notification = useNotificationApi(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const loadData = async (vId: string) => { + setLoading(true); + try { + const res = await fetchChessConsolidations(vId); + setData(res); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!version_id) return; + loadData(version_id); + }, [version_id]); + + const matrixData = useMemo(() => { + if (!data?.cells) return {}; + const result: Record> = {}; + + data.cells.forEach((cell) => { + if (!result[cell.company_a_id]) result[cell.company_a_id] = {}; + result[cell.company_a_id][cell.company_b_id] = cell; + }); + + return result; + }, [data?.cells]); + + const columns: ColumnsType = useMemo(() => { + if (!data?.companies) return []; + + const firstColumn: ColumnsType[number] = { + title: 'ДБ / КТ', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 200, + render: (text) => {text}, + onCell: () => ({ style: { background: '#fff', zIndex: 2 } }), + }; + + // Внутри companyColumns + const companyColumns = data.companies.map((company) => ({ + title: company.name, + dataIndex: company.id, + key: company.id, + width: 150, + align: 'right' as const, + + onCell: (row: MatrixCompany) => { + const cell = matrixData[row.id]?.[company.id]; + if (!cell) { + // Возвращаем класс через onCell, это работает надежнее + return { className: 'empty-cell' }; + } + return {}; + }, + + render: (_: unknown, row: MatrixCompany) => { + const cell = matrixData[row.id]?.[company.id]; + + if (!cell) { + return —; + } + + return ( +
+ {cell.amount_a} + {cell.amount_b} + + {cell.variance_amount} + +
+ ); + }, + })); + + return [firstColumn, ...companyColumns]; + }, [data?.companies, matrixData]); + + return ( + + dataSource={data?.companies || []} + columns={columns} + rowKey='id' + pagination={false} + loading={loading} + scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }} + size='small' + bordered + /> + ); +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 9597458..7e0e9f4 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -1,26 +1,79 @@ import { Segmented, Tooltip } from 'antd'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; -const SOON = 'Будет в следующих срезах'; -const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента']; +const TAB_CONFIG = [ + { key: 'osv', label: 'ОСВ' }, + { key: 'chess', label: 'Шахматка' }, + { + key: 'correction', + label: 'Таблица корректировки', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'goodwill', + label: 'Гудвилл', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'kfo', + label: 'КФО', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'kfo_common', + label: 'КФО Общий', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'kfo_confirm', + label: 'Подтверждение КФО клиента', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, +]; -/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */ export function OsvTabs() { + const navigate = useNavigate(); + const location = useLocation(); + const { tab } = useParams<{ tab?: string }>(); + + const activeKey = tab || 'osv'; + + const handleTabChange = (newKey: string | number) => { + const newPath = location.pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`); + navigate(newPath); + }; + + const options = TAB_CONFIG.map((tabItem) => { + const isActive = tabItem.key === activeKey; + + const label = ( + + {tabItem.label} + + ); + + return { + value: tabItem.key, + disabled: tabItem.disabled, + label: tabItem.tooltip ? {label} : label, + }; + }); + return ( ОСВ }, - ...TABS.map((t) => ({ - value: t, - disabled: true, - label: ( - - {t} - - ), - })), - ]} + options={options} /> ); } diff --git a/web/src/layouts/ConsolidatedOsvPage.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx similarity index 50% rename from web/src/layouts/ConsolidatedOsvPage.tsx rename to web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index f9dcd0e..dca5fec 100644 --- a/web/src/layouts/ConsolidatedOsvPage.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -1,51 +1,33 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Space, message } from 'antd'; -import { useEffect, useState } from 'react'; +import { Alert, Button, message } from 'antd'; +import { useState } from 'react'; import { 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 { getVersionCard } from '../services/api/consolidations'; -import { exportOsv } from '../services/api/osv'; -import type { ConsolidatedRowOut } from '../types/api'; -import type { ConsRussianStatus } from '../types/consolidations/common_types'; -import type { VersionCard } from '../types/consolidations/version_members'; -import { exportFilename, saveBlob } from '../utils/download'; -import { quarterShortLabel } from '../utils/labels'; -import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; -import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal'; -import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; -import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; -import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; -import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping'; -import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; +import { ApiError } from '../../api/client'; +import { exportOsv } from '../../services/api/osv'; +import type { ConsolidatedRowOut } from '../../types/api'; +import type { VersionCard } from '../../types/consolidations/version_members'; +import { exportFilename, saveBlob } from '../../utils/download'; +import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; +import { OsvGaps } from '../ConsolidatedOsv/OsvGaps'; +import { OsvTable } from '../ConsolidatedOsv/OsvTable'; +import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping'; +import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv'; -function OsvContent({ versionId }: { versionId: string }) { - const [card, setCard] = useState(null); +export function OsvContent({ card }: { card: VersionCard | null }) { const [exporting, setExporting] = useState(false); + const { version_id } = useParams<{ version_id: string }>(); + // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) const [mapping, setMapping] = useState(null); - const osv = useConsolidatedOsv(versionId); - - useEffect(() => { - let actual = true; - getVersionCard(versionId) - .then((c) => { - if (actual) setCard(c); - }) - .catch((e: unknown) => { - if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию'); - }); - return () => { - actual = false; - }; - }, [versionId]); + const osv = useConsolidatedOsv(version_id ?? ''); const download = async () => { + if (!version_id) { + return; + } setExporting(true); try { - const { blob, filename } = await exportOsv(versionId, osv.filters); + const { blob, filename } = await exportOsv(version_id, osv.filters); saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); } catch (e: unknown) { message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод'); @@ -57,27 +39,9 @@ function OsvContent({ versionId }: { versionId: string }) { const s = osv.summary; const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; - const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; - const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; - const groupName = card?.company_group.name ?? ''; - const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; + return ( -
- - Статус отчёта - - - ) - } - /> - + <>
- {mapping && ( + {mapping && version_id && ( setMapping(null)} onMapped={osv.refresh} /> )} -
+ ); } - -export function ConsolidatedOsvPage() { - const { version_id = '' } = useParams(); - // key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново - return ; -} diff --git a/web/src/widgets/DocumentReview/CommentModal.tsx b/web/src/widgets/DocumentReview/CommentModal.tsx deleted file mode 100644 index bfc491c..0000000 --- a/web/src/widgets/DocumentReview/CommentModal.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Form, Input, Modal, message } from 'antd'; -import { useState } from 'react'; -import { ApiError } from '../../api/client'; -import { postComment } from '../../services/api/documents'; - -type CommentModalProps = { documentId: string; open: boolean; onClose: () => void }; - -export default function CommentModal({ documentId, open, onClose }: CommentModalProps) { - const [comment, setComment] = useState(''); - const [saving, setSaving] = useState(false); - const close = () => { - setComment(''); - onClose(); - }; - const handleOk = async () => { - setSaving(true); - try { - await postComment(documentId, comment.trim()); - message.success('Комментарий добавлен'); - close(); - } catch (e: unknown) { - // окно не закрываем: текст не теряется, можно повторить - message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий'); - } finally { - setSaving(false); - } - }; - return ( - -
- Комментарий}> - setComment(e.target.value)} - autoSize={{ minRows: 3 }} - maxLength={4000} - placeholder='Опишите результат проверки или внесенные правки' - /> - - -
- ); -} diff --git a/web/src/widgets/DocumentReview/StatementReview.tsx b/web/src/widgets/DocumentReview/StatementReview.tsx index 349b107..5ac0ffd 100644 --- a/web/src/widgets/DocumentReview/StatementReview.tsx +++ b/web/src/widgets/DocumentReview/StatementReview.tsx @@ -20,7 +20,6 @@ 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'; @@ -69,11 +68,6 @@ 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]); @@ -301,11 +295,6 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut )} {serverErrorOffScreen && } - {counters && ( -

- Изменённых значений: {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/web/src/widgets/DocumentReview/reviewCounters.ts b/web/src/widgets/DocumentReview/reviewCounters.ts deleted file mode 100644 index 7dfb197..0000000 --- a/web/src/widgets/DocumentReview/reviewCounters.ts +++ /dev/null @@ -1,31 +0,0 @@ -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/web/src/widgets/DocumentReview/reviewView.test.ts b/web/src/widgets/DocumentReview/reviewView.test.ts index 8ec4a94..108cae4 100644 --- a/web/src/widgets/DocumentReview/reviewView.test.ts +++ b/web/src/widgets/DocumentReview/reviewView.test.ts @@ -1,14 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { - columnTitle, - documentPageTitle, - documentSubtitle, - formTitle, - formatReportDate, - optionStatusBadge, - reviewActions, - statusBadge, -} from './reviewView'; +import { columnTitle, documentPageTitle, documentSubtitle, formTitle, formatReportDate, reviewActions, statusBadge } from './reviewView'; describe('formatReportDate', () => { it('ISO → дд.мм.гггг', () => { @@ -78,12 +69,3 @@ 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/web/src/widgets/DocumentReview/reviewView.ts b/web/src/widgets/DocumentReview/reviewView.ts index 9007893..5ad4efa 100644 --- a/web/src/widgets/DocumentReview/reviewView.ts +++ b/web/src/widgets/DocumentReview/reviewView.ts @@ -1,8 +1,6 @@ -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 type { RowStatus } from '../FilesDrawer/documentRows'; /** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */ export function formatReportDate(value: string | null | undefined): string | null { @@ -34,17 +32,6 @@ 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 { - return OPTION_BADGES[status]; -} export function documentSubtitle(file: Pick): string { return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · '); diff --git a/web/src/widgets/FilesDrawer/DeleteModal.tsx b/web/src/widgets/FilesDrawer/DeleteModal.tsx deleted file mode 100644 index c01ac60..0000000 --- a/web/src/widgets/FilesDrawer/DeleteModal.tsx +++ /dev/null @@ -1,88 +0,0 @@ -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 }: { title: string; detail: string; action: ReactNode }) => { - const { token } = theme.useToken(); - return ( -
-
- {title} - {detail} -
- {action} -
- ); -}; - -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 = async () => { - setIsDeleting(true); - try { - await removeFile(targetRow.fileId); - message.success('Файл удалён'); - onDelete(); - } catch (e: unknown) { - // 409 — например, в файле подтверждённый документ; сервер объясняет причину - message.error(e instanceof ApiError ? e.message : 'Не удалось удалить файл'); - } finally { - setIsDeleting(false); - } - }; - return ( - -
-

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

-

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

- - - Удалить - - } - /> - - - - - } - /> -
-
- ); -} diff --git a/web/src/widgets/FilesDrawer/FilesDrawer.tsx b/web/src/widgets/FilesDrawer/FilesDrawer.tsx index 7d18a64..3026379 100644 --- a/web/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/web/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,19 +1,13 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; -import { Button, Drawer, Switch, Tooltip, Typography, message } from 'antd'; +import { Button, Drawer, Switch, Tooltip, Typography } 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'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -28,21 +22,7 @@ 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); @@ -64,36 +44,6 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () }; }, [open, current_legal_entity_id]); - const handleRowDelete = (row: DocumentRow) => { - setTargetRow(row); - setRemovalOpen(true); - }; - // Замена = загрузка нового файла в ту же консолидацию: прежний ядро переводит в SUPERSEDED после разбора, - // если совпали тип, счёт и период (05-domain-rules §9.5 п. 3). Причина — комментарием к заменяемому документу. - const handleReplaceSubmit = async (file: File, reason: string) => { - if (!current_legal_entity_id || !targetRow) return; - setReplacing(true); - try { - 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); - void reload(); - } catch (e: unknown) { - message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файл'); - } finally { - setReplacing(false); - } - }; - const uploadButton = ( - - - - ); -} diff --git a/web/src/widgets/FilesDrawer/documentRows.test.ts b/web/src/widgets/FilesDrawer/documentRows.test.ts index d99e3fc..88683a7 100644 --- a/web/src/widgets/FilesDrawer/documentRows.test.ts +++ b/web/src/widgets/FilesDrawer/documentRows.test.ts @@ -145,13 +145,6 @@ 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/web/src/widgets/FilesDrawer/documentRows.ts b/web/src/widgets/FilesDrawer/documentRows.ts index 2a62265..d5873ee 100644 --- a/web/src/widgets/FilesDrawer/documentRows.ts +++ b/web/src/widgets/FilesDrawer/documentRows.ts @@ -10,8 +10,6 @@ export interface DocumentRow { key: string; /** id документа; null — строка файла без документов */ documentId: string | null; - /** id файла: удаление и замена — файлом целиком */ - fileId: string; name: string; fileType: FileType; subtitle: string; @@ -66,7 +64,6 @@ 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]), @@ -90,7 +87,6 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s return { key: d.id, documentId: d.id, - fileId: f.id, name, fileType: f.file_type, subtitle: join([typeText, f.file_type, date, extra]), -- 2.47.2 From 9bd8a2a015eef56e1b7ea23551b28775f42f5e60 Mon Sep 17 00:00:00 2001 From: Grog Date: Wed, 21 Oct 2026 10:51:13 +0300 Subject: [PATCH 02/21] added chess table --- web/src/App.css | 6 + web/src/api/client.ts | 12 +- .../TreeFilterDropdown/TreeFilterDropdown.css | 3 + .../TreeFilterDropdown/TreeFilterDropdown.tsx | 149 +++++++++++ web/src/services/consodilation/chess.ts | 22 +- web/src/types/chess/chess.ts | 11 +- .../ConsolidatedChessContent.css | 6 - .../ConsolidatedChessContent.tsx | 219 +++++++++------ .../ConsolidatedChessTable.css | 19 ++ .../ConsolidationChessTable.tsx | 183 +++++++++++++ .../ConsolidationChessTableFilter.tsx | 253 ++++++++++++++++++ 11 files changed, 786 insertions(+), 97 deletions(-) create mode 100644 web/src/components/TreeFilterDropdown/TreeFilterDropdown.css create mode 100644 web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx delete mode 100644 web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css create mode 100644 web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css create mode 100644 web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx create mode 100644 web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx diff --git a/web/src/App.css b/web/src/App.css index e69de29..8dd8ac0 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -0,0 +1,6 @@ +.ant-select-selection-item{ + background-color: #ecfdf3 !important; + color: #1f5f35 !important; + box-shadow: inset 0 0 0 1px #2ea64b38 !important; + border-radius: 999px !important; +} \ No newline at end of file diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e6522d2..5966bd4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -127,10 +127,16 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise } /** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */ -export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> { - const response = await send(path, {}); +export async function apiFetchBlob( + path: string, + init: RequestInit = {} // Добавили init +): Promise<{ blob: Blob; filename: string | null }> { + const response = await send(path, init); const blob = await response.blob(); - return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) }; + return { + blob, + filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) + }; } function withJsonBody(method: string, body: unknown): RequestInit { diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css new file mode 100644 index 0000000..b71b9a1 --- /dev/null +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css @@ -0,0 +1,3 @@ +.ant-tree-switcher-noop{ + display: none !important; +} \ No newline at end of file diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx new file mode 100644 index 0000000..7bdf118 --- /dev/null +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx @@ -0,0 +1,149 @@ +import { useState, useMemo, useEffect } from 'react'; +import { Input, Button, Tree, Space } from 'antd'; +import { SearchOutlined } from '@ant-design/icons'; +import './TreeFilterDropdown.css'; + +export type OptionType = { + title: string; + key: string; + code?:string; + children?: OptionType[]; +}; + +interface TreeFilterDropdownProps { + title: string; + placeholder?: string; + treeData: OptionType[]; + selectedKeys: string[]; + onChange: (keys: string[]) => void; + onClose: () => void; +} + +export const TreeFilterDropdown: React.FC = ({ + title, + placeholder = 'Введите значение', + treeData, + selectedKeys, + onChange, + onClose, +}) => { + const [query, setQuery] = useState(''); + + const [checkedKeys, setCheckedKeys] = useState(selectedKeys); + const [expandedKeys, setExpandedKeys] = useState([]); + + useEffect(() => { + setCheckedKeys(selectedKeys); + }, [selectedKeys]); + + const filteredTreeData = useMemo(() => { + if (!query) return treeData; + + const filterNode = (node: OptionType): OptionType | null => { + const titleStr = String(node.title).toLowerCase(); + const isMatch = titleStr.includes(query.toLowerCase()); + + let filteredChildren: OptionType[] = []; + if (node.children) { + filteredChildren = node.children.map(filterNode).filter((child): child is OptionType => child !== null); + } + + if (isMatch || filteredChildren.length > 0) { + return { ...node, children: filteredChildren.length > 0 ? filteredChildren : undefined }; + } + return null; + }; + + return treeData.map(filterNode).filter((node): node is OptionType => node !== null); + }, [treeData, query]); + + useEffect(() => { + if (query) { + const getAllKeys = (nodes: OptionType[]): string[] => { + let keys: string[] = []; + nodes.forEach((node) => { + keys.push(node.key); + if (node.children) keys = [...keys, ...getAllKeys(node.children)]; + }); + return keys; + }; + setExpandedKeys(getAllKeys(filteredTreeData)); + } + }, [query, filteredTreeData]); + + const getAllNodeKeys = (nodes: OptionType[]): string[] => { + let keys: string[] = []; + nodes.forEach((node) => { + keys.push(node.key); + if (node.children) keys = [...keys, ...getAllNodeKeys(node.children)]; + }); + return keys; + }; + + const handleSelectAll = () => { + setCheckedKeys(getAllNodeKeys(treeData)); + }; + + const handleClear = () => { + setCheckedKeys([]); + }; + + const handleCancel = () => { + setCheckedKeys(selectedKeys); + onClose(); + }; + + return ( +
+
{title}
+ +
Поиск
+ } + placeholder={placeholder} + value={query} + onChange={(e) => setQuery(e.target.value)} + style={{ marginBottom: 12 }} + /> + + + + + + +
+ { + const checked = Array.isArray(keys) ? keys : keys.checked; + setCheckedKeys(checked as string[]); + }} + expandedKeys={expandedKeys} + onExpand={(keys) => setExpandedKeys(keys as string[])} + /> +
+ +
+ + +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/services/consodilation/chess.ts b/web/src/services/consodilation/chess.ts index 7e61aa4..6e5513d 100644 --- a/web/src/services/consodilation/chess.ts +++ b/web/src/services/consodilation/chess.ts @@ -1,4 +1,20 @@ -import { api } from "../../api/client"; -import { IntercompanyMatrixResponse } from "../../types/chess/chess"; +import { api, apiFetchBlob } from '../../api/client'; +import { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess'; -export const fetchChessConsolidations = (id: string) => api.get(`/consolidations/versions/${id}/intercompany-matrix/all`); +export const fetchChessConsolidations = (id: string) => + api.get(`/consolidations/versions/${id}/intercompany-matrix/all`); + +export const fetchChessConsolidationsManual = (versionId: string, data: ChessManualRequest): Promise => { + return api.post(`/consolidations/versions/${versionId}/intercompany-matrix/manual`, data); +}; + +export const exportChessTableAll = (versionId: string) => + apiFetchBlob(`/consolidations/versions/${versionId}/intercompany-matrix/all/export`); +export const exportChessTableManual = (versionId: string, data: ChessManualRequest) => + apiFetchBlob(`/consolidations/versions/${versionId}/intercompany-matrix/manual/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); diff --git a/web/src/types/chess/chess.ts b/web/src/types/chess/chess.ts index 0524ce7..c7efcf5 100644 --- a/web/src/types/chess/chess.ts +++ b/web/src/types/chess/chess.ts @@ -9,9 +9,18 @@ export interface MatrixCell { amount_a: string; amount_b: string; variance_amount: string; + exceeds_threshold:boolean + one_sided:boolean + variance_percent:string| null + skipped_osv_entry_count:number } export interface IntercompanyMatrixResponse { companies: MatrixCompany[]; cells: MatrixCell[]; -} \ No newline at end of file +} + +export type ChessManualRequest = { + debit_account_ids: string[]; + credit_account_ids: string[]; +}; diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css deleted file mode 100644 index d9c6dc3..0000000 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.css +++ /dev/null @@ -1,6 +0,0 @@ -.empty-cell { - background-color: #f2f4f7; -} -.ant-table-tbody > tr:hover > td.empty-cell { - background-color: #f2f4f7 !important; -} \ No newline at end of file diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx index 768a4ac..7058f11 100644 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -1,15 +1,22 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Table, Typography } from 'antd'; -import type { ColumnsType } from 'antd/es/table'; +import { useEffect, useMemo, useState, useRef } from 'react'; import { useParams } from 'react-router-dom'; +import { Button } from 'antd'; +import { FullscreenOutlined, FullscreenExitOutlined, DownloadOutlined } from '@ant-design/icons'; import { ApiError } from '../../api/client'; -import { fetchChessConsolidations } from '../../services/consodilation/chess'; -import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess'; +import { + exportChessTableAll, + exportChessTableManual, + fetchChessConsolidations, + fetchChessConsolidationsManual, +} from '../../services/consodilation/chess'; +import type { IntercompanyMatrixResponse } from '../../types/chess/chess'; import { useNotificationApi } from '../../utils/useNotification'; +import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; +import { ConsolidationChessTableFilter } from '../ConsolidationChessTable/ConsolidationChessTableFilter'; +import { saveBlob } from '../../utils/download'; + -import './ConsolidatedChessContent.css'; -const { Text } = Typography; export const ChessContent = () => { const { version_id } = useParams<{ version_id: string }>(); @@ -17,103 +24,147 @@ export const ChessContent = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); + const [expoerLoading, setExportLoading] = useState(false); + const [companies, setCompanies] = useState([]); + const [decimals, setDecimals] = useState(2); + const [units, setUnits] = useState('thousand'); + const [debit, setDebit] = useState([]); + const [credit, setCredit] = useState([]); - const loadData = async (vId: string) => { + const containerRef = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + + const toggleFullscreen = async () => { + if (!containerRef.current) return; + try { + if (!document.fullscreenElement) { + await containerRef.current.requestFullscreen(); + } else { + await document.exitFullscreen(); + } + } catch (e) { + console.log(e); + } + }; + + const loadData = async (vId: string, dIds: string[], cIds: string[]) => { setLoading(true); try { - const res = await fetchChessConsolidations(vId); + let res; + if (dIds?.length && cIds?.length) { + res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds }); + } else { + res = await fetchChessConsolidations(vId); + } setData(res); } catch (e) { if (e instanceof ApiError) { notification.error({ description: e.message }); - } + } } finally { setLoading(false); } }; + const download = async () => { + if (!version_id) { + return; + } + setExportLoading(true); + try { + if (debit?.length && credit?.length) { + const { blob, filename } = await exportChessTableManual(version_id, { debit_account_ids: debit, credit_account_ids: credit }); + saveBlob(blob, filename ?? 'Шахматка'); + } else { + const { blob, filename } = await exportChessTableAll(version_id); + saveBlob(blob, filename ?? 'Шахматка'); + } + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setExportLoading(false); + } + }; + useEffect(() => { if (!version_id) return; - loadData(version_id); - }, [version_id]); + loadData(version_id, debit, credit); + }, [version_id, debit, credit]); - const matrixData = useMemo(() => { - if (!data?.cells) return {}; - const result: Record> = {}; - - data.cells.forEach((cell) => { - if (!result[cell.company_a_id]) result[cell.company_a_id] = {}; - result[cell.company_a_id][cell.company_b_id] = cell; - }); - - return result; - }, [data?.cells]); - - const columns: ColumnsType = useMemo(() => { - if (!data?.companies) return []; - - const firstColumn: ColumnsType[number] = { - title: 'ДБ / КТ', - dataIndex: 'name', - key: 'name', - fixed: 'left', - width: 200, - render: (text) => {text}, - onCell: () => ({ style: { background: '#fff', zIndex: 2 } }), + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); }; + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, []); - // Внутри companyColumns - const companyColumns = data.companies.map((company) => ({ - title: company.name, - dataIndex: company.id, - key: company.id, - width: 150, - align: 'right' as const, + const filteredData = useMemo(() => { + if (!data) return null; + if (companies.length === 0) return data; - onCell: (row: MatrixCompany) => { - const cell = matrixData[row.id]?.[company.id]; - if (!cell) { - // Возвращаем класс через onCell, это работает надежнее - return { className: 'empty-cell' }; - } - return {}; - }, + const filteredCompanies = data.companies.filter((c) => companies.includes(c.id)); + const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id)); - render: (_: unknown, row: MatrixCompany) => { - const cell = matrixData[row.id]?.[company.id]; - - if (!cell) { - return —; - } - - return ( -
- {cell.amount_a} - {cell.amount_b} - - {cell.variance_amount} - -
- ); - }, - })); - - return [firstColumn, ...companyColumns]; - }, [data?.companies, matrixData]); + return { companies: filteredCompanies, cells: filteredCells }; + }, [data, companies]); return ( - - dataSource={data?.companies || []} - columns={columns} - rowKey='id' - pagination={false} - loading={loading} - scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }} - size='small' - bordered - /> +
+ +
+ +
+ + +
+ } + + /> + ); }; diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css b/web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css new file mode 100644 index 0000000..b462e36 --- /dev/null +++ b/web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css @@ -0,0 +1,19 @@ +.empty-cell { + background-color: #f2f4f7; +} +.ant-table-tbody > tr:hover > td.empty-cell { + background-color: #f2f4f7 !important; +} + +.ant-table-summary{ + border-end-end-radius: 8px !important; + border-end-start-radius: 8px !important; +} + + +.ant-table-cell-fix { + background-color: #f8fafc !important; +} +.resRow{ + background-color:#e6f4ea !important ; +} diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx new file mode 100644 index 0000000..c41e4d2 --- /dev/null +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -0,0 +1,183 @@ +import React, { useMemo } from 'react'; +import { Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess'; +import './ConsolidatedChessTable.css'; + +const { Text } = Typography; + +interface ConsolidationChessTableI { + data: IntercompanyMatrixResponse | null; + loading: boolean; + decimals: number; + units: string; + toolbar?: React.ReactNode; + isFullscreen?: boolean; +} + +type TableRow = MatrixCompany & { isTotal?: boolean }; + +export const ConsolidationChessTable: React.FC = ({ + data, + loading, + decimals, + units, + toolbar, + isFullscreen = false, +}) => { + const matrixData = useMemo(() => { + if (!data?.cells) return {}; + const result: Record> = {}; + data.cells.forEach((cell) => { + if (!result[cell.company_a_id]) result[cell.company_a_id] = {}; + result[cell.company_a_id][cell.company_b_id] = cell; + }); + return result; + }, [data?.cells]); + + const formatValue = (val: string | number) => { + if (val === undefined || val === null || val === '') return ''; + let num = typeof val === 'string' ? parseFloat(val) : val; + if (isNaN(num)) return String(val); + + if (units === 'thousand') { + num /= 1000; + } else if (units === 'million') { + num /= 1000000; + } else if (units === 'billion') { + num /= 1_000_000_000; + } + + return num.toFixed(decimals).replace('.', ','); + }; + + const parseAmount = (val: string | null | undefined): number => { + if (!val) return 0; + + const isNegative: boolean = val.startsWith('(') && val.endsWith(')'); + const cleanVal = isNegative ? val.slice(1, -1) : val; + + const num = parseFloat(cleanVal); + + if (isNaN(num)) { + return 0; + } + + return isNegative ? -num : num; + }; + + const columns: ColumnsType = useMemo(() => { + if (!data?.companies) return []; + + const firstColumn: ColumnsType[number] = { + title: 'ДБ / КТ', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 200, + render: (text, row) => {text}, + onCell: (row) => ({ + style: { + background: row.isTotal ? '#e6f4ea' : '#fff', + zIndex: 2, + }, + }), + }; + + const companyColumns = data.companies.map((company) => ({ + title: company.name, + dataIndex: company.id, + key: company.id, + width: 150, + align: 'right' as const, + onCell: (row: TableRow) => { + if (row.isTotal) return { style: { background: '#e6f4ea' } }; + + const cell = matrixData[row.id]?.[company.id]; + if (!cell) return { className: 'empty-cell' }; + return {}; + }, + render: (_: unknown, row: TableRow) => { + if (row.isTotal) { + const totalVal = (row as any)[company.id]; + return {formatValue(totalVal)}; + } + const cell = matrixData[row.id]?.[company.id]; + if (!cell) return —; + return ( +
+ {formatValue(cell.amount_a)} + {formatValue(cell.amount_b)} + {formatValue(cell.variance_amount)} +
+ ); + }, + })); + + return [firstColumn, ...companyColumns]; + }, [data?.companies, matrixData, decimals, units]); + + const totalRowData = useMemo(() => { + if (!data?.companies) return null; + + const totalRow: Record = { + id: 'total-row', + name: 'Итого', + isTotal: true, + }; + + data.companies.forEach((colCompany) => { + let colTotal = 0; + + data.companies.forEach((rowCompany) => { + const cell = matrixData[rowCompany.id]?.[colCompany.id]; + if (cell?.variance_amount) { + colTotal += parseAmount(cell.variance_amount); + } + }); + + totalRow[colCompany.id] = colTotal; + }); + + return totalRow as TableRow; + }, [data?.companies, matrixData]); + + const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436; + + return ( + + dataSource={data?.companies || []} + columns={columns} + rowKey='id' + pagination={false} + loading={loading} + scroll={{ x: 'max-content', y: scrollY }} + size='middle' + bordered + title={() => toolbar} + summary={() => { + if (!totalRowData) return null; + + return ( + + + +
+ Итого +
+
+ + {data?.companies.map((company, index) => ( + +
+ {formatValue((totalRowData as any)[company.id])} +
+
+ ))} +
+
+ ); + }} + /> + ); +}; \ No newline at end of file diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx new file mode 100644 index 0000000..1775e11 --- /dev/null +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx @@ -0,0 +1,253 @@ +import React, { useEffect, useState } from 'react'; +import { Space, Select, Button } from 'antd'; +import type { MatrixCompany } from '../../types/chess/chess'; +import { OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown'; +import { api, ApiError } from '../../api/client'; +import { useNotificationApi } from '../../utils/useNotification'; + +interface Account { + id: string; + code: string; + parent_id: string | null; + name: string; + kind: string; + row_type: string; + is_off_balance: boolean; + is_debit: boolean; + is_credit: boolean; +} + +interface ConsolidationChessTableFilterI { + companies: string[]; + onCompaniesChange: (val: string[]) => void; + decimals: number; + onDecimalsChange: (val: number) => void; + debit: string[]; + onDebitChange: (val: string[]) => void; + credit: string[]; + onCreditChange: (val: string[]) => void; + allCompanies: MatrixCompany[]; + units: string; + onUnitsChange: (val: string) => void; +} + +const buildTree = (items: Account[]): OptionType[] => { + const map: Record = {}; + const roots: OptionType[] = []; + + items.forEach((item) => { + map[item.id] = { + title: `${item.code} - ${item.name}`, + key: item.id, + code: item.code, + children: [], + }; + }); + + items.forEach((item) => { + if (item.parent_id && map[item.parent_id]) { + map[item.parent_id].children.push(map[item.id]); + } else { + roots.push(map[item.id]); + } + }); + + const cleanUp = (nodes: OptionType[]): OptionType[] => { + return nodes.map((node) => { + if (node.children && node.children.length > 0) { + return { ...node, children: cleanUp(node.children) }; + } + return { title: node.title, key: node.key, code: node.code }; + }); + }; + + return cleanUp(roots); +}; + +export const ConsolidationChessTableFilter: React.FC = ({ + companies, + onCompaniesChange, + decimals, + onDecimalsChange, + debit, + onDebitChange, + credit, + onCreditChange, + allCompanies, + units, + onUnitsChange, +}) => { + const [companyOpen, setCompanyOpen] = useState(false); + const [debitOpen, setDebitOpen] = useState(false); + const [creditOpen, setCreditOpen] = useState(false); + + const [optionsLoading, setOptionsLoading] = useState(false); + const [debitTreeData, setDebitTreeData] = useState([]); + const [creditTreeData, setCreditTreeData] = useState([]); + + const notification = useNotificationApi(); + + const companyOptions = allCompanies.map((c) => ({ label: c.name, value: c.id })); + const companyTreeData = allCompanies.map((c) => ({ title: c.name, key: c.id })); + + const UNIT_OPTIONS = [ + { value: 'thousand', label: 'тыс. руб.' }, + { value: 'million', label: 'млн. руб.' }, + { value: 'billion', label: 'млрд руб.' }, + ]; + const DECIMALS_OPTIONS = [ + { value: 0, label: '0' }, + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + ]; + + async function getOptionHandler() { + setOptionsLoading(true); + try { + const res = await api.get('/directories/accounts'); + + const debitAccounts = res.filter((acc) => acc.is_debit); + const creditAccounts = res.filter((acc) => acc.is_credit); + + setDebitTreeData(buildTree(debitAccounts)); + setCreditTreeData(buildTree(creditAccounts)); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } else { + notification.error({ description: 'Не удалось загрузить счета' }); + } + } finally { + setOptionsLoading(false); + } + } + + function dropFiltersHandler() { + onCompaniesChange([]); + onDebitChange([]); + onCreditChange([]); + } + + useEffect(() => { + getOptionHandler(); + }, []); + + return ( +
+ + + Год + + + + Компании + ({ label: node.code, value: node.key }))} + popupRender={() => ( + setDebitOpen(false)} + /> + )} + loading={optionsLoading} + /> + + + + Кт + + + + + Знаков после запятой +
+ + + ); +} diff --git a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx index d21cf2f..44aa8fa 100644 --- a/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx +++ b/web/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -10,6 +10,7 @@ import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/c 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'; @@ -18,6 +19,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe 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 +49,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 @@ -60,61 +66,78 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen, reloadKey }: { onDrawe }); return ( -
- -
-

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

- {rawRows.length} шт. - {consolidationData && ( - // «Утверждён в РФ» / «Верифицирован в ГО» — маршрут согласования, Срез 7 - - Ответственный: {responsibleName(consolidationData)}   Утверждён в РФ: —   Верифицирован в ГО: — - - )} -
-
- {hasActiveFilters && ( - + )} + + - )} - - -
-
- -
+ + + + + {consolidationData && ( + { + setChecklistOpen(false); + }} + onDownload={downloadChecklist} + consolidationData={consolidationData} + /> + )} + ); } diff --git a/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts b/web/src/widgets/ConsoleLegalEntities/checklist_utils.test.ts new file mode 100644 index 0000000..fc67fd6 --- /dev/null +++ b/web/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/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx new file mode 100644 index 0000000..606d72b --- /dev/null +++ b/web/src/widgets/ConsoleLegalEntities/checklist_utils.tsx @@ -0,0 +1,127 @@ +import { + CheckCircleOutlined, + ClockCircleOutlined, + EllipsisOutlined, + ExclamationCircleOutlined, + EyeOutlined, + MinusCircleOutlined, +} from '@ant-design/icons'; +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 type ChecklistTableRow = { + key: string; + name: string; + inn: string; + balance: FileStatus; + ofr: FileStatus; + osv: Record; +}; + +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) as [FileStatus, string][]).map(([status, label]) => ( +
+ {mapStatus(status)} {label} +
+ ))} +
+ ); +} + +/** Строки таблицы из ответа `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' && cell.account && !osvCodes.includes(cell.account)) { + osvCodes.push(cell.account); + } + } + } + return [ + { + key: 'name', + title: 'ОРГАНИЗАЦИЯ', + render: (_, r) => ( +
+ {r.name}: {r.inn} +
+ ), + }, + { 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'), + })), + }, + ]; +} diff --git a/web/src/widgets/DocumentReview/CommentModal.tsx b/web/src/widgets/DocumentReview/CommentModal.tsx new file mode 100644 index 0000000..bfc491c --- /dev/null +++ b/web/src/widgets/DocumentReview/CommentModal.tsx @@ -0,0 +1,52 @@ +import { Form, Input, Modal, message } from 'antd'; +import { useState } from 'react'; +import { ApiError } from '../../api/client'; +import { postComment } from '../../services/api/documents'; + +type CommentModalProps = { documentId: string; open: boolean; onClose: () => void }; + +export default function CommentModal({ documentId, open, onClose }: CommentModalProps) { + const [comment, setComment] = useState(''); + const [saving, setSaving] = useState(false); + const close = () => { + setComment(''); + onClose(); + }; + const handleOk = async () => { + setSaving(true); + try { + await postComment(documentId, comment.trim()); + message.success('Комментарий добавлен'); + close(); + } catch (e: unknown) { + // окно не закрываем: текст не теряется, можно повторить + message.error(e instanceof ApiError ? e.message : 'Не удалось добавить комментарий'); + } finally { + setSaving(false); + } + }; + return ( + +
+ Комментарий}> + setComment(e.target.value)} + autoSize={{ minRows: 3 }} + maxLength={4000} + placeholder='Опишите результат проверки или внесенные правки' + /> + + +
+ ); +} diff --git a/web/src/widgets/DocumentReview/StatementReview.tsx b/web/src/widgets/DocumentReview/StatementReview.tsx index 5ac0ffd..349b107 100644 --- a/web/src/widgets/DocumentReview/StatementReview.tsx +++ b/web/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,6 +301,11 @@ export function StatementReview({ document, onChanged }: { document: DocumentOut )} {serverErrorOffScreen && } + {counters && ( +

+ Изменённых значений: {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/web/src/widgets/DocumentReview/reviewCounters.ts b/web/src/widgets/DocumentReview/reviewCounters.ts new file mode 100644 index 0000000..7dfb197 --- /dev/null +++ b/web/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/web/src/widgets/DocumentReview/reviewView.test.ts b/web/src/widgets/DocumentReview/reviewView.test.ts index 108cae4..8ec4a94 100644 --- a/web/src/widgets/DocumentReview/reviewView.test.ts +++ b/web/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/web/src/widgets/DocumentReview/reviewView.ts b/web/src/widgets/DocumentReview/reviewView.ts index 5ad4efa..9007893 100644 --- a/web/src/widgets/DocumentReview/reviewView.ts +++ b/web/src/widgets/DocumentReview/reviewView.ts @@ -1,6 +1,8 @@ +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 type { RowStatus } from '../FilesDrawer/documentRows'; /** '2025-09-30' (или ISO с временем) → '30.09.2025'; пусто → null; нераспознанное — как есть. */ export function formatReportDate(value: string | null | undefined): string | null { @@ -32,6 +34,17 @@ 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 { + return OPTION_BADGES[status]; +} export function documentSubtitle(file: Pick): string { return [file.original_name, file.file_type, formatMoscowDate(file.uploaded_at)].filter((p) => p).join(' · '); diff --git a/web/src/widgets/FilesDrawer/DeleteModal.tsx b/web/src/widgets/FilesDrawer/DeleteModal.tsx new file mode 100644 index 0000000..c01ac60 --- /dev/null +++ b/web/src/widgets/FilesDrawer/DeleteModal.tsx @@ -0,0 +1,88 @@ +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 }: { title: string; detail: string; action: ReactNode }) => { + const { token } = theme.useToken(); + return ( +
+
+ {title} + {detail} +
+ {action} +
+ ); +}; + +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 = async () => { + setIsDeleting(true); + try { + await removeFile(targetRow.fileId); + message.success('Файл удалён'); + onDelete(); + } catch (e: unknown) { + // 409 — например, в файле подтверждённый документ; сервер объясняет причину + message.error(e instanceof ApiError ? e.message : 'Не удалось удалить файл'); + } finally { + setIsDeleting(false); + } + }; + return ( + +
+

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

+

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

+ + + Удалить + + } + /> + + + + + } + /> +
+
+ ); +} diff --git a/web/src/widgets/FilesDrawer/FilesDrawer.tsx b/web/src/widgets/FilesDrawer/FilesDrawer.tsx index 3026379..7d18a64 100644 --- a/web/src/widgets/FilesDrawer/FilesDrawer.tsx +++ b/web/src/widgets/FilesDrawer/FilesDrawer.tsx @@ -1,13 +1,19 @@ import { CloseOutlined, UploadOutlined } from '@ant-design/icons'; -import { Button, Drawer, 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'; const UPLOAD_DISABLED_HINT = 'Загрузка доступна только в версии в работе'; @@ -22,7 +28,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 +64,36 @@ export function DocumentsDrawer({ open, onClose }: { open: boolean; onClose: () }; }, [open, current_legal_entity_id]); + const handleRowDelete = (row: DocumentRow) => { + setTargetRow(row); + setRemovalOpen(true); + }; + // Замена = загрузка нового файла в ту же консолидацию: прежний ядро переводит в SUPERSEDED после разбора, + // если совпали тип, счёт и период (05-domain-rules §9.5 п. 3). Причина — комментарием к заменяемому документу. + const handleReplaceSubmit = async (file: File, reason: string) => { + if (!current_legal_entity_id || !targetRow) return; + setReplacing(true); + try { + 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); + void reload(); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить файл'); + } finally { + setReplacing(false); + } + }; + const uploadButton = ( + + + + ); +} diff --git a/web/src/widgets/FilesDrawer/documentRows.test.ts b/web/src/widgets/FilesDrawer/documentRows.test.ts index 88683a7..d99e3fc 100644 --- a/web/src/widgets/FilesDrawer/documentRows.test.ts +++ b/web/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/web/src/widgets/FilesDrawer/documentRows.ts b/web/src/widgets/FilesDrawer/documentRows.ts index d5873ee..2a62265 100644 --- a/web/src/widgets/FilesDrawer/documentRows.ts +++ b/web/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; @@ -64,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]), @@ -87,6 +90,7 @@ export function buildDocumentRows(files: FileOut[], labelFor?: (f: FileOut) => s return { key: d.id, documentId: d.id, + fileId: f.id, name, fileType: f.file_type, subtitle: join([typeText, f.file_type, date, extra]), -- 2.47.2 From e19291c886dc39996021801ad18d7ab51c2d0390 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 21 Oct 2026 14:05:23 +0400 Subject: [PATCH 04/21] =?UTF-8?q?chore(web):=20format=20=D0=B8=20biome=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=88=D0=B0=D1=85=D0=BC=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm run format (eslint --fix, biome check --write, prettier --write) по файлам шахматки; - biome: import type и сортировка импортов; явный тип IntercompanyMatrixResponse у `let res` (ConsolidatedChessContent); Number.isNaN вместо isNaN (ConsolidationChessTable); key у span в OsvTabs; `?? null` вместо пустого фрагмента (ConsolidatioReportPage). Поведение не менялось. Остаются предупреждения eslint в коде шахматки (any, deps хуков, console) — за автором. Co-Authored-By: Claude Sonnet 5.5 --- web/src/App.css | 12 ++++----- web/src/App.tsx | 4 +-- web/src/api/client.ts | 10 +++---- .../TreeFilterDropdown/TreeFilterDropdown.css | 6 ++--- .../TreeFilterDropdown/TreeFilterDropdown.tsx | 24 +++++++---------- web/src/layouts/ConsolidatioReportPage.tsx | 8 +++--- web/src/services/consodilation/chess.ts | 2 +- web/src/types/chess/chess.ts | 26 +++++++++---------- .../ConsolidatedChessContent.tsx | 14 ++++------ web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 1 + .../ConsolidatedChessTable.css | 17 ++++++------ .../ConsolidationChessTable.tsx | 17 ++++++------ .../ConsolidationChessTableFilter.tsx | 9 ++++--- 13 files changed, 71 insertions(+), 79 deletions(-) diff --git a/web/src/App.css b/web/src/App.css index 8dd8ac0..d703b0d 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -1,6 +1,6 @@ -.ant-select-selection-item{ - background-color: #ecfdf3 !important; - color: #1f5f35 !important; - box-shadow: inset 0 0 0 1px #2ea64b38 !important; - border-radius: 999px !important; -} \ No newline at end of file +.ant-select-selection-item { + background-color: #ecfdf3 !important; + color: #1f5f35 !important; + box-shadow: inset 0 0 0 1px #2ea64b38 !important; + border-radius: 999px !important; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 044ea1e..3d972ee 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,9 +17,9 @@ import { Main, Mono, } from './layouts/'; +import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; -import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage'; // Каркас: экраны по docs/context/10-user-journey.md добавляются срезами. function App() { @@ -37,7 +37,7 @@ function App() { } /> } /> - } /> + } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5966bd4..b916f1f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -129,13 +129,13 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise /** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */ export async function apiFetchBlob( path: string, - init: RequestInit = {} // Добавили init + init: RequestInit = {}, // Добавили init ): Promise<{ blob: Blob; filename: string | null }> { - const response = await send(path, init); + const response = await send(path, init); const blob = await response.blob(); - return { - blob, - filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) + return { + blob, + filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')), }; } diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css index b71b9a1..8f0c3e4 100644 --- a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css @@ -1,3 +1,3 @@ -.ant-tree-switcher-noop{ - display: none !important; -} \ No newline at end of file +.ant-tree-switcher-noop { + display: none !important; +} diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx index 7bdf118..b33a829 100644 --- a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx @@ -1,12 +1,12 @@ -import { useState, useMemo, useEffect } from 'react'; -import { Input, Button, Tree, Space } from 'antd'; import { SearchOutlined } from '@ant-design/icons'; +import { Button, Input, Space, Tree } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; import './TreeFilterDropdown.css'; export type OptionType = { title: string; key: string; - code?:string; + code?: string; children?: OptionType[]; }; @@ -31,7 +31,7 @@ export const TreeFilterDropdown: React.FC = ({ const [checkedKeys, setCheckedKeys] = useState(selectedKeys); const [expandedKeys, setExpandedKeys] = useState([]); - + useEffect(() => { setCheckedKeys(selectedKeys); }, [selectedKeys]); @@ -94,7 +94,7 @@ export const TreeFilterDropdown: React.FC = ({ }; return ( -
+
{title}
Поиск
@@ -107,12 +107,8 @@ export const TreeFilterDropdown: React.FC = ({ /> - - + +
@@ -131,9 +127,7 @@ export const TreeFilterDropdown: React.FC = ({
- +
); -}; \ No newline at end of file +}; diff --git a/web/src/layouts/ConsolidatioReportPage.tsx b/web/src/layouts/ConsolidatioReportPage.tsx index f3bb56f..19693f6 100644 --- a/web/src/layouts/ConsolidatioReportPage.tsx +++ b/web/src/layouts/ConsolidatioReportPage.tsx @@ -1,5 +1,5 @@ import { Space, message } from 'antd'; -import { ReactNode, useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; @@ -10,9 +10,9 @@ import type { ConsRussianStatus } from '../types/consolidations/common_types'; import type { VersionCard } from '../types/consolidations/version_members'; import { quarterShortLabel } from '../utils/labels'; import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; +import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; -import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent'; function ReportContent() { const [card, setCard] = useState(null); @@ -21,10 +21,10 @@ function ReportContent() { const TAB_COMPONENTS: Record = { osv: , - chess: , + chess: , }; - const ActiveTab = TAB_COMPONENTS[tab as string] ?? <>; + const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; diff --git a/web/src/services/consodilation/chess.ts b/web/src/services/consodilation/chess.ts index 6e5513d..5ecf609 100644 --- a/web/src/services/consodilation/chess.ts +++ b/web/src/services/consodilation/chess.ts @@ -1,5 +1,5 @@ import { api, apiFetchBlob } from '../../api/client'; -import { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess'; +import type { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess'; export const fetchChessConsolidations = (id: string) => api.get(`/consolidations/versions/${id}/intercompany-matrix/all`); diff --git a/web/src/types/chess/chess.ts b/web/src/types/chess/chess.ts index c7efcf5..67e4d15 100644 --- a/web/src/types/chess/chess.ts +++ b/web/src/types/chess/chess.ts @@ -1,23 +1,23 @@ export interface MatrixCompany { - id: string; - name: string; + id: string; + name: string; } export interface MatrixCell { - company_a_id: string; - company_b_id: string; - amount_a: string; - amount_b: string; - variance_amount: string; - exceeds_threshold:boolean - one_sided:boolean - variance_percent:string| null - skipped_osv_entry_count:number + company_a_id: string; + company_b_id: string; + amount_a: string; + amount_b: string; + variance_amount: string; + exceeds_threshold: boolean; + one_sided: boolean; + variance_percent: string | null; + skipped_osv_entry_count: number; } export interface IntercompanyMatrixResponse { - companies: MatrixCompany[]; - cells: MatrixCell[]; + companies: MatrixCompany[]; + cells: MatrixCell[]; } export type ChessManualRequest = { diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx index 7058f11..fc076a4 100644 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState, useRef } from 'react'; -import { useParams } from 'react-router-dom'; +import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; import { Button } from 'antd'; -import { FullscreenOutlined, FullscreenExitOutlined, DownloadOutlined } from '@ant-design/icons'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; import { exportChessTableAll, @@ -10,13 +10,10 @@ import { fetchChessConsolidationsManual, } from '../../services/consodilation/chess'; import type { IntercompanyMatrixResponse } from '../../types/chess/chess'; +import { saveBlob } from '../../utils/download'; import { useNotificationApi } from '../../utils/useNotification'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTable/ConsolidationChessTableFilter'; -import { saveBlob } from '../../utils/download'; - - - export const ChessContent = () => { const { version_id } = useParams<{ version_id: string }>(); @@ -50,7 +47,7 @@ export const ChessContent = () => { const loadData = async (vId: string, dIds: string[], cIds: string[]) => { setLoading(true); try { - let res; + let res: IntercompanyMatrixResponse; if (dIds?.length && cIds?.length) { res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds }); } else { @@ -163,7 +160,6 @@ export const ChessContent = () => {
} - /> ); diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 7e0e9f4..dc03aa3 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -53,6 +53,7 @@ export function OsvTabs() { const label = ( tr:hover > td.empty-cell { - background-color: #f2f4f7 !important; + background-color: #f2f4f7 !important; } -.ant-table-summary{ - border-end-end-radius: 8px !important; - border-end-start-radius: 8px !important; +.ant-table-summary { + border-end-end-radius: 8px !important; + border-end-start-radius: 8px !important; } - .ant-table-cell-fix { - background-color: #f8fafc !important; + background-color: #f8fafc !important; } -.resRow{ - background-color:#e6f4ea !important ; +.resRow { + background-color: #e6f4ea !important ; } diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx index c41e4d2..d380f64 100644 --- a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -1,6 +1,7 @@ -import React, { useMemo } from 'react'; import { Table, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import type React from 'react'; +import { useMemo } from 'react'; import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess'; import './ConsolidatedChessTable.css'; @@ -37,8 +38,8 @@ export const ConsolidationChessTable: React.FC = ({ const formatValue = (val: string | number) => { if (val === undefined || val === null || val === '') return ''; - let num = typeof val === 'string' ? parseFloat(val) : val; - if (isNaN(num)) return String(val); + let num = typeof val === 'string' ? Number.parseFloat(val) : val; + if (Number.isNaN(num)) return String(val); if (units === 'thousand') { num /= 1000; @@ -57,9 +58,9 @@ export const ConsolidationChessTable: React.FC = ({ const isNegative: boolean = val.startsWith('(') && val.endsWith(')'); const cleanVal = isNegative ? val.slice(1, -1) : val; - const num = parseFloat(cleanVal); + const num = Number.parseFloat(cleanVal); - if (isNaN(num)) { + if (Number.isNaN(num)) { return 0; } @@ -146,7 +147,7 @@ export const ConsolidationChessTable: React.FC = ({ return ( - dataSource={data?.companies || []} + dataSource={data?.companies || []} columns={columns} rowKey='id' pagination={false} @@ -159,7 +160,7 @@ export const ConsolidationChessTable: React.FC = ({ if (!totalRowData) return null; return ( - +
@@ -180,4 +181,4 @@ export const ConsolidationChessTable: React.FC = ({ }} /> ); -}; \ No newline at end of file +}; diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx index 1775e11..2a0f3af 100644 --- a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useState } from 'react'; -import { Space, Select, Button } from 'antd'; +import { Button, Select, Space } from 'antd'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { ApiError, api } from '../../api/client'; +import { type OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown'; import type { MatrixCompany } from '../../types/chess/chess'; -import { OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown'; -import { api, ApiError } from '../../api/client'; import { useNotificationApi } from '../../utils/useNotification'; interface Account { -- 2.47.2 From 91fdc6c0f27004fa8d60d599049e7bf58dd31127 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 21 Oct 2026 14:06:40 +0400 Subject: [PATCH 05/21] =?UTF-8?q?docs(openspec):=20change=20intercompany-m?= =?UTF-8?q?atrix-ui=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20=D1=88?= =?UTF-8?q?=D0=B0=D1=85=D0=BC=D0=B0=D1=82=D0=BA=D0=B8=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposal, design (Known issues — открытые дефекты ветки за автором; Decisions made without discussion), tasks и спека intercompany-matrix-ui: вкладки по маршруту :tab, таблица ЮЛ x ЮЛ, фильтры компаний и счетов Дт/Кт, режимы all/manual, единицы и знаки, экспорт XLSX, полноэкранный режим. Описано фактическое поведение ветки; дефекты в требования не вошли. Co-Authored-By: Claude Sonnet 5.5 --- .../changes/intercompany-matrix-ui/design.md | 67 +++++++++++++ .../intercompany-matrix-ui/proposal.md | 45 +++++++++ .../specs/intercompany-matrix-ui/spec.md | 97 +++++++++++++++++++ .../changes/intercompany-matrix-ui/tasks.md | 6 ++ 4 files changed, 215 insertions(+) create mode 100644 openspec/changes/intercompany-matrix-ui/design.md create mode 100644 openspec/changes/intercompany-matrix-ui/proposal.md create mode 100644 openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md create mode 100644 openspec/changes/intercompany-matrix-ui/tasks.md diff --git a/openspec/changes/intercompany-matrix-ui/design.md b/openspec/changes/intercompany-matrix-ui/design.md new file mode 100644 index 0000000..69d920d --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/design.md @@ -0,0 +1,67 @@ +## Context + +Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`) как вторая вкладка страницы отчёта по +версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит `layouts/ConsolidatioReportPage.tsx`, +содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений поведения) и +`widgets/ConsolidatedChessContent`. Бэкенд шахматки — change `2026-10-16-intercompany-matrix`; расчёт — чистая функция +версии, ничего не хранится. + +## Decisions (как устроено в ветке) + +- **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет + последний сегмент адреса. Страница выбирает содержимое по таблице `tab → компонент`; неизвестный `tab` даёт пустое содержимое. +- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка, + фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`). +- **D-3. Режим `all` / `manual` выводится из выбора счетов:** `manual` только при непустых Дт и Кт (иначе бэкенд ответит 422), + в остальных случаях `all`. Один и тот же признак выбирает маршрут экспорта. +- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии. +- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`. +- **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее. +- **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока шахматки, состояние следует за `fullscreenchange`. + +## Known issues + +Открытые проблемы ветки, которые исправляет автор PR; спека их НЕ закрепляет как требования. + +1. Глобальный CSS (`App.css`, `ConsolidatedChessTable.css`, `TreeFilterDropdown.css`) с `!important` на классах antd — влияет на + все таблицы и деревья приложения; стили должны быть локальными (CSS-модули или `className` экрана). +2. В полноэкранном режиме выпадающие списки фильтров (Компании, Дт, Кт, Единицы, Знаки) не видны: popup рендерится в `body`, + вне полноэкранного элемента; нужен `getPopupContainer` на контейнер шахматки. +3. Суммы форматируются через `number` / `parseFloat` / `toFixed` (float в денежном домене), без разделителей тысяч и без «руб.»; + `utils/money.ts` работает со строками — использовать его. Деление на единицы делать над десятичной строкой. +4. Строка «Итого» суммирует расхождения (`variance_amount`), а в макете 31-01 в итогах — верхние числа (суммы стороны A); итоги нужны + по каждой из трёх величин либо по макету. Знак `(…)` разбирается эвристикой `parseAmount`, хотя API отдаёт десятичные строки. +5. Подписи дочерних счетов в выборе Дт/Кт — UUID: `Select` получает `options` только корневых узлов, выбранные дочерние ключи + выводятся как идентификаторы; нужны подписи «код - название» для любых узлов. +6. Выбор счетов только с одной стороны молча даёт режим `all`: пользователь видит выбранный счёт, но данные по всем счетам; + нужны подсказка/блокировка либо явное поведение. +7. Гонка ответов: `loadData` не отменяет и не отбрасывает устаревшие ответы (последний пришедший побеждает, не последний + запрошенный); нужен `AbortController` или счётчик запросов. Эффект зависит от `loadData`/`notification` не по правилам хуков. +8. Нет результирующей таблицы ВГО (`GET …/intercompany-matrix/result-table`), пресетов выбора счетов и выбора года/квартала + (заглушки `disabled` в панели фильтров), экран макета 31-01 реализован не полностью. +9. Прочее по ревью: `any` в таблице и дереве, `console.log` в обработчике полноэкранного режима, ошибки не `ApiError` молча + глотаются (`loadData`, `download`), экспорт не передаёт единицы/знаки/компании, пустой `Select` для года/квартала, + опечатки в именах (`ConsolidatioReportPage`, `consodilation`, `expoerLoading`). + +После исправления дефектов требования соответствующих разделов спеки уточняются отдельным change. + +## Decisions made without discussion + +- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить + автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change. +- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Обоснование: спека — единственный + источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена + ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть + и после исправления потребуют MODIFIED. +- **2026-10-21 (в). При восстановлении затёртого фронта** (коммит 175343b) из `origin/main` взяты целиком все файлы без + шахматочных изменений, а в `App.tsx`, `layouts/index.ts`, `api/client.ts` (параметр `init`), `OsvTabs.tsx`, + `ConsolidatedOsvContent.tsx` оставлены изменения шахматки. Возвращён `key={version_id}` (разделением страницы на обёртку и + содержимое — поведение как в main). Обоснование: изменения PR в этих файлах относятся к шахматке; цена ошибки — незамеченная + откатившаяся правка main в этих пяти файлах (построчно сверено с `origin/main`, расхождений кроме шахматочных нет). +- **2026-10-21 (г). Механические правки biome без смены поведения** (коммит e19291c): тип `IntercompanyMatrixResponse` у `let res`, + `Number.isNaN`, `key` у `span` в `OsvTabs`, `?? null` вместо пустого фрагмента. Предупреждения eslint (8 новых в коде шахматки: + `any`, deps хуков, `console`) не исправлялись — они входят в Known issues. Цена ошибки: `key` на `span` внутри `Tooltip` + безвреден, но избыточен. +- **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст + `consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают + один компонент (`OsvTabs`). diff --git a/openspec/changes/intercompany-matrix-ui/proposal.md b/openspec/changes/intercompany-matrix-ui/proposal.md new file mode 100644 index 0000000..a9fd070 --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/proposal.md @@ -0,0 +1,45 @@ +## Why + +В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы +отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main +(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку пишет ревьюер в ветке автора по решению владельца +(21.10.2026): она фиксирует фактическое поведение экрана и отделяет от него открытые дефекты (`design.md`, «Known issues»), +чтобы дефекты не закрепились требованием. + +## What Changes + +Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core: +`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`, `GET …/intercompany-matrix/all/export`, +`POST …/intercompany-matrix/manual/export`, `GET /directories/accounts`. + +- Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidatioReportPage`) с общей шапкой и + вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать. +- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение), строка «Итого». +- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`. +- Экспорт в XLSX (`all` / `manual`), полноэкранный режим. +- Новая спека `intercompany-matrix-ui` (ADDED Requirements). + +## Non-goals + +- Исправление дефектов экрана (перечень — `design.md`, «Known issues»): они остаются автору PR. +- Результирующая таблица ВГО (`GET …/intercompany-matrix/result-table`) и пресеты счетов — во фронте не реализованы. +- Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом. +- Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5). +- Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны. + +## Capabilities + +### New Capabilities +- `intercompany-matrix-ui`: экран «Шахматка» версии консолидации. + +### Modified Capabilities + +Нет: `consolidated-osv-ui` не правится (вкладки описаны в новой спеке, см. design.md). + +## Impact + +- `web/src/App.tsx`, `web/src/layouts/{ConsolidatioReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`, + `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`, + `web/src/widgets/ConsolidationChessTable/`, `web/src/components/TreeFilterDropdown/`, + `web/src/services/consodilation/chess.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`). +- Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается. diff --git a/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md new file mode 100644 index 0000000..9c37bd1 --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: Вкладки страницы отчёта по версии +Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз +и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: +`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки +(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Вкладки «Таблица корректировки», +«Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». +При смене `version_id` всё состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. + +#### Scenario: Переход на шахматку +- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» +- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка + +#### Scenario: Прежний адрес сводной ОСВ +- **WHEN** открыт адрес `…/versions/{id}/osv` +- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна + +### Requirement: Таблица шахматки ЮЛ × ЮЛ +Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать +таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки — +те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b` +и расхождение `variance_amount`; расхождение выделяется красным, если `exceeds_threshold = true`. Если ячейки пары в ответе нет +(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы SHALL закрепляться строка «Итого» +с одним числом по каждой колонке. Пока идёт загрузка, таблица показывает индикатор загрузки; ошибка API показывается +уведомлением с текстом сервера. + +#### Scenario: Ячейка с расхождением выше порога +- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true` +- **THEN** третье число ячейки (расхождение) показано красным, первые два — обычным цветом + +#### Scenario: Пары нет в ответе +- **WHEN** в `cells[]` нет ячейки для пары компаний +- **THEN** на их пересечении показан прочерк «—» + +### Requirement: Единицы и знаки +Экран SHALL позволять выбрать единицы суммы — «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков после +запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает): +значение делится на 1000 / 1 000 000 / 1 000 000 000 и показывается с выбранным числом знаков, десятичный разделитель — запятая. +Значение, которое не удаётся прочитать как число, SHALL показываться как пришло. + +#### Scenario: Смена единиц +- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"` +- **THEN** в ячейке показано `2,3` + +### Requirement: Фильтр компаний +Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать таблицу на клиенте: остаются строки и +колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список +вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками +применения и сброса. + +#### Scenario: Две компании из периметра +- **WHEN** выбраны две компании из пяти +- **THEN** таблица содержит две строки и две колонки, и «Итого» считается по оставшимся ячейкам + +### Requirement: Фильтр счетов Дт и Кт и режимы расчёта +Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии +`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название». Выбор счетов Дт и Кт — +множественный, пустой выбор — «Все счета». + +Режим расчёта определяется выбором: если выбраны счета и в Дт, и в Кт, SHALL выполняться +`POST …/intercompany-matrix/manual` с `{debit_account_ids, credit_account_ids}` (режим `manual`); в остальных случаях +(ничего не выбрано либо выбрана только одна сторона) SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`). Каждое +изменение выбора счетов перезапрашивает матрицу. Кнопка «Сбросить фильтры» появляется, когда выбрано что-либо в компаниях, Дт +или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не сбрасывается. Ошибка загрузки плана счетов показывается уведомлением. + +#### Scenario: Обе стороны выбраны +- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт +- **THEN** отправляется `POST …/intercompany-matrix/manual` с идентификаторами этих счетов и таблица показывает ответ + +#### Scenario: Выбрана одна сторона +- **WHEN** выбран счёт только в Дт +- **THEN** отправляется `GET …/intercompany-matrix/all` (режим `all`); выбор Дт остаётся в фильтре + +#### Scenario: Сброс +- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры» +- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла + +### Requirement: Экспорт шахматки в XLSX +Кнопка «Экспорт» SHALL скачивать XLSX в том же режиме, что и таблица: при выбранных счетах и в Дт, и в Кт — +`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, иначе +`GET …/intercompany-matrix/all/export`. Файл сохраняется под именем из `Content-Disposition`, при его отсутствии — «Шахматка». +Пока идёт выгрузка, кнопка в состоянии загрузки; ошибка API показывается уведомлением. Фильтр компаний, единицы и знаки +в запрос экспорта не передаются. + +#### Scenario: Экспорт по всем счетам +- **WHEN** счета не выбраны и нажато «Экспорт» +- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx` + +### Requirement: Полноэкранный режим +Кнопка «На весь экран» SHALL переводить блок шахматки (панель фильтров и таблицу) в полноэкранный режим средствами Fullscreen API +с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть». Выход из полноэкранного режима клавишей Esc +SHALL возвращать обычный вид и подпись кнопки. + +#### Scenario: Вход и выход +- **WHEN** нажато «На весь экран», затем Esc +- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран» diff --git a/openspec/changes/intercompany-matrix-ui/tasks.md b/openspec/changes/intercompany-matrix-ui/tasks.md new file mode 100644 index 0000000..3fca357 --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/tasks.md @@ -0,0 +1,6 @@ +## 1. Спека и ревью (RSHB/arm-core#158) + +- [x] 1.1 Спека `intercompany-matrix-ui` по фактическому поведению ветки `feat/chessTable`. +- [ ] 1.2 Автор исправляет пункты «Known issues» из `design.md` и обновляет требования спеки отдельным коммитом. +- [ ] 1.3 Перед PR: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR. +- [ ] 1.4 Сверка экрана с макетом 31-01 в браузере (`docs/mockup/INDEX.md`). -- 2.47.2 From cbeb9daa384f2f0ae7a80bd9a97e33abc2a23e37 Mon Sep 17 00:00:00 2001 From: Grog Date: Wed, 21 Oct 2026 16:48:11 +0300 Subject: [PATCH 06/21] fixed bugs --- web/src/App.tsx | 4 +- .../TreeFilterDropdown/TreeFilterDropdown.tsx | 29 +++- ...rtPage.tsx => ConsolidationReportPage.tsx} | 20 ++- web/src/layouts/index.ts | 2 +- .../{chess.ts => intercompanyMatrix.ts} | 3 + web/src/types/api.ts | 12 ++ web/src/types/chess/chess.ts | 14 ++ .../ConsolidatedChessContent.tsx | 144 ++++++++++-------- .../ConsolidationChessTable.tsx | 68 ++++++--- .../ConsolidationChessTableFilter.tsx | 27 ++-- 10 files changed, 214 insertions(+), 109 deletions(-) rename web/src/layouts/{ConsolidatioReportPage.tsx => ConsolidationReportPage.tsx} (83%) rename web/src/services/consodilation/{chess.ts => intercompanyMatrix.ts} (85%) rename web/src/widgets/{ConsolidationChessTable => ConsolidationChessTableFilter}/ConsolidationChessTableFilter.tsx (93%) diff --git a/web/src/App.tsx b/web/src/App.tsx index 3d972ee..871c730 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,7 +17,7 @@ import { Main, Mono, } from './layouts/'; -import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage'; +import { ConsolidationReportPage } from './layouts/ConsolidationReportPage'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; @@ -37,7 +37,7 @@ function App() { } /> } /> - } /> + } /> } /> } /> diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx index b33a829..1c313a0 100644 --- a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx @@ -80,6 +80,9 @@ export const TreeFilterDropdown: React.FC = ({ return keys; }; + const allFilteredKeys = getAllNodeKeys(filteredTreeData); + const visibleCheckedKeys = checkedKeys.filter((key) => allFilteredKeys.includes(key)); + const handleSelectAll = () => { setCheckedKeys(getAllNodeKeys(treeData)); }; @@ -116,10 +119,28 @@ export const TreeFilterDropdown: React.FC = ({ checkable selectable={false} treeData={filteredTreeData} - checkedKeys={checkedKeys} - onCheck={(keys) => { - const checked = Array.isArray(keys) ? keys : keys.checked; - setCheckedKeys(checked as string[]); + checkedKeys={visibleCheckedKeys} + onCheck={(keys, e) => { + const newChecked = Array.isArray(keys) ? keys : keys.checked; + const targetKey = e.node.key as string; + const isChecked = newChecked.includes(targetKey); + + if (isChecked) { + setCheckedKeys(Array.from(new Set([...checkedKeys, ...newChecked.map(String)]))); + } else { + const keysToRemove = [targetKey]; + const findChildren = (node: OptionType) => { + if (node.children) { + node.children.forEach((child) => { + keysToRemove.push(child.key); + findChildren(child); + }); + } + }; + findChildren(e.node); + + setCheckedKeys(checkedKeys.filter((k) => !keysToRemove.includes(k))); + } }} expandedKeys={expandedKeys} onExpand={(keys) => setExpandedKeys(keys as string[])} diff --git a/web/src/layouts/ConsolidatioReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx similarity index 83% rename from web/src/layouts/ConsolidatioReportPage.tsx rename to web/src/layouts/ConsolidationReportPage.tsx index 19693f6..512a3eb 100644 --- a/web/src/layouts/ConsolidatioReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -1,6 +1,6 @@ import { Space, message } from 'antd'; import { type ReactNode, useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; import { TableTag } from '../components/TableTag/TableTag'; @@ -17,11 +17,12 @@ import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvCon function ReportContent() { const [card, setCard] = useState(null); const { version_id, tab } = useParams<{ tab?: string; version_id: string }>(); - + const navigation = useNavigate(); + const { pathname } = useLocation(); const TAB_COMPONENTS: Record = { osv: , - chess: , + chess: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; @@ -46,6 +47,17 @@ function ReportContent() { }; } }, [version_id]); + + useEffect(() => { + const isValidTab = !tab || tab === 'osv' || tab === 'chess'; + + if (!isValidTab) { + const pathParts = pathname.split('/'); + pathParts[pathParts.length - 1] = 'osv'; + const newPath = pathParts.join('/'); + navigation(newPath, { replace: true }); + } + }, [tab, navigation, pathname]); return (
(); // key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново return ; diff --git a/web/src/layouts/index.ts b/web/src/layouts/index.ts index 90015f7..33cee41 100644 --- a/web/src/layouts/index.ts +++ b/web/src/layouts/index.ts @@ -9,4 +9,4 @@ export { GroupsLayout } from './Groups'; export { AuditLogging } from './AuditLogging'; export { Admin } from './Admin'; export { DocumentPage } from './DocumentPage'; -export { ConsolidatioReportPage } from './ConsolidatioReportPage'; +export { ConsolidationReportPage } from './ConsolidationReportPage'; diff --git a/web/src/services/consodilation/chess.ts b/web/src/services/consodilation/intercompanyMatrix.ts similarity index 85% rename from web/src/services/consodilation/chess.ts rename to web/src/services/consodilation/intercompanyMatrix.ts index 5ecf609..4ee2f1f 100644 --- a/web/src/services/consodilation/chess.ts +++ b/web/src/services/consodilation/intercompanyMatrix.ts @@ -18,3 +18,6 @@ export const exportChessTableManual = (versionId: string, data: ChessManualReque }, body: JSON.stringify(data), }); + +export const fetchChessConsolidationsResult = (id: string) => + api.get(`/consolidations/versions/${id}/intercompany-matrix/result-table`); diff --git a/web/src/types/api.ts b/web/src/types/api.ts index c6e0850..cf2c7c9 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -394,3 +394,15 @@ export interface LegalEntityBrief { name: string; inn: string; } + +export interface Account { + id: string; + code: string; + parent_id: string | null; + name: string; + kind: string; + row_type: string; + is_off_balance: boolean; + is_debit: boolean; + is_credit: boolean; +} diff --git a/web/src/types/chess/chess.ts b/web/src/types/chess/chess.ts index 67e4d15..7da93a6 100644 --- a/web/src/types/chess/chess.ts +++ b/web/src/types/chess/chess.ts @@ -2,6 +2,10 @@ export interface MatrixCompany { id: string; name: string; } +export interface MatrixAmount { + company_id: string; + amount: string; +} export interface MatrixCell { company_a_id: string; @@ -15,10 +19,20 @@ export interface MatrixCell { skipped_osv_entry_count: number; } +export interface MatrixResCell { + code: string; + name: string; + group_total: string; + amounts: MatrixAmount[]; +} export interface IntercompanyMatrixResponse { companies: MatrixCompany[]; cells: MatrixCell[]; } +export interface IntercompanyMatrixResultResponse { + companies: MatrixCompany[]; + articles: MatrixResCell[]; +} export type ChessManualRequest = { debit_account_ids: string[]; diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx index fc076a4..f2a60f6 100644 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -1,5 +1,5 @@ import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; -import { Button } from 'antd'; +import { Button, ConfigProvider } from 'antd'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; @@ -8,14 +8,16 @@ import { exportChessTableManual, fetchChessConsolidations, fetchChessConsolidationsManual, -} from '../../services/consodilation/chess'; +} from '../../services/consodilation/intercompanyMatrix'; import type { IntercompanyMatrixResponse } from '../../types/chess/chess'; -import { saveBlob } from '../../utils/download'; +import { exportFilename, saveBlob } from '../../utils/download'; import { useNotificationApi } from '../../utils/useNotification'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; -import { ConsolidationChessTableFilter } from '../ConsolidationChessTable/ConsolidationChessTableFilter'; +import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; +import { VersionCard } from '../../types/consolidations/version_members'; -export const ChessContent = () => { + +export const ChessContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); const notification = useNotificationApi(); @@ -27,7 +29,7 @@ export const ChessContent = () => { const [units, setUnits] = useState('thousand'); const [debit, setDebit] = useState([]); const [credit, setCredit] = useState([]); - + const containerRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); @@ -44,7 +46,11 @@ export const ChessContent = () => { } }; - const loadData = async (vId: string, dIds: string[], cIds: string[]) => { + const loadData = async (vId: string, dIds: string[], cIds: string[], isActual: () => boolean) => { + if ((dIds.length > 0 && cIds.length === 0) || (dIds.length === 0 && cIds.length > 0)) { + notification.info({description:'Для сравнений по Дт и Кт нужно заполнить оба фильтра'}) + return; + } setLoading(true); try { let res: IntercompanyMatrixResponse; @@ -53,13 +59,17 @@ export const ChessContent = () => { } else { res = await fetchChessConsolidations(vId); } - setData(res); + if (isActual()) { + setData(res); + } } catch (e) { - if (e instanceof ApiError) { + if (isActual() && e instanceof ApiError) { notification.error({ description: e.message }); } } finally { - setLoading(false); + if (isActual()) { + setLoading(false); + } } }; @@ -71,7 +81,7 @@ export const ChessContent = () => { try { if (debit?.length && credit?.length) { const { blob, filename } = await exportChessTableManual(version_id, { debit_account_ids: debit, credit_account_ids: credit }); - saveBlob(blob, filename ?? 'Шахматка'); + saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); } else { const { blob, filename } = await exportChessTableAll(version_id); saveBlob(blob, filename ?? 'Шахматка'); @@ -87,7 +97,15 @@ export const ChessContent = () => { useEffect(() => { if (!version_id) return; - loadData(version_id, debit, credit); + + let isMounted = true; + const isActual = () => isMounted; + + loadData(version_id, debit, credit, isActual); + + return () => { + isMounted = false; + }; }, [version_id, debit, credit]); useEffect(() => { @@ -111,56 +129,58 @@ export const ChessContent = () => { }, [data, companies]); return ( -
- -
- + containerRef.current || document.body}> +
+ +
+ +
+ +
- - -
- } - /> -
+ } + /> +
+ ); }; diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx index d380f64..5a226dd 100644 --- a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -36,20 +36,53 @@ export const ConsolidationChessTable: React.FC = ({ return result; }, [data?.cells]); - const formatValue = (val: string | number) => { + const formatValue = (val: string | number | null | undefined) => { if (val === undefined || val === null || val === '') return ''; - let num = typeof val === 'string' ? Number.parseFloat(val) : val; - if (Number.isNaN(num)) return String(val); - if (units === 'thousand') { - num /= 1000; - } else if (units === 'million') { - num /= 1000000; - } else if (units === 'billion') { - num /= 1_000_000_000; + let strVal = String(val); + + if (strVal.startsWith('(') && strVal.endsWith(')')) { + strVal = `-${strVal.slice(1, -1)}`; } - return num.toFixed(decimals).replace('.', ','); + if (units !== 'rub') { + let shift = 0; + if (units === 'thousand') shift = 3; + else if (units === 'million') shift = 6; + else if (units === 'billion') shift = 9; + + if (shift > 0) { + const isNegative = strVal.startsWith('-'); + const clean = isNegative ? strVal.slice(1) : strVal; + const [intPart = '0', fracPart = ''] = clean.split('.'); + + let newInt = intPart; + let newFrac = fracPart; + + if (intPart.length <= shift) { + const zeros = '0'.repeat(shift - intPart.length); + newInt = '0'; + newFrac = `${zeros}${intPart}${fracPart}`; + } else { + newInt = intPart.slice(0, -shift); + newFrac = `${intPart.slice(-shift)}${fracPart}`; + } + + strVal = `${isNegative ? '-' : ''}${newInt}.${newFrac.slice(0, 4)}`; + } + } + + + const num = Number(strVal); + + if (Number.isNaN(num)) { + return strVal; + } + + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(num); }; const parseAmount = (val: string | null | undefined): number => { @@ -58,7 +91,7 @@ export const ConsolidationChessTable: React.FC = ({ const isNegative: boolean = val.startsWith('(') && val.endsWith(')'); const cleanVal = isNegative ? val.slice(1, -1) : val; - const num = Number.parseFloat(cleanVal); + const num = Number(cleanVal); if (Number.isNaN(num)) { return 0; @@ -76,13 +109,7 @@ export const ConsolidationChessTable: React.FC = ({ key: 'name', fixed: 'left', width: 200, - render: (text, row) => {text}, - onCell: (row) => ({ - style: { - background: row.isTotal ? '#e6f4ea' : '#fff', - zIndex: 2, - }, - }), + }; const companyColumns = data.companies.map((company) => ({ @@ -92,17 +119,12 @@ export const ConsolidationChessTable: React.FC = ({ width: 150, align: 'right' as const, onCell: (row: TableRow) => { - if (row.isTotal) return { style: { background: '#e6f4ea' } }; const cell = matrixData[row.id]?.[company.id]; if (!cell) return { className: 'empty-cell' }; return {}; }, render: (_: unknown, row: TableRow) => { - if (row.isTotal) { - const totalVal = (row as any)[company.id]; - return {formatValue(totalVal)}; - } const cell = matrixData[row.id]?.[company.id]; if (!cell) return —; return ( diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx similarity index 93% rename from web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx rename to web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx index 2a0f3af..f60f706 100644 --- a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTableFilter.tsx +++ b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx @@ -5,18 +5,9 @@ import { ApiError, api } from '../../api/client'; import { type OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown'; import type { MatrixCompany } from '../../types/chess/chess'; import { useNotificationApi } from '../../utils/useNotification'; +import { Account } from '../../types/api'; + -interface Account { - id: string; - code: string; - parent_id: string | null; - name: string; - kind: string; - row_type: string; - is_off_balance: boolean; - is_debit: boolean; - is_credit: boolean; -} interface ConsolidationChessTableFilterI { companies: string[]; @@ -124,6 +115,16 @@ export const ConsolidationChessTableFilter: React.FC { + let result: { label: string; value: string }[] = []; + nodes.forEach((node) => { + result.push({ label: String(node.code), value: String(node.key) }); + if (node.children) { + result = [...result, ...flattenTree(node.children)]; + } + }); + return result; + }; function dropFiltersHandler() { onCompaniesChange([]); onDebitChange([]); @@ -187,7 +188,7 @@ export const ConsolidationChessTableFilter: React.FC ({ label: node.code, value: node.key }))} + options={flattenTree(debitTreeData)} popupRender={() => ( ({ label: node.code, value: node.key }))} + options={flattenTree(creditTreeData)} popupRender={() => ( Date: Thu, 22 Oct 2026 10:07:33 +0300 Subject: [PATCH 07/21] =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D0=B8=D1=80=D1=83=D1=8E=D1=89=D0=B0=D1=8F=20=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=86=D0=B2=20=D0=92=D0=93=D0=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/layouts/ConsolidationReportPage.tsx | 32 +-- .../consodilation/intercompanyMatrix.ts | 4 +- web/src/utils/chessTableCellFormatValue.ts | 44 +++ .../ConsolidatedChessContent.tsx | 252 ++++++++++++------ .../ConsolidationChessFormatTableFilter.tsx | 50 ++++ .../ConsolidatedChessResTable.css | 11 + .../ConsolidationChessResTable.tsx | 148 ++++++++++ .../ConsolidationChessTable.tsx | 66 +---- .../ConsolidationChessTableFilter.tsx | 233 +++++++--------- 9 files changed, 554 insertions(+), 286 deletions(-) create mode 100644 web/src/utils/chessTableCellFormatValue.ts create mode 100644 web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx create mode 100644 web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css create mode 100644 web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 512a3eb..02d72b4 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -60,21 +60,23 @@ function ReportContent() { }, [tab, navigation, pathname]); return (
- - Статус отчёта - - - ) - } - /> - +
+ + Статус отчёта + + + ) + } + /> + +
{ActiveTab}
); diff --git a/web/src/services/consodilation/intercompanyMatrix.ts b/web/src/services/consodilation/intercompanyMatrix.ts index 4ee2f1f..78702ca 100644 --- a/web/src/services/consodilation/intercompanyMatrix.ts +++ b/web/src/services/consodilation/intercompanyMatrix.ts @@ -1,5 +1,5 @@ import { api, apiFetchBlob } from '../../api/client'; -import type { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess'; +import type { ChessManualRequest, IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/chess/chess'; export const fetchChessConsolidations = (id: string) => api.get(`/consolidations/versions/${id}/intercompany-matrix/all`); @@ -20,4 +20,4 @@ export const exportChessTableManual = (versionId: string, data: ChessManualReque }); export const fetchChessConsolidationsResult = (id: string) => - api.get(`/consolidations/versions/${id}/intercompany-matrix/result-table`); + api.get(`/consolidations/versions/${id}/intercompany-matrix/result-table`); diff --git a/web/src/utils/chessTableCellFormatValue.ts b/web/src/utils/chessTableCellFormatValue.ts new file mode 100644 index 0000000..7c27739 --- /dev/null +++ b/web/src/utils/chessTableCellFormatValue.ts @@ -0,0 +1,44 @@ +export const chessTableCellFormatValue = (val: string | number | null | undefined, units: string, decimals: number) => { + if (val === undefined || val === null || val === '') return ''; + + let strVal = String(val); + + if (strVal.startsWith('(') && strVal.endsWith(')')) { + strVal = `-${strVal.slice(1, -1)}`; + } + + if (units !== 'rub') { + let shift = 0; + if (units === 'thousand') shift = 3; + else if (units === 'million') shift = 6; + else if (units === 'billion') shift = 9; + + if (shift > 0) { + const isNegative = strVal.startsWith('-'); + const clean = isNegative ? strVal.slice(1) : strVal; + const [intPart = '0', fracPart = ''] = clean.split('.'); + + let newInt = intPart; + let newFrac = fracPart; + + if (intPart.length <= shift) { + const zeros = '0'.repeat(shift - intPart.length); + newInt = '0'; + newFrac = `${zeros}${intPart}${fracPart}`; + } else { + newInt = intPart.slice(0, -shift); + newFrac = `${intPart.slice(-shift)}${fracPart}`; + } + + strVal = `${isNegative ? '-' : ''}${newInt}.${newFrac.slice(0, 4)}`; + } + } + + const num = Number(strVal); + if (Number.isNaN(num)) return strVal; + + return new Intl.NumberFormat('ru-RU', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(num); +}; diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx index f2a60f6..61a23d3 100644 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -8,14 +8,16 @@ import { exportChessTableManual, fetchChessConsolidations, fetchChessConsolidationsManual, + fetchChessConsolidationsResult, } from '../../services/consodilation/intercompanyMatrix'; -import type { IntercompanyMatrixResponse } from '../../types/chess/chess'; +import type { IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/chess/chess'; +import type { VersionCard } from '../../types/consolidations/version_members'; import { exportFilename, saveBlob } from '../../utils/download'; import { useNotificationApi } from '../../utils/useNotification'; +import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter'; +import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; -import { VersionCard } from '../../types/consolidations/version_members'; - export const ChessContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); @@ -29,26 +31,48 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { const [units, setUnits] = useState('thousand'); const [debit, setDebit] = useState([]); const [credit, setCredit] = useState([]); - - const containerRef = useRef(null); - const [isFullscreen, setIsFullscreen] = useState(false); - const toggleFullscreen = async () => { - if (!containerRef.current) return; + const [chessResData, setChessResData] = useState(null); + const [chessResUnits, setChessResUnits] = useState('thousand'); + const [chessResDecimals, setChessResDecimals] = useState(2); + const [chessResLoading, setChessResLoading] = useState(false); + + const chessTableRef = useRef(null); + const resTableRef = useRef(null); + const [isChessFullscreen, setIsChessFullscreen] = useState(false); + const [isResFullscreen, setIsResFullscreen] = useState(false); + + const toggleFullscreen = async (ref: React.RefObject, setFullscreen: (val: boolean) => void) => { + if (!ref.current) return; try { if (!document.fullscreenElement) { - await containerRef.current.requestFullscreen(); + await ref.current.requestFullscreen(); + setFullscreen(true); } else { await document.exitFullscreen(); + setFullscreen(false); } } catch (e) { - console.log(e); + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } } }; + useEffect(() => { + const handleFullscreenChange = () => { + if (!document.fullscreenElement) { + setIsChessFullscreen(false); + setIsResFullscreen(false); + } + }; + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); + }, []); + const loadData = async (vId: string, dIds: string[], cIds: string[], isActual: () => boolean) => { if ((dIds.length > 0 && cIds.length === 0) || (dIds.length === 0 && cIds.length > 0)) { - notification.info({description:'Для сравнений по Дт и Кт нужно заполнить оба фильтра'}) + notification.info({ description: 'Для сравнений по Дт и Кт нужно заполнить оба фильтра' }); return; } setLoading(true); @@ -72,6 +96,19 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { } } }; + const loadResData = async (vid: string) => { + setChessResLoading(true); + try { + const res = await fetchChessConsolidationsResult(vid); + setChessResData(res); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setChessResLoading(false); + } + }; const download = async () => { if (!version_id) { @@ -84,7 +121,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); } else { const { blob, filename } = await exportChessTableAll(version_id); - saveBlob(blob, filename ?? 'Шахматка'); + saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); } } catch (e) { if (e instanceof ApiError) { @@ -95,6 +132,16 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { } }; + const filteredData = useMemo(() => { + if (!data) return null; + if (companies.length === 0) return data; + + const filteredCompanies = data.companies.filter((c) => companies.includes(c.id)); + const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id)); + + return { companies: filteredCompanies, cells: filteredCells }; + }, [data, companies]); + useEffect(() => { if (!version_id) return; @@ -109,77 +156,132 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { }, [version_id, debit, credit]); useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => { - document.removeEventListener('fullscreenchange', handleFullscreenChange); - }; - }, []); - - const filteredData = useMemo(() => { - if (!data) return null; - if (companies.length === 0) return data; - - const filteredCompanies = data.companies.filter((c) => companies.includes(c.id)); - const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id)); - - return { companies: filteredCompanies, cells: filteredCells }; - }, [data, companies]); + if (!version_id) return; + loadResData(version_id); + }, [version_id]); return ( - containerRef.current || document.body}> + (document.fullscreenElement as HTMLElement) || document.body}>
- -
- +
+ +
+ +
+ +
+
+ + +
- - -
- } - /> + } + /> +
+ +
+ +
+ +
+
+ +
+
+ } + /> +
); diff --git a/web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx b/web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx new file mode 100644 index 0000000..4497794 --- /dev/null +++ b/web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx @@ -0,0 +1,50 @@ +import { Select, Space } from 'antd'; +import type React from 'react'; + +interface ConsolidationChessFormatTableFilterI { + decimals: number; + onDecimalsChange: (val: number) => void; + units: string; + onUnitsChange: (val: string) => void; +} + +export const ConsolidationChessFormatTableFilter: React.FC = ({ + decimals, + onDecimalsChange, + units, + onUnitsChange, +}) => { + const UNIT_OPTIONS = [ + { value: 'thousand', label: 'тыс. руб.' }, + { value: 'million', label: 'млн. руб.' }, + { value: 'billion', label: 'млрд руб.' }, + ]; + const DECIMALS_OPTIONS = [ + { value: 0, label: '0' }, + { value: 1, label: '1' }, + { value: 2, label: '2' }, + { value: 3, label: '3' }, + ]; + + return ( +
+ + + Единицы + onDecimalsChange(v || 0)} + options={DECIMALS_OPTIONS} + variant='underlined' + /> + + +
+ ); +}; diff --git a/web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css b/web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css new file mode 100644 index 0000000..6adec5f --- /dev/null +++ b/web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css @@ -0,0 +1,11 @@ +.ant-table-summary { + border-end-end-radius: 8px !important; + border-end-start-radius: 8px !important; +} + +.ant-table-cell-fix { + background-color: #f8fafc !important; +} +.resRow { + background-color: #e6f4ea !important ; +} diff --git a/web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx b/web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx new file mode 100644 index 0000000..1bd77b1 --- /dev/null +++ b/web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx @@ -0,0 +1,148 @@ +import { Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import type React from 'react'; +import { useMemo } from 'react'; +import type { IntercompanyMatrixResultResponse, MatrixResCell } from '../../types/chess/chess'; +import './ConsolidatedChessResTable.css'; +import { chessTableCellFormatValue } from '../../utils/chessTableCellFormatValue'; + +const { Text } = Typography; + +interface ConsolidationChessResTableI { + data: IntercompanyMatrixResultResponse | null; + loading: boolean; + decimals: number; + units: string; + toolbar?: React.ReactNode; + isFullscreen?: boolean; +} + +export const ConsolidationChessResTable: React.FC = ({ + data, + loading, + decimals, + units, + toolbar, + isFullscreen = false, +}) => { + const matrixData = useMemo(() => { + if (!data?.articles) return {}; + const result: Record> = {}; + + data.articles.forEach((article) => { + result[article.code] = {}; + article.amounts.forEach((amount) => { + result[article.code][amount.company_id] = amount.amount; + }); + }); + + return result; + }, [data?.articles]); + + const columns: ColumnsType = useMemo(() => { + if (!data?.companies) return []; + + const firstColumn: ColumnsType[number] = { + title: 'Статья', + dataIndex: 'name', + key: 'name', + fixed: 'left', + width: 250, + + }; + + const companyColumns = data.companies.map((company) => ({ + title: company.name, + dataIndex: company.id, + key: company.id, + width: 150, + align: 'right' as const, + render: (_: unknown, row: MatrixResCell) => { + const amount = matrixData[row.code]?.[company.id]; + const formatted = chessTableCellFormatValue(amount, units, decimals); + + if (formatted === '—') { + return —; + } + return formatted; + }, + })); + + const totalColumn: ColumnsType[number] = { + title: 'Итого', + dataIndex: 'group_total', + key: 'group_total', + width: 150, + align: 'right', + render: (val: string) => {chessTableCellFormatValue(val, units, decimals)}, + }; + + return [firstColumn, ...companyColumns, totalColumn]; + }, [data?.companies, matrixData, decimals, units]); + + // 4. Считаем итоговую строку (сумма всех group_total) + const summaryTotal = useMemo(() => { + if (!data?.articles) return '0'; + let sum = 0; + data.articles.forEach((article) => { + sum += Number(article.group_total || 0); + }); + return String(sum); + }, [data?.articles]); + + const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436; + + return ( + + className='chessTable' + dataSource={data?.articles || []} + columns={columns} + rowKey='code' + pagination={false} + loading={loading} + scroll={{ x: 'max-content', y: scrollY }} + size='medium' + bordered + title={() => toolbar} + summary={() => { + if (!data?.articles) return null; + + return ( + + + +
+ Итого +
+
+ + {data.companies.map((company, index) => { + let colTotal = 0; + data.articles.forEach((article) => { + const amount = matrixData[article.code]?.[company.id]; + if (amount && amount !== '0') { + colTotal += Number(amount); + } + }); + + return ( + +
+ {chessTableCellFormatValue(String(colTotal), units, decimals)} +
+
+ ); + })} + + +
+ {chessTableCellFormatValue(summaryTotal, units, decimals)} +
+
+
+
+ ); + }} + /> + ); +}; diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx index 5a226dd..1f3e8d4 100644 --- a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -4,6 +4,7 @@ import type React from 'react'; import { useMemo } from 'react'; import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess'; import './ConsolidatedChessTable.css'; +import { chessTableCellFormatValue } from '../../utils/chessTableCellFormatValue'; const { Text } = Typography; @@ -36,55 +37,6 @@ export const ConsolidationChessTable: React.FC = ({ return result; }, [data?.cells]); - const formatValue = (val: string | number | null | undefined) => { - if (val === undefined || val === null || val === '') return ''; - - let strVal = String(val); - - if (strVal.startsWith('(') && strVal.endsWith(')')) { - strVal = `-${strVal.slice(1, -1)}`; - } - - if (units !== 'rub') { - let shift = 0; - if (units === 'thousand') shift = 3; - else if (units === 'million') shift = 6; - else if (units === 'billion') shift = 9; - - if (shift > 0) { - const isNegative = strVal.startsWith('-'); - const clean = isNegative ? strVal.slice(1) : strVal; - const [intPart = '0', fracPart = ''] = clean.split('.'); - - let newInt = intPart; - let newFrac = fracPart; - - if (intPart.length <= shift) { - const zeros = '0'.repeat(shift - intPart.length); - newInt = '0'; - newFrac = `${zeros}${intPart}${fracPart}`; - } else { - newInt = intPart.slice(0, -shift); - newFrac = `${intPart.slice(-shift)}${fracPart}`; - } - - strVal = `${isNegative ? '-' : ''}${newInt}.${newFrac.slice(0, 4)}`; - } - } - - - const num = Number(strVal); - - if (Number.isNaN(num)) { - return strVal; - } - - return new Intl.NumberFormat('ru-RU', { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }).format(num); - }; - const parseAmount = (val: string | null | undefined): number => { if (!val) return 0; @@ -109,7 +61,6 @@ export const ConsolidationChessTable: React.FC = ({ key: 'name', fixed: 'left', width: 200, - }; const companyColumns = data.companies.map((company) => ({ @@ -119,7 +70,6 @@ export const ConsolidationChessTable: React.FC = ({ width: 150, align: 'right' as const, onCell: (row: TableRow) => { - const cell = matrixData[row.id]?.[company.id]; if (!cell) return { className: 'empty-cell' }; return {}; @@ -129,9 +79,11 @@ export const ConsolidationChessTable: React.FC = ({ if (!cell) return —; return (
- {formatValue(cell.amount_a)} - {formatValue(cell.amount_b)} - {formatValue(cell.variance_amount)} + {chessTableCellFormatValue(cell.amount_a, units, decimals)} + {chessTableCellFormatValue(cell.amount_b, units, decimals)} + + {chessTableCellFormatValue(cell.variance_amount, units, decimals)} +
); }, @@ -165,7 +117,7 @@ export const ConsolidationChessTable: React.FC = ({ return totalRow as TableRow; }, [data?.companies, matrixData]); - const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436; + const scrollY = isFullscreen ? 'calc(100vh - 150px)' : 436; return ( @@ -175,7 +127,7 @@ export const ConsolidationChessTable: React.FC = ({ pagination={false} loading={loading} scroll={{ x: 'max-content', y: scrollY }} - size='middle' + size='small' bordered title={() => toolbar} summary={() => { @@ -193,7 +145,7 @@ export const ConsolidationChessTable: React.FC = ({ {data?.companies.map((company, index) => (
- {formatValue((totalRowData as any)[company.id])} + {chessTableCellFormatValue((totalRowData as any)[company.id], units, decimals)}
))} diff --git a/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx index f60f706..54c82bf 100644 --- a/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx +++ b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx @@ -3,24 +3,18 @@ import type React from 'react'; import { useEffect, useState } from 'react'; import { ApiError, api } from '../../api/client'; import { type OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown'; +import type { Account } from '../../types/api'; import type { MatrixCompany } from '../../types/chess/chess'; import { useNotificationApi } from '../../utils/useNotification'; -import { Account } from '../../types/api'; - - interface ConsolidationChessTableFilterI { companies: string[]; onCompaniesChange: (val: string[]) => void; - decimals: number; - onDecimalsChange: (val: number) => void; debit: string[]; onDebitChange: (val: string[]) => void; credit: string[]; onCreditChange: (val: string[]) => void; allCompanies: MatrixCompany[]; - units: string; - onUnitsChange: (val: string) => void; } const buildTree = (items: Account[]): OptionType[] => { @@ -59,15 +53,11 @@ const buildTree = (items: Account[]): OptionType[] => { export const ConsolidationChessTableFilter: React.FC = ({ companies, onCompaniesChange, - decimals, - onDecimalsChange, debit, onDebitChange, credit, onCreditChange, allCompanies, - units, - onUnitsChange, }) => { const [companyOpen, setCompanyOpen] = useState(false); const [debitOpen, setDebitOpen] = useState(false); @@ -82,18 +72,6 @@ export const ConsolidationChessTableFilter: React.FC ({ label: c.name, value: c.id })); const companyTreeData = allCompanies.map((c) => ({ title: c.name, key: c.id })); - const UNIT_OPTIONS = [ - { value: 'thousand', label: 'тыс. руб.' }, - { value: 'million', label: 'млн. руб.' }, - { value: 'billion', label: 'млрд руб.' }, - ]; - const DECIMALS_OPTIONS = [ - { value: 0, label: '0' }, - { value: 1, label: '1' }, - { value: 2, label: '2' }, - { value: 3, label: '3' }, - ]; - async function getOptionHandler() { setOptionsLoading(true); try { @@ -136,120 +114,101 @@ export const ConsolidationChessTableFilter: React.FC - - - Год - - - - Компании - ( - setDebitOpen(false)} - /> - )} - loading={optionsLoading} - /> - - - - Кт - - - - - Знаков после запятой - - + + Квартал + ( + setCompanyOpen(false)} + /> + )} + /> + + + + Дт + ( + setCreditOpen(false)} + /> + )} + loading={optionsLoading} + /> + + {companies?.length || debit?.length || credit?.length ? : null} + ); }; -- 2.47.2 From a0ef90159ea98cd04e25e491e1e363badd484e6f Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 22 Oct 2026 10:14:01 +0300 Subject: [PATCH 08/21] fixed padding in full screen table --- .../ConsolidatedChessContent/ConsolidatedChessContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx index 61a23d3..62ca106 100644 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -176,7 +176,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { ref={chessTableRef} style={{ backgroundColor: isChessFullscreen ? '#fff' : 'transparent', - padding: isChessFullscreen ? 24 : 0, + padding: isChessFullscreen ? 6 : 0, height: isChessFullscreen ? '100vh' : 'auto', overflow: isChessFullscreen ? 'hidden' : 'visible', display: 'flex', @@ -241,7 +241,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { ref={resTableRef} style={{ backgroundColor: isResFullscreen ? '#fff' : 'transparent', - padding: isResFullscreen ? 24 : 0, + padding: isResFullscreen ? 6 : 0, height: isResFullscreen ? '100vh' : 'auto', overflow: isResFullscreen ? 'hidden' : 'visible', display: 'flex', -- 2.47.2 From 512cbf6cea25b89ab8cb0a26e0e518a1c6d2fad3 Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 22 Oct 2026 10:26:31 +0300 Subject: [PATCH 09/21] fixed style bug --- .../components/TreeFilterDropdown/TreeFilterDropdown.css | 2 +- .../components/TreeFilterDropdown/TreeFilterDropdown.tsx | 1 + .../ConsolidatedChessResTable.css | 4 ++-- .../ConsolidationChessTable/ConsolidatedChessTable.css | 8 ++++---- .../ConsolidationChessTable/ConsolidationChessTable.tsx | 1 + 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css index 8f0c3e4..adf8ee1 100644 --- a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css @@ -1,3 +1,3 @@ -.ant-tree-switcher-noop { +.filterTree .ant-tree-switcher-noop { display: none !important; } diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx index 1c313a0..7a3fc46 100644 --- a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx @@ -116,6 +116,7 @@ export const TreeFilterDropdown: React.FC = ({
tr:hover > td.empty-cell { +.chessTable .ant-table-tbody > tr:hover > td.empty-cell { background-color: #f2f4f7 !important; } -.ant-table-summary { +.chessTable .ant-table-summary { border-end-end-radius: 8px !important; border-end-start-radius: 8px !important; } -.ant-table-cell-fix { +.chessTable .ant-table-cell-fix { background-color: #f8fafc !important; } -.resRow { +.chessTable .resRow { background-color: #e6f4ea !important ; } diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx index 1f3e8d4..7d99803 100644 --- a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -121,6 +121,7 @@ export const ConsolidationChessTable: React.FC = ({ return ( + className='chessTable' dataSource={data?.companies || []} columns={columns} rowKey='id' -- 2.47.2 From 473492f6f070eaef3354959d54f4911fea99d426 Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 22 Oct 2026 10:37:27 +0300 Subject: [PATCH 10/21] added export res chess table --- .../consodilation/intercompanyMatrix.ts | 4 ++++ .../ConsolidatedChessContent.tsx | 24 +++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/web/src/services/consodilation/intercompanyMatrix.ts b/web/src/services/consodilation/intercompanyMatrix.ts index 78702ca..db06267 100644 --- a/web/src/services/consodilation/intercompanyMatrix.ts +++ b/web/src/services/consodilation/intercompanyMatrix.ts @@ -21,3 +21,7 @@ export const exportChessTableManual = (versionId: string, data: ChessManualReque export const fetchChessConsolidationsResult = (id: string) => api.get(`/consolidations/versions/${id}/intercompany-matrix/result-table`); +export const exportChessConsolidationsResult = (versionId: string) => + apiFetchBlob(`/consolidations/versions/${versionId}/intercompany-matrix/result-table/export`, { + method: 'GET', + }); diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx index 62ca106..d58e2e0 100644 --- a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; import { + exportChessConsolidationsResult, exportChessTableAll, exportChessTableManual, fetchChessConsolidations, @@ -25,7 +26,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); - const [expoerLoading, setExportLoading] = useState(false); + const [exportLoading, setExportLoading] = useState(false); const [companies, setCompanies] = useState([]); const [decimals, setDecimals] = useState(2); const [units, setUnits] = useState('thousand'); @@ -131,6 +132,22 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { setExportLoading(false); } }; + const downloadResTable = async () => { + if (!version_id) { + return; + } + setExportLoading(true); + try { + const { blob, filename } = await exportChessConsolidationsResult(version_id); + saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setExportLoading(false); + } + }; const filteredData = useMemo(() => { if (!data) return null; @@ -223,7 +240,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { />
-
+