aurora-1544 добавление таблицы гудвил #220
@ -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('/');
|
||||
|
||||
23
web/src/services/goodwill.ts
Normal file
23
web/src/services/goodwill.ts
Normal 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`);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
43
web/src/types/goodwill/goodwill.ts
Normal file
43
web/src/types/goodwill/goodwill.ts
Normal 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;
|
||||
};
|
||||
@ -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}
|
||||
{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}
|
||||
{pluralRu(filterdData?.length ?? 0, ['запись', 'записи', 'записей'])}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => dispatch(setRelatedGroupFilter(null))}
|
||||
style={{
|
||||
borderColor: '#d9d9d9',
|
||||
color: '#262626',
|
||||
}}>
|
||||
Сбросить
|
||||
</Button>
|
||||
}></Alert>
|
||||
)}
|
||||
<DataTable
|
||||
sortState={sort}
|
||||
|
||||
@ -27,8 +27,6 @@ const TAB_CONFIG = [
|
||||
{
|
||||
key: 'goodwill',
|
||||
label: 'Гудвилл',
|
||||
disabled: true,
|
||||
tooltip: 'Будет в следующих срезах',
|
||||
},
|
||||
{
|
||||
key: 'kfo',
|
||||
|
||||
@ -169,7 +169,6 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
||||
}, [data, companies]);
|
||||
|
||||
useEffect(() => {
|
||||
// выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку
|
||||
if (!version_id || isHalfFiltered) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
@ -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) ?? '';
|
||||
});
|
||||
|
||||
|
||||
@ -16,7 +16,6 @@ interface ConsolidationChessTableFilterI {
|
||||
credit: string[];
|
||||
onCreditChange: (val: string[]) => void;
|
||||
allCompanies: MatrixCompany[];
|
||||
/** Год и квартал версии — только для показа: версия задаётся адресом. */
|
||||
year?: number;
|
||||
quarter?: Quarter;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
|
||||
} 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]);
|
||||
|
Veronika
commented
Сейчас ошибка будет отображаться, только в случае Предлагаю заменить на: Аналогично стоит обрабатывать остальные вызовы Сейчас ошибка будет отображаться, только в случае `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>
|
||||
);
|
||||
};
|
||||
@ -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 ;
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
|
Veronika
commented
Подсчёт строки «Всего» в группах лучше перевести на код сведется к:
Подсчёт строки «Всего» в группах лучше перевести на `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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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 || '—';
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user
может стоит вынести в отдельный хук? логика повторяется в трех таблицах: Шахматка, таблица корректировок, Гудвилл.
Пример реализации: