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 || '—'; + }, + }, + ]; +};