added consolidation chess table
This commit is contained in:
parent
681697f728
commit
a63701c8b2
@ -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 />} />
|
||||
|
||||
69
src/layouts/ConsolidatioReportPage.tsx
Normal file
69
src/layouts/ConsolidatioReportPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
4
src/services/consodilation/chess.ts
Normal file
4
src/services/consodilation/chess.ts
Normal 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
17
src/types/chess/chess.ts
Normal 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[];
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
.empty-cell {
|
||||
background-color: #f2f4f7;
|
||||
}
|
||||
.ant-table-tbody > tr:hover > td.empty-cell {
|
||||
background-color: #f2f4f7 !important;
|
||||
}
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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} />;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user