From 70802d788c111b5ada244aeb040684c7d4305c78 Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 5 Nov 2026 09:10:18 +0300 Subject: [PATCH 01/15] added basis of goodwill table --- web/src/layouts/ConsolidationReportPage.tsx | 4 +- web/src/services/goodwill.ts | 23 ++ web/src/types/api.ts | 2 - web/src/types/goodwill/goodwill.ts | 43 +++ .../CatalogueJurTable/CatalogueJurTable.tsx | 72 ++-- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 2 - .../ConsolidatedChessContent.tsx | 1 - .../ConsolidationChessTable.tsx | 4 +- .../ConsolidationChessTableFilter.tsx | 1 - .../ConsolidationGoodwillContent.tsx | 362 ++++++++++++++++++ .../ConsolidationGoodwillTable.css | 18 + .../ConsolidationGoodwillTable.tsx | 184 +++++++++ .../ConsolidationGoodwillTable/config.tsx | 185 +++++++++ 13 files changed, 850 insertions(+), 51 deletions(-) create mode 100644 web/src/services/goodwill.ts create mode 100644 web/src/types/goodwill/goodwill.ts create mode 100644 web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx create mode 100644 web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css create mode 100644 web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx create mode 100644 web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 5df7e85..5823352 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -17,6 +17,7 @@ import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; import { ChessContent } from '../widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; +import { GoodwillContent } from '../widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent'; function ReportContent() { const [card, setCard] = useState(null); @@ -31,6 +32,7 @@ function ReportContent() { osv: , chess: , + goodwill: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; @@ -61,7 +63,7 @@ function ReportContent() { }, [version_id]); useEffect(() => { - const isValidTab = !tab || tab === 'osv' || tab === 'chess'; + const isValidTab = !tab || Object.keys(TAB_COMPONENTS).includes(tab); if (!isValidTab) { const pathParts = pathname.split('/'); diff --git a/web/src/services/goodwill.ts b/web/src/services/goodwill.ts new file mode 100644 index 0000000..294d801 --- /dev/null +++ b/web/src/services/goodwill.ts @@ -0,0 +1,23 @@ +import type { GoodwillTableResponse, GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload } from '../types/goodwill/goodwill'; +import { api, apiFetchBlob } from '../api/client'; + +export function fetchGoodwillTable(versionId: string): Promise { + return api.get(`/consolidations/versions/${versionId}/goodwill`); +} + +export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise { + return api.put(`/consolidations/versions/${versionId}/goodwill-settings`, payload); +} + +export function saveGoodwillRow( + versionId: string, + headId: string, + subsidiaryId: string, + payload: GoodwillRowPayload, +): Promise { + return api.put(`/consolidations/versions/${versionId}/goodwill/${headId}/${subsidiaryId}`, payload); +} + +export function exportGoodwillTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { + return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export`); +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 3293854..e1dcc8c 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -433,7 +433,6 @@ export interface Account { is_credit: boolean; } -/** Шахматка ВГО (`IntercompanyMatrixOut`): компании периметра и направленные ячейки пар. */ export interface MatrixCompany { id: string; name: string; @@ -462,7 +461,6 @@ export type ChessManualRequest = { credit_account_ids: string[]; }; -/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */ export interface MatrixAmount { company_id: string; amount: string; diff --git a/web/src/types/goodwill/goodwill.ts b/web/src/types/goodwill/goodwill.ts new file mode 100644 index 0000000..4616126 --- /dev/null +++ b/web/src/types/goodwill/goodwill.ts @@ -0,0 +1,43 @@ +export interface Ref { + id: string; + name: string; +} + +export interface GoodwillRow { + head_legal_entity: Ref; + subsidiary_legal_entity: Ref; + ownership_share: string | null; + financial_investment_amount: string; + charter_capital_amount: string; + additional_capital_amount: string; + participation_amount: string; + goodwill_amount: string; + include_in_kfo: boolean; + comment: string | null; + updated_by: Ref | null; + updated_at: string | null; +} + +export interface GoodwillTotals { + financial_investment_amount: string; + participation_amount: string; + goodwill_amount: string; + goodwill_in_kfo_amount: string; +} + +export interface GoodwillTableResponse { + version_id: string; + goodwill_enabled: boolean; + editable: boolean; + read_only_reason: string | null; + rows: GoodwillRow[]; + totals: GoodwillTotals; +} + +// Для дерева Ant Design (с добавлением группировки) +export type GoodwillTreeRow = GoodwillRow & { + key: string; + children?: GoodwillTreeRow[]; + isGroup?: boolean; + isTotal?: boolean; +}; \ No newline at end of file diff --git a/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx b/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx index 1a0e884..eaa4d30 100644 --- a/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx +++ b/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx @@ -7,7 +7,7 @@ import { SearchOutlined, SwapOutlined, } from '@ant-design/icons'; -import { Button, Checkbox, Dropdown, Input, Space } from 'antd'; +import { Alert, Button, Checkbox, Dropdown, Input, Space } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -239,46 +239,36 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur {relatedGroupFilter?.id && !outside_group && ( -
-
- - Фильтр по: - {relatedGroupFilter.code} - - — {filterdData?.length}   - {pluralRu(filterdData?.length ?? 0, ['запись', 'записи', 'записей'])} - -
- - -
+ + + Фильтр по: + {relatedGroupFilter.code} + + — {filterdData?.length}   + {pluralRu(filterdData?.length ?? 0, ['запись', 'записи', 'записей'])} + + + } + action={ + + }> )} { }, [data, companies]); useEffect(() => { - // выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку if (!version_id || isHalfFiltered) return; let isMounted = true; diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessTable/ConsolidationChessTable.tsx index b657f42..2666087 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessTable/ConsolidationChessTable.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -24,7 +24,7 @@ interface ConsolidationChessTableI { type TotalRow = { id: string; name: string; - [key: string]: string; // Здесь будут храниться суммы по ID компаний + [key: string]: string; }; export const ConsolidationChessTable: React.FC = ({ @@ -82,7 +82,6 @@ export const ConsolidationChessTable: React.FC = ({ {chessTableCellFormatValue(cell.amount_a, units, decimals)} {chessTableCellFormatValue(cell.amount_b, units, decimals)} - {/* подсветка как в API и xlsx: порог > 5 % или пара без встречной стороны (AURORA-1651) */} ({chessTableCellFormatValue(cell.variance_amount, units, decimals)}) @@ -112,7 +111,6 @@ export const ConsolidationChessTable: React.FC = ({ } }); - // сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—» totalRow[colCompany.id] = sumAmounts(amountsToSum) ?? ''; }); diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx index dc61696..5b94295 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx @@ -16,7 +16,6 @@ interface ConsolidationChessTableFilterI { credit: string[]; onCreditChange: (val: string[]) => void; allCompanies: MatrixCompany[]; - /** Год и квартал версии — только для показа: версия задаётся адресом. */ year?: number; quarter?: Quarter; } diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx new file mode 100644 index 0000000..9a4327c --- /dev/null +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -0,0 +1,362 @@ +import { EditOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; +import { Alert, Button, ConfigProvider, Select, Space, Tooltip } from 'antd'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import type { GoodwillRow, GoodwillTableResponse } from '../../../types/goodwill/goodwill'; +import { useNotificationApi } from '../../../utils/useNotification'; +import { ConsolidationChessFormatTableFilter } from '../../ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter'; +import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable'; +import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; +import { ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; +import { ApiError } from '../../../api/client'; + +// --- ТЕСТОВЫЕ ДАННЫЕ --- +const MOCK_DATA: GoodwillTableResponse = { + version_id: '30d45049-4dd6-4479-9cf7-33f4e71301ef', + goodwill_enabled: true, + editable: true, + read_only_reason: null, + rows: [ + { + head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, + subsidiary_legal_entity: { id: 'sub-1-1', name: 'ООО "Дон Агро"' }, + ownership_share: '100.0000', + financial_investment_amount: '4060.00', + charter_capital_amount: '371.17', + additional_capital_amount: '0.00', + participation_amount: '371.14', + goodwill_amount: '3688.86', + include_in_kfo: true, + comment: 'Основная дочерняя компания', + updated_by: { id: 'user-1', name: 'Иванов И.И.' }, + updated_at: '2026-10-15T10:00:00Z', + }, + { + head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, + subsidiary_legal_entity: { id: 'sub-1-2', name: 'АО "Дон Агро"' }, + ownership_share: '75.0000', + financial_investment_amount: '260.01', + charter_capital_amount: '120.00', + additional_capital_amount: '0.00', + participation_amount: '119.99', + goodwill_amount: '-140.02', + include_in_kfo: false, + comment: null, + updated_by: null, + updated_at: null, + }, + { + head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, + subsidiary_legal_entity: { id: 'sub-1-3', name: 'ООО "Дон Мучнов"' }, + ownership_share: '100.0000', + financial_investment_amount: '130.00', + charter_capital_amount: '0.01', + additional_capital_amount: '0.00', + participation_amount: '0.01', + goodwill_amount: '129.99', + include_in_kfo: true, + comment: '', + updated_by: null, + updated_at: null, + }, + { + head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, + subsidiary_legal_entity: { id: 'sub-1-4', name: 'ООО "Инвестком"' }, + ownership_share: '100.0000', + financial_investment_amount: '267.00', + charter_capital_amount: '0.01', + additional_capital_amount: '0.00', + participation_amount: '0.01', + goodwill_amount: '266.99', + include_in_kfo: false, + comment: null, + updated_by: null, + updated_at: null, + }, + { + head_legal_entity: { id: 'head-2', name: 'Инвестком АО' }, + subsidiary_legal_entity: { id: 'sub-2-1', name: 'ООО "Юг-Инвест"' }, + ownership_share: '50.0000', + financial_investment_amount: '8500.00', + charter_capital_amount: '1000.00', + additional_capital_amount: '500.00', + participation_amount: '500.00', + goodwill_amount: '8000.00', + include_in_kfo: true, + comment: 'Требует пересмотра', + updated_by: { id: 'user-2', name: 'Петров П.П.' }, + updated_at: '2026-10-20T14:30:00Z', + }, + ], + totals: { + financial_investment_amount: '13217.01', + participation_amount: '991.15', + goodwill_amount: '12225.86', + goodwill_in_kfo_amount: '11817.85', + }, +}; +// ------------------------ + +export const GoodwillContent = () => { + const { version_id } = useParams<{ version_id: string }>(); + const notification = useNotificationApi(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [decimals, setDecimals] = useState(2); + const [units, setUnits] = useState('thousand'); + + const [isEditMode, setIsEditMode] = useState(false); + const [editedRows, setEditedRows] = useState>>({}); + + const goodwillTableRef = useRef(null); + const [isGoodwillFullscreen, setIsGoodwillFullscreen] = useState(false); + const [goodwillFilter, setGoodwillFilter] = useState('allValue'); + + const handleRowEdit = useCallback( + (rowKey: string, field: keyof GoodwillRow, value: any) => { + const isParent = rowKey.startsWith('head-') && !rowKey.includes('-sub-'); + + if (isParent && field === 'include_in_kfo') { + const headId = rowKey.replace('head-', ''); + const children = data?.rows.filter((r) => r.head_legal_entity.id === headId) || []; + + setEditedRows((prev) => { + const next = { ...prev }; + children.forEach((child) => { + const childKey = `${headId}-${child.subsidiary_legal_entity.id}`; + next[childKey] = { ...next[childKey], include_in_kfo: value }; + }); + return next; + }); + return; + } + + setEditedRows((prev) => ({ + ...prev, + [rowKey]: { ...prev[rowKey], [field]: value }, + })); + }, + [data?.rows], + ); + const columns = useMemo(() => { + return getGoodwillColumns({ units, decimals, isEditMode, editedRows, onRowEdit: handleRowEdit }); + }, [units, decimals, isEditMode, editedRows, handleRowEdit]); + + const groupsConfig = useMemo(() => { + return { rowKey: 'key' as any, columns: columns as any }; + }, [columns]); + + const { visibleKeys, setVisibleKeys } = useVisibleColumns(groupsConfig); + + const toggleFullscreen = async (ref: React.RefObject, setFullscreen: (val: boolean) => void) => { + if (!ref.current) return; + try { + if (!document.fullscreenElement) { + await ref.current.requestFullscreen(); + setFullscreen(true); + } else { + await document.exitFullscreen(); + setFullscreen(false); + } + } catch (e) { + notification.error({ description: e instanceof Error ? e.message : 'Ошибка' }); + } + }; + const dropFiltersHandler = () => { + setGoodwillFilter('allValue'); + }; + + const loadData = useCallback(async () => { + setLoading(true); + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + setData(MOCK_DATA); + + const initialEdited: Record> = {}; + MOCK_DATA.rows.forEach((row) => { + if (row.include_in_kfo) { + const key = `${row.head_legal_entity.id}-${row.subsidiary_legal_entity.id}`; + initialEdited[key] = { + include_in_kfo: true, + ownership_share: row.ownership_share, + comment: row.comment, + }; + } + }); + setEditedRows(initialEdited); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setLoading(false); + } + }, [notification]); + + const handleSave = async () => { + setSaving(true); + try { + await new Promise((resolve) => setTimeout(resolve, 800)); + notification.success({ description: 'Изменения сохранены (тестовый режим)' }); + setEditedRows({}); + setIsEditMode(false); + loadData(); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setSaving(false); + } + }; + const filteredData = useMemo(() => { + if (!data) return null; + if (goodwillFilter === 'allValue') return data; + + const filteredRows = data.rows.filter((row) => { + const amount = Number(row.goodwill_amount); + + if (goodwillFilter === 'positive') return amount > 0; + if (goodwillFilter === 'negative') return amount < 0; + return true; + }); + + return { + ...data, + rows: filteredRows, + }; + }, [data, goodwillFilter]); + + const canSaveEditData = useMemo(() => { + if (!isEditMode) { + return; + } + const hasErrors = Object.values(editedRows).some((elem) => { + if (!elem.include_in_kfo) return false; + + const isShareEmpty = !elem.ownership_share || String(elem.ownership_share).trim() === ''; + const isCommentEmpty = !elem.comment || String(elem.comment).trim() === ''; + + return isShareEmpty || isCommentEmpty; + }); + return !hasErrors; + }, [editedRows, isEditMode]); + + useEffect(() => { + loadData(); + }, [loadData]); + + return ( + (document.fullscreenElement as HTMLElement) || document.body}> +
+ {isEditMode && ( + Доступны для редактирования столбцы 'Доля владения' и 'Комментарий'.} + type='warning' + action={ + + + + + + + } + /> + )} + +
+ +
+ + + Гудвил + + { + const intValue = e.target.value.replace(/\D/g, ''); + onRowEdit(row.key, 'ownership_share', intValue); + }} + placeholder='%' + /> + ); + } + + return val ? `${Math.floor(Number(val))}%` : '—'; + }, + }, + + { + title: 'Фондовые вложения головной организации', + dataIndex: 'financial_investment_amount', + key: 'financial_investment_amount', + width: 150, + align: 'right', + render: (val, row) => (row.isGroup ? null : formatMoney(val)), + }, + { + title: 'Доля участия головной организации в уставном капитале', + dataIndex: 'participation_amount', + key: 'participation_amount', + width: 150, + align: 'right', + render: (val, row) => (row.isGroup ? null : formatMoney(val)), + }, + { + title: 'Уставный капитал', + dataIndex: 'charter_capital_amount', + key: 'charter_capital_amount', + width: 150, + align: 'right', + render: (val, row) => (row.isGroup ? null : formatMoney(val)), + }, + { + title: 'Гудвилл (+/-)', + dataIndex: 'goodwill_amount', + key: 'goodwill_amount', + width: 150, + align: 'right', + render: (val, row) => { + if (row.isGroup) return null; + + const formatted = formatMoney(val); + if (formatted === '—') return —; + + + const isNegative = String(val).trim().startsWith('-'); + + return ( + + {formatted} + + ); + }, + }, + { + title: 'Комментарий', + key: 'comment', + width: 200, + onCell: (row) => { + if (row.isGroup) return {}; + const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; + if (!isChecked) return {}; + + const comment = editedRows[row.key]?.comment ?? row.comment; + + const isCommentEmpty = !comment || String(comment).trim() === ''; + + const isInvalid = isCommentEmpty; + + return { + style: { + backgroundColor: isInvalid ? '#fffbe6' : '#f6ffed', + }, + }; + }, + render: (_, row) => { + if (row.isGroup) return null; + + const val = editedRows[row.key]?.comment ?? row.comment; + const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; + + if (isEditMode && isChecked) { + return ( + onRowEdit(row.key, 'comment', e.target.value)} + placeholder='Комментарий' + /> + ); + } + return val || '—'; + }, + }, + ]; +}; -- 2.47.2 From a1054f08c9d06a10a0ea212ef0ff71366045d11d Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 5 Nov 2026 13:57:54 +0300 Subject: [PATCH 02/15] =?UTF-8?q?aurora-1544=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BD=D0=B0=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=20=D0=B3=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=20=D0=B8=20=D0=B7=D0=BD=D0=B0=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B7=D0=B0=D0=BF=D1=8F=D1=82?= =?UTF-8?q?=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsolidationFormatTableFilter.tsx} | 4 +- web/src/layouts/ConsolidationReportPage.tsx | 2 +- web/src/services/{ => api}/goodwill.ts | 4 +- web/src/types/goodwill/goodwill.ts | 14 ++- .../ConsolidatedChessContent.tsx | 6 +- .../ConsolidationGoodwillContent.tsx | 93 ++++++++++++++----- .../ConsolidationGoodwillTable.css | 5 + .../ConsolidationGoodwillTable.tsx | 73 ++++++++++++--- .../ConsolidationGoodwillTable/config.tsx | 9 +- 9 files changed, 161 insertions(+), 49 deletions(-) rename web/src/{widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx => components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx} (90%) rename web/src/services/{ => api}/goodwill.ts (88%) diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx b/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx similarity index 90% rename from web/src/widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx rename to web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx index 5c2b5d9..79ee8f5 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx +++ b/web/src/components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter.tsx @@ -1,14 +1,14 @@ import { Select, Space } from 'antd'; import type React from 'react'; -interface ConsolidationChessFormatTableFilterI { +interface ConsolidationFormatTableFilterI { decimals: number; onDecimalsChange: (val: number) => void; units: string; onUnitsChange: (val: string) => void; } -export const ConsolidationChessFormatTableFilter: React.FC = ({ +export const ConsolidationFormatTableFilter: React.FC = ({ decimals, onDecimalsChange, units, diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 5823352..bf19cd7 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -32,7 +32,7 @@ function ReportContent() { osv: , chess: , - goodwill: , + goodwill: , }; const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; diff --git a/web/src/services/goodwill.ts b/web/src/services/api/goodwill.ts similarity index 88% rename from web/src/services/goodwill.ts rename to web/src/services/api/goodwill.ts index 294d801..90a97e6 100644 --- a/web/src/services/goodwill.ts +++ b/web/src/services/api/goodwill.ts @@ -1,5 +1,5 @@ -import type { GoodwillTableResponse, GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload } from '../types/goodwill/goodwill'; -import { api, apiFetchBlob } from '../api/client'; +import type { GoodwillTableResponse, GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload } from '../../types/goodwill/goodwill'; +import { api, apiFetchBlob } from '../../api/client'; export function fetchGoodwillTable(versionId: string): Promise { return api.get(`/consolidations/versions/${versionId}/goodwill`); diff --git a/web/src/types/goodwill/goodwill.ts b/web/src/types/goodwill/goodwill.ts index 4616126..993dce9 100644 --- a/web/src/types/goodwill/goodwill.ts +++ b/web/src/types/goodwill/goodwill.ts @@ -34,10 +34,20 @@ export interface GoodwillTableResponse { totals: GoodwillTotals; } -// Для дерева Ant Design (с добавлением группировки) export type GoodwillTreeRow = GoodwillRow & { key: string; children?: GoodwillTreeRow[]; isGroup?: boolean; isTotal?: boolean; -}; \ No newline at end of file + isGroupTotal?:boolean +}; + +export interface GoodwillRowPayload { + ownership_share: string; + include_in_kfo: boolean; + comment: string | null; +} + +export interface GoodwillSettingsPayload { + goodwill_enabled: boolean; +} \ No newline at end of file diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx index 3af164a..7009d92 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx @@ -16,7 +16,7 @@ import type { VersionCard } from '../../../types/consolidations/version_members' import { chessMatrixMode } from '../../../utils/chessMatrixMode'; import { saveBlob, xlsxFilename } from '../../../utils/download'; import { useNotificationApi } from '../../../utils/useNotification'; -import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter'; +import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; @@ -245,7 +245,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { backgroundColor: '#e5e7eb', }} /> - { gap: 16, }}>
- { +export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); const notification = useNotificationApi(); @@ -113,9 +115,11 @@ export const GoodwillContent = () => { const goodwillTableRef = useRef(null); const [isGoodwillFullscreen, setIsGoodwillFullscreen] = useState(false); const [goodwillFilter, setGoodwillFilter] = useState('allValue'); + const [exportLoading, setExportLoading] = useState(false); + const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null; const handleRowEdit = useCallback( - (rowKey: string, field: keyof GoodwillRow, value: any) => { + (rowKey: string, field: keyof GoodwillRow, value: boolean | string) => { const isParent = rowKey.startsWith('head-') && !rowKey.includes('-sub-'); if (isParent && field === 'include_in_kfo') { @@ -126,7 +130,7 @@ export const GoodwillContent = () => { const next = { ...prev }; children.forEach((child) => { const childKey = `${headId}-${child.subsidiary_legal_entity.id}`; - next[childKey] = { ...next[childKey], include_in_kfo: value }; + next[childKey] = { ...next[childKey], include_in_kfo: value as boolean }; }); return next; }); @@ -140,12 +144,13 @@ export const GoodwillContent = () => { }, [data?.rows], ); + const columns = useMemo(() => { return getGoodwillColumns({ units, decimals, isEditMode, editedRows, onRowEdit: handleRowEdit }); }, [units, decimals, isEditMode, editedRows, handleRowEdit]); const groupsConfig = useMemo(() => { - return { rowKey: 'key' as any, columns: columns as any }; + return { rowKey: 'key' as const, columns: columns as ColumnDef[] }; }, [columns]); const { visibleKeys, setVisibleKeys } = useVisibleColumns(groupsConfig); @@ -164,18 +169,19 @@ export const GoodwillContent = () => { notification.error({ description: e instanceof Error ? e.message : 'Ошибка' }); } }; + const dropFiltersHandler = () => { setGoodwillFilter('allValue'); }; - const loadData = useCallback(async () => { + const loadData = useCallback(async (vId: string) => { setLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 500)); - setData(MOCK_DATA); + const res = await fetchGoodwillTable(vId); + setData(res); const initialEdited: Record> = {}; - MOCK_DATA.rows.forEach((row) => { + res.rows.forEach((row) => { if (row.include_in_kfo) { const key = `${row.head_legal_entity.id}-${row.subsidiary_legal_entity.id}`; initialEdited[key] = { @@ -196,13 +202,35 @@ export const GoodwillContent = () => { }, [notification]); const handleSave = async () => { + if (!version_id || !data) return; setSaving(true); try { - await new Promise((resolve) => setTimeout(resolve, 800)); - notification.success({ description: 'Изменения сохранены (тестовый режим)' }); + const promises = Object.keys(editedRows).map((rowKey) => { + const changes = editedRows[rowKey]; + const originalRow = data.rows.find( + (r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey + ); + if (!originalRow) return; + + const payload: GoodwillRowPayload = { + ownership_share: String(changes.ownership_share ?? originalRow.ownership_share ?? ''), + include_in_kfo: changes.include_in_kfo ?? originalRow.include_in_kfo, + comment: changes.comment !== undefined ? changes.comment : (originalRow.comment ?? null), + }; + + return saveGoodwillRow( + version_id, + originalRow.head_legal_entity.id, + originalRow.subsidiary_legal_entity.id, + payload + ); + }); + + await Promise.all(promises); + notification.success({ description: 'Изменения сохранены' }); setEditedRows({}); setIsEditMode(false); - loadData(); + loadData(version_id); } catch (e) { if (e instanceof ApiError) { notification.error({ description: e.message }); @@ -211,6 +239,24 @@ export const GoodwillContent = () => { setSaving(false); } }; + + const download = async () => { + if (!version_id) { + return; + } + setExportLoading(true); + try { + const { blob, filename } = await exportGoodwillTable(version_id); + saveBlob(blob, filename ?? xlsxFilename('Гудвил', versionInfo)); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); + } + } finally { + setExportLoading(false); + } + }; + const filteredData = useMemo(() => { if (!data) return null; if (goodwillFilter === 'allValue') return data; @@ -245,8 +291,10 @@ export const GoodwillContent = () => { }, [editedRows, isEditMode]); useEffect(() => { - loadData(); - }, [loadData]); + if (version_id) { + loadData(version_id); + } + }, [version_id, loadData]); return ( (document.fullscreenElement as HTMLElement) || document.body}> @@ -330,7 +378,7 @@ export const GoodwillContent = () => { backgroundColor: '#e5e7eb', }} /> - { disabled={isEditMode || !data?.editable}> Редактировать +
); -}; +}; \ No newline at end of file diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css index a7f5cac..56addcd 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css @@ -16,3 +16,8 @@ .goodwillTable .resRow { background-color: #e6f4ea !important ; } + +.goodwillTable .group-total-row td { + background-color: #ecfdf3 !important; + font-weight: 600; +} diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 47a6a5d..5020f2b 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -18,7 +18,7 @@ interface ConsolidationGoodwillTableI { isFullscreen?: boolean; isEditMode: boolean; editedRows: Record>; - onRowEdit: (rowId: string, field: keyof GoodwillRow, value: any) => void; + onRowEdit: (rowId: string, field: keyof GoodwillRow, value: boolean | string) => void; visibleKeys: string[]; } @@ -42,10 +42,10 @@ export const ConsolidationGoodwillTable: React.FC = data.rows.forEach((row) => { const headId = row.head_legal_entity.id; - + if (!heads.has(headId)) { const groupNode: GoodwillTreeRow = { - ...row, + ...row, key: `head-${headId}`, isGroup: true, head_legal_entity: row.head_legal_entity, @@ -73,12 +73,44 @@ export const ConsolidationGoodwillTable: React.FC = }); }); - tree.forEach(parent => { + tree.forEach((parent) => { if (parent.children && parent.children.length > 0) { - const allChecked = parent.children.every(child => - editedRows[child.key]?.include_in_kfo ?? child.include_in_kfo - ); + const allChecked = parent.children.every((child) => editedRows[child.key]?.include_in_kfo ?? child.include_in_kfo); parent.include_in_kfo = allChecked; + + let totalFinInv = 0; + let totalParticipation = 0; + let totalCharter = 0; + let totalGoodwill = 0; + + parent.children.forEach((child) => { + totalFinInv += Number(child.financial_investment_amount || 0); + totalParticipation += Number(child.participation_amount || 0); + totalCharter += Number(child.charter_capital_amount || 0); + totalGoodwill += Number(child.goodwill_amount || 0); + }); + + const groupTotalRow: GoodwillTreeRow = { + key: `total-${parent.key}`, + head_legal_entity: parent.head_legal_entity, + subsidiary_legal_entity: { id: '', name: 'Всего' }, + isGroupTotal: true, + isGroup: false, + + financial_investment_amount: String(totalFinInv), + participation_amount: String(totalParticipation), + charter_capital_amount: String(totalCharter), + goodwill_amount: String(totalGoodwill), + + ownership_share: null, + additional_capital_amount: '0', + include_in_kfo: false, + comment: null, + updated_at: null, + updated_by: null, + }; + + parent.children.push(groupTotalRow); } }); @@ -90,7 +122,7 @@ export const ConsolidationGoodwillTable: React.FC = }, [units, decimals, isEditMode, editedRows, onRowEdit]); const visibleColumns = useMemo(() => { - return columns.filter(col => visibleKeys.includes(col.key as string)); + return columns.filter((col) => visibleKeys.includes(col.key as string)); }, [columns, visibleKeys]); const scrollY = isFullscreen ? 'calc(100vh - 200px)' : 'auto'; @@ -98,7 +130,7 @@ export const ConsolidationGoodwillTable: React.FC = const renderSummary = () => { if (!data?.totals) return null; - const ownershipIndex = visibleColumns.findIndex(c => c.key === 'ownership_share'); + const ownershipIndex = visibleColumns.findIndex((c) => c.key === 'ownership_share'); return ( @@ -106,7 +138,7 @@ export const ConsolidationGoodwillTable: React.FC = {visibleColumns.map((col, index) => { if (index === 0) { return ( - 0 ? ownershipIndex : 1} align='left'> + 0 ? ownershipIndex : 1} align='left'>
Итого по листу
@@ -117,7 +149,11 @@ export const ConsolidationGoodwillTable: React.FC = if (index < ownershipIndex) return null; if (col.key === 'ownership_share') { - return —; + return ( + + — + + ); } if (col.key === 'financial_investment_amount') { @@ -141,7 +177,11 @@ export const ConsolidationGoodwillTable: React.FC = } if (col.key === 'charter_capital_amount') { - return —; + return ( + + — + + ); } if (col.key === 'goodwill_amount') { @@ -155,7 +195,11 @@ export const ConsolidationGoodwillTable: React.FC = } if (col.key === 'comment') { - return —; + return ( + + — + + ); } return null; @@ -179,6 +223,7 @@ export const ConsolidationGoodwillTable: React.FC = title={() => toolbar} expandable={{ defaultExpandAllRows: true }} summary={renderSummary} + rowClassName={(record) => (record.isGroupTotal ? 'group-total-row' : '')} /> ); -}; \ No newline at end of file +}; diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx index c0c99aa..adfc6c5 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx @@ -10,7 +10,7 @@ interface GetColumnsParams { decimals: number; isEditMode: boolean; editedRows: Record>; - onRowEdit: (rowId: string, field: keyof GoodwillRow, value: any) => void; + onRowEdit: (rowId: string, field: keyof GoodwillRow, value: boolean | string) => void; } export const getGoodwillColumns = ({ @@ -32,6 +32,8 @@ export const getGoodwillColumns = ({ width: 120, align: 'center', render: (_, row) => { + if (row.isGroupTotal) return null; + const isChecked = row.isGroup ? row.include_in_kfo : (editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo); return ( @@ -63,7 +65,7 @@ export const getGoodwillColumns = ({ return { style: { - backgroundColor: isInvalid ? '#fffbe6' : '#f6ffed', + backgroundColor: isInvalid ? '#fffbe6' : '#f6ffed', }, }; }, @@ -80,7 +82,7 @@ export const getGoodwillColumns = ({ min={0} max={100} step={1} - style={{ width: '100%' }} + style={{ width: '100%' }} value={val ? Math.floor(Number(val)) : ''} onChange={(e) => { const intValue = e.target.value.replace(/\D/g, ''); @@ -131,7 +133,6 @@ export const getGoodwillColumns = ({ const formatted = formatMoney(val); if (formatted === '—') return —; - const isNegative = String(val).trim().startsWith('-'); return ( -- 2.47.2 From 0ee7fe95f18b68603f337cf2f1d1a296c04cb32e Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 5 Nov 2026 13:59:17 +0300 Subject: [PATCH 03/15] =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/layouts/ConsolidationReportPage.tsx | 2 +- web/src/services/api/goodwill.ts | 2 +- .../ConsolidatedChessContent.tsx | 2 +- .../ConsolidationGoodwillContent.tsx | 182 +++++++++--------- 4 files changed, 94 insertions(+), 94 deletions(-) diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index bf19cd7..f35816a 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -16,8 +16,8 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; import { ChessContent } from '../widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent'; -import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; import { GoodwillContent } from '../widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent'; +import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; function ReportContent() { const [card, setCard] = useState(null); diff --git a/web/src/services/api/goodwill.ts b/web/src/services/api/goodwill.ts index 90a97e6..d55b08d 100644 --- a/web/src/services/api/goodwill.ts +++ b/web/src/services/api/goodwill.ts @@ -1,5 +1,5 @@ -import type { GoodwillTableResponse, GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload } from '../../types/goodwill/goodwill'; import { api, apiFetchBlob } from '../../api/client'; +import type { GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload, GoodwillTableResponse } from '../../types/goodwill/goodwill'; export function fetchGoodwillTable(versionId: string): Promise { return api.get(`/consolidations/versions/${versionId}/goodwill`); diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx index 7009d92..c7fdaa7 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx @@ -3,6 +3,7 @@ import { Button, ConfigProvider } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../../api/client'; +import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { exportChessConsolidationsResult, exportChessTableAll, @@ -16,7 +17,6 @@ import type { VersionCard } from '../../../types/consolidations/version_members' import { chessMatrixMode } from '../../../utils/chessMatrixMode'; import { saveBlob, xlsxFilename } from '../../../utils/download'; import { useNotificationApi } from '../../../utils/useNotification'; -import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index a6d3b4a..ac1982b 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -2,102 +2,102 @@ import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutli import { Alert, Button, ConfigProvider, Select, Space, Tooltip } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; -import type { GoodwillRow, GoodwillTableResponse, GoodwillTreeRow, GoodwillRowPayload } from '../../../types/goodwill/goodwill'; -import { useNotificationApi } from '../../../utils/useNotification'; +import { ApiError } from '../../../api/client'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; +import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; +import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRow } from '../../../services/api/goodwill'; +import type { VersionCard } from '../../../types/consolidations/version_members'; +import type { GoodwillRow, GoodwillRowPayload, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; +import { saveBlob, xlsxFilename } from '../../../utils/download'; +import { useNotificationApi } from '../../../utils/useNotification'; import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable'; import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; -import { ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; -import { ApiError } from '../../../api/client'; -import { saveBlob, xlsxFilename } from '../../../utils/download'; -import { VersionCard } from '../../../types/consolidations/version_members'; -import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRow } from '../../../services/api/goodwill'; // #TODO --- ТЕСТОВЫЕ ДАННЫЕ --- -const MOCK_DATA: GoodwillTableResponse = { - version_id: '30d45049-4dd6-4479-9cf7-33f4e71301ef', - goodwill_enabled: true, - editable: true, - read_only_reason: null, - rows: [ - { - head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, - subsidiary_legal_entity: { id: 'sub-1-1', name: 'ООО "Дон Агро"' }, - ownership_share: '100.0000', - financial_investment_amount: '4060.00', - charter_capital_amount: '371.17', - additional_capital_amount: '0.00', - participation_amount: '371.14', - goodwill_amount: '3688.86', - include_in_kfo: true, - comment: 'Основная дочерняя компания', - updated_by: { id: 'user-1', name: 'Иванов И.И.' }, - updated_at: '2026-10-15T10:00:00Z', - }, - { - head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, - subsidiary_legal_entity: { id: 'sub-1-2', name: 'АО "Дон Агро"' }, - ownership_share: '75.0000', - financial_investment_amount: '260.01', - charter_capital_amount: '120.00', - additional_capital_amount: '0.00', - participation_amount: '119.99', - goodwill_amount: '-140.02', - include_in_kfo: false, - comment: null, - updated_by: null, - updated_at: null, - }, - { - head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, - subsidiary_legal_entity: { id: 'sub-1-3', name: 'ООО "Дон Мучнов"' }, - ownership_share: '100.0000', - financial_investment_amount: '130.00', - charter_capital_amount: '0.01', - additional_capital_amount: '0.00', - participation_amount: '0.01', - goodwill_amount: '129.99', - include_in_kfo: true, - comment: '', - updated_by: null, - updated_at: null, - }, - { - head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, - subsidiary_legal_entity: { id: 'sub-1-4', name: 'ООО "Инвестком"' }, - ownership_share: '100.0000', - financial_investment_amount: '267.00', - charter_capital_amount: '0.01', - additional_capital_amount: '0.00', - participation_amount: '0.01', - goodwill_amount: '266.99', - include_in_kfo: false, - comment: null, - updated_by: null, - updated_at: null, - }, - { - head_legal_entity: { id: 'head-2', name: 'Инвестком АО' }, - subsidiary_legal_entity: { id: 'sub-2-1', name: 'ООО "Юг-Инвест"' }, - ownership_share: '50.0000', - financial_investment_amount: '8500.00', - charter_capital_amount: '1000.00', - additional_capital_amount: '500.00', - participation_amount: '500.00', - goodwill_amount: '8000.00', - include_in_kfo: true, - comment: 'Требует пересмотра', - updated_by: { id: 'user-2', name: 'Петров П.П.' }, - updated_at: '2026-10-20T14:30:00Z', - }, - ], - totals: { - financial_investment_amount: '13217.01', - participation_amount: '991.15', - goodwill_amount: '12225.86', - goodwill_in_kfo_amount: '11817.85', - }, -}; +// const MOCK_DATA: GoodwillTableResponse = { +// version_id: '30d45049-4dd6-4479-9cf7-33f4e71301ef', +// goodwill_enabled: true, +// editable: true, +// read_only_reason: null, +// rows: [ +// { +// head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, +// subsidiary_legal_entity: { id: 'sub-1-1', name: 'ООО "Дон Агро"' }, +// ownership_share: '100.0000', +// financial_investment_amount: '4060.00', +// charter_capital_amount: '371.17', +// additional_capital_amount: '0.00', +// participation_amount: '371.14', +// goodwill_amount: '3688.86', +// include_in_kfo: true, +// comment: 'Основная дочерняя компания', +// updated_by: { id: 'user-1', name: 'Иванов И.И.' }, +// updated_at: '2026-10-15T10:00:00Z', +// }, +// { +// head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, +// subsidiary_legal_entity: { id: 'sub-1-2', name: 'АО "Дон Агро"' }, +// ownership_share: '75.0000', +// financial_investment_amount: '260.01', +// charter_capital_amount: '120.00', +// additional_capital_amount: '0.00', +// participation_amount: '119.99', +// goodwill_amount: '-140.02', +// include_in_kfo: false, +// comment: null, +// updated_by: null, +// updated_at: null, +// }, +// { +// head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, +// subsidiary_legal_entity: { id: 'sub-1-3', name: 'ООО "Дон Мучнов"' }, +// ownership_share: '100.0000', +// financial_investment_amount: '130.00', +// charter_capital_amount: '0.01', +// additional_capital_amount: '0.00', +// participation_amount: '0.01', +// goodwill_amount: '129.99', +// include_in_kfo: true, +// comment: '', +// updated_by: null, +// updated_at: null, +// }, +// { +// head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, +// subsidiary_legal_entity: { id: 'sub-1-4', name: 'ООО "Инвестком"' }, +// ownership_share: '100.0000', +// financial_investment_amount: '267.00', +// charter_capital_amount: '0.01', +// additional_capital_amount: '0.00', +// participation_amount: '0.01', +// goodwill_amount: '266.99', +// include_in_kfo: false, +// comment: null, +// updated_by: null, +// updated_at: null, +// }, +// { +// head_legal_entity: { id: 'head-2', name: 'Инвестком АО' }, +// subsidiary_legal_entity: { id: 'sub-2-1', name: 'ООО "Юг-Инвест"' }, +// ownership_share: '50.0000', +// financial_investment_amount: '8500.00', +// charter_capital_amount: '1000.00', +// additional_capital_amount: '500.00', +// participation_amount: '500.00', +// goodwill_amount: '8000.00', +// include_in_kfo: true, +// comment: 'Требует пересмотра', +// updated_by: { id: 'user-2', name: 'Петров П.П.' }, +// updated_at: '2026-10-20T14:30:00Z', +// }, +// ], +// totals: { +// financial_investment_amount: '13217.01', +// participation_amount: '991.15', +// goodwill_amount: '12225.86', +// goodwill_in_kfo_amount: '11817.85', +// }, +// }; export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); -- 2.47.2 From 12f7c4824d940daff6c3708e2ac362df19292a5b Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 5 Nov 2026 15:56:31 +0300 Subject: [PATCH 04/15] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B3=D1=83=D0=B4=D0=B2=D0=B8=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx index adfc6c5..b6c882a 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx @@ -136,7 +136,7 @@ export const getGoodwillColumns = ({ const isNegative = String(val).trim().startsWith('-'); return ( - + {formatted} ); -- 2.47.2 From 446cd583d56fcecdcef8c8cd501bd28b8dd1ad02 Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 5 Nov 2026 17:34:59 +0300 Subject: [PATCH 05/15] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsolidationGoodwillContent.tsx | 125 +++++++++++------- .../ConsolidationGoodwillTable.tsx | 33 +++-- .../ConsolidationGoodwillTable/config.tsx | 36 ++--- 3 files changed, 123 insertions(+), 71 deletions(-) diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index ac1982b..4eb127e 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -23,7 +23,7 @@ import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; // { // head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, // subsidiary_legal_entity: { id: 'sub-1-1', name: 'ООО "Дон Агро"' }, -// ownership_share: '100.0000', +// ownership_share: '100', // financial_investment_amount: '4060.00', // charter_capital_amount: '371.17', // additional_capital_amount: '0.00', @@ -37,7 +37,7 @@ import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; // { // head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, // subsidiary_legal_entity: { id: 'sub-1-2', name: 'АО "Дон Агро"' }, -// ownership_share: '75.0000', +// ownership_share: '75', // financial_investment_amount: '260.01', // charter_capital_amount: '120.00', // additional_capital_amount: '0.00', @@ -51,7 +51,7 @@ import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; // { // head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, // subsidiary_legal_entity: { id: 'sub-1-3', name: 'ООО "Дон Мучнов"' }, -// ownership_share: '100.0000', +// ownership_share: '7.12', // financial_investment_amount: '130.00', // charter_capital_amount: '0.01', // additional_capital_amount: '0.00', @@ -65,7 +65,7 @@ import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; // { // head_legal_entity: { id: 'head-1', name: 'Агрохолдинг "Просторы" АО' }, // subsidiary_legal_entity: { id: 'sub-1-4', name: 'ООО "Инвестком"' }, -// ownership_share: '100.0000', +// ownership_share: '100', // financial_investment_amount: '267.00', // charter_capital_amount: '0.01', // additional_capital_amount: '0.00', @@ -79,7 +79,7 @@ import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; // { // head_legal_entity: { id: 'head-2', name: 'Инвестком АО' }, // subsidiary_legal_entity: { id: 'sub-2-1', name: 'ООО "Юг-Инвест"' }, -// ownership_share: '50.0000', +// ownership_share: '50', // financial_investment_amount: '8500.00', // charter_capital_amount: '1000.00', // additional_capital_amount: '500.00', @@ -130,17 +130,42 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const next = { ...prev }; children.forEach((child) => { const childKey = `${headId}-${child.subsidiary_legal_entity.id}`; - next[childKey] = { ...next[childKey], include_in_kfo: value as boolean }; + const currentChild = next[childKey] || {}; + + next[childKey] = { + ...currentChild, + include_in_kfo: value as boolean, + ...(value + ? { + ownership_share: currentChild.ownership_share ?? child.ownership_share, + comment: currentChild.comment ?? child.comment, + } + : {}), + }; }); return next; }); return; } - setEditedRows((prev) => ({ - ...prev, - [rowKey]: { ...prev[rowKey], [field]: value }, - })); + setEditedRows((prev) => { + const current = prev[rowKey] || {}; + const originalRow = data?.rows.find((r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey); + + return { + ...prev, + [rowKey]: { + ...current, + [field]: value, + ...(field === 'include_in_kfo' && value && originalRow + ? { + ownership_share: current.ownership_share ?? originalRow.ownership_share, + comment: current.comment ?? originalRow.comment, + } + : {}), + }, + }; + }); }, [data?.rows], ); @@ -169,37 +194,40 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { notification.error({ description: e instanceof Error ? e.message : 'Ошибка' }); } }; - + const dropFiltersHandler = () => { setGoodwillFilter('allValue'); }; - const loadData = useCallback(async (vId: string) => { - setLoading(true); - try { - const res = await fetchGoodwillTable(vId); - setData(res); + const loadData = useCallback( + async (vId: string) => { + setLoading(true); + try { + const res = await fetchGoodwillTable(vId); + setData(res); - const initialEdited: Record> = {}; - res.rows.forEach((row) => { - if (row.include_in_kfo) { - const key = `${row.head_legal_entity.id}-${row.subsidiary_legal_entity.id}`; - initialEdited[key] = { - include_in_kfo: true, - ownership_share: row.ownership_share, - comment: row.comment, - }; + const initialEdited: Record> = {}; + res.rows.forEach((row) => { + if (row.include_in_kfo) { + const key = `${row.head_legal_entity.id}-${row.subsidiary_legal_entity.id}`; + initialEdited[key] = { + include_in_kfo: true, + ownership_share: row.ownership_share, + comment: row.comment, + }; + } + }); + setEditedRows(initialEdited); + } catch (e) { + if (e instanceof ApiError) { + notification.error({ description: e.message }); } - }); - setEditedRows(initialEdited); - } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); + } finally { + setLoading(false); } - } finally { - setLoading(false); - } - }, [notification]); + }, + [notification], + ); const handleSave = async () => { if (!version_id || !data) return; @@ -207,9 +235,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { try { const promises = Object.keys(editedRows).map((rowKey) => { const changes = editedRows[rowKey]; - const originalRow = data.rows.find( - (r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey - ); + const originalRow = data.rows.find((r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey); if (!originalRow) return; const payload: GoodwillRowPayload = { @@ -218,12 +244,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { comment: changes.comment !== undefined ? changes.comment : (originalRow.comment ?? null), }; - return saveGoodwillRow( - version_id, - originalRow.head_legal_entity.id, - originalRow.subsidiary_legal_entity.id, - payload - ); + return saveGoodwillRow(version_id, originalRow.head_legal_entity.id, originalRow.subsidiary_legal_entity.id, payload); }); await Promise.all(promises); @@ -256,7 +277,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { setExportLoading(false); } }; - + const filteredData = useMemo(() => { if (!data) return null; if (goodwillFilter === 'allValue') return data; @@ -281,7 +302,6 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { } const hasErrors = Object.values(editedRows).some((elem) => { if (!elem.include_in_kfo) return false; - const isShareEmpty = !elem.ownership_share || String(elem.ownership_share).trim() === ''; const isCommentEmpty = !elem.comment || String(elem.comment).trim() === ''; @@ -290,6 +310,19 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { return !hasErrors; }, [editedRows, isEditMode]); + useEffect(() => { + const handleFullscreenChange = () => { + if (!document.fullscreenElement) { + setIsGoodwillFullscreen(false); + } + }; + + document.addEventListener('fullscreenchange', handleFullscreenChange); + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange); + }; + }, []); + useEffect(() => { if (version_id) { loadData(version_id); @@ -410,4 +443,4 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
); -}; \ No newline at end of file +}; diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 5020f2b..230b68f 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -6,6 +6,7 @@ import type { GoodwillRow, GoodwillTableResponse, GoodwillTreeRow } from '../../ import './ConsolidationGoodwillTable.css'; import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; import { getGoodwillColumns } from './config'; +import { sumAmounts } from '../../../utils/money'; const { Text } = Typography; @@ -128,17 +129,28 @@ export const ConsolidationGoodwillTable: React.FC = const scrollY = isFullscreen ? 'calc(100vh - 200px)' : 'auto'; const renderSummary = () => { - if (!data?.totals) return null; + if (!data?.rows) return null; - const ownershipIndex = visibleColumns.findIndex((c) => c.key === 'ownership_share'); + const totalFinInv = sumAmounts(data.rows.map((c) => c.financial_investment_amount)) ?? '0.00'; + const totalParticipation = sumAmounts(data.rows.map((c) => c.participation_amount)) ?? '0.00'; + const totalCharter = sumAmounts(data.rows.map((c) => c.charter_capital_amount)) ?? '0.00'; + const totalGoodwill = sumAmounts(data.rows.map((c) => c.goodwill_amount)) ?? '0.00'; return ( {visibleColumns.map((col, index) => { - if (index === 0) { + if (col.key === 'include_in_kfo') { return ( - 0 ? ownershipIndex : 1} align='left'> + + — + + ); + } + + if (col.key === 'name') { + return ( +
Итого по листу
@@ -146,8 +158,6 @@ export const ConsolidationGoodwillTable: React.FC = ); } - if (index < ownershipIndex) return null; - if (col.key === 'ownership_share') { return ( @@ -160,7 +170,7 @@ export const ConsolidationGoodwillTable: React.FC = return (
- {chessTableCellFormatValue(data.totals.financial_investment_amount, units, decimals)} + {chessTableCellFormatValue(String(totalFinInv), units, decimals)}
); @@ -170,7 +180,7 @@ export const ConsolidationGoodwillTable: React.FC = return (
- {chessTableCellFormatValue(data.totals.participation_amount, units, decimals)} + {chessTableCellFormatValue(String(totalParticipation), units, decimals)}
); @@ -179,7 +189,9 @@ export const ConsolidationGoodwillTable: React.FC = if (col.key === 'charter_capital_amount') { return ( - — +
+ {chessTableCellFormatValue(String(totalCharter), units, decimals)} +
); } @@ -188,7 +200,7 @@ export const ConsolidationGoodwillTable: React.FC = return (
- {chessTableCellFormatValue(data.totals.goodwill_amount, units, decimals)} + {chessTableCellFormatValue(String(totalGoodwill), units, decimals)}
); @@ -221,6 +233,7 @@ export const ConsolidationGoodwillTable: React.FC = size='small' bordered title={() => toolbar} + key={data?.rows?.length} expandable={{ defaultExpandAllRows: true }} summary={renderSummary} rowClassName={(record) => (record.isGroupTotal ? 'group-total-row' : '')} diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx index b6c882a..685b5c6 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx @@ -74,26 +74,36 @@ export const getGoodwillColumns = ({ const val = editedRows[row.key]?.ownership_share ?? row.ownership_share; const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; - if (isEditMode && isChecked) { return ( { - const intValue = e.target.value.replace(/\D/g, ''); - onRowEdit(row.key, 'ownership_share', intValue); + let inputValue = e.target.value; + + inputValue = inputValue.replace('.', ','); + + inputValue = inputValue.replace(/[^\d,]/g, ''); + + const parts = inputValue.split(','); + if (parts.length > 1 && parts[1].length > 2) { + inputValue = `${parts[0]},${parts[1].slice(0, 2)}`; + } + + const numericValue = Number(inputValue.replace(',', '.')); + if (!isNaN(numericValue) && numericValue > 100) { + inputValue = '100'; + } + + onRowEdit(row.key, 'ownership_share', inputValue); }} placeholder='%' /> ); } - return val ? `${Math.floor(Number(val))}%` : '—'; + return val ? `${Number(val)}%` : '—'; }, }, @@ -135,11 +145,7 @@ export const getGoodwillColumns = ({ const isNegative = String(val).trim().startsWith('-'); - return ( - - {formatted} - - ); + return {formatted}; }, }, { -- 2.47.2 From db8641a1a8272a79e9e6565448e8c8e10bd73b76 Mon Sep 17 00:00:00 2001 From: Grog Date: Fri, 6 Nov 2026 08:37:53 +0300 Subject: [PATCH 06/15] =?UTF-8?q?=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/layouts/CatalogueGroups.tsx | 11 ++--------- .../CompanyGroupForm/CompanyGroupForm.tsx | 8 ++------ .../CatalogueJurForm/CatalogueJurForm.tsx | 13 +++---------- .../CatalogueJurMoveForm/CatalogueJurMoveForm.tsx | 11 ++--------- .../CatalogueJurTable/CatalogueJurTable.tsx | 14 ++------------ .../CatalogueJurWithdrawForm.tsx | 11 ++--------- .../ConsolidatedChessContent.tsx | 12 +++--------- .../ConsolidationGoodwillContent.tsx | 15 ++++----------- .../RelatedGroupForm/RelatedGroupForm.tsx | 4 +--- .../RelatedGroupTable/RelatedGroupTable.tsx | 7 +------ 10 files changed, 22 insertions(+), 84 deletions(-) diff --git a/web/src/layouts/CatalogueGroups.tsx b/web/src/layouts/CatalogueGroups.tsx index dfcf853..aa443be 100644 --- a/web/src/layouts/CatalogueGroups.tsx +++ b/web/src/layouts/CatalogueGroups.tsx @@ -1,7 +1,6 @@ import { PlusOutlined } from '@ant-design/icons'; import { Button, Empty, Space } from 'antd'; import { useEffect, useMemo, useState } from 'react'; -import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; import { createCatalogueJur } from '../services/api/directories/catalogue_jur'; import { @@ -108,11 +107,7 @@ export function CatalogueGroup() { setCompanyGroupDeleteOpen(false); } catch (e) { - if (e instanceof ApiError) { - notification.error({ - description: e.message, - }); - } + notification.error({ description: e instanceof Error ? e.message : 'Не удалось обновить компанию' }); } finally { setCompanyGroupDeleteLoading(false); } @@ -128,9 +123,7 @@ export function CatalogueGroup() { setGskData(gsks); setGszData(gszs); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении связанных групп' }); } finally { setRelatedGroupLoading(false); } diff --git a/web/src/widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm.tsx b/web/src/widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm.tsx index fe8520a..c0c268e 100644 --- a/web/src/widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm.tsx +++ b/web/src/widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm.tsx @@ -37,9 +37,7 @@ export const CompanyGroupForm: React.FC = ({ open, onCancel, onOk, compan base_regional_office_id: data.base_regional_office_id, }); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении данных' }); } finally { setInitialDataLoading(false); } @@ -53,9 +51,7 @@ export const CompanyGroupForm: React.FC = ({ open, onCancel, onOk, compan const options = res.map((elem) => elem && { value: elem.id, label: elem.name }); setOfficesOptions(options); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получения региональных офисов' }); } finally { setLoadingOptions(false); } diff --git a/web/src/widgets/CatalogueJur/CatalogueJurForm/CatalogueJurForm.tsx b/web/src/widgets/CatalogueJur/CatalogueJurForm/CatalogueJurForm.tsx index afce0b8..d81421a 100644 --- a/web/src/widgets/CatalogueJur/CatalogueJurForm/CatalogueJurForm.tsx +++ b/web/src/widgets/CatalogueJur/CatalogueJurForm/CatalogueJurForm.tsx @@ -18,7 +18,6 @@ type Props = { jurId?: string; }; -// Ошибки сервера, относящиеся к одному полю, показываем у этого поля const FIELD_BY_ERROR_CODE: Record = { INN_EXISTS: 'inn', INVALID_INN: 'inn', @@ -61,9 +60,7 @@ export const CatalogueJurForm: React.FC = ({ open, onCancel, onOk, jurId }); getRelatedGroupsHandler(data.company_group_id as string); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получнии данных' }); } finally { setInitialDataLoading(false); } @@ -82,9 +79,7 @@ export const CatalogueJurForm: React.FC = ({ open, onCancel, onOk, jurId const options = res.map((elem) => elem && { value: elem.id, label: elem.name }); setOfficesOptions(options); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получения офисов рф' }); } finally { setLoadingOfficesOptions(false); } @@ -101,9 +96,7 @@ export const CatalogueJurForm: React.FC = ({ open, onCancel, onOk, jurId setGskData(gsks); setGszData(gszs); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении связанных компаний' }); } finally { setLoadingRelatedGroupsOptions(false); } diff --git a/web/src/widgets/CatalogueJur/CatalogueJurMoveForm/CatalogueJurMoveForm.tsx b/web/src/widgets/CatalogueJur/CatalogueJurMoveForm/CatalogueJurMoveForm.tsx index cf1b1c6..47d2b1f 100644 --- a/web/src/widgets/CatalogueJur/CatalogueJurMoveForm/CatalogueJurMoveForm.tsx +++ b/web/src/widgets/CatalogueJur/CatalogueJurMoveForm/CatalogueJurMoveForm.tsx @@ -3,7 +3,6 @@ import FormItem from 'antd/es/form/FormItem'; import dayjs from 'dayjs'; import type React from 'react'; import { useEffect, useState } from 'react'; -import { ApiError } from '../../../api/client'; import { AppModal } from '../../../components/AppModal/AppModal'; import { fetchOneCatalogueJur } from '../../../services/api/directories/catalogue_jur'; import { getCompanyGroupsThunk } from '../../../store/slices/companyGroupsSlice/thunks'; @@ -47,9 +46,7 @@ export const MoveCatalogueJurForm: React.FC = ({ open, onCancel, onOk, ju effective_date: '', }); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении данных' }); } finally { setInitialDataLoading(false); } @@ -61,11 +58,7 @@ export const MoveCatalogueJurForm: React.FC = ({ open, onCancel, onOk, ju const { effective_date, company_group_id } = await form.validateFields(); await onOk({ effective_date, company_group_id }); } catch (e) { - if (e instanceof ApiError) { - notification.error({ - description: e.message, - }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при сохранении данных' }); } finally { setConfirmLoading(false); } diff --git a/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx b/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx index eaa4d30..85848fd 100644 --- a/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx +++ b/web/src/widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable.tsx @@ -11,7 +11,6 @@ import { Alert, Button, Checkbox, Dropdown, Input, Space } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ApiError } from '../../../api/client'; import { ColumnSettings, DataTable, useFilteredRows, useTableFilters, useVisibleColumns } from '../../../components/GenericTable'; import { editCatalogueJur, moveCatalogueJur, withdrawCatalogueJur } from '../../../services/api/directories/catalogue_jur'; import { setIsGettingArchive, setRelatedGroupFilter } from '../../../store/slices/catalogueJurSlice/catalogueJurSlice'; @@ -100,11 +99,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur dispatch(getCompanyGroupsThunk()); closeMoveCatalogueJurHandler(); } catch (e) { - if (e instanceof ApiError) { - notification.error({ - description: e.message, - }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при перемещении ЮЛ в другую ГК' }); } } async function withdrawCatalogueJurHandler(values: WithdrawCatalogueJurProps) { @@ -115,11 +110,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur closeWithdrawCatalogueJurHandler(); } catch (e) { - if (e instanceof ApiError) { - notification.error({ - description: e.message, - }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при выводе ЮЛ из ГК' }); } } @@ -130,7 +121,6 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur } useEffect(() => { - // «ЮЛ вне ГК» грузится без выбранной ГК; список ГК — только по выбранной (без неё запрос ушёл бы по всем ГК) if (outside_group || activeCompanyGroup?.id) { getCatalogueJur(); } diff --git a/web/src/widgets/CatalogueJur/CatalogueJurWithdrawForm/CatalogueJurWithdrawForm.tsx b/web/src/widgets/CatalogueJur/CatalogueJurWithdrawForm/CatalogueJurWithdrawForm.tsx index c43430f..0ae80a7 100644 --- a/web/src/widgets/CatalogueJur/CatalogueJurWithdrawForm/CatalogueJurWithdrawForm.tsx +++ b/web/src/widgets/CatalogueJur/CatalogueJurWithdrawForm/CatalogueJurWithdrawForm.tsx @@ -4,7 +4,6 @@ import TextArea from 'antd/es/input/TextArea'; import dayjs from 'dayjs'; import type React from 'react'; import { useEffect, useState } from 'react'; -import { ApiError } from '../../../api/client'; import { AppModal } from '../../../components/AppModal/AppModal'; import { fetchOneCatalogueJur } from '../../../services/api/directories/catalogue_jur'; import type { WithdrawCatalogueJurProps } from '../../../types/catalogueJur/catalogueJur'; @@ -44,9 +43,7 @@ export const WithdrawCatalogueJurForm: React.FC = ({ open, onCancel, onOk comment: '', }); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении данных' }); } finally { setInitialDataLoading(false); } @@ -58,11 +55,7 @@ export const WithdrawCatalogueJurForm: React.FC = ({ open, onCancel, onOk const { effective_date, event, comment = '' } = await form.validateFields(); await onOk({ effective_date, event, comment }); } catch (e) { - if (e instanceof ApiError) { - notification.error({ - description: e.message, - }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при сохранении данных' }); } finally { setConfirmLoading(false); } diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx index c7fdaa7..874abf1 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx @@ -108,9 +108,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { const res = await fetchChessConsolidationsResult(vid); setChessResData(res); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении данных' }); } finally { setChessResLoading(false); } @@ -134,9 +132,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo)); } } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при экспорте данных' }); } finally { setMatrixExportLoading(false); } @@ -150,9 +146,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { const { blob, filename } = await exportChessConsolidationsResult(version_id); saveBlob(blob, filename ?? xlsxFilename('Результирующая_таблица_ВГО', versionInfo)); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при экспорте данных' }); } finally { setResExportLoading(false); } diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index 4eb127e..b051c9e 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -2,7 +2,6 @@ import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutli import { Alert, Button, ConfigProvider, Select, Space, Tooltip } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { ApiError } from '../../../api/client'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRow } from '../../../services/api/goodwill'; @@ -191,7 +190,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { setFullscreen(false); } } catch (e) { - notification.error({ description: e instanceof Error ? e.message : 'Ошибка' }); + notification.error({ description: e instanceof Error ? e.message : 'Ошибка раскрытия на весь экран' }); } }; @@ -219,9 +218,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { }); setEditedRows(initialEdited); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при загрузке данных' }); } finally { setLoading(false); } @@ -253,9 +250,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { setIsEditMode(false); loadData(version_id); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при сохранении данных' }); } finally { setSaving(false); } @@ -270,9 +265,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const { blob, filename } = await exportGoodwillTable(version_id); saveBlob(blob, filename ?? xlsxFilename('Гудвил', versionInfo)); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при экспорте таблицы' }); } finally { setExportLoading(false); } diff --git a/web/src/widgets/RelatedGroup/RelatedGroupForm/RelatedGroupForm.tsx b/web/src/widgets/RelatedGroup/RelatedGroupForm/RelatedGroupForm.tsx index b796691..83976fa 100644 --- a/web/src/widgets/RelatedGroup/RelatedGroupForm/RelatedGroupForm.tsx +++ b/web/src/widgets/RelatedGroup/RelatedGroupForm/RelatedGroupForm.tsx @@ -48,9 +48,7 @@ export const RelatedGroupForm: React.FC = ({ open, onCancel, onOk, relate company_group_id: data?.company_group_id, }); } catch (e) { - if (e instanceof ApiError) { - notification.error({ description: e.message }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при получении данных' }); } finally { setInitialDataLoading(false); } diff --git a/web/src/widgets/RelatedGroup/RelatedGroupTable/RelatedGroupTable.tsx b/web/src/widgets/RelatedGroup/RelatedGroupTable/RelatedGroupTable.tsx index 1c45a40..87b62d7 100644 --- a/web/src/widgets/RelatedGroup/RelatedGroupTable/RelatedGroupTable.tsx +++ b/web/src/widgets/RelatedGroup/RelatedGroupTable/RelatedGroupTable.tsx @@ -2,7 +2,6 @@ import { DeleteOutlined, EditOutlined, EllipsisOutlined } from '@ant-design/icon import { Button, Dropdown, Space } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { type ReactNode, useMemo, useState } from 'react'; -import { ApiError } from '../../../api/client'; import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '../../../components/GenericTable'; import { useTableFilters } from '../../../components/GenericTable/useTableFilters'; import { deleteRelatedGroup, editRelatedGroup } from '../../../services/api/directories/related-groups'; @@ -51,11 +50,7 @@ export function RelatedGroupTable({ tabsComponent, data, loading, getData, activ setDeleteModalOpen(false); setGroupId(''); } catch (e) { - if (e instanceof ApiError) { - notification.error({ - description: e.message, - }); - } + notification.error({ description: e instanceof Error ? e.message : 'Ошибка при удалении данных' }); } finally { setDeletingLoading(false); } -- 2.47.2 From 29a630c5e564bbc2798b96d4e9fa183f2c183240 Mon Sep 17 00:00:00 2001 From: Grog Date: Fri, 6 Nov 2026 08:51:16 +0300 Subject: [PATCH 07/15] =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=B2=D0=B5=D1=81=D1=8C=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=85=D1=83=D0=BA=20useFullScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/hooks/useFullScreen.tsx | 29 +++++++++++ .../ConsolidatedChessContent.tsx | 48 ++++++------------- .../ConsolidationGoodwillContent.tsx | 38 ++++----------- .../CorrectionTable/CorrectionContent.tsx | 36 +++----------- 4 files changed, 58 insertions(+), 93 deletions(-) create mode 100644 web/src/hooks/useFullScreen.tsx diff --git a/web/src/hooks/useFullScreen.tsx b/web/src/hooks/useFullScreen.tsx new file mode 100644 index 0000000..baac1d6 --- /dev/null +++ b/web/src/hooks/useFullScreen.tsx @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNotificationApi } from "../utils/useNotification"; + +export function useFullscreen() { + const ref = useRef(null); + const [isFullscreen, setIsFullscreen] = useState(false); + const notification = useNotificationApi(); + + useEffect(() => { + const onChange = () => setIsFullscreen(document.fullscreenElement === ref.current); + document.addEventListener('fullscreenchange', onChange); + return () => document.removeEventListener('fullscreenchange', onChange); + }, []); + + const toggle = useCallback(async () => { + if (!ref.current) return; + try { + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await ref.current.requestFullscreen(); + } + } catch (e) { + notification.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' }); + } + }, [notification]); + + return { ref, isFullscreen, toggle }; +} \ No newline at end of file diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx index 874abf1..c4bc6a4 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx @@ -1,6 +1,6 @@ import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; import { Button, ConfigProvider } from 'antd'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../../api/client'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; @@ -20,6 +20,7 @@ import { useNotificationApi } from '../../../utils/useNotification'; import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; +import { useFullscreen } from '../../../hooks/useFullScreen'; export const ChessContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); @@ -40,38 +41,17 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { 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 { + ref: chessTableRef, + isFullscreen: isChessFullscreen, + toggle: toggleChessFullscreen, + } = useFullscreen(); - const toggleFullscreen = async (ref: React.RefObject, setFullscreen: (val: boolean) => void) => { - if (!ref.current) return; - try { - if (!document.fullscreenElement) { - await ref.current.requestFullscreen(); - setFullscreen(true); - } else { - await document.exitFullscreen(); - setFullscreen(false); - } - } catch (e) { - notification.error({ - description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим', - }); - } - }; - - useEffect(() => { - const handleFullscreenChange = () => { - if (!document.fullscreenElement) { - setIsChessFullscreen(false); - setIsResFullscreen(false); - } - }; - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); - }, []); + const { + ref: resTableRef, + isFullscreen: isResFullscreen, + toggle: toggleResFullscreen, + } = useFullscreen(); const matrixMode = chessMatrixMode(debit, credit); const isHalfFiltered = matrixMode === 'half'; @@ -251,7 +231,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { Экспорт @@ -300,7 +280,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => { Экспорт diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index b051c9e..9424918 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -1,6 +1,6 @@ import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; import { Alert, Button, ConfigProvider, Select, Space, Tooltip } from 'antd'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; @@ -11,6 +11,7 @@ import { saveBlob, xlsxFilename } from '../../../utils/download'; import { useNotificationApi } from '../../../utils/useNotification'; import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable'; import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; +import { useFullscreen } from '../../../hooks/useFullScreen'; // #TODO --- ТЕСТОВЫЕ ДАННЫЕ --- // const MOCK_DATA: GoodwillTableResponse = { @@ -111,8 +112,6 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const [isEditMode, setIsEditMode] = useState(false); const [editedRows, setEditedRows] = useState>>({}); - const goodwillTableRef = useRef(null); - const [isGoodwillFullscreen, setIsGoodwillFullscreen] = useState(false); const [goodwillFilter, setGoodwillFilter] = useState('allValue'); const [exportLoading, setExportLoading] = useState(false); const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null; @@ -179,20 +178,6 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const { visibleKeys, setVisibleKeys } = useVisibleColumns(groupsConfig); - const toggleFullscreen = async (ref: React.RefObject, setFullscreen: (val: boolean) => void) => { - if (!ref.current) return; - try { - if (!document.fullscreenElement) { - await ref.current.requestFullscreen(); - setFullscreen(true); - } else { - await document.exitFullscreen(); - setFullscreen(false); - } - } catch (e) { - notification.error({ description: e instanceof Error ? e.message : 'Ошибка раскрытия на весь экран' }); - } - }; const dropFiltersHandler = () => { setGoodwillFilter('allValue'); @@ -303,18 +288,11 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { return !hasErrors; }, [editedRows, isEditMode]); - useEffect(() => { - const handleFullscreenChange = () => { - if (!document.fullscreenElement) { - setIsGoodwillFullscreen(false); - } - }; - - document.addEventListener('fullscreenchange', handleFullscreenChange); - return () => { - document.removeEventListener('fullscreenchange', handleFullscreenChange); - }; - }, []); + const { + ref: goodwillTableRef, + isFullscreen: isGoodwillFullscreen, + toggle: toggleGoodwillFullscreen, + } = useFullscreen(); useEffect(() => { if (version_id) { @@ -424,7 +402,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { Экспорт diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index a8fc454..4aa77ab 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -16,6 +16,7 @@ import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; import { errorOutcome, readOnlyHint } from './readOnly'; import { EMPTY_FILTERS, type TableFilters, aggregateKey, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel'; import { useCorrectionTable } from './useCorrectionTable'; +import { useFullscreen } from '../../hooks/useFullScreen'; const LOAD_ERROR_KEY = 'corrections-load-error'; @@ -23,10 +24,9 @@ type Props = { versionId: string; card?: VersionCard | null }; export function CorrectionContent({ versionId, card = null }: Props) { const { table, loading, error, reload, replace, replaceAggregate } = useCorrectionTable(versionId); - const boxRef = useRef(null); // уведомления рисуются внутри бокса вкладки: в полноэкранном режиме виден только полноэкранный элемент, // глобальные уведомления за ним не видны - const [notification, holder] = antdNotification.useNotification({ getContainer: () => boxRef.current ?? document.body }); + const [notification, holder] = antdNotification.useNotification({ getContainer: () => tableRef.current ?? document.body }); const notify = useMemo( () => ({ error: ({ description }: { description: ReactNode }) => @@ -40,7 +40,6 @@ export function CorrectionContent({ versionId, card = null }: Props) { const [expanded, setExpanded] = useState>(new Set()); const { visibleKeys, setVisibleKeys } = useVisibleColumns({ rowKey: 'include', columns: COLUMN_DEFS }); const [exporting, setExporting] = useState(false); - const [fullscreen, setFullscreen] = useState(false); // идёт запрос решения по строке: на это время контролы строк неактивны, чтобы не слать два запроса подряд const [busy, setBusy] = useState(false); // ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно @@ -71,28 +70,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { // ушли с вкладки — уведомление об ошибке закрываем, оно относится к этому экрану useEffect(() => () => notification.destroy(LOAD_ERROR_KEY), [notification]); - useEffect(() => { - const onChange = () => { - if (!document.fullscreenElement) setFullscreen(false); - }; - document.addEventListener('fullscreenchange', onChange); - return () => document.removeEventListener('fullscreenchange', onChange); - }, []); - - const toggleFullscreen = async () => { - if (!boxRef.current) return; - try { - if (!document.fullscreenElement) { - await boxRef.current.requestFullscreen(); - setFullscreen(true); - } else { - await document.exitFullscreen(); - setFullscreen(false); - } - } catch (e) { - notify.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' }); - } - }; + const { ref: tableRef, isFullscreen: isFullscreen, toggle: toggleFullscreen } = useFullscreen(); const download = async () => { setExporting(true); @@ -171,7 +149,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { onFiltersChange={setFilters} lineOptions={lines} visibleKeys={visibleKeys as ColKey[]} - scrollY={fullscreen ? 'calc(100vh - 190px)' : 'calc(100vh - 360px)'} + scrollY={isFullscreen ? 'calc(100vh - 190px)' : 'calc(100vh - 360px)'} emptyText={emptyText} /> @@ -194,7 +172,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { return ( (document.fullscreenElement as HTMLElement) || document.body}>
{holder} @@ -221,7 +199,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { onExport={download} exporting={exporting} exportDisabled={!table} - fullscreen={fullscreen} + fullscreen={isFullscreen} onToggleFullscreen={toggleFullscreen} /> {body} -- 2.47.2 From 67899fc9637cb189aafb2d1c0363e3305a7b9235 Mon Sep 17 00:00:00 2001 From: Grog Date: Fri, 6 Nov 2026 08:52:33 +0300 Subject: [PATCH 08/15] =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/layouts/ConsolidationReportPage.tsx | 2 +- .../ConsolidationChessContent/ConsolidatedChessContent.tsx | 2 +- .../ConsolidationGoodwillContent.tsx | 2 +- .../ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx | 2 +- web/src/widgets/CorrectionTable/CorrectionContent.tsx | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 156c097..96cc894 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -16,8 +16,8 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts'; import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; import { ChessContent } from '../widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent'; -import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent'; import { GoodwillContent } from '../widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent'; +import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent'; import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; function ReportContent() { diff --git a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx index c4bc6a4..62cb4b3 100644 --- a/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx +++ b/web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../../api/client'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; +import { useFullscreen } from '../../../hooks/useFullScreen'; import { exportChessConsolidationsResult, exportChessTableAll, @@ -20,7 +21,6 @@ import { useNotificationApi } from '../../../utils/useNotification'; import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable'; import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable'; import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter'; -import { useFullscreen } from '../../../hooks/useFullScreen'; export const ChessContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index 9424918..4660644 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; +import { useFullscreen } from '../../../hooks/useFullScreen'; import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRow } from '../../../services/api/goodwill'; import type { VersionCard } from '../../../types/consolidations/version_members'; import type { GoodwillRow, GoodwillRowPayload, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; @@ -11,7 +12,6 @@ import { saveBlob, xlsxFilename } from '../../../utils/download'; import { useNotificationApi } from '../../../utils/useNotification'; import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable'; import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; -import { useFullscreen } from '../../../hooks/useFullScreen'; // #TODO --- ТЕСТОВЫЕ ДАННЫЕ --- // const MOCK_DATA: GoodwillTableResponse = { diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 230b68f..7631f8e 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -5,8 +5,8 @@ import { useMemo } from 'react'; import type { GoodwillRow, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; import './ConsolidationGoodwillTable.css'; import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; -import { getGoodwillColumns } from './config'; import { sumAmounts } from '../../../utils/money'; +import { getGoodwillColumns } from './config'; const { Text } = Typography; diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx index 4aa77ab..d10f2b1 100644 --- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx +++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx @@ -4,6 +4,7 @@ import { Button, ConfigProvider, Spin, notification as antdNotification } from ' import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns'; +import { useFullscreen } from '../../hooks/useFullScreen'; import { allocate, exportCorrectionTable } from '../../services/api/corrections'; import type { VersionCard } from '../../types/consolidations/version_members'; import type { AllocationIn } from '../../types/corrections'; @@ -16,7 +17,6 @@ import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; import { errorOutcome, readOnlyHint } from './readOnly'; import { EMPTY_FILTERS, type TableFilters, aggregateKey, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel'; import { useCorrectionTable } from './useCorrectionTable'; -import { useFullscreen } from '../../hooks/useFullScreen'; const LOAD_ERROR_KEY = 'corrections-load-error'; @@ -70,7 +70,7 @@ export function CorrectionContent({ versionId, card = null }: Props) { // ушли с вкладки — уведомление об ошибке закрываем, оно относится к этому экрану useEffect(() => () => notification.destroy(LOAD_ERROR_KEY), [notification]); - const { ref: tableRef, isFullscreen: isFullscreen, toggle: toggleFullscreen } = useFullscreen(); + const { ref: tableRef, isFullscreen, toggle: toggleFullscreen } = useFullscreen(); const download = async () => { setExporting(true); -- 2.47.2 From 1ee71355daf2104acb093fc6e9617d161bab41cc Mon Sep 17 00:00:00 2001 From: Grog Date: Fri, 6 Nov 2026 09:08:39 +0300 Subject: [PATCH 09/15] =?UTF-8?q?=D1=84=D1=83=D1=82=D0=B5=D1=80=20=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=8B=20=D0=B3=D1=83=D0=B4=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D0=B0=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsolidationGoodwillSummary.tsx | 108 ++++++++++++++++++ .../ConsolidationGoodwillTable.tsx | 103 +---------------- 2 files changed, 112 insertions(+), 99 deletions(-) create mode 100644 web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx new file mode 100644 index 0000000..194833b --- /dev/null +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx @@ -0,0 +1,108 @@ +import { Table, Typography } from 'antd'; +import type { GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; +import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; +import { sumAmounts } from '../../../utils/money'; +import { ColumnGroupType } from 'antd/es/table'; +import { ColumnType } from 'antd/lib/table'; + +const { Text } = Typography; + +interface ConsolidationGoodwillSummaryI { + data: GoodwillTableResponse | null; + visibleColumns: (ColumnGroupType | ColumnType)[]; + units: string; + decimals: number; +} + +export const ConsolidationGoodwillSummary: React.FC = ({ data, visibleColumns, units, decimals }) => { + if (!data?.rows) return null; + + const totalFinInv = sumAmounts(data.rows.map((c) => c.financial_investment_amount)) ?? '0.00'; + const totalParticipation = sumAmounts(data.rows.map((c) => c.participation_amount)) ?? '0.00'; + const totalCharter = sumAmounts(data.rows.map((c) => c.charter_capital_amount)) ?? '0.00'; + const totalGoodwill = sumAmounts(data.rows.map((c) => c.goodwill_amount)) ?? '0.00'; + + return ( + + + {visibleColumns.map((col, index) => { + if (col.key === 'include_in_kfo') { + return ( + + — + + ); + } + + if (col.key === 'name') { + return ( + +
+ Итого по листу +
+
+ ); + } + + if (col.key === 'ownership_share') { + return ( + + — + + ); + } + + if (col.key === 'financial_investment_amount') { + return ( + +
+ {chessTableCellFormatValue(totalFinInv, units, decimals)} +
+
+ ); + } + + if (col.key === 'participation_amount') { + return ( + +
+ {chessTableCellFormatValue(totalParticipation, units, decimals)} +
+
+ ); + } + + if (col.key === 'charter_capital_amount') { + return ( + +
+ {chessTableCellFormatValue(totalCharter, units, decimals)} +
+
+ ); + } + + if (col.key === 'goodwill_amount') { + return ( + +
+ {chessTableCellFormatValue(totalGoodwill, units, decimals)} +
+
+ ); + } + + if (col.key === 'comment') { + return ( + + — + + ); + } + + return null; + })} +
+
+ ); +}; diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 7631f8e..73d6878 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -1,14 +1,12 @@ -import { Table, Typography } from 'antd'; +import { Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type React from 'react'; import { useMemo } from 'react'; import type { GoodwillRow, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; import './ConsolidationGoodwillTable.css'; -import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; -import { sumAmounts } from '../../../utils/money'; -import { getGoodwillColumns } from './config'; -const { Text } = Typography; +import { getGoodwillColumns } from './config'; +import { ConsolidationGoodwillSummary } from '../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary'; interface ConsolidationGoodwillTableI { data: GoodwillTableResponse | null; @@ -128,99 +126,6 @@ export const ConsolidationGoodwillTable: React.FC = const scrollY = isFullscreen ? 'calc(100vh - 200px)' : 'auto'; - const renderSummary = () => { - if (!data?.rows) return null; - - const totalFinInv = sumAmounts(data.rows.map((c) => c.financial_investment_amount)) ?? '0.00'; - const totalParticipation = sumAmounts(data.rows.map((c) => c.participation_amount)) ?? '0.00'; - const totalCharter = sumAmounts(data.rows.map((c) => c.charter_capital_amount)) ?? '0.00'; - const totalGoodwill = sumAmounts(data.rows.map((c) => c.goodwill_amount)) ?? '0.00'; - - return ( - - - {visibleColumns.map((col, index) => { - if (col.key === 'include_in_kfo') { - return ( - - — - - ); - } - - if (col.key === 'name') { - return ( - -
- Итого по листу -
-
- ); - } - - if (col.key === 'ownership_share') { - return ( - - — - - ); - } - - if (col.key === 'financial_investment_amount') { - return ( - -
- {chessTableCellFormatValue(String(totalFinInv), units, decimals)} -
-
- ); - } - - if (col.key === 'participation_amount') { - return ( - -
- {chessTableCellFormatValue(String(totalParticipation), units, decimals)} -
-
- ); - } - - if (col.key === 'charter_capital_amount') { - return ( - -
- {chessTableCellFormatValue(String(totalCharter), units, decimals)} -
-
- ); - } - - if (col.key === 'goodwill_amount') { - return ( - -
- {chessTableCellFormatValue(String(totalGoodwill), units, decimals)} -
-
- ); - } - - if (col.key === 'comment') { - return ( - - — - - ); - } - - return null; - })} -
-
- ); - }; - return ( className='goodwillTable' @@ -235,7 +140,7 @@ export const ConsolidationGoodwillTable: React.FC = title={() => toolbar} key={data?.rows?.length} expandable={{ defaultExpandAllRows: true }} - summary={renderSummary} + summary={() => } rowClassName={(record) => (record.isGroupTotal ? 'group-total-row' : '')} /> ); -- 2.47.2 From 5304c97fb4828a9413356ebd03559dfe05f79de5 Mon Sep 17 00:00:00 2001 From: Grog Date: Fri, 6 Nov 2026 09:09:24 +0300 Subject: [PATCH 10/15] =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsolidationGoodwillSummary.tsx | 4 ++-- .../ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx index 194833b..9e8db26 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx @@ -1,9 +1,9 @@ import { Table, Typography } from 'antd'; +import type { ColumnGroupType } from 'antd/es/table'; +import type { ColumnType } from 'antd/lib/table'; import type { GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; import { sumAmounts } from '../../../utils/money'; -import { ColumnGroupType } from 'antd/es/table'; -import { ColumnType } from 'antd/lib/table'; const { Text } = Typography; diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 73d6878..83e7d8b 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -5,8 +5,8 @@ import { useMemo } from 'react'; import type { GoodwillRow, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; import './ConsolidationGoodwillTable.css'; -import { getGoodwillColumns } from './config'; import { ConsolidationGoodwillSummary } from '../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary'; +import { getGoodwillColumns } from './config'; interface ConsolidationGoodwillTableI { data: GoodwillTableResponse | null; -- 2.47.2 From eb65c2dc1c12fd61d2f6d4a37b53812ef74ca5d4 Mon Sep 17 00:00:00 2001 From: Grog Date: Fri, 6 Nov 2026 09:24:06 +0300 Subject: [PATCH 11/15] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=B7=D0=B0=D0=B4=D0=BD=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=20=D0=B8=D0=BD=D0=BF=D1=83=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BB=D1=8F=20=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx index 685b5c6..0b04700 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx @@ -77,7 +77,6 @@ export const getGoodwillColumns = ({ if (isEditMode && isChecked) { return ( { let inputValue = e.target.value; @@ -178,7 +177,6 @@ export const getGoodwillColumns = ({ if (isEditMode && isChecked) { return ( onRowEdit(row.key, 'comment', e.target.value)} placeholder='Комментарий' -- 2.47.2 From 66acde778613c023de2cb773c3e25e0cc5298e30 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:33:14 +0400 Subject: [PATCH 12/15] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=BB:=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B8=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D1=8B=20=D0=BF=D0=BE=20API=20=D0=B1?= =?UTF-8?q?=D1=8D=D0=BA=D0=B0,=20=D1=87=D0=B8=D1=81=D1=82=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Типы по schemas/goodwill.py (has_investments, warnings, nullable суммы, groups, УК в итогах), пакетный saveGoodwillRows, экспорт с sign; utils/goodwill.ts — parseShare (два знака, без обрезки), previewAmounts в копейках (HALF_UP, как бэк), matchesSign (null — только «все»), changedRows (только изменённые строки). Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/goodwill.ts | 20 +++--- web/src/types/goodwill/goodwill.ts | 56 +++++++++++----- web/src/utils/goodwill.test.ts | 100 +++++++++++++++++++++++++++++ web/src/utils/goodwill.ts | 91 ++++++++++++++++++++++++++ 4 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 web/src/utils/goodwill.test.ts create mode 100644 web/src/utils/goodwill.ts diff --git a/web/src/services/api/goodwill.ts b/web/src/services/api/goodwill.ts index d55b08d..dd1e7fe 100644 --- a/web/src/services/api/goodwill.ts +++ b/web/src/services/api/goodwill.ts @@ -1,23 +1,19 @@ import { api, apiFetchBlob } from '../../api/client'; -import type { GoodwillRow, GoodwillRowPayload, GoodwillSettingsPayload, GoodwillTableResponse } from '../../types/goodwill/goodwill'; +import type { GoodwillRowKeyIn, GoodwillSettingsPayload, GoodwillSign, GoodwillTableResponse } from '../../types/goodwill/goodwill'; export function fetchGoodwillTable(versionId: string): Promise { return api.get(`/consolidations/versions/${versionId}/goodwill`); } +/** Пакет строк одной транзакцией: ошибка любой строки — ничего не сохранено, в `ApiError.body.detail` её ключ. */ +export function saveGoodwillRows(versionId: string, rows: GoodwillRowKeyIn[]): Promise { + return api.put(`/consolidations/versions/${versionId}/goodwill`, { rows }); +} + export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise { return api.put(`/consolidations/versions/${versionId}/goodwill-settings`, payload); } -export function saveGoodwillRow( - versionId: string, - headId: string, - subsidiaryId: string, - payload: GoodwillRowPayload, -): Promise { - return api.put(`/consolidations/versions/${versionId}/goodwill/${headId}/${subsidiaryId}`, payload); -} - -export function exportGoodwillTable(versionId: string): Promise<{ blob: Blob; filename: string | null }> { - return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export`); +export function exportGoodwillTable(versionId: string, sign: GoodwillSign): Promise<{ blob: Blob; filename: string | null }> { + return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export?sign=${sign}`); } diff --git a/web/src/types/goodwill/goodwill.ts b/web/src/types/goodwill/goodwill.ts index 8a1c834..96439be 100644 --- a/web/src/types/goodwill/goodwill.ts +++ b/web/src/types/goodwill/goodwill.ts @@ -1,49 +1,64 @@ +/** DTO таблицы гудвилла — по `api/src/schemas/goodwill.py` и `../docs/dev/api-contract-goodwill.md`. Деньги и доля — строки. */ export interface Ref { id: string; name: string; } +export interface GoodwillWarning { + code: 'CAPITAL_SOURCE_MISSING' | 'CAPITAL_SOURCE_AMBIGUOUS' | 'OWNERSHIP_TOTAL_EXCEEDED' | string; + message: string; +} + export interface GoodwillRow { head_legal_entity: Ref; subsidiary_legal_entity: Ref; ownership_share: string | null; - financial_investment_amount: string; - charter_capital_amount: string; - additional_capital_amount: string; - participation_amount: string; - goodwill_amount: string; + /** null — «Нет вложений» */ + financial_investment_amount: string | null; + /** null — нет однозначного 1310 или «Нет вложений» */ + charter_capital_amount: string | null; + additional_capital_amount: string | null; + /** только у действующей отмеченной строки с долей и УК */ + participation_amount: string | null; + goodwill_amount: string | null; include_in_kfo: boolean; comment: string | null; + has_investments: boolean; + warnings: GoodwillWarning[]; updated_by: Ref | null; updated_at: string | null; } export interface GoodwillTotals { financial_investment_amount: string; + charter_capital_amount: string; participation_amount: string; goodwill_amount: string; goodwill_in_kfo_amount: string; } +export interface GoodwillGroup { + head_legal_entity: Ref; + totals: GoodwillTotals; +} + +export type GoodwillReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER'; + export interface GoodwillTableResponse { version_id: string; goodwill_enabled: boolean; editable: boolean; - read_only_reason: string | null; + read_only_reason: GoodwillReadOnlyReason | null; rows: GoodwillRow[]; + groups: GoodwillGroup[]; totals: GoodwillTotals; } -export type GoodwillTreeRow = GoodwillRow & { - key: string; - children?: GoodwillTreeRow[]; - isGroup?: boolean; - isTotal?: boolean; - isGroupTotal?: boolean; -}; - -export interface GoodwillRowPayload { - ownership_share: string; +/** Строка пакета `PUT …/goodwill`. */ +export interface GoodwillRowKeyIn { + head_legal_entity_id: string; + subsidiary_legal_entity_id: string; + ownership_share: string | null; include_in_kfo: boolean; comment: string | null; } @@ -51,3 +66,12 @@ export interface GoodwillRowPayload { export interface GoodwillSettingsPayload { goodwill_enabled: boolean; } + +export type GoodwillSign = 'all' | 'positive' | 'negative' | 'zero'; + +/** Несохранённая правка строки в режиме «Редактировать»; отсутствующее поле — как в сохранённой строке. */ +export interface GoodwillEdit { + include_in_kfo?: boolean; + ownership_share?: string | null; + comment?: string | null; +} diff --git a/web/src/utils/goodwill.test.ts b/web/src/utils/goodwill.test.ts new file mode 100644 index 0000000..505edc1 --- /dev/null +++ b/web/src/utils/goodwill.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import type { GoodwillRow } from '../types/goodwill/goodwill'; +import { changedRows, matchesSign, parseShare, previewAmounts, rowKey } from './goodwill'; + +function row(patch: Partial = {}): GoodwillRow { + return { + head_legal_entity: { id: 'a', name: 'Альфа' }, + subsidiary_legal_entity: { id: 'b', name: 'Бета' }, + ownership_share: null, + financial_investment_amount: '180000.00', + charter_capital_amount: '100000.00', + additional_capital_amount: null, + participation_amount: null, + goodwill_amount: null, + include_in_kfo: false, + comment: null, + has_investments: true, + warnings: [], + updated_by: null, + updated_at: null, + ...patch, + }; +} + +describe('parseShare', () => { + it('принимает запятую и точку, не больше двух знаков', () => { + expect(parseShare('80,12')).toEqual({ ok: true, value: '80.12' }); + expect(parseShare('100')).toEqual({ ok: true, value: '100' }); + expect(parseShare(' 7.5 ')).toEqual({ ok: true, value: '7.5' }); + }); + it('пустое — null', () => { + expect(parseShare('')).toEqual({ ok: true, value: null }); + }); + it.each(['80,123', '100,01', '-1', 'abc', '1e2', '101'])('отклоняет %s без обрезки', (input) => { + expect(parseShare(input).ok).toBe(false); + }); +}); + +describe('previewAmounts (DOC-259025 §11.1)', () => { + it('Бета: P = 80 000, G = +100 000', () => { + expect(previewAmounts(row(), '80', true)).toEqual({ participation: '80000.00', goodwill: '100000.00' }); + }); + it('Гамма: G отрицательный', () => { + expect(previewAmounts(row({ financial_investment_amount: '50000.00' }), '80', true)).toEqual({ + participation: '80000.00', + goodwill: '-30000.00', + }); + }); + it('округление половина вверх до копеек', () => { + expect(previewAmounts(row({ charter_capital_amount: '371.17' }), '99.99', true).participation).toBe('371.13'); + expect(previewAmounts(row({ charter_capital_amount: '0.05' }), '50', true).participation).toBe('0.03'); + }); + it('не отмечена, нет доли или УК — не рассчитано', () => { + const empty = { participation: null, goodwill: null }; + expect(previewAmounts(row(), '80', false)).toEqual(empty); + expect(previewAmounts(row(), null, true)).toEqual(empty); + expect(previewAmounts(row({ charter_capital_amount: null }), '80', true)).toEqual(empty); + expect(previewAmounts(row({ has_investments: false, financial_investment_amount: null }), '80', true)).toEqual(empty); + }); +}); + +describe('matchesSign', () => { + it('нерассчитанный гудвилл попадает только во «все»', () => { + expect(matchesSign(null, 'all')).toBe(true); + expect(matchesSign(null, 'positive')).toBe(false); + expect(matchesSign(null, 'negative')).toBe(false); + expect(matchesSign(null, 'zero')).toBe(false); + }); + it('ноль — только «нулевые»', () => { + expect(matchesSign('0.00', 'zero')).toBe(true); + expect(matchesSign('0.00', 'positive')).toBe(false); + expect(matchesSign('-0.01', 'negative')).toBe(true); + expect(matchesSign('0.01', 'positive')).toBe(true); + }); +}); + +describe('changedRows', () => { + const saved = row({ ownership_share: '80.00', include_in_kfo: true, comment: 'Устав' }); + it('правка, совпадающая с сохранённым, не уходит', () => { + const edits = { [rowKey(saved)]: { include_in_kfo: true, ownership_share: '80', comment: 'Устав' } }; + expect(changedRows([saved], edits)).toEqual([]); + }); + it('изменённая строка уходит целиком', () => { + const edits = { [rowKey(saved)]: { comment: 'Устав 2024' } }; + expect(changedRows([saved], edits)).toEqual([ + { head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: '80.00', include_in_kfo: true, comment: 'Устав 2024' }, + ]); + }); + it('снятая галочка у строки без доли — ownership_share: null', () => { + const fresh = row({ include_in_kfo: true }); + expect(changedRows([fresh], { [rowKey(fresh)]: { include_in_kfo: false } })).toEqual([ + { head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: null, include_in_kfo: false, comment: null }, + ]); + }); + it('доля с запятой уходит с точкой', () => { + const fresh = row(); + const edits = { [rowKey(fresh)]: { include_in_kfo: true, ownership_share: '80,5', comment: 'Да' } }; + expect(changedRows([fresh], edits)[0].ownership_share).toBe('80.5'); + }); +}); diff --git a/web/src/utils/goodwill.ts b/web/src/utils/goodwill.ts new file mode 100644 index 0000000..fcd1748 --- /dev/null +++ b/web/src/utils/goodwill.ts @@ -0,0 +1,91 @@ +/** Чистые функции вкладки «Гудвилл» (DOC-259025 §4–§6): ввод доли, предпросмотр P/G, фильтр знака, изменённые строки. */ +import type { GoodwillEdit, GoodwillRow, GoodwillRowKeyIn, GoodwillSign } from '../types/goodwill/goodwill'; +import { fromCents, toCents } from './money'; + +const SHARE = /^\d{1,3}(?:\.\d{1,2})?$/; + +export function rowKey(row: Pick): string { + return `${row.head_legal_entity.id}:${row.subsidiary_legal_entity.id}`; +} + +/** Доля 0–100, не больше двух знаков, запятая или точка; пусто — null. Лишние знаки — ошибка, не обрезка. */ +export function parseShare(input: string): { ok: true; value: string | null } | { ok: false; error: string } { + const value = input.trim().replace(',', '.'); + if (value === '') return { ok: true, value: null }; + if (!SHARE.test(value) || shareHundredths(value) > 10000n) { + return { ok: false, error: 'Число от 0 до 100, не больше двух знаков после запятой' }; + } + return { ok: true, value }; +} + +/** Доля строкой («80.5») → сотые доли процента (8050n). */ +function shareHundredths(value: string): bigint { + const [int, frac = ''] = value.split('.'); + return BigInt(int) * 100n + BigInt(frac.padEnd(2, '0')); +} + +/** Предварительные P = round(УК × d / 100, 2) и G = ФВ − P в копейках, половина вверх; как на бэке. */ +export function previewAmounts( + row: GoodwillRow, + share: string | null, + include: boolean, +): { participation: string | null; goodwill: string | null } { + const fv = row.financial_investment_amount; + const capital = row.charter_capital_amount; + if (!row.has_investments || !include || share === null || fv === null || capital === null) { + return { participation: null, goodwill: null }; + } + const parsed = parseShare(share); + if (!parsed.ok || parsed.value === null) return { participation: null, goodwill: null }; + const product = toCents(capital) * shareHundredths(parsed.value); + const divisor = 10000n; + const negative = product < 0n; + const abs = negative ? -product : product; + const rounded = (abs + divisor / 2n) / divisor; + const p = negative ? -rounded : rounded; + return { participation: fromCents(p), goodwill: fromCents(toCents(fv) - p) }; +} + +/** Фильтр «Гудвилл»: нерассчитанный (null) — только во «Все значения». */ +export function matchesSign(goodwill: string | null, sign: GoodwillSign): boolean { + if (sign === 'all') return true; + if (goodwill === null) return false; + const cents = toCents(goodwill); + if (sign === 'positive') return cents > 0n; + if (sign === 'negative') return cents < 0n; + return cents === 0n; +} + +/** Значения строки с учётом правки: что уйдёт в пакет. */ +export function effectiveRow(row: GoodwillRow, edit: GoodwillEdit | undefined): GoodwillRowKeyIn { + const share = edit && 'ownership_share' in edit ? (edit.ownership_share ?? null) : row.ownership_share; + const parsed = share === null ? null : parseShare(share); + const comment = edit && 'comment' in edit ? (edit.comment ?? null) : row.comment; + return { + head_legal_entity_id: row.head_legal_entity.id, + subsidiary_legal_entity_id: row.subsidiary_legal_entity.id, + ownership_share: parsed?.ok ? parsed.value : share, + include_in_kfo: edit?.include_in_kfo ?? row.include_in_kfo, + comment: comment?.trim() ? comment : null, + }; +} + +function sameShare(a: string | null, b: string | null): boolean { + if (a === null || b === null) return a === b; + if (!SHARE.test(a) || !SHARE.test(b)) return a === b; + return shareHundredths(a) === shareHundredths(b); +} + +/** Строки пакета — только те, где правка отличается от сохранённого. */ +export function changedRows(rows: GoodwillRow[], edits: Record): GoodwillRowKeyIn[] { + return rows.flatMap((row) => { + const edit = edits[rowKey(row)]; + if (!edit) return []; + const next = effectiveRow(row, edit); + const same = + next.include_in_kfo === row.include_in_kfo && + sameShare(next.ownership_share, row.ownership_share) && + (next.comment?.trim() ?? null) === (row.comment?.trim() || null); + return same ? [] : [next]; + }); +} -- 2.47.2 From 2f5b455aaa36e4742fa9841639b68688cc905ec6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:37:11 +0400 Subject: [PATCH 13/15] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=BB:=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=20API=20=D0=B8=20DOC-259025?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Суммы null — прочерк, ноль — 0,00; «Нет вложений» серым без чекбокса; предупреждения иконкой с подсказкой; итоги «Всего» и «Итого по листу» из groups/totals ответа; в режиме редактирования — предпросмотр P/G, проверка доли (два знака, без обрезки) и комментария, групповой чекбокс по действующим строкам блока с indeterminate; сохранение одним пакетом изменённых строк, ошибка 422 подсвечивает поле своей строки; переключатель «Учитывать гудвилл в КФО»; фильтр «Нулевые»; экспорт с текущим фильтром. Co-Authored-By: Claude Opus 5.5 --- .../ConsolidationGoodwillContent.tsx | 402 +++++++++--------- .../ConsolidationGoodwillSummary.tsx | 106 +---- .../ConsolidationGoodwillTable.css | 4 + .../ConsolidationGoodwillTable.tsx | 148 ++----- .../ConsolidationGoodwillTable/config.tsx | 294 +++++++------ 5 files changed, 420 insertions(+), 534 deletions(-) diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index bc3f73b..2640cf2 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -1,17 +1,43 @@ import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; -import { Alert, Button, ConfigProvider, Select, Space, Tooltip } from 'antd'; +import { Alert, Button, ConfigProvider, Select, Space, Switch, Tooltip, Typography } from 'antd'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; +import { ApiError } from '../../../api/client'; import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter'; import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable'; import { useFullscreen } from '../../../hooks/useFullScreen'; -import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRow } from '../../../services/api/goodwill'; +import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRows, saveGoodwillSettings } from '../../../services/api/goodwill'; import type { VersionCard } from '../../../types/consolidations/version_members'; -import type { GoodwillRow, GoodwillRowPayload, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; +import type { GoodwillEdit, GoodwillSign, GoodwillTableResponse } from '../../../types/goodwill/goodwill'; import { saveBlob, xlsxFilename } from '../../../utils/download'; +import { changedRows, effectiveRow, matchesSign, parseShare, rowKey } from '../../../utils/goodwill'; import { useNotificationApi } from '../../../utils/useNotification'; +import { readOnlyHint } from '../../CorrectionTable/readOnly'; import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable'; -import { getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; +import { type GoodwillTreeRow, type RowIssues, formatMoney, getGoodwillColumns } from '../ConsolidationGoodwillTable/config'; + +const { Text } = Typography; + +const SIGN_OPTIONS: { value: GoodwillSign; label: string }[] = [ + { value: 'all', label: 'Все значения' }, + { value: 'positive', label: 'Положительные' }, + { value: 'negative', label: 'Отрицательные' }, + { value: 'zero', label: 'Нулевые' }, +]; + +/** Поле строки, к которому относится ошибка сервера (422 с ключом пары). */ +const ERROR_FIELD: Record = { COMMENT_REQUIRED: 'comment', INVALID_SHARE: 'ownership_share' }; + +type RowErrorDetail = { code?: string; message?: string; head_legal_entity_id?: string; subsidiary_legal_entity_id?: string }; + +/** Ключ пары из ошибки пакетного сохранения; ошибка не по строке — null. */ +function rowErrorDetail(error: unknown): RowErrorDetail | null { + if (!(error instanceof ApiError)) return null; + const detail = (error.body as { detail?: unknown } | undefined)?.detail; + if (typeof detail !== 'object' || detail === null || Array.isArray(detail)) return null; + const d = detail as RowErrorDetail; + return d.head_legal_entity_id && d.subsidiary_legal_entity_id ? d : null; +} export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const { version_id } = useParams<{ version_id: string }>(); @@ -20,103 +46,24 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [settingsSaving, setSettingsSaving] = useState(false); const [decimals, setDecimals] = useState(2); const [units, setUnits] = useState('thousand'); - const [isEditMode, setIsEditMode] = useState(false); - const [editedRows, setEditedRows] = useState>>({}); - - const [goodwillFilter, setGoodwillFilter] = useState('allValue'); + const [edits, setEdits] = useState>({}); + const [serverIssues, setServerIssues] = useState>({}); + const [sign, setSign] = useState('all'); const [exportLoading, setExportLoading] = useState(false); const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null; - - const handleRowEdit = useCallback( - (rowKey: string, field: keyof GoodwillRow, value: boolean | string) => { - const isParent = rowKey.startsWith('head-') && !rowKey.includes('-sub-'); - - if (isParent && field === 'include_in_kfo') { - const headId = rowKey.replace('head-', ''); - const children = data?.rows.filter((r) => r.head_legal_entity.id === headId) || []; - - setEditedRows((prev) => { - const next = { ...prev }; - children.forEach((child) => { - const childKey = `${headId}-${child.subsidiary_legal_entity.id}`; - const currentChild = next[childKey] || {}; - - next[childKey] = { - ...currentChild, - include_in_kfo: value as boolean, - ...(value - ? { - ownership_share: currentChild.ownership_share ?? child.ownership_share, - comment: currentChild.comment ?? child.comment, - } - : {}), - }; - }); - return next; - }); - return; - } - - setEditedRows((prev) => { - const current = prev[rowKey] || {}; - const originalRow = data?.rows.find((r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey); - - return { - ...prev, - [rowKey]: { - ...current, - [field]: value, - ...(field === 'include_in_kfo' && value && originalRow - ? { - ownership_share: current.ownership_share ?? originalRow.ownership_share, - comment: current.comment ?? originalRow.comment, - } - : {}), - }, - }; - }); - }, - [data?.rows], - ); - - const columns = useMemo(() => { - return getGoodwillColumns({ units, decimals, isEditMode, editedRows, onRowEdit: handleRowEdit }); - }, [units, decimals, isEditMode, editedRows, handleRowEdit]); - - const groupsConfig = useMemo(() => { - return { rowKey: 'key' as const, columns: columns as ColumnDef[] }; - }, [columns]); - - const { visibleKeys, setVisibleKeys } = useVisibleColumns(groupsConfig); - - const dropFiltersHandler = () => { - setGoodwillFilter('allValue'); - }; + const { ref: tableRef, isFullscreen, toggle: toggleFullscreen } = useFullscreen(); const loadData = useCallback( async (vId: string) => { setLoading(true); try { - const res = await fetchGoodwillTable(vId); - setData(res); - - const initialEdited: Record> = {}; - res.rows.forEach((row) => { - if (row.include_in_kfo) { - const key = `${row.head_legal_entity.id}-${row.subsidiary_legal_entity.id}`; - initialEdited[key] = { - include_in_kfo: true, - ownership_share: row.ownership_share, - comment: row.comment, - }; - } - }); - setEditedRows(initialEdited); + setData(await fetchGoodwillTable(vId)); } catch (e) { - notification.error({ description: e instanceof Error ? e.message : 'Ошибка при загрузке данных' }); + if (e instanceof ApiError) notification.error({ description: e.message }); } finally { setLoading(false); } @@ -124,109 +71,146 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { [notification], ); + useEffect(() => { + if (version_id) loadData(version_id); + }, [version_id, loadData]); + + const editRow = useCallback((key: string, patch: GoodwillEdit, clear?: keyof RowIssues) => { + setEdits((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } })); + if (clear) { + setServerIssues((prev) => (prev[key]?.[clear] ? { ...prev, [key]: { ...prev[key], [clear]: undefined } } : prev)); + } + }, []); + + const onToggleGroup = useCallback( + (headId: string, checked: boolean) => { + // все действующие строки блока, включая скрытые фильтром; «Нет вложений» не трогаем (DOC-259025 §4) + const keys = (data?.rows ?? []).filter((r) => r.head_legal_entity.id === headId && r.has_investments).map(rowKey); + setEdits((prev) => { + const next = { ...prev }; + for (const key of keys) next[key] = { ...next[key], include_in_kfo: checked }; + return next; + }); + }, + [data], + ); + + /** Ошибки ввода: отмеченной действующей строке нужны корректная доля и комментарий (DOC-259025 §5.2). */ + const issues = useMemo(() => { + const result: Record = {}; + if (!data || !isEditMode) return result; + for (const row of data.rows) { + const key = rowKey(row); + const next = effectiveRow(row, edits[key]); + const own: RowIssues = { ...serverIssues[key] }; + if (row.has_investments && next.include_in_kfo) { + const parsed = next.ownership_share === null ? null : parseShare(next.ownership_share); + if (parsed === null) own.ownership_share = own.ownership_share ?? 'Заполните долю'; + else if (!parsed.ok) own.ownership_share = parsed.error; + if (next.comment === null) own.comment = own.comment ?? 'Заполните комментарий'; + } + if (own.ownership_share || own.comment) result[key] = own; + } + return result; + }, [data, edits, isEditMode, serverIssues]); + + const pending = useMemo(() => (data ? changedRows(data.rows, edits) : []), [data, edits]); + const hasIssues = Object.keys(issues).length > 0; + + const columns = useMemo( + () => + getGoodwillColumns({ + units, + decimals, + isEditMode, + edits, + issues, + rows: data?.rows ?? [], + onToggleRow: (key, checked) => editRow(key, { include_in_kfo: checked }), + onToggleGroup, + onShare: (key, value) => editRow(key, { ownership_share: value }, 'ownership_share'), + onComment: (key, value) => editRow(key, { comment: value }, 'comment'), + }), + [units, decimals, isEditMode, edits, issues, data, editRow, onToggleGroup], + ); + const columnsConfig = useMemo(() => ({ rowKey: 'key' as const, columns: columns as ColumnDef[] }), [columns]); + const { visibleKeys, setVisibleKeys } = useVisibleColumns(columnsConfig); + const visibleColumns = useMemo( + () => columns.filter((col) => (visibleKeys as string[]).includes(String(col.key))), + [columns, visibleKeys], + ); + + const filteredRows = useMemo(() => (data?.rows ?? []).filter((row) => matchesSign(row.goodwill_amount, sign)), [data, sign]); + + const closeEditing = () => { + setEdits({}); + setServerIssues({}); + setIsEditMode(false); + }; + const handleSave = async () => { if (!version_id || !data) return; + if (pending.length === 0) { + closeEditing(); + return; + } setSaving(true); try { - const promises = Object.keys(editedRows).map((rowKey) => { - const changes = editedRows[rowKey]; - const originalRow = data.rows.find((r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey); - if (!originalRow) return; - - const payload: GoodwillRowPayload = { - ownership_share: String(changes.ownership_share ?? originalRow.ownership_share ?? ''), - include_in_kfo: changes.include_in_kfo ?? originalRow.include_in_kfo, - comment: changes.comment !== undefined ? changes.comment : (originalRow.comment ?? null), - }; - - return saveGoodwillRow(version_id, originalRow.head_legal_entity.id, originalRow.subsidiary_legal_entity.id, payload); - }); - - await Promise.all(promises); + setData(await saveGoodwillRows(version_id, pending)); + closeEditing(); notification.success({ description: 'Изменения сохранены' }); - setEditedRows({}); - setIsEditMode(false); - loadData(version_id); } catch (e) { - notification.error({ description: e instanceof Error ? e.message : 'Ошибка при сохранении данных' }); + const detail = rowErrorDetail(e); + if (detail) { + const key = `${detail.head_legal_entity_id}:${detail.subsidiary_legal_entity_id}`; + const field = ERROR_FIELD[detail.code ?? ''] ?? 'ownership_share'; + setServerIssues((prev) => ({ ...prev, [key]: { ...prev[key], [field]: detail.message } })); + } + if (e instanceof ApiError) notification.error({ description: e.message }); } finally { setSaving(false); } }; - const download = async () => { - if (!version_id) { - return; + const handleSettings = async (enabled: boolean) => { + if (!version_id) return; + setSettingsSaving(true); + try { + setData(await saveGoodwillSettings(version_id, { goodwill_enabled: enabled })); + } catch (e) { + if (e instanceof ApiError) notification.error({ description: e.message }); + } finally { + setSettingsSaving(false); } + }; + + const download = async () => { + if (!version_id) return; setExportLoading(true); try { - const { blob, filename } = await exportGoodwillTable(version_id); - saveBlob(blob, filename ?? xlsxFilename('Гудвил', versionInfo)); + const { blob, filename } = await exportGoodwillTable(version_id, sign); + saveBlob(blob, filename ?? xlsxFilename('Гудвилл', versionInfo)); } catch (e) { - notification.error({ description: e instanceof Error ? e.message : 'Ошибка при экспорте таблицы' }); + if (e instanceof ApiError) notification.error({ description: e.message }); } finally { setExportLoading(false); } }; - const filteredData = useMemo(() => { - if (!data) return null; - if (goodwillFilter === 'allValue') return data; - - const filteredRows = data.rows.filter((row) => { - const amount = Number(row.goodwill_amount); - - if (goodwillFilter === 'positive') return amount > 0; - if (goodwillFilter === 'negative') return amount < 0; - return true; - }); - - return { - ...data, - rows: filteredRows, - }; - }, [data, goodwillFilter]); - - const canSaveEditData = useMemo(() => { - if (!isEditMode) { - return; - } - const hasErrors = Object.values(editedRows).some((elem) => { - if (!elem.include_in_kfo) return false; - const isShareEmpty = !elem.ownership_share || String(elem.ownership_share).trim() === ''; - const isCommentEmpty = !elem.comment || String(elem.comment).trim() === ''; - - return isShareEmpty || isCommentEmpty; - }); - return !hasErrors; - }, [editedRows, isEditMode]); - - const { ref: goodwillTableRef, isFullscreen: isGoodwillFullscreen, toggle: toggleGoodwillFullscreen } = useFullscreen(); - - useEffect(() => { - if (version_id) { - loadData(version_id); - } - }, [version_id, loadData]); + const readOnly = data && !data.editable ? readOnlyHint(data.read_only_reason) : null; return ( (document.fullscreenElement as HTMLElement) || document.body}>
{isEditMode && ( Доступны для редактирования столбцы 'Доля владения' и 'Комментарий'.} + title={Доступны для редактирования столбцы «Доля владения» и «Комментарий».} type='warning' action={ - - - + + @@ -236,24 +220,22 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { )}
{
- Гудвил - - - {goodwillFilter !== 'allValue' ? : null} + {sign !== 'all' ? : null} - -
+
+
+ + + + + + Учитывать гудвилл в КФО + + + {data?.goodwill_enabled + ? `В КФО: ${formatMoney(data.totals.goodwill_in_kfo_amount, units, decimals)}` + : 'Гудвилл не учитывается в КФО'} + +
- - - + + -
diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx index 9e8db26..d62af30 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx @@ -1,106 +1,46 @@ import { Table, Typography } from 'antd'; -import type { ColumnGroupType } from 'antd/es/table'; -import type { ColumnType } from 'antd/lib/table'; -import type { GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; -import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; -import { sumAmounts } from '../../../utils/money'; +import type { ColumnsType } from 'antd/es/table'; +import type { GoodwillTotals } from '../../../types/goodwill/goodwill'; +import { type GoodwillTreeRow, type MoneyKey, formatMoney } from '../ConsolidationGoodwillTable/config'; const { Text } = Typography; +const MONEY_KEYS = new Set(['financial_investment_amount', 'participation_amount', 'charter_capital_amount', 'goodwill_amount']); + interface ConsolidationGoodwillSummaryI { - data: GoodwillTableResponse | null; - visibleColumns: (ColumnGroupType | ColumnType)[]; + totals: GoodwillTotals | null; + columns: ColumnsType; units: string; decimals: number; } -export const ConsolidationGoodwillSummary: React.FC = ({ data, visibleColumns, units, decimals }) => { - if (!data?.rows) return null; - - const totalFinInv = sumAmounts(data.rows.map((c) => c.financial_investment_amount)) ?? '0.00'; - const totalParticipation = sumAmounts(data.rows.map((c) => c.participation_amount)) ?? '0.00'; - const totalCharter = sumAmounts(data.rows.map((c) => c.charter_capital_amount)) ?? '0.00'; - const totalGoodwill = sumAmounts(data.rows.map((c) => c.goodwill_amount)) ?? '0.00'; - +/** «Итого по листу» — `totals` ответа (по действующим строкам, P и G — по отмеченным). */ +export const ConsolidationGoodwillSummary: React.FC = ({ totals, columns, units, decimals }) => { + if (!totals) return null; return ( - {visibleColumns.map((col, index) => { - if (col.key === 'include_in_kfo') { + {columns.map((col, index) => { + const key = String(col.key); + if (key === 'name') { return ( - - — + + Итого по листу ); } - - if (col.key === 'name') { + if (MONEY_KEYS.has(key)) { return ( - -
- Итого по листу -
+ + {formatMoney(totals[key as MoneyKey], units, decimals)} ); } - - if (col.key === 'ownership_share') { - return ( - - — - - ); - } - - if (col.key === 'financial_investment_amount') { - return ( - -
- {chessTableCellFormatValue(totalFinInv, units, decimals)} -
-
- ); - } - - if (col.key === 'participation_amount') { - return ( - -
- {chessTableCellFormatValue(totalParticipation, units, decimals)} -
-
- ); - } - - if (col.key === 'charter_capital_amount') { - return ( - -
- {chessTableCellFormatValue(totalCharter, units, decimals)} -
-
- ); - } - - if (col.key === 'goodwill_amount') { - return ( - -
- {chessTableCellFormatValue(totalGoodwill, units, decimals)} -
-
- ); - } - - if (col.key === 'comment') { - return ( - - — - - ); - } - - return null; + return ( + + — + + ); })}
diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css index 56addcd..322ce45 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.css @@ -21,3 +21,7 @@ background-color: #ecfdf3 !important; font-weight: 600; } + +.goodwillTable .goodwill-inactive td { + color: #98a2b3; +} diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 3f45562..4253d7c 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -2,146 +2,78 @@ import { Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type React from 'react'; import { useMemo } from 'react'; -import type { GoodwillRow, GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; +import type { GoodwillRow, GoodwillTableResponse } from '../../../types/goodwill/goodwill'; +import { rowKey } from '../../../utils/goodwill'; import './ConsolidationGoodwillTable.css'; - import { ConsolidationGoodwillSummary } from '../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary'; -import { getGoodwillColumns } from './config'; +import type { GoodwillTreeRow } from './config'; interface ConsolidationGoodwillTableI { data: GoodwillTableResponse | null; + /** строки после фильтра знака */ + rows: GoodwillRow[]; + columns: ColumnsType; loading: boolean; decimals: number; units: string; toolbar?: React.ReactNode; isFullscreen?: boolean; - isEditMode: boolean; - editedRows: Record>; - onRowEdit: (rowId: string, field: keyof GoodwillRow, value: boolean | string) => void; - visibleKeys: string[]; +} + +/** Дерево «ГО → строки пар → Всего»; итоги блока — из `groups` ответа, фронт их не считает. */ +export function buildTree(data: GoodwillTableResponse | null, rows: GoodwillRow[]): GoodwillTreeRow[] { + if (!data) return []; + const totals = new Map(data.groups.map((g) => [g.head_legal_entity.id, g.totals])); + const groups = new Map>(); + for (const row of rows) { + const headId = row.head_legal_entity.id; + let group = groups.get(headId); + if (!group) { + group = { key: `head:${headId}`, kind: 'group', headId, headName: row.head_legal_entity.name, children: [] }; + groups.set(headId, group); + } + group.children.push({ key: rowKey(row), kind: 'row', row }); + } + for (const group of groups.values()) { + group.children.push({ key: `total:${group.headId}`, kind: 'total', totals: totals.get(group.headId) ?? null }); + } + return [...groups.values()]; +} + +function rowClassName(node: GoodwillTreeRow): string { + if (node.kind === 'total') return 'group-total-row'; + if (node.kind === 'row' && !node.row.has_investments) return 'goodwill-inactive'; + return ''; } export const ConsolidationGoodwillTable: React.FC = ({ data, + rows, + columns, loading, decimals, units, toolbar, isFullscreen = false, - isEditMode, - editedRows, - onRowEdit, - visibleKeys, }) => { - const treeData = useMemo(() => { - if (!data?.rows) return []; - - const tree: GoodwillTreeRow[] = []; - const heads = new Map(); - - data.rows.forEach((row) => { - const headId = row.head_legal_entity.id; - - if (!heads.has(headId)) { - const groupNode: GoodwillTreeRow = { - ...row, - key: `head-${headId}`, - isGroup: true, - head_legal_entity: row.head_legal_entity, - subsidiary_legal_entity: { id: '', name: '' }, - ownership_share: null, - financial_investment_amount: '0', - charter_capital_amount: '0', - additional_capital_amount: '0', - participation_amount: '0', - goodwill_amount: '0', - include_in_kfo: false, - comment: null, - updated_at: null, - updated_by: null, - children: [], - }; - heads.set(headId, groupNode); - tree.push(groupNode); - } - - const group = heads.get(headId); - group?.children?.push({ - ...row, - key: `${headId}-${row.subsidiary_legal_entity.id}`, - }); - }); - - tree.forEach((parent) => { - if (parent.children && parent.children.length > 0) { - const allChecked = parent.children.every((child) => editedRows[child.key]?.include_in_kfo ?? child.include_in_kfo); - parent.include_in_kfo = allChecked; - - let totalFinInv = 0; - let totalParticipation = 0; - let totalCharter = 0; - let totalGoodwill = 0; - - parent.children.forEach((child) => { - totalFinInv += Number(child.financial_investment_amount || 0); - totalParticipation += Number(child.participation_amount || 0); - totalCharter += Number(child.charter_capital_amount || 0); - totalGoodwill += Number(child.goodwill_amount || 0); - }); - - const groupTotalRow: GoodwillTreeRow = { - key: `total-${parent.key}`, - head_legal_entity: parent.head_legal_entity, - subsidiary_legal_entity: { id: '', name: 'Всего' }, - isGroupTotal: true, - isGroup: false, - - financial_investment_amount: String(totalFinInv), - participation_amount: String(totalParticipation), - charter_capital_amount: String(totalCharter), - goodwill_amount: String(totalGoodwill), - - ownership_share: null, - additional_capital_amount: '0', - include_in_kfo: false, - comment: null, - updated_at: null, - updated_by: null, - }; - - parent.children.push(groupTotalRow); - } - }); - - return tree; - }, [data?.rows, editedRows]); - - const columns: ColumnsType = useMemo(() => { - return getGoodwillColumns({ units, decimals, isEditMode, editedRows, onRowEdit }); - }, [units, decimals, isEditMode, editedRows, onRowEdit]); - - const visibleColumns = useMemo(() => { - return columns.filter((col) => visibleKeys.includes(col.key as string)); - }, [columns, visibleKeys]); - - const scrollY = isFullscreen ? 'calc(100vh - 200px)' : 'auto'; + const treeData = useMemo(() => buildTree(data, rows), [data, rows]); return ( className='goodwillTable' dataSource={treeData} - columns={visibleColumns} + columns={columns} rowKey='key' pagination={false} loading={loading} - scroll={{ x: 'max-content', y: scrollY }} + scroll={{ x: 'max-content', y: isFullscreen ? 'calc(100vh - 200px)' : undefined }} size='small' bordered title={() => toolbar} - key={data?.rows?.length} + key={treeData.map((g) => g.key).join('|')} expandable={{ defaultExpandAllRows: true }} - summary={() => } - rowClassName={(record) => (record.isGroupTotal ? 'group-total-row' : '')} + summary={() => } + rowClassName={rowClassName} /> ); }; diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx index 01d14c0..e636d24 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx @@ -1,29 +1,88 @@ -import { Checkbox, Input, Typography } from 'antd'; +import { WarningOutlined } from '@ant-design/icons'; +import { Checkbox, Input, Tag, Tooltip, Typography } from 'antd'; import type { ColumnsType } from 'antd/es/table'; -import type { GoodwillRow, GoodwillTreeRow } from '../../../types/goodwill/goodwill'; +import type { GoodwillEdit, GoodwillRow, GoodwillTotals } from '../../../types/goodwill/goodwill'; import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue'; +import { effectiveRow, previewAmounts, rowKey } from '../../../utils/goodwill'; const { Text } = Typography; +/** Узел дерева таблицы: блок ГО, строка пары или «Всего» блока. */ +export type GoodwillTreeRow = + | { key: string; kind: 'group'; headId: string; headName: string; children: GoodwillTreeRow[] } + | { key: string; kind: 'row'; row: GoodwillRow } + | { key: string; kind: 'total'; totals: GoodwillTotals | null }; + +/** Ошибки ввода строки: клиентская проверка и ответ сервера (422 с ключом пары). */ +export type RowIssues = { ownership_share?: string; comment?: string }; + +export type MoneyKey = 'financial_investment_amount' | 'participation_amount' | 'charter_capital_amount' | 'goodwill_amount'; + interface GetColumnsParams { units: string; decimals: number; isEditMode: boolean; - editedRows: Record>; - onRowEdit: (rowId: string, field: keyof GoodwillRow, value: boolean | string) => void; + edits: Record; + issues: Record; + /** все строки ответа (для группового чекбокса, включая скрытые фильтром) */ + rows: GoodwillRow[]; + onToggleRow: (key: string, checked: boolean) => void; + onToggleGroup: (headId: string, checked: boolean) => void; + onShare: (key: string, value: string) => void; + onComment: (key: string, value: string) => void; +} + +export function formatMoney(value: string | null | undefined, units: string, decimals: number): string { + return value === null || value === undefined ? '—' : chessTableCellFormatValue(value, units, decimals); +} + +function moneyColor(value: string | null): string | undefined { + if (value === null || /^-?0(\.0+)?$/.test(value)) return undefined; + return value.startsWith('-') ? '#cf1322' : '#3f8600'; +} + +/** Суммы строки на экране: в режиме редактирования P и G — предварительный расчёт по правке. */ +export function displayedAmounts(row: GoodwillRow, edit: GoodwillEdit | undefined, isEditMode: boolean): Record { + const base = { + financial_investment_amount: row.financial_investment_amount, + charter_capital_amount: row.charter_capital_amount, + participation_amount: row.participation_amount, + goodwill_amount: row.goodwill_amount, + }; + if (!isEditMode) return base; + const next = effectiveRow(row, edit); + const preview = previewAmounts(row, next.ownership_share, next.include_in_kfo); + return { ...base, participation_amount: preview.participation, goodwill_amount: preview.goodwill }; } export const getGoodwillColumns = ({ units, decimals, isEditMode, - editedRows, - onRowEdit, + edits, + issues, + rows, + onToggleRow, + onToggleGroup, + onShare, + onComment, }: GetColumnsParams): ColumnsType => { - const formatMoney = (val: string | undefined) => { - if (!val || val === '0' || val === '0.00') return '—'; - return chessTableCellFormatValue(val, units, decimals); - }; + const included = (row: GoodwillRow) => edits[rowKey(row)]?.include_in_kfo ?? row.include_in_kfo; + + const moneyColumn = (key: MoneyKey, title: string) => ({ + title, + key, + width: 160, + align: 'right' as const, + render: (_: unknown, node: GoodwillTreeRow) => { + if (node.kind === 'group') return null; + if (node.kind === 'total') return {formatMoney(node.totals?.[key], units, decimals)}; + const value = displayedAmounts(node.row, edits[node.key], isEditMode)[key]; + const formatted = formatMoney(value, units, decimals); + if (key !== 'goodwill_amount' || value === null) return formatted; + return {formatted}; + }, + }); return [ { @@ -31,13 +90,29 @@ export const getGoodwillColumns = ({ key: 'include_in_kfo', width: 120, align: 'center', - render: (_, row) => { - if (row.isGroupTotal) return null; - - const isChecked = row.isGroup ? row.include_in_kfo : (editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo); - + render: (_, node) => { + if (node.kind === 'total') return —; + if (node.kind === 'group') { + const active = rows.filter((r) => r.head_legal_entity.id === node.headId && r.has_investments); + const checked = active.filter(included).length; + return ( + 0 && checked === active.length} + indeterminate={checked > 0 && checked < active.length} + onChange={(e) => onToggleGroup(node.headId, e.target.checked)}> + + Выделить все + + + ); + } return ( - onRowEdit(row.key, 'include_in_kfo', e.target.checked)} /> + onToggleRow(node.key, e.target.checked)} + /> ); }, }, @@ -45,139 +120,90 @@ export const getGoodwillColumns = ({ title: 'Наименование дочерней организации', key: 'name', width: 300, - render: (_, row) => (row.isGroup ? {row.head_legal_entity.name} : row.subsidiary_legal_entity.name), + render: (_, node) => { + if (node.kind === 'group') return {node.headName}; + if (node.kind === 'total') return Всего; + const { row } = node; + return ( + + {row.subsidiary_legal_entity.name} + {!row.has_investments && Нет вложений} + {row.warnings.length > 0 && ( + w.message).join('; ')}> + + + )} + + ); + }, }, { title: 'Доля владения', key: 'ownership_share', width: 150, align: 'right', - onCell: (row) => { - if (row.isGroup) return {}; - const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; - if (!isChecked) return {}; - - const val = editedRows[row.key]?.ownership_share ?? row.ownership_share; - - const isShareEmpty = !val || String(val).trim() === ''; - - const isInvalid = isShareEmpty; - - return { - style: { - backgroundColor: isInvalid ? '#fffbe6' : '#f6ffed', - }, - }; - }, - render: (_, row) => { - if (row.isGroup) return null; - - const val = editedRows[row.key]?.ownership_share ?? row.ownership_share; - const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; - if (isEditMode && isChecked) { + render: (_, node) => { + if (node.kind !== 'row') return null; + const { row } = node; + const value = effectiveRow(row, edits[node.key]).ownership_share; + if (isEditMode && row.has_investments && included(row)) { + const edit = edits[node.key]; + const raw = edit && 'ownership_share' in edit ? (edit.ownership_share ?? '') : (row.ownership_share?.replace('.', ',') ?? ''); + const error = issues[node.key]?.ownership_share; return ( - { - let inputValue = e.target.value; - - inputValue = inputValue.replace('.', ','); - - inputValue = inputValue.replace(/[^\d,]/g, ''); - - const parts = inputValue.split(','); - if (parts.length > 1 && parts[1].length > 2) { - inputValue = `${parts[0]},${parts[1].slice(0, 2)}`; - } - - const numericValue = Number(inputValue.replace(',', '.')); - if (!Number.isNaN(numericValue) && numericValue > 100) { - inputValue = '100'; - } - - onRowEdit(row.key, 'ownership_share', inputValue); - }} - placeholder='%' - /> +
+ onShare(node.key, e.target.value)} + placeholder='%' + aria-label='Доля владения' + /> + {error && ( + + {error} + + )} +
); } - - return val ? `${Number(val)}%` : '—'; - }, - }, - - { - title: 'Фондовые вложения головной организации', - dataIndex: 'financial_investment_amount', - key: 'financial_investment_amount', - width: 150, - align: 'right', - render: (val, row) => (row.isGroup ? null : formatMoney(val)), - }, - { - title: 'Доля участия головной организации в уставном капитале', - dataIndex: 'participation_amount', - key: 'participation_amount', - width: 150, - align: 'right', - render: (val, row) => (row.isGroup ? null : formatMoney(val)), - }, - { - title: 'Уставный капитал', - dataIndex: 'charter_capital_amount', - key: 'charter_capital_amount', - width: 150, - align: 'right', - render: (val, row) => (row.isGroup ? null : formatMoney(val)), - }, - { - title: 'Гудвилл (+/-)', - dataIndex: 'goodwill_amount', - key: 'goodwill_amount', - width: 150, - align: 'right', - render: (val, row) => { - if (row.isGroup) return null; - - const formatted = formatMoney(val); - if (formatted === '—') return —; - - const isNegative = String(val).trim().startsWith('-'); - - return {formatted}; + return value === null ? '—' : `${chessTableCellFormatValue(value, 'rub', 2)} %`; }, }, + moneyColumn('financial_investment_amount', 'Фондовые вложения головной организации'), + moneyColumn('participation_amount', 'Доля участия головной организации в уставном капитале'), + moneyColumn('charter_capital_amount', 'Уставный капитал'), + moneyColumn('goodwill_amount', 'Гудвилл (+/-)'), { title: 'Комментарий', key: 'comment', - width: 200, - onCell: (row) => { - if (row.isGroup) return {}; - const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; - if (!isChecked) return {}; - - const comment = editedRows[row.key]?.comment ?? row.comment; - - const isCommentEmpty = !comment || String(comment).trim() === ''; - - const isInvalid = isCommentEmpty; - - return { - style: { - backgroundColor: isInvalid ? '#fffbe6' : '#f6ffed', - }, - }; - }, - render: (_, row) => { - if (row.isGroup) return null; - - const val = editedRows[row.key]?.comment ?? row.comment; - const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo; - - if (isEditMode && isChecked) { - return onRowEdit(row.key, 'comment', e.target.value)} placeholder='Комментарий' />; + width: 240, + render: (_, node) => { + if (node.kind !== 'row') return null; + const { row } = node; + const edit = edits[node.key]; + const value = edit && 'comment' in edit ? (edit.comment ?? '') : (row.comment ?? ''); + if (isEditMode && row.has_investments && included(row)) { + const error = issues[node.key]?.comment; + return ( +
+ onComment(node.key, e.target.value)} + placeholder='Комментарий' + aria-label='Комментарий' + /> + {error && ( + + {error} + + )} +
+ ); } - return val || '—'; + return value || '—'; }, }, ]; -- 2.47.2 From 5f2b0e890f48385ac4f26eb608cf6592c457adfe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:44:02 +0400 Subject: [PATCH 14/15] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=BB:=20UI-=D1=81=D0=BF=D0=B5=D0=BA=D0=B0=20goodwill-ui,=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B5=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSpec change goodwill-ui (таблица, редактирование, переключатель/фильтр/экспорт; вкладка «Гудвилл» в intercompany-matrix-ui больше не заглушка; R-1…R-3). По стенду: стрелка раскрытия блока — у названия, «Выделить все» в одну строку, текст пустой таблицы. Co-Authored-By: Claude Opus 5.5 --- openspec/changes/goodwill-ui/.openspec.yaml | 2 + openspec/changes/goodwill-ui/design.md | 26 ++++++++ openspec/changes/goodwill-ui/proposal.md | 29 +++++++++ .../goodwill-ui/specs/goodwill-ui/spec.md | 62 +++++++++++++++++++ .../specs/intercompany-matrix-ui/spec.md | 38 ++++++++++++ openspec/changes/goodwill-ui/tasks.md | 6 ++ .../ConsolidationGoodwillTable.tsx | 3 +- .../ConsolidationGoodwillTable/config.tsx | 4 +- 8 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/goodwill-ui/.openspec.yaml create mode 100644 openspec/changes/goodwill-ui/design.md create mode 100644 openspec/changes/goodwill-ui/proposal.md create mode 100644 openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md create mode 100644 openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md create mode 100644 openspec/changes/goodwill-ui/tasks.md diff --git a/openspec/changes/goodwill-ui/.openspec.yaml b/openspec/changes/goodwill-ui/.openspec.yaml new file mode 100644 index 0000000..8a94d76 --- /dev/null +++ b/openspec/changes/goodwill-ui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/goodwill-ui/design.md b/openspec/changes/goodwill-ui/design.md new file mode 100644 index 0000000..196dd0f --- /dev/null +++ b/openspec/changes/goodwill-ui/design.md @@ -0,0 +1,26 @@ +## Context + +Экран — по контракту `../docs/dev/api-contract-goodwill.md`, DOC-259025 §4–§6 и макету `[31-03]`, `[31-03-01]`. Суммы — +строки, арифметика только в копейках (`utils/money.ts`), чистые функции вкладки — `utils/goodwill.ts` с vitest. + +## Decisions + +- Итоги «Всего» и «Итого по листу» берутся из ответа (`groups[].totals`, `totals`), фронт их не считает. +- В режиме редактирования P и G строки — предпросмотр (`previewAmounts`, та же формула и округление, что на бэке). +- Сохранение — один `PUT …/goodwill` с изменёнными строками (`changedRows`); ошибка строки (422 с ключом пары) + подсвечивает поле этой строки, режим не закрывается. +- Переключатель `goodwill_enabled` меняется вне режима редактирования, отдельным запросом. + +## Decisions made without discussion + +Ревью arm-core #220 (06.11.2026, владелец: «давай всё сделаем сами»). + +- **R-1 (2026-11-06). Правки вне задачи в PR откатены к `main`.** Обработка ошибок в 8 файлах справочников, `Alert` в + `CatalogueJurTable`, удалённые комментарии в `types/api.ts`, шахматке и корректировках. Обоснование: не относятся к + AURORA-1544, давали ложный тост на незаполненной форме (`validateFields` бросает не `Error`) и конфликтовали с #219/#221. + Оставлены общий фильтр единиц и `useFullscreen` (используется только вкладкой гудвилла). Цена ошибки: если правки нужны — + отдельной задачей. +- **R-2 (2026-11-06). Итоги — только с бэка** (правило владельца «агрегаты отдаёт бэк»). Цена ошибки: нет. +- **R-3 (2026-11-06). Предпросмотр — только у строк;** «Всего» и «Итого по листу» в режиме правки показывают сохранённое + и обновляются после «Сохранить». Обоснование: агрегаты не считаем на фронте (R-2). Цена ошибки: аналитик видит итог + после сохранения, а не во время ввода (DOC-259025 §5.1 п. 3 говорит «предварительные … итоги»). diff --git a/openspec/changes/goodwill-ui/proposal.md b/openspec/changes/goodwill-ui/proposal.md new file mode 100644 index 0000000..e784ddb --- /dev/null +++ b/openspec/changes/goodwill-ui/proposal.md @@ -0,0 +1,29 @@ +## Why + +AURORA-1544: вкладка «Гудвилл» страницы отчёта версии. Первая версия экрана (PR arm-core #220, Голов) строилась на +фейковых данных по черновику контракта 30.10, а бэкенд (#222, AURORA-1543) сделан по аналитике DOC-259025 «Гудвилл» +(Лысенко, 03.11.2026): пары по фактическим вложениям, строки «Нет вложений», nullable суммы, предупреждения, итоги по ГО, +пакетное сохранение. Ревью 06.11.2026: экран переведён на фактический API и DOC-259025. + +## What Changes + +Репозиторий — arm-core, только `web/`. Контракт — `../docs/dev/api-contract-goodwill.md` (docs #204). + +- Вкладка «Гудвилл» (`…/versions/{id}/goodwill`) доступна. +- Таблица по макету `[31-03]`: блоки ГО, строки ДО, «Всего» блока и «Итого по листу» из ответа (`groups`, `totals`). +- Режим «Редактировать» (`[31-03-01]`): галочки, доля, комментарий, предпросмотр P/G, пакетное сохранение. +- Переключатель «Учитывать гудвилл в КФО» (`goodwill_enabled`, OQ-27), фильтр знака, экспорт с фильтром. + +## Capabilities + +### New Capabilities +- `goodwill-ui`: вкладка «Гудвилл». + +### Modified Capabilities +- `intercompany-matrix-ui`: вкладка «Гудвилл» больше не заглушка. + +## Impact + +`web/src/widgets/ConsolidationGoodwill/*`, `web/src/utils/goodwill.ts` (+ тесты), `web/src/types/goodwill/goodwill.ts`, +`web/src/services/api/goodwill.ts`, `web/src/hooks/useFullScreen.tsx`, общий фильтр единиц +`web/src/components/ConsolidationFormatTableFilter` (шахматка переведена на него), вкладки отчёта. diff --git a/openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md b/openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md new file mode 100644 index 0000000..0424a29 --- /dev/null +++ b/openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Таблица гудвилла +Вкладка `goodwill` страницы отчёта версии SHALL загружать таблицу одним запросом `GET /consolidations/versions/{id}/goodwill` +(контракт `../docs/dev/api-contract-goodwill.md`) и показывать блоки по ГО в порядке ответа: строка ГО, строки ДО, строка +«Всего» блока из `groups[].totals`, внизу «Итого по листу» из `totals`. Колонки — по макету `[31-03]`: «Учет в отчете», +«Наименование дочерней организации», «Доля владения», «Фондовые вложения головной организации», «Доля участия головной +организации в уставном капитале», «Уставный капитал», «Гудвилл (+/-)», «Комментарий». Сумма `null` SHALL показываться +прочерком, ноль — нулём в выбранных единицах; итоги фронт MUST NOT считать сам. + +#### Scenario: Нулевой и нерассчитанный гудвилл +- **WHEN** у одной строки `goodwill_amount = "0.00"`, у другой `null` +- **THEN** первая показывает «0,00», вторая — «—» + +#### Scenario: Строка «Нет вложений» +- **WHEN** у строки `has_investments = false` +- **THEN** строка серая, с меткой «Нет вложений», чекбокс недоступен, суммы — прочерки, сохранённые доля и комментарий видны + +#### Scenario: Предупреждения +- **WHEN** у строки есть `warnings` (например, `CAPITAL_SOURCE_MISSING`) +- **THEN** рядом с названием ДО иконка предупреждения, по наведению — тексты `message` + +### Requirement: Редактирование таблицы гудвилла +Кнопка «Редактировать» SHALL быть доступна при `editable = true`; иначе — недоступна с подсказкой причины +(`read_only_reason`). В режиме редактирования (баннер «Доступны для редактирования столбцы «Доля владения» и +«Комментарий».») у действующих строк доступна галочка; у отмеченной действующей строки — поля доли и комментария, P и G +строки SHALL пересчитываться предварительно по введённой доле. Доля — 0–100, не больше двух знаков, запятая или точка; +ошибочная доля MUST NOT обрезаться или подменяться, а показывается ошибкой у поля. «Сохранить» SHALL быть недоступно, +пока у отмеченной действующей строки нет корректной доли или комментария. Групповой чекбокс блока SHALL отмечать и снимать +все действующие строки блока, включая скрытые фильтром, и показывать промежуточное состояние при частичном выборе. +«Отменить изменения» SHALL возвращать последнее сохранённое состояние. + +#### Scenario: Сохранение набора +- **WHEN** аналитик отметил две строки, ввёл доли «80,5» и «100» и комментарии и нажал «Сохранить» +- **THEN** уходит один `PUT …/goodwill` только с изменёнными строками; ответ заменяет таблицу, режим редактирования закрывается + +#### Scenario: Снятие галочки у строки без доли +- **WHEN** аналитик снимает галочку у строки, где доля не вводилась, и сохраняет +- **THEN** строка уходит с `ownership_share: null` и сохраняется без ошибки + +#### Scenario: Ошибка строки от сервера +- **WHEN** сервер отвечает 422 `COMMENT_REQUIRED` с ключом пары +- **THEN** поле комментария этой строки подсвечено ошибкой, режим редактирования не закрыт, введённое не потеряно + +#### Scenario: Лишние знаки доли +- **WHEN** аналитик вводит долю «80,123» +- **THEN** у поля ошибка «Число от 0 до 100, не больше двух знаков после запятой», значение в поле не меняется, «Сохранить» недоступно + +### Requirement: Переключатель, фильтр и экспорт гудвилла +Панель вкладки SHALL содержать переключатель «Учитывать гудвилл в КФО» (`PUT …/goodwill-settings`, доступен при +`editable` вне режима редактирования) с подписью суммы `goodwill_in_kfo_amount` или «Гудвилл не учитывается в КФО»; +фильтр «Гудвилл»: «Все значения», «Положительные», «Отрицательные», «Нулевые» (нерассчитанный гудвилл — только во «Все +значения»); единицы и знаки после запятой; «Настройка таблицы»; «Экспорт» — `GET …/goodwill/export` с текущим фильтром +(`sign`); «На весь экран». + +#### Scenario: Экспорт с фильтром +- **WHEN** выбран фильтр «Положительные» и нажат «Экспорт» +- **THEN** запрос уходит с `sign=positive` + +#### Scenario: Выключение гудвилла +- **WHEN** аналитик выключает переключатель +- **THEN** таблица перечитывается из ответа, под переключателем подпись «Гудвилл не учитывается в КФО», ручной ввод строк не меняется diff --git a/openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md new file mode 100644 index 0000000..88185e4 --- /dev/null +++ b/openspec/changes/goodwill-ui/specs/intercompany-matrix-ui/spec.md @@ -0,0 +1,38 @@ +## MODIFIED Requirements + +### Requirement: Вкладки страницы отчёта по версии +Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз +и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: +`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка», `correction` — «Таблица корректировки» (спека `corrections-table-ui`), `goodwill` — «Гудвилл» (спека `goodwill-ui`). +Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки +(навигация, не локальное состояние) заменой текущей записи истории браузера, без новой записи: кнопка «Назад» в шапке и +кнопка «Назад» браузера ведут туда, откуда открыт отчёт, а не по переключениям вкладок (AURORA-1851). Активная вкладка +подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL +заменяться на `osv` без новой записи в истории браузера. Вкладки «КФО», «КФО Общий», +«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё +состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. + +#### Scenario: Переход на шахматку +- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» +- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка + +#### Scenario: Переход на таблицу корректировки +- **WHEN** открыта шахматка версии по адресу `…/versions/{id}/chess` и нажата вкладка «Таблица корректировки» +- **THEN** адрес становится `…/versions/{id}/correction`, шапка версии остаётся, под вкладками показана таблица корректировок + +#### Scenario: Прежний адрес сводной ОСВ +- **WHEN** открыт адрес `…/versions/{id}/osv` +- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна + +#### Scenario: Неизвестная вкладка +- **WHEN** открыт адрес `…/versions/{id}/abc` +- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc` + +#### Scenario: Назад после переключения вкладок +- **WHEN** аналитик открыл отчёт версии кнопкой «К отчётности» в списке консолидаций, переключился «Шахматка» → «ОСВ» → + «Шахматка» и нажал «Назад» в шапке +- **THEN** одним нажатием открыт список консолидаций ГК + +#### Scenario: Переход на гудвилл +- **WHEN** открыта таблица корректировки версии и нажата вкладка «Гудвилл» +- **THEN** адрес становится `…/versions/{id}/goodwill`, шапка версии остаётся, под вкладками показана таблица гудвилла diff --git a/openspec/changes/goodwill-ui/tasks.md b/openspec/changes/goodwill-ui/tasks.md new file mode 100644 index 0000000..51f1cef --- /dev/null +++ b/openspec/changes/goodwill-ui/tasks.md @@ -0,0 +1,6 @@ +## 1. Доводка по ревью (план docs/superpowers/plans/2026-11-06-goodwill-review-front.md) + +- [x] 1.1 Ветка без правок вне задачи, влит main (R-1). +- [x] 1.2 Типы и запросы по API бэка, чистые функции `utils/goodwill.ts` с тестами. +- [x] 1.3 Вкладка по API, DOC-259025 и макету. +- [x] 1.4 UI-спека `goodwill-ui`, проверка на стенде. diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx index 4253d7c..4ac6abf 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/ConsolidationGoodwillTable.tsx @@ -66,12 +66,13 @@ export const ConsolidationGoodwillTable: React.FC = rowKey='key' pagination={false} loading={loading} + locale={{ emptyText: 'Вложений по 58.01 между компаниями периметра нет' }} scroll={{ x: 'max-content', y: isFullscreen ? 'calc(100vh - 200px)' : undefined }} size='small' bordered title={() => toolbar} key={treeData.map((g) => g.key).join('|')} - expandable={{ defaultExpandAllRows: true }} + expandable={{ defaultExpandAllRows: true, expandIconColumnIndex: 1 }} summary={() => } rowClassName={rowClassName} /> diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx index e636d24..bd4e473 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable/config.tsx @@ -88,7 +88,7 @@ export const getGoodwillColumns = ({ { title: 'Учет в отчете', key: 'include_in_kfo', - width: 120, + width: 150, align: 'center', render: (_, node) => { if (node.kind === 'total') return —; @@ -101,7 +101,7 @@ export const getGoodwillColumns = ({ checked={active.length > 0 && checked === active.length} indeterminate={checked > 0 && checked < active.length} onChange={(e) => onToggleGroup(node.headId, e.target.checked)}> - + Выделить все
-- 2.47.2 From 6be3936d9f1ea5b0f8ed9dd73249b708f0bcec5f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 18:50:27 +0400 Subject: [PATCH 15/15] =?UTF-8?q?=D0=93=D1=83=D0=B4=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D0=BB:=20=D1=81=D0=BD=D1=8F=D1=82=D0=B0=D1=8F=20=D0=B3=D0=B0?= =?UTF-8?q?=D0=BB=D0=BE=D1=87=D0=BA=D0=B0=20=D1=81=20=D0=BD=D0=B5=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BD=D0=BE=D0=B9=20=D0=B4=D0=BE=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D1=83?= =?UTF-8?q?=D0=B5=D1=82=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финальное ревью I-1: у неотмеченной строки поле доли скрыто — effectiveRow отправляет сохранённую долю вместо неверного ввода; подсказка у «Сохранить» называет строки с ошибкой и предупреждает о скрытых фильтром. design.md goodwill-ui: R-4…R-6. Co-Authored-By: Claude Opus 5.5 --- openspec/changes/goodwill-ui/design.md | 10 ++++++++++ web/src/utils/goodwill.test.ts | 14 +++++++++++++- web/src/utils/goodwill.ts | 8 ++++++-- .../ConsolidationGoodwillContent.tsx | 12 +++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/openspec/changes/goodwill-ui/design.md b/openspec/changes/goodwill-ui/design.md index 196dd0f..2b3544d 100644 --- a/openspec/changes/goodwill-ui/design.md +++ b/openspec/changes/goodwill-ui/design.md @@ -24,3 +24,13 @@ - **R-3 (2026-11-06). Предпросмотр — только у строк;** «Всего» и «Итого по листу» в режиме правки показывают сохранённое и обновляются после «Сохранить». Обоснование: агрегаты не считаем на фронте (R-2). Цена ошибки: аналитик видит итог после сохранения, а не во время ввода (DOC-259025 §5.1 п. 3 говорит «предварительные … итоги»). +- **R-4 (2026-11-06). `buildTree`/`rowClassName` без unit-теста.** Компоненты в проекте не тестируются (`web/CLAUDE.md`), + логика тривиальна; чистая логика вкладки (`utils/goodwill.ts`, в т. ч. `effectiveRow`) покрыта vitest. Цена ошибки: + регрессия дерева видна только глазами. +- **R-5 (2026-11-06). Проверка на стенде — частично моками.** На локальном стенде нет пар 58.01 между участниками периметра: + реальный бэк ветки проверен на пустой таблице, переключателе и экспорте, насыщенные состояния и 422 — подменой ответов + в Playwright. Цена ошибки: совпадение предпросмотра P/G с сервером на реальных данных подтверждено только тестами; после + мержа #222 пройти один сценарий на реальной паре. +- **R-6 (2026-11-06). Снятая галочка с неверной долей уходит с сохранённой долей** (финальное ревью, I-1): поле доли у + неотмеченной строки скрыто, неверный ввод там не исправить. Подсказка у «Сохранить» называет строки с ошибкой и + предупреждает, если они скрыты фильтром. Цена ошибки: неверный ввод в скрытом поле молча отбрасывается. diff --git a/web/src/utils/goodwill.test.ts b/web/src/utils/goodwill.test.ts index 505edc1..86bd8bd 100644 --- a/web/src/utils/goodwill.test.ts +++ b/web/src/utils/goodwill.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { GoodwillRow } from '../types/goodwill/goodwill'; -import { changedRows, matchesSign, parseShare, previewAmounts, rowKey } from './goodwill'; +import { changedRows, effectiveRow, matchesSign, parseShare, previewAmounts, rowKey } from './goodwill'; function row(patch: Partial = {}): GoodwillRow { return { @@ -98,3 +98,15 @@ describe('changedRows', () => { expect(changedRows([fresh], edits)[0].ownership_share).toBe('80.5'); }); }); + +describe('effectiveRow', () => { + it('снятая галочка с неверной долей уходит с сохранённой долей (ошибку у скрытого поля не исправить)', () => { + const saved = row({ ownership_share: '80.00' }); + expect(effectiveRow(saved, { include_in_kfo: false, ownership_share: '80,123' }).ownership_share).toBe('80.00'); + const fresh = row(); + expect(effectiveRow(fresh, { include_in_kfo: false, ownership_share: 'abc' }).ownership_share).toBeNull(); + }); + it('у отмеченной строки неверная доля остаётся как введена — ошибку видно у поля', () => { + expect(effectiveRow(row(), { include_in_kfo: true, ownership_share: '80,123' }).ownership_share).toBe('80,123'); + }); +}); diff --git a/web/src/utils/goodwill.ts b/web/src/utils/goodwill.ts index fcd1748..6b32a5f 100644 --- a/web/src/utils/goodwill.ts +++ b/web/src/utils/goodwill.ts @@ -58,14 +58,18 @@ export function matchesSign(goodwill: string | null, sign: GoodwillSign): boolea /** Значения строки с учётом правки: что уйдёт в пакет. */ export function effectiveRow(row: GoodwillRow, edit: GoodwillEdit | undefined): GoodwillRowKeyIn { + const include = edit?.include_in_kfo ?? row.include_in_kfo; const share = edit && 'ownership_share' in edit ? (edit.ownership_share ?? null) : row.ownership_share; const parsed = share === null ? null : parseShare(share); const comment = edit && 'comment' in edit ? (edit.comment ?? null) : row.comment; + let ownership = parsed?.ok ? parsed.value : share; + // у неотмеченной строки поле доли скрыто: неверный ввод не исправить, поэтому уходит сохранённая доля + if (!include && parsed && !parsed.ok) ownership = row.ownership_share; return { head_legal_entity_id: row.head_legal_entity.id, subsidiary_legal_entity_id: row.subsidiary_legal_entity.id, - ownership_share: parsed?.ok ? parsed.value : share, - include_in_kfo: edit?.include_in_kfo ?? row.include_in_kfo, + ownership_share: ownership, + include_in_kfo: include, comment: comment?.trim() ? comment : null, }; } diff --git a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx index 2640cf2..21d0fc3 100644 --- a/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx +++ b/web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx @@ -142,6 +142,16 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { const filteredRows = useMemo(() => (data?.rows ?? []).filter((row) => matchesSign(row.goodwill_amount, sign)), [data, sign]); + /** Подсказка у «Сохранить»: какие строки мешают, и не скрыты ли они фильтром или настройкой колонок. */ + const issuesHint = useMemo(() => { + if (!data || !hasIssues) return undefined; + const keys = Object.keys(issues); + const names = data.rows.filter((r) => keys.includes(rowKey(r))).map((r) => r.subsidiary_legal_entity.name); + const visible = new Set(filteredRows.map(rowKey)); + const hidden = keys.some((k) => !visible.has(k)) ? ' Часть строк скрыта фильтром — сбросьте фильтр.' : ''; + return `Заполните долю и комментарий: ${names.join(', ')}.${hidden}`; + }, [data, hasIssues, issues, filteredRows]); + const closeEditing = () => { setEdits({}); setServerIssues({}); @@ -209,7 +219,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => { action={ - + -- 2.47.2