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