Гудвилл: вкладка по API и DOC-259025
Суммы null — прочерк, ноль — 0,00; «Нет вложений» серым без чекбокса; предупреждения иконкой с подсказкой; итоги «Всего» и «Итого по листу» из groups/totals ответа; в режиме редактирования — предпросмотр P/G, проверка доли (два знака, без обрезки) и комментария, групповой чекбокс по действующим строкам блока с indeterminate; сохранение одним пакетом изменённых строк, ошибка 422 подсвечивает поле своей строки; переключатель «Учитывать гудвилл в КФО»; фильтр «Нулевые»; экспорт с текущим фильтром. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
66acde7786
commit
2f5b455aaa
@ -1,17 +1,43 @@
|
|||||||
import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
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 { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { ApiError } from '../../../api/client';
|
||||||
import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter';
|
import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter';
|
||||||
import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable';
|
import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable';
|
||||||
import { useFullscreen } from '../../../hooks/useFullScreen';
|
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 { 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 { saveBlob, xlsxFilename } from '../../../utils/download';
|
||||||
|
import { changedRows, effectiveRow, matchesSign, parseShare, rowKey } from '../../../utils/goodwill';
|
||||||
import { useNotificationApi } from '../../../utils/useNotification';
|
import { useNotificationApi } from '../../../utils/useNotification';
|
||||||
|
import { readOnlyHint } from '../../CorrectionTable/readOnly';
|
||||||
import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable';
|
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<string, keyof RowIssues> = { 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 }) => {
|
export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
|
||||||
const { version_id } = useParams<{ version_id: string }>();
|
const { version_id } = useParams<{ version_id: string }>();
|
||||||
@ -20,103 +46,24 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
const [data, setData] = useState<GoodwillTableResponse | null>(null);
|
const [data, setData] = useState<GoodwillTableResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||||
const [decimals, setDecimals] = useState(2);
|
const [decimals, setDecimals] = useState(2);
|
||||||
const [units, setUnits] = useState('thousand');
|
const [units, setUnits] = useState('thousand');
|
||||||
|
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [editedRows, setEditedRows] = useState<Record<string, Partial<GoodwillRow>>>({});
|
const [edits, setEdits] = useState<Record<string, GoodwillEdit>>({});
|
||||||
|
const [serverIssues, setServerIssues] = useState<Record<string, RowIssues>>({});
|
||||||
const [goodwillFilter, setGoodwillFilter] = useState('allValue');
|
const [sign, setSign] = useState<GoodwillSign>('all');
|
||||||
const [exportLoading, setExportLoading] = useState(false);
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
|
const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
|
||||||
|
const { ref: tableRef, isFullscreen, toggle: toggleFullscreen } = useFullscreen<HTMLDivElement>();
|
||||||
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<GoodwillTreeRow>[] };
|
|
||||||
}, [columns]);
|
|
||||||
|
|
||||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns(groupsConfig);
|
|
||||||
|
|
||||||
const dropFiltersHandler = () => {
|
|
||||||
setGoodwillFilter('allValue');
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (vId: string) => {
|
async (vId: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetchGoodwillTable(vId);
|
setData(await fetchGoodwillTable(vId));
|
||||||
setData(res);
|
|
||||||
|
|
||||||
const initialEdited: Record<string, Partial<GoodwillRow>> = {};
|
|
||||||
res.rows.forEach((row) => {
|
|
||||||
if (row.include_in_kfo) {
|
|
||||||
const key = `${row.head_legal_entity.id}-${row.subsidiary_legal_entity.id}`;
|
|
||||||
initialEdited[key] = {
|
|
||||||
include_in_kfo: true,
|
|
||||||
ownership_share: row.ownership_share,
|
|
||||||
comment: row.comment,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setEditedRows(initialEdited);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notification.error({ description: e instanceof Error ? e.message : 'Ошибка при загрузке данных' });
|
if (e instanceof ApiError) notification.error({ description: e.message });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -124,109 +71,146 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
[notification],
|
[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<string, RowIssues> = {};
|
||||||
|
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<GoodwillTreeRow>[] }), [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 () => {
|
const handleSave = async () => {
|
||||||
if (!version_id || !data) return;
|
if (!version_id || !data) return;
|
||||||
|
if (pending.length === 0) {
|
||||||
|
closeEditing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const promises = Object.keys(editedRows).map((rowKey) => {
|
setData(await saveGoodwillRows(version_id, pending));
|
||||||
const changes = editedRows[rowKey];
|
closeEditing();
|
||||||
const originalRow = data.rows.find((r) => `${r.head_legal_entity.id}-${r.subsidiary_legal_entity.id}` === rowKey);
|
|
||||||
if (!originalRow) return;
|
|
||||||
|
|
||||||
const payload: GoodwillRowPayload = {
|
|
||||||
ownership_share: String(changes.ownership_share ?? originalRow.ownership_share ?? ''),
|
|
||||||
include_in_kfo: changes.include_in_kfo ?? originalRow.include_in_kfo,
|
|
||||||
comment: changes.comment !== undefined ? changes.comment : (originalRow.comment ?? null),
|
|
||||||
};
|
|
||||||
|
|
||||||
return saveGoodwillRow(version_id, originalRow.head_legal_entity.id, originalRow.subsidiary_legal_entity.id, payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
notification.success({ description: 'Изменения сохранены' });
|
notification.success({ description: 'Изменения сохранены' });
|
||||||
setEditedRows({});
|
|
||||||
setIsEditMode(false);
|
|
||||||
loadData(version_id);
|
|
||||||
} catch (e) {
|
} 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 {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const download = async () => {
|
const handleSettings = async (enabled: boolean) => {
|
||||||
if (!version_id) {
|
if (!version_id) return;
|
||||||
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);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await exportGoodwillTable(version_id);
|
const { blob, filename } = await exportGoodwillTable(version_id, sign);
|
||||||
saveBlob(blob, filename ?? xlsxFilename('Гудвил', versionInfo));
|
saveBlob(blob, filename ?? xlsxFilename('Гудвилл', versionInfo));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notification.error({ description: e instanceof Error ? e.message : 'Ошибка при экспорте таблицы' });
|
if (e instanceof ApiError) notification.error({ description: e.message });
|
||||||
} finally {
|
} finally {
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const readOnly = data && !data.editable ? readOnlyHint(data.read_only_reason) : null;
|
||||||
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<HTMLDivElement>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (version_id) {
|
|
||||||
loadData(version_id);
|
|
||||||
}
|
|
||||||
}, [version_id, loadData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
|
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%' }}>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<Alert
|
<Alert
|
||||||
title={<span style={{ color: '#874d00' }}>Доступны для редактирования столбцы 'Доля владения' и 'Комментарий'.</span>}
|
title={<span style={{ color: '#874d00' }}>Доступны для редактирования столбцы «Доля владения» и «Комментарий».</span>}
|
||||||
type='warning'
|
type='warning'
|
||||||
action={
|
action={
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button onClick={closeEditing}>Отменить изменения</Button>
|
||||||
onClick={() => {
|
<Tooltip title={hasIssues ? 'Заполните долю и комментарий у отмеченных строк' : undefined}>
|
||||||
setIsEditMode(false);
|
<Button type='primary' disabled={hasIssues} loading={saving} onClick={handleSave}>
|
||||||
setEditedRows({});
|
|
||||||
}}>
|
|
||||||
Отменить изменения
|
|
||||||
</Button>
|
|
||||||
<Tooltip title={!canSaveEditData ? 'Не все данные заполнены' : undefined}>
|
|
||||||
<Button type='primary' disabled={!canSaveEditData} loading={saving} onClick={handleSave}>
|
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -236,24 +220,22 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={goodwillTableRef}
|
ref={tableRef}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
height: isGoodwillFullscreen ? '100vh' : 'auto',
|
height: isFullscreen ? '100vh' : 'auto',
|
||||||
padding: isGoodwillFullscreen ? 16 : 0,
|
padding: isFullscreen ? 16 : 0,
|
||||||
backgroundColor: isGoodwillFullscreen ? '#fff' : 'transparent',
|
backgroundColor: isFullscreen ? '#fff' : 'transparent',
|
||||||
}}>
|
}}>
|
||||||
<ConsolidationGoodwillTable
|
<ConsolidationGoodwillTable
|
||||||
data={filteredData}
|
data={data}
|
||||||
|
rows={filteredRows}
|
||||||
|
columns={visibleColumns}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decimals={decimals}
|
decimals={decimals}
|
||||||
units={units}
|
units={units}
|
||||||
isFullscreen={isGoodwillFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
isEditMode={isEditMode}
|
|
||||||
editedRows={editedRows}
|
|
||||||
onRowEdit={handleRowEdit}
|
|
||||||
visibleKeys={visibleKeys}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -267,53 +249,55 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
|
||||||
<Space>
|
<Space>
|
||||||
<Space orientation='vertical' size={0}>
|
<Space orientation='vertical' size={0}>
|
||||||
<span style={{ fontSize: 12, color: '#667085' }}>Гудвил</span>
|
<span style={{ fontSize: 12, color: '#667085' }}>Гудвилл</span>
|
||||||
|
<Select style={{ minWidth: 200 }} value={sign} options={SIGN_OPTIONS} onChange={setSign} variant='underlined' />
|
||||||
<Select
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
value={goodwillFilter}
|
|
||||||
options={[
|
|
||||||
{ value: 'allValue', label: 'Все значения' },
|
|
||||||
{ value: 'positive', label: 'Положительные' },
|
|
||||||
{ value: 'negative', label: 'Отрицательные' },
|
|
||||||
]}
|
|
||||||
onChange={(e) => setGoodwillFilter(e)}
|
|
||||||
variant='underlined'
|
|
||||||
/>
|
|
||||||
</Space>
|
</Space>
|
||||||
{goodwillFilter !== 'allValue' ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null}
|
{sign !== 'all' ? <Button onClick={() => setSign('all')}>Сбросить фильтры</Button> : null}
|
||||||
</Space>
|
</Space>
|
||||||
|
<div style={{ width: 1, height: 50, backgroundColor: '#e5e7eb' }} />
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 1,
|
|
||||||
height: 50,
|
|
||||||
backgroundColor: '#e5e7eb',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ConsolidationFormatTableFilter
|
<ConsolidationFormatTableFilter
|
||||||
decimals={decimals}
|
decimals={decimals}
|
||||||
units={units}
|
units={units}
|
||||||
onDecimalsChange={setDecimals}
|
onDecimalsChange={setDecimals}
|
||||||
onUnitsChange={setUnits}
|
onUnitsChange={setUnits}
|
||||||
/>
|
/>
|
||||||
|
<div style={{ width: 1, height: 50, backgroundColor: '#e5e7eb' }} />
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<Space>
|
||||||
|
<Tooltip title={readOnly}>
|
||||||
|
<Switch
|
||||||
|
checked={data?.goodwill_enabled ?? false}
|
||||||
|
disabled={!data?.editable || isEditMode}
|
||||||
|
loading={settingsSaving}
|
||||||
|
onChange={handleSettings}
|
||||||
|
aria-label='Учитывать гудвилл в КФО'
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<span>Учитывать гудвилл в КФО</span>
|
||||||
|
</Space>
|
||||||
|
<Text type='secondary' style={{ fontSize: 12 }}>
|
||||||
|
{data?.goodwill_enabled
|
||||||
|
? `В КФО: ${formatMoney(data.totals.goodwill_in_kfo_amount, units, decimals)}`
|
||||||
|
: 'Гудвилл не учитывается в КФО'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<ColumnSettings defs={groupsConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
<ColumnSettings defs={columnsConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
||||||
<Button
|
<Tooltip title={readOnly}>
|
||||||
type={isEditMode ? 'default' : 'primary'}
|
<Button
|
||||||
icon={<EditOutlined />}
|
type={isEditMode ? 'default' : 'primary'}
|
||||||
onClick={() => setIsEditMode(true)}
|
icon={<EditOutlined />}
|
||||||
disabled={isEditMode || !data?.editable}>
|
onClick={() => setIsEditMode(true)}
|
||||||
Редактировать
|
disabled={isEditMode || !data?.editable}>
|
||||||
</Button>
|
Редактировать
|
||||||
<Button loading={exportLoading} onClick={() => download()} icon={<DownloadOutlined />}>
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Button loading={exportLoading} onClick={download} icon={<DownloadOutlined />}>
|
||||||
Экспорт
|
Экспорт
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={toggleFullscreen} icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
|
||||||
onClick={() => toggleGoodwillFullscreen()}
|
{isFullscreen ? 'Свернуть' : 'На весь экран'}
|
||||||
icon={isGoodwillFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
|
|
||||||
{isGoodwillFullscreen ? 'Свернуть' : 'На весь экран'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,106 +1,46 @@
|
|||||||
import { Table, Typography } from 'antd';
|
import { Table, Typography } from 'antd';
|
||||||
import type { ColumnGroupType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { ColumnType } from 'antd/lib/table';
|
import type { GoodwillTotals } from '../../../types/goodwill/goodwill';
|
||||||
import type { GoodwillTableResponse, GoodwillTreeRow } from '../../../types/goodwill/goodwill';
|
import { type GoodwillTreeRow, type MoneyKey, formatMoney } from '../ConsolidationGoodwillTable/config';
|
||||||
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
|
||||||
import { sumAmounts } from '../../../utils/money';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const MONEY_KEYS = new Set<string>(['financial_investment_amount', 'participation_amount', 'charter_capital_amount', 'goodwill_amount']);
|
||||||
|
|
||||||
interface ConsolidationGoodwillSummaryI {
|
interface ConsolidationGoodwillSummaryI {
|
||||||
data: GoodwillTableResponse | null;
|
totals: GoodwillTotals | null;
|
||||||
visibleColumns: (ColumnGroupType<GoodwillTreeRow> | ColumnType<GoodwillTreeRow>)[];
|
columns: ColumnsType<GoodwillTreeRow>;
|
||||||
units: string;
|
units: string;
|
||||||
decimals: number;
|
decimals: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConsolidationGoodwillSummary: React.FC<ConsolidationGoodwillSummaryI> = ({ data, visibleColumns, units, decimals }) => {
|
/** «Итого по листу» — `totals` ответа (по действующим строкам, P и G — по отмеченным). */
|
||||||
if (!data?.rows) return null;
|
export const ConsolidationGoodwillSummary: React.FC<ConsolidationGoodwillSummaryI> = ({ totals, columns, units, decimals }) => {
|
||||||
|
if (!totals) return null;
|
||||||
const totalFinInv = sumAmounts(data.rows.map((c) => c.financial_investment_amount)) ?? '0.00';
|
|
||||||
const totalParticipation = sumAmounts(data.rows.map((c) => c.participation_amount)) ?? '0.00';
|
|
||||||
const totalCharter = sumAmounts(data.rows.map((c) => c.charter_capital_amount)) ?? '0.00';
|
|
||||||
const totalGoodwill = sumAmounts(data.rows.map((c) => c.goodwill_amount)) ?? '0.00';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Summary fixed>
|
<Table.Summary fixed>
|
||||||
<Table.Summary.Row className='resRow'>
|
<Table.Summary.Row className='resRow'>
|
||||||
{visibleColumns.map((col, index) => {
|
{columns.map((col, index) => {
|
||||||
if (col.key === 'include_in_kfo') {
|
const key = String(col.key);
|
||||||
|
if (key === 'name') {
|
||||||
return (
|
return (
|
||||||
<Table.Summary.Cell key={col.key} index={index} align='center'>
|
<Table.Summary.Cell key={key} index={index}>
|
||||||
<Text strong>—</Text>
|
<Text strong>Итого по листу</Text>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (MONEY_KEYS.has(key)) {
|
||||||
if (col.key === 'name') {
|
|
||||||
return (
|
return (
|
||||||
<Table.Summary.Cell key={col.key} index={index} align='left'>
|
<Table.Summary.Cell key={key} index={index} align='right'>
|
||||||
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
|
<Text strong>{formatMoney(totals[key as MoneyKey], units, decimals)}</Text>
|
||||||
<Text strong>Итого по листу</Text>
|
|
||||||
</div>
|
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
if (col.key === 'ownership_share') {
|
<Table.Summary.Cell key={key} index={index} align='center'>
|
||||||
return (
|
<Text strong>—</Text>
|
||||||
<Table.Summary.Cell key={col.key} index={index} align='right'>
|
</Table.Summary.Cell>
|
||||||
<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(totalFinInv, 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(totalParticipation, units, decimals)}</Text>
|
|
||||||
</div>
|
|
||||||
</Table.Summary.Cell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (col.key === 'charter_capital_amount') {
|
|
||||||
return (
|
|
||||||
<Table.Summary.Cell key={col.key} index={index} align='right'>
|
|
||||||
<div style={{ backgroundColor: '#e6f4ea' }}>
|
|
||||||
<Text strong>{chessTableCellFormatValue(totalCharter, units, decimals)}</Text>
|
|
||||||
</div>
|
|
||||||
</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(totalGoodwill, 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.Row>
|
||||||
</Table.Summary>
|
</Table.Summary>
|
||||||
|
|||||||
@ -21,3 +21,7 @@
|
|||||||
background-color: #ecfdf3 !important;
|
background-color: #ecfdf3 !important;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.goodwillTable .goodwill-inactive td {
|
||||||
|
color: #98a2b3;
|
||||||
|
}
|
||||||
|
|||||||
@ -2,146 +2,78 @@ import { Table } from 'antd';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { useMemo } 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 './ConsolidationGoodwillTable.css';
|
||||||
|
|
||||||
import { ConsolidationGoodwillSummary } from '../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary';
|
import { ConsolidationGoodwillSummary } from '../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary';
|
||||||
import { getGoodwillColumns } from './config';
|
import type { GoodwillTreeRow } from './config';
|
||||||
|
|
||||||
interface ConsolidationGoodwillTableI {
|
interface ConsolidationGoodwillTableI {
|
||||||
data: GoodwillTableResponse | null;
|
data: GoodwillTableResponse | null;
|
||||||
|
/** строки после фильтра знака */
|
||||||
|
rows: GoodwillRow[];
|
||||||
|
columns: ColumnsType<GoodwillTreeRow>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
decimals: number;
|
decimals: number;
|
||||||
units: string;
|
units: string;
|
||||||
toolbar?: React.ReactNode;
|
toolbar?: React.ReactNode;
|
||||||
isFullscreen?: boolean;
|
isFullscreen?: boolean;
|
||||||
isEditMode: boolean;
|
}
|
||||||
editedRows: Record<string, Partial<GoodwillRow>>;
|
|
||||||
onRowEdit: (rowId: string, field: keyof GoodwillRow, value: boolean | string) => void;
|
/** Дерево «ГО → строки пар → Всего»; итоги блока — из `groups` ответа, фронт их не считает. */
|
||||||
visibleKeys: string[];
|
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<string, Extract<GoodwillTreeRow, { kind: 'group' }>>();
|
||||||
|
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<ConsolidationGoodwillTableI> = ({
|
export const ConsolidationGoodwillTable: React.FC<ConsolidationGoodwillTableI> = ({
|
||||||
data,
|
data,
|
||||||
|
rows,
|
||||||
|
columns,
|
||||||
loading,
|
loading,
|
||||||
decimals,
|
decimals,
|
||||||
units,
|
units,
|
||||||
toolbar,
|
toolbar,
|
||||||
isFullscreen = false,
|
isFullscreen = false,
|
||||||
isEditMode,
|
|
||||||
editedRows,
|
|
||||||
onRowEdit,
|
|
||||||
visibleKeys,
|
|
||||||
}) => {
|
}) => {
|
||||||
const treeData = useMemo(() => {
|
const treeData = useMemo(() => buildTree(data, rows), [data, rows]);
|
||||||
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;
|
|
||||||
|
|
||||||
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<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';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table<GoodwillTreeRow>
|
<Table<GoodwillTreeRow>
|
||||||
className='goodwillTable'
|
className='goodwillTable'
|
||||||
dataSource={treeData}
|
dataSource={treeData}
|
||||||
columns={visibleColumns}
|
columns={columns}
|
||||||
rowKey='key'
|
rowKey='key'
|
||||||
pagination={false}
|
pagination={false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
scroll={{ x: 'max-content', y: scrollY }}
|
scroll={{ x: 'max-content', y: isFullscreen ? 'calc(100vh - 200px)' : undefined }}
|
||||||
size='small'
|
size='small'
|
||||||
bordered
|
bordered
|
||||||
title={() => toolbar}
|
title={() => toolbar}
|
||||||
key={data?.rows?.length}
|
key={treeData.map((g) => g.key).join('|')}
|
||||||
expandable={{ defaultExpandAllRows: true }}
|
expandable={{ defaultExpandAllRows: true }}
|
||||||
summary={() => <ConsolidationGoodwillSummary data={data} visibleColumns={visibleColumns} units={units} decimals={decimals} />}
|
summary={() => <ConsolidationGoodwillSummary totals={data?.totals ?? null} columns={columns} units={units} decimals={decimals} />}
|
||||||
rowClassName={(record) => (record.isGroupTotal ? 'group-total-row' : '')}
|
rowClassName={rowClassName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 { 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 { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
||||||
|
import { effectiveRow, previewAmounts, rowKey } from '../../../utils/goodwill';
|
||||||
|
|
||||||
const { Text } = Typography;
|
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 {
|
interface GetColumnsParams {
|
||||||
units: string;
|
units: string;
|
||||||
decimals: number;
|
decimals: number;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
editedRows: Record<string, Partial<GoodwillRow>>;
|
edits: Record<string, GoodwillEdit>;
|
||||||
onRowEdit: (rowId: string, field: keyof GoodwillRow, value: boolean | string) => void;
|
issues: Record<string, RowIssues>;
|
||||||
|
/** все строки ответа (для группового чекбокса, включая скрытые фильтром) */
|
||||||
|
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<MoneyKey, string | null> {
|
||||||
|
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 = ({
|
export const getGoodwillColumns = ({
|
||||||
units,
|
units,
|
||||||
decimals,
|
decimals,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
editedRows,
|
edits,
|
||||||
onRowEdit,
|
issues,
|
||||||
|
rows,
|
||||||
|
onToggleRow,
|
||||||
|
onToggleGroup,
|
||||||
|
onShare,
|
||||||
|
onComment,
|
||||||
}: GetColumnsParams): ColumnsType<GoodwillTreeRow> => {
|
}: GetColumnsParams): ColumnsType<GoodwillTreeRow> => {
|
||||||
const formatMoney = (val: string | undefined) => {
|
const included = (row: GoodwillRow) => edits[rowKey(row)]?.include_in_kfo ?? row.include_in_kfo;
|
||||||
if (!val || val === '0' || val === '0.00') return '—';
|
|
||||||
return chessTableCellFormatValue(val, units, decimals);
|
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 <Text strong>{formatMoney(node.totals?.[key], units, decimals)}</Text>;
|
||||||
|
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 <Text style={{ color: moneyColor(value) }}>{formatted}</Text>;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -31,13 +90,29 @@ export const getGoodwillColumns = ({
|
|||||||
key: 'include_in_kfo',
|
key: 'include_in_kfo',
|
||||||
width: 120,
|
width: 120,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_, row) => {
|
render: (_, node) => {
|
||||||
if (row.isGroupTotal) return null;
|
if (node.kind === 'total') return <Text strong>—</Text>;
|
||||||
|
if (node.kind === 'group') {
|
||||||
const isChecked = row.isGroup ? row.include_in_kfo : (editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo);
|
const active = rows.filter((r) => r.head_legal_entity.id === node.headId && r.has_investments);
|
||||||
|
const checked = active.filter(included).length;
|
||||||
|
return (
|
||||||
|
<Checkbox
|
||||||
|
disabled={!isEditMode || active.length === 0}
|
||||||
|
checked={active.length > 0 && checked === active.length}
|
||||||
|
indeterminate={checked > 0 && checked < active.length}
|
||||||
|
onChange={(e) => onToggleGroup(node.headId, e.target.checked)}>
|
||||||
|
<Text type='secondary' style={{ fontSize: 12 }}>
|
||||||
|
Выделить все
|
||||||
|
</Text>
|
||||||
|
</Checkbox>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Checkbox disabled={!isEditMode} checked={isChecked} onChange={(e) => onRowEdit(row.key, 'include_in_kfo', e.target.checked)} />
|
<Checkbox
|
||||||
|
disabled={!isEditMode || !node.row.has_investments}
|
||||||
|
checked={included(node.row)}
|
||||||
|
onChange={(e) => onToggleRow(node.key, e.target.checked)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -45,139 +120,90 @@ export const getGoodwillColumns = ({
|
|||||||
title: 'Наименование дочерней организации',
|
title: 'Наименование дочерней организации',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
width: 300,
|
width: 300,
|
||||||
render: (_, row) => (row.isGroup ? <Text strong>{row.head_legal_entity.name}</Text> : row.subsidiary_legal_entity.name),
|
render: (_, node) => {
|
||||||
|
if (node.kind === 'group') return <Text strong>{node.headName}</Text>;
|
||||||
|
if (node.kind === 'total') return <Text strong>Всего</Text>;
|
||||||
|
const { row } = node;
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{row.subsidiary_legal_entity.name}
|
||||||
|
{!row.has_investments && <Tag style={{ marginLeft: 8 }}>Нет вложений</Tag>}
|
||||||
|
{row.warnings.length > 0 && (
|
||||||
|
<Tooltip title={row.warnings.map((w) => w.message).join('; ')}>
|
||||||
|
<WarningOutlined style={{ marginLeft: 8, color: '#d48806' }} aria-label='Предупреждение' />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Доля владения',
|
title: 'Доля владения',
|
||||||
key: 'ownership_share',
|
key: 'ownership_share',
|
||||||
width: 150,
|
width: 150,
|
||||||
align: 'right',
|
align: 'right',
|
||||||
onCell: (row) => {
|
render: (_, node) => {
|
||||||
if (row.isGroup) return {};
|
if (node.kind !== 'row') return null;
|
||||||
const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo;
|
const { row } = node;
|
||||||
if (!isChecked) return {};
|
const value = effectiveRow(row, edits[node.key]).ownership_share;
|
||||||
|
if (isEditMode && row.has_investments && included(row)) {
|
||||||
const val = editedRows[row.key]?.ownership_share ?? row.ownership_share;
|
const edit = edits[node.key];
|
||||||
|
const raw = edit && 'ownership_share' in edit ? (edit.ownership_share ?? '') : (row.ownership_share?.replace('.', ',') ?? '');
|
||||||
const isShareEmpty = !val || String(val).trim() === '';
|
const error = issues[node.key]?.ownership_share;
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Input
|
<div>
|
||||||
value={val ?? ''}
|
<Input
|
||||||
onChange={(e) => {
|
value={raw}
|
||||||
let inputValue = e.target.value;
|
status={error ? 'error' : undefined}
|
||||||
|
onChange={(e) => onShare(node.key, e.target.value)}
|
||||||
inputValue = inputValue.replace('.', ',');
|
placeholder='%'
|
||||||
|
aria-label='Доля владения'
|
||||||
inputValue = inputValue.replace(/[^\d,]/g, '');
|
/>
|
||||||
|
{error && (
|
||||||
const parts = inputValue.split(',');
|
<Text type='danger' style={{ fontSize: 12 }}>
|
||||||
if (parts.length > 1 && parts[1].length > 2) {
|
{error}
|
||||||
inputValue = `${parts[0]},${parts[1].slice(0, 2)}`;
|
</Text>
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
const numericValue = Number(inputValue.replace(',', '.'));
|
|
||||||
if (!Number.isNaN(numericValue) && numericValue > 100) {
|
|
||||||
inputValue = '100';
|
|
||||||
}
|
|
||||||
|
|
||||||
onRowEdit(row.key, 'ownership_share', inputValue);
|
|
||||||
}}
|
|
||||||
placeholder='%'
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return value === null ? '—' : `${chessTableCellFormatValue(value, 'rub', 2)} %`;
|
||||||
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 <Text type='secondary'>—</Text>;
|
|
||||||
|
|
||||||
const isNegative = String(val).trim().startsWith('-');
|
|
||||||
|
|
||||||
return <Text style={{ color: isNegative ? '#cf1322' : '#3f8600' }}>{formatted}</Text>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
moneyColumn('financial_investment_amount', 'Фондовые вложения головной организации'),
|
||||||
|
moneyColumn('participation_amount', 'Доля участия головной организации в уставном капитале'),
|
||||||
|
moneyColumn('charter_capital_amount', 'Уставный капитал'),
|
||||||
|
moneyColumn('goodwill_amount', 'Гудвилл (+/-)'),
|
||||||
{
|
{
|
||||||
title: 'Комментарий',
|
title: 'Комментарий',
|
||||||
key: 'comment',
|
key: 'comment',
|
||||||
width: 200,
|
width: 240,
|
||||||
onCell: (row) => {
|
render: (_, node) => {
|
||||||
if (row.isGroup) return {};
|
if (node.kind !== 'row') return null;
|
||||||
const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo;
|
const { row } = node;
|
||||||
if (!isChecked) return {};
|
const edit = edits[node.key];
|
||||||
|
const value = edit && 'comment' in edit ? (edit.comment ?? '') : (row.comment ?? '');
|
||||||
const comment = editedRows[row.key]?.comment ?? row.comment;
|
if (isEditMode && row.has_investments && included(row)) {
|
||||||
|
const error = issues[node.key]?.comment;
|
||||||
const isCommentEmpty = !comment || String(comment).trim() === '';
|
return (
|
||||||
|
<div>
|
||||||
const isInvalid = isCommentEmpty;
|
<Input
|
||||||
|
value={value}
|
||||||
return {
|
maxLength={1000}
|
||||||
style: {
|
status={error ? 'error' : undefined}
|
||||||
backgroundColor: isInvalid ? '#fffbe6' : '#f6ffed',
|
onChange={(e) => onComment(node.key, e.target.value)}
|
||||||
},
|
placeholder='Комментарий'
|
||||||
};
|
aria-label='Комментарий'
|
||||||
},
|
/>
|
||||||
render: (_, row) => {
|
{error && (
|
||||||
if (row.isGroup) return null;
|
<Text type='danger' style={{ fontSize: 12 }}>
|
||||||
|
{error}
|
||||||
const val = editedRows[row.key]?.comment ?? row.comment;
|
</Text>
|
||||||
const isChecked = editedRows[row.key]?.include_in_kfo ?? row.include_in_kfo;
|
)}
|
||||||
|
</div>
|
||||||
if (isEditMode && isChecked) {
|
);
|
||||||
return <Input value={val ?? ''} onChange={(e) => onRowEdit(row.key, 'comment', e.target.value)} placeholder='Комментарий' />;
|
|
||||||
}
|
}
|
||||||
return val || '—';
|
return value || '—';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user