aurora-1544 добавление таблицы гудвил #220

Merged
aep merged 18 commits from feat/goodWill into main 2026-11-06 18:08:01 +03:00
13 changed files with 850 additions and 51 deletions
Showing only changes of commit 70802d788c - Show all commits

View File

@ -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<VersionCard | null>(null);
@ -31,6 +32,7 @@ function ReportContent() {
osv: <OsvContent card={card} onSummaryChange={setOsvSummary} />,
chess: <ChessContent card={card} />,
goodwill: <GoodwillContent />,
};
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('/');

View File

@ -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<GoodwillTableResponse> {
return api.get<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill`);
}
export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise<GoodwillTableResponse> {
return api.put<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill-settings`, payload);
}
export function saveGoodwillRow(
versionId: string,
headId: string,
subsidiaryId: string,
payload: GoodwillRowPayload,
): Promise<GoodwillRow> {
return api.put<GoodwillRow>(`/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`);
}

View File

@ -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;

View File

@ -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;
};

View File

@ -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
</Space>
</Space>
{relatedGroupFilter?.id && !outside_group && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '10px 16px',
background: '#fff8e1', // светло-жёлтый фон
borderTop: '1px solid #ffe58f',
borderBottom: '1px solid #ffe58f',
borderRadius: 8,
color: '#874d00',
fontSize: 14,
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}>
<SearchOutlined style={{ fontSize: 16, color: '#874d00' }} />
<span style={{ color: '#874d00' }}>Фильтр по:</span>
<span style={{ fontWeight: 600, color: '#ad4e00' }}>{relatedGroupFilter.code}</span>
<span style={{ color: '#874d00' }}>
— {filterdData?.length} &nbsp;
{pluralRu(filterdData?.length ?? 0, ['запись', 'записи', 'записей'])}
</span>
</div>
<Button
icon={<CloseOutlined />}
onClick={() => dispatch(setRelatedGroupFilter(null))}
style={{
borderColor: '#d9d9d9',
color: '#262626',
}}>
Сбросить
</Button>
</div>
<Alert
type='warning'
title={
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}>
<SearchOutlined style={{ fontSize: 16, color: '#874d00' }} />
<span style={{ color: '#874d00' }}>Фильтр по:</span>
<span style={{ fontWeight: 600, color: '#ad4e00' }}>{relatedGroupFilter.code}</span>
<span style={{ color: '#874d00' }}>
— {filterdData?.length} &nbsp;
{pluralRu(filterdData?.length ?? 0, ['запись', 'записи', 'записей'])}
</span>
</div>
}
action={
<Button
icon={<CloseOutlined />}
onClick={() => dispatch(setRelatedGroupFilter(null))}
style={{
borderColor: '#d9d9d9',
color: '#262626',
}}>
Сбросить
</Button>
}></Alert>
)}
<DataTable
sortState={sort}

View File

@ -27,8 +27,6 @@ const TAB_CONFIG = [
{
key: 'goodwill',
label: 'Гудвилл',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo',

View File

@ -169,7 +169,6 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
}, [data, companies]);
useEffect(() => {
// выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку
if (!version_id || isHalfFiltered) return;
let isMounted = true;

View File

@ -24,7 +24,7 @@ interface ConsolidationChessTableI {
type TotalRow = {
id: string;
name: string;
[key: string]: string; // Здесь будут храниться суммы по ID компаний
[key: string]: string;
};
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
@ -82,7 +82,6 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
{chessTableCellFormatValue(cell.amount_a, units, decimals)}
</span>
<span>{chessTableCellFormatValue(cell.amount_b, units, decimals)}</span>
{/* подсветка как в API и xlsx: порог > 5 % или пара без встречной стороны (AURORA-1651) */}
<span style={{ color: cell.exceeds_threshold || cell.one_sided ? 'red' : 'inherit' }}>
({chessTableCellFormatValue(cell.variance_amount, units, decimals)})
</span>
@ -112,7 +111,6 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
}
});
// сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—»
totalRow[colCompany.id] = sumAmounts(amountsToSum) ?? '';
});

View File

@ -16,7 +16,6 @@ interface ConsolidationChessTableFilterI {
credit: string[];
onCreditChange: (val: string[]) => void;
allCompanies: MatrixCompany[];
/** Год и квартал версии — только для показа: версия задаётся адресом. */
year?: number;
quarter?: Quarter;
}

View File

@ -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<GoodwillTableResponse | null>(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<Record<string, Partial<GoodwillRow>>>({});
const goodwillTableRef = useRef<HTMLDivElement>(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<HTMLDivElement>, setFullscreen: (val: boolean) => void) => {
if (!ref.current) return;
try {
if (!document.fullscreenElement) {
await ref.current.requestFullscreen();
setFullscreen(true);

может стоит вынести в отдельный хук? логика повторяется в трех таблицах: Шахматка, таблица корректировок, Гудвилл.

Пример реализации:

export function useFullscreen<T extends HTMLElement = HTMLDivElement>() {
	const ref = useRef<T>(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 };
}
может стоит вынести в отдельный хук? логика повторяется в трех таблицах: Шахматка, таблица корректировок, Гудвилл. Пример реализации: ``` export function useFullscreen<T extends HTMLElement = HTMLDivElement>() { const ref = useRef<T>(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 }; } ```
} 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<string, Partial<GoodwillRow>> = {};
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]);

Сейчас ошибка будет отображаться, только в случае e instanceof ApiError. Во всех остальных случаях пользователь не увидит никакого уведомления.

Предлагаю заменить на:
notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось загрузить данные Гудвилла' });

Аналогично стоит обрабатывать остальные вызовы notification.error в handleSave и download

Сейчас ошибка будет отображаться, только в случае `e instanceof ApiError`. Во всех остальных случаях пользователь не увидит никакого уведомления. Предлагаю заменить на: `notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось загрузить данные Гудвилла' });` Аналогично стоит обрабатывать остальные вызовы `notification.error` в `handleSave` и `download`
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 (
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%' }}>
{isEditMode && (
<Alert
title={<span style={{ color: '#874d00' }}>Доступны для редактирования столбцы 'Доля владения' и 'Комментарий'.</span>}
type='warning'
action={
<Space>
<Button
onClick={() => {
setIsEditMode(false);
setEditedRows({});
}}>
Отменить изменения
</Button>
<Tooltip title={!canSaveEditData ? 'Не все данные заполнены' : undefined}>
<Button type='primary' disabled={!canSaveEditData} loading={saving} onClick={handleSave}>
Сохранить
</Button>
</Tooltip>
</Space>
}
/>
)}
<div
ref={goodwillTableRef}
style={{
display: 'flex',
flexDirection: 'column',
height: isGoodwillFullscreen ? '100vh' : 'auto',
padding: isGoodwillFullscreen ? 16 : 0,
backgroundColor: isGoodwillFullscreen ? '#fff' : 'transparent',
}}>
<ConsolidationGoodwillTable
data={filteredData}
loading={loading}
decimals={decimals}
units={units}
isFullscreen={isGoodwillFullscreen}
isEditMode={isEditMode}
editedRows={editedRows}
onRowEdit={handleRowEdit}
visibleKeys={visibleKeys}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Гудвил</span>
<Select
style={{ minWidth: 200 }}
value={goodwillFilter}
options={[
{ value: 'allValue', label: 'Все значения' },
{ value: 'positive', label: 'Положительные' },
{ value: 'negative', label: 'Отрицательные' },
]}
onChange={(e) => setGoodwillFilter(e)}
variant='underlined'
/>
</Space>
{goodwillFilter !== 'allValue' ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null}
</Space>
<div
style={{
width: 1,
height: 50,
backgroundColor: '#e5e7eb',
}}
/>
<ConsolidationChessFormatTableFilter
decimals={decimals}
units={units}
onDecimalsChange={setDecimals}
onUnitsChange={setUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<ColumnSettings defs={groupsConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
<Button
type={isEditMode ? 'default' : 'primary'}
icon={<EditOutlined />}
onClick={() => setIsEditMode(true)}
disabled={isEditMode || !data?.editable}>
Редактировать
</Button>
<Button
onClick={() => toggleFullscreen(goodwillTableRef, setIsGoodwillFullscreen)}
icon={isGoodwillFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isGoodwillFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
</div>
</ConfigProvider>
);
};

View File

@ -0,0 +1,18 @@
.goodwillTable .empty-cell {
background-color: #f2f4f7;
}
.goodwillTable .ant-table-tbody > tr:hover > td.empty-cell {
background-color: #f2f4f7 !important;
}
.goodwillTable .ant-table-summary {
border-end-end-radius: 8px !important;
border-end-start-radius: 8px !important;
}
.goodwillTable .ant-table-cell-fix {
background-color: #f8fafc !important;
}
.goodwillTable .resRow {
background-color: #e6f4ea !important ;
}

View File

@ -0,0 +1,184 @@
import { Table, Typography } 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 { getGoodwillColumns } from './config';
const { Text } = Typography;
interface ConsolidationGoodwillTableI {
data: GoodwillTableResponse | null;
loading: boolean;
decimals: number;
units: string;
toolbar?: React.ReactNode;
isFullscreen?: boolean;
isEditMode: boolean;
editedRows: Record<string, Partial<GoodwillRow>>;
onRowEdit: (rowId: string, field: keyof GoodwillRow, value: any) => void;
visibleKeys: string[];
}
export const ConsolidationGoodwillTable: React.FC<ConsolidationGoodwillTableI> = ({
data,
loading,
decimals,
units,
toolbar,
isFullscreen = false,
isEditMode,
editedRows,
onRowEdit,
visibleKeys,
}) => {
const treeData = useMemo(() => {
if (!data?.rows) return [];
const tree: GoodwillTreeRow[] = [];
const heads = new Map<string, GoodwillTreeRow>();
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;
}
});
return tree;
}, [data?.rows, editedRows]);

Подсчёт строки «Всего» в группах лучше перевести на sumAmounts из utils/money. В функции уже учитывается, что суммы приходят строчками и позволяет избежать ошибок округления. также не нужно будет использовать +=.

код сведется к:

// вместо четырёх let и forEach:
const children = parent.children;

const groupTotalRow: GoodwillTreeRow = {
	...
	financial_investment_amount: sumAmounts(children.map((c) => c.financial_investment_amount)) ?? '0.00',
	participation_amount: sumAmounts(children.map((c) => c.participation_amount)) ?? '0.00',
	charter_capital_amount: sumAmounts(children.map((c) => c.charter_capital_amount)) ?? '0.00',
	goodwill_amount: sumAmounts(children.map((c) => c.goodwill_amount)) ?? '0.00',

Подсчёт строки «Всего» в группах лучше перевести на `sumAmounts` из `utils/money`. В функции уже учитывается, что суммы приходят строчками и позволяет избежать ошибок округления. также не нужно будет использовать `+=`. код сведется к: ``` // вместо четырёх let и forEach: const children = parent.children; const groupTotalRow: GoodwillTreeRow = { ... financial_investment_amount: sumAmounts(children.map((c) => c.financial_investment_amount)) ?? '0.00', participation_amount: sumAmounts(children.map((c) => c.participation_amount)) ?? '0.00', charter_capital_amount: sumAmounts(children.map((c) => c.charter_capital_amount)) ?? '0.00', goodwill_amount: sumAmounts(children.map((c) => c.goodwill_amount)) ?? '0.00', ```
const columns: ColumnsType<GoodwillTreeRow> = 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 renderSummary = () => {
if (!data?.totals) return null;
const ownershipIndex = visibleColumns.findIndex(c => c.key === 'ownership_share');
return (
<Table.Summary fixed>
<Table.Summary.Row className='resRow'>
{visibleColumns.map((col, index) => {
if (index === 0) {
return (
<Table.Summary.Cell key="total-label" index={0} colSpan={ownershipIndex > 0 ? ownershipIndex : 1} align='left'>
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
<Text strong>Итого по листу</Text>
</div>
</Table.Summary.Cell>
);
}
if (index < ownershipIndex) return null;
if (col.key === 'ownership_share') {
return <Table.Summary.Cell key={col.key} index={index} align='right'><Text strong>—</Text></Table.Summary.Cell>;
}
if (col.key === 'financial_investment_amount') {
return (
<Table.Summary.Cell key={col.key} index={index} align='right'>
<div style={{ backgroundColor: '#e6f4ea' }}>
<Text strong>{chessTableCellFormatValue(data.totals.financial_investment_amount, units, decimals)}</Text>
</div>
</Table.Summary.Cell>
);
}
if (col.key === 'participation_amount') {
return (
<Table.Summary.Cell key={col.key} index={index} align='right'>
<div style={{ backgroundColor: '#e6f4ea' }}>
<Text strong>{chessTableCellFormatValue(data.totals.participation_amount, units, decimals)}</Text>
</div>
</Table.Summary.Cell>
);
}
if (col.key === 'charter_capital_amount') {
return <Table.Summary.Cell key={col.key} index={index} align='right'><Text strong>—</Text></Table.Summary.Cell>;
}
if (col.key === 'goodwill_amount') {
return (
<Table.Summary.Cell key={col.key} index={index} align='right'>
<div style={{ backgroundColor: '#e6f4ea' }}>
<Text strong>{chessTableCellFormatValue(data.totals.goodwill_amount, units, decimals)}</Text>
</div>
</Table.Summary.Cell>
);
}
if (col.key === 'comment') {
return <Table.Summary.Cell key={col.key} index={index} align='right'><Text strong>—</Text></Table.Summary.Cell>;
}
return null;
})}
</Table.Summary.Row>
</Table.Summary>
);
};
return (
<Table<GoodwillTreeRow>
className='goodwillTable'
dataSource={treeData}
columns={visibleColumns}
rowKey='key'
pagination={false}
loading={loading}
scroll={{ x: 'max-content', y: scrollY }}
size='small'
bordered
title={() => toolbar}
expandable={{ defaultExpandAllRows: true }}
summary={renderSummary}
/>
);
};

View File

@ -0,0 +1,185 @@
import { Checkbox, Input, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { GoodwillRow, GoodwillTreeRow } from '../../../types/goodwill/goodwill';
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
const { Text } = Typography;
interface GetColumnsParams {
units: string;
decimals: number;
isEditMode: boolean;
editedRows: Record<string, Partial<GoodwillRow>>;
onRowEdit: (rowId: string, field: keyof GoodwillRow, value: any) => void;
}
export const getGoodwillColumns = ({
units,
decimals,
isEditMode,
editedRows,
onRowEdit,
}: GetColumnsParams): ColumnsType<GoodwillTreeRow> => {
const formatMoney = (val: string | undefined) => {
if (!val || val === '0' || val === '0.00') return '—';
return chessTableCellFormatValue(val, units, decimals);
};
return [
{
title: 'Учет в отчете',
key: 'include_in_kfo',
width: 120,
align: 'center',
render: (_, row) => {
const isChecked = row.isGroup ? row.include_in_kfo : (editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo);
return (
<Checkbox disabled={!isEditMode} checked={isChecked} onChange={(e) => onRowEdit(row.key, 'include_in_kfo', e.target.checked)} />
);
},
},
{
title: 'Наименование дочерней организации',
key: 'name',
width: 300,
render: (_, row) => (row.isGroup ? <Text strong>{row.head_legal_entity.name}</Text> : row.subsidiary_legal_entity.name),
},
{
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) {
return (
<Input
type='number'
min={0}
max={100}
step={1}
style={{ width: '100%' }}
value={val ? Math.floor(Number(val)) : ''}
onChange={(e) => {
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 <Text type='secondary'>—</Text>;
const isNegative = String(val).trim().startsWith('-');
return (
<Text strong style={{ color: isNegative ? '#cf1322' : '#3f8600' }}>
{formatted}
</Text>
);
},
},
{
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 (
<Input
style={{ width: '100%' }}
value={val ?? ''}
onChange={(e) => onRowEdit(row.key, 'comment', e.target.value)}
placeholder='Комментарий'
/>
);
}
return val || '—';
},
},
];
};