added consolidation chess table

This commit is contained in:
Grog 2026-10-19 17:09:17 +03:00
parent 681697f728
commit a63701c8b2
9 changed files with 312 additions and 86 deletions

View File

@ -10,7 +10,6 @@ import {
AuditLogging,
CatalogueGroup,
CatalogueJur,
ConsolidatedOsvPage,
ConsolidationPage,
DocStorage,
DocumentPage,
@ -20,6 +19,7 @@ import {
} from './layouts/';
import { store } from './store/store';
import { Sidebar } from './widgets/Sidebar/Sidebar';
import { ConsolidatioReportPage } from './layouts/ConsolidatioReportPage';
// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами.
function App() {
@ -37,7 +37,7 @@ function App() {
<Route path='/groups'>
<Route index element={<GroupsLayout />} />
<Route path=':group_id' element={<ConsolidationPage />} />
<Route path=':group_id/versions/:version_id/osv' element={<ConsolidatedOsvPage />} />
<Route path=':group_id/versions/:version_id/:tab' element={<ConsolidatioReportPage/>} />
<Route path='monozaemchiki' element={<Mono />} />
</Route>
<Route path='/catalogue/groups' element={<CatalogueGroup />} />

View File

@ -0,0 +1,69 @@
import { Space, message } from 'antd';
import { ReactNode, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
export function ConsolidatioReportPage() {
const [card, setCard] = useState<VersionCard | null>(null);
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
const TAB_COMPONENTS: Record<string, ReactNode> = {
osv: <OsvContent card={card} />,
chess: <ChessContent />,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? <></>;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
useEffect(() => {
if (version_id) {
let actual = true;
getVersionCard(version_id)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}
}, [version_id]);
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<PageTitle
backable
location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={russianStatus}
extra={
card && (
<Space size={8}>
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
<TableTag text={russianStatus} {...preset} />
</Space>
)
}
/>
<OsvTabs />
{ActiveTab}
</div>
);
}

View File

@ -9,4 +9,4 @@ export { GroupsLayout } from './Groups';
export { AuditLogging } from './AuditLogging';
export { Admin } from './Admin';
export { DocumentPage } from './DocumentPage';
export { ConsolidatedOsvPage } from './ConsolidatedOsvPage';
export { ConsolidatioReportPage } from './ConsolidatioReportPage';

View File

@ -0,0 +1,4 @@
import { api } from "../../api/client";
import { IntercompanyMatrixResponse } from "../../types/chess/chess";
export const fetchChessConsolidations = (id: string) => api.get<IntercompanyMatrixResponse>(`/consolidations/versions/${id}/intercompany-matrix/all`);

17
src/types/chess/chess.ts Normal file
View File

@ -0,0 +1,17 @@
export interface MatrixCompany {
id: string;
name: string;
}
export interface MatrixCell {
company_a_id: string;
company_b_id: string;
amount_a: string;
amount_b: string;
variance_amount: string;
}
export interface IntercompanyMatrixResponse {
companies: MatrixCompany[];
cells: MatrixCell[];
}

View File

@ -0,0 +1,6 @@
.empty-cell {
background-color: #f2f4f7;
}
.ant-table-tbody > tr:hover > td.empty-cell {
background-color: #f2f4f7 !important;
}

View File

@ -0,0 +1,119 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { fetchChessConsolidations } from '../../services/consodilation/chess';
import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess';
import { useNotificationApi } from '../../utils/useNotification';
import './ConsolidatedChessContent.css';
const { Text } = Typography;
export const ChessContent = () => {
const { version_id } = useParams<{ version_id: string }>();
const notification = useNotificationApi();
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
const [loading, setLoading] = useState(false);
const loadData = async (vId: string) => {
setLoading(true);
try {
const res = await fetchChessConsolidations(vId);
setData(res);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (!version_id) return;
loadData(version_id);
}, [version_id]);
const matrixData = useMemo(() => {
if (!data?.cells) return {};
const result: Record<string, Record<string, MatrixCell>> = {};
data.cells.forEach((cell) => {
if (!result[cell.company_a_id]) result[cell.company_a_id] = {};
result[cell.company_a_id][cell.company_b_id] = cell;
});
return result;
}, [data?.cells]);
const columns: ColumnsType<MatrixCompany> = useMemo(() => {
if (!data?.companies) return [];
const firstColumn: ColumnsType<MatrixCompany>[number] = {
title: 'ДБ / КТ',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
render: (text) => <Text strong>{text}</Text>,
onCell: () => ({ style: { background: '#fff', zIndex: 2 } }),
};
// Внутри companyColumns
const companyColumns = data.companies.map((company) => ({
title: company.name,
dataIndex: company.id,
key: company.id,
width: 150,
align: 'right' as const,
onCell: (row: MatrixCompany) => {
const cell = matrixData[row.id]?.[company.id];
if (!cell) {
// Возвращаем класс через onCell, это работает надежнее
return { className: 'empty-cell' };
}
return {};
},
render: (_: unknown, row: MatrixCompany) => {
const cell = matrixData[row.id]?.[company.id];
if (!cell) {
return <span style={{ color: '#bfbfbf' }}>—</span>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 13 }}>
<span>{cell.amount_a}</span>
<span>{cell.amount_b}</span>
<span
style={{
color: cell.variance_amount.startsWith('-') || cell.variance_amount.startsWith('(') ? 'red' : 'inherit',
}}>
{cell.variance_amount}
</span>
</div>
);
},
}));
return [firstColumn, ...companyColumns];
}, [data?.companies, matrixData]);
return (
<Table<MatrixCompany>
dataSource={data?.companies || []}
columns={columns}
rowKey='id'
pagination={false}
loading={loading}
scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }}
size='small'
bordered
/>
);
};

View File

@ -1,26 +1,79 @@
import { Segmented, Tooltip } from 'antd';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
const SOON = 'Будет в следующих срезах';
const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента'];
const TAB_CONFIG = [
{ key: 'osv', label: 'ОСВ' },
{ key: 'chess', label: 'Шахматка' },
{
key: 'correction',
label: 'Таблица корректировки',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'goodwill',
label: 'Гудвилл',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo',
label: 'КФО',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo_common',
label: 'КФО Общий',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo_confirm',
label: 'Подтверждение КФО клиента',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
];
/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */
export function OsvTabs() {
const navigate = useNavigate();
const location = useLocation();
const { tab } = useParams<{ tab?: string }>();
const activeKey = tab || 'osv';
const handleTabChange = (newKey: string | number) => {
const newPath = location.pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`);
navigate(newPath);
};
const options = TAB_CONFIG.map((tabItem) => {
const isActive = tabItem.key === activeKey;
const label = (
<span
style={{
color: isActive ? '#2a8c3a' : undefined,
fontWeight: isActive ? 600 : 400,
}}>
{tabItem.label}
</span>
);
return {
value: tabItem.key,
disabled: tabItem.disabled,
label: tabItem.tooltip ? <Tooltip title={tabItem.tooltip}>{label}</Tooltip> : label,
};
});
return (
<Segmented
value='ОСВ'
value={activeKey}
onChange={handleTabChange}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={[
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> },
...TABS.map((t) => ({
value: t,
disabled: true,
label: (
<Tooltip title={SOON}>
<span>{t}</span>
</Tooltip>
),
})),
]}
options={options}
/>
);
}

View File

@ -1,51 +1,33 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Space, message } from 'antd';
import { useEffect, useState } from 'react';
import { Alert, Button, message } from 'antd';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import { exportOsv } from '../services/api/osv';
import type { ConsolidatedRowOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../utils/download';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../ConsolidatedOsv/OsvTable';
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null);
export function OsvContent({ card }: { card: VersionCard | null }) {
const [exporting, setExporting] = useState(false);
const { version_id } = useParams<{ version_id: string }>();
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(versionId);
useEffect(() => {
let actual = true;
getVersionCard(versionId)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}, [versionId]);
const osv = useConsolidatedOsv(version_id ?? '');
const download = async () => {
if (!version_id) {
return;
}
setExporting(true);
try {
const { blob, filename } = await exportOsv(versionId, osv.filters);
const { blob, filename } = await exportOsv(version_id, osv.filters);
saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? ''));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод');
@ -57,27 +39,9 @@ function OsvContent({ versionId }: { versionId: string }) {
const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<PageTitle
backable
location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={russianStatus}
extra={
card && (
<Space size={8}>
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
<TableTag text={russianStatus} {...preset} />
</Space>
)
}
/>
<OsvTabs />
<>
<main
style={{
flex: '1 1 auto',
@ -135,21 +99,15 @@ function OsvContent({ versionId }: { versionId: string }) {
onMap={setMapping}
/>
</main>
{mapping && (
{mapping && version_id && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={versionId}
versionId={version_id}
row={mapping}
onClose={() => setMapping(null)}
onMapped={osv.refresh}
/>
)}
</div>
</>
);
}
export function ConsolidatedOsvPage() {
const { version_id = '' } = useParams();
// key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново
return <OsvContent key={version_id} versionId={version_id} />;
}