added chess table
This commit is contained in:
parent
7891430fce
commit
9bd8a2a015
@ -0,0 +1,6 @@
|
|||||||
|
.ant-select-selection-item{
|
||||||
|
background-color: #ecfdf3 !important;
|
||||||
|
color: #1f5f35 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #2ea64b38 !important;
|
||||||
|
border-radius: 999px !important;
|
||||||
|
}
|
||||||
@ -127,10 +127,16 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */
|
/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */
|
||||||
export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> {
|
export async function apiFetchBlob(
|
||||||
const response = await send(path, {});
|
path: string,
|
||||||
|
init: RequestInit = {} // Добавили init
|
||||||
|
): Promise<{ blob: Blob; filename: string | null }> {
|
||||||
|
const response = await send(path, init);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) };
|
return {
|
||||||
|
blob,
|
||||||
|
filename: filenameFromContentDisposition(response.headers.get('Content-Disposition'))
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function withJsonBody(method: string, body: unknown): RequestInit {
|
function withJsonBody(method: string, body: unknown): RequestInit {
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
.ant-tree-switcher-noop{
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
149
web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx
Normal file
149
web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
|
import { Input, Button, Tree, Space } from 'antd';
|
||||||
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
|
import './TreeFilterDropdown.css';
|
||||||
|
|
||||||
|
export type OptionType = {
|
||||||
|
title: string;
|
||||||
|
key: string;
|
||||||
|
code?:string;
|
||||||
|
children?: OptionType[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TreeFilterDropdownProps {
|
||||||
|
title: string;
|
||||||
|
placeholder?: string;
|
||||||
|
treeData: OptionType[];
|
||||||
|
selectedKeys: string[];
|
||||||
|
onChange: (keys: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TreeFilterDropdown: React.FC<TreeFilterDropdownProps> = ({
|
||||||
|
title,
|
||||||
|
placeholder = 'Введите значение',
|
||||||
|
treeData,
|
||||||
|
selectedKeys,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const [checkedKeys, setCheckedKeys] = useState<string[]>(selectedKeys);
|
||||||
|
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCheckedKeys(selectedKeys);
|
||||||
|
}, [selectedKeys]);
|
||||||
|
|
||||||
|
const filteredTreeData = useMemo(() => {
|
||||||
|
if (!query) return treeData;
|
||||||
|
|
||||||
|
const filterNode = (node: OptionType): OptionType | null => {
|
||||||
|
const titleStr = String(node.title).toLowerCase();
|
||||||
|
const isMatch = titleStr.includes(query.toLowerCase());
|
||||||
|
|
||||||
|
let filteredChildren: OptionType[] = [];
|
||||||
|
if (node.children) {
|
||||||
|
filteredChildren = node.children.map(filterNode).filter((child): child is OptionType => child !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMatch || filteredChildren.length > 0) {
|
||||||
|
return { ...node, children: filteredChildren.length > 0 ? filteredChildren : undefined };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return treeData.map(filterNode).filter((node): node is OptionType => node !== null);
|
||||||
|
}, [treeData, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query) {
|
||||||
|
const getAllKeys = (nodes: OptionType[]): string[] => {
|
||||||
|
let keys: string[] = [];
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
keys.push(node.key);
|
||||||
|
if (node.children) keys = [...keys, ...getAllKeys(node.children)];
|
||||||
|
});
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
setExpandedKeys(getAllKeys(filteredTreeData));
|
||||||
|
}
|
||||||
|
}, [query, filteredTreeData]);
|
||||||
|
|
||||||
|
const getAllNodeKeys = (nodes: OptionType[]): string[] => {
|
||||||
|
let keys: string[] = [];
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
keys.push(node.key);
|
||||||
|
if (node.children) keys = [...keys, ...getAllNodeKeys(node.children)];
|
||||||
|
});
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
setCheckedKeys(getAllNodeKeys(treeData));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setCheckedKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setCheckedKeys(selectedKeys);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 12, width: 380}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 12, fontSize: 16 }}>{title}</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, marginBottom: 4 }}>Поиск</div>
|
||||||
|
<Input
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 12, justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<Button onClick={handleSelectAll}>
|
||||||
|
Выбрать все
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleClear}>
|
||||||
|
Очистить
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, padding: 8 }}>
|
||||||
|
<Tree
|
||||||
|
checkable
|
||||||
|
selectable={false}
|
||||||
|
treeData={filteredTreeData}
|
||||||
|
checkedKeys={checkedKeys}
|
||||||
|
onCheck={(keys) => {
|
||||||
|
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||||
|
setCheckedKeys(checked as string[]);
|
||||||
|
}}
|
||||||
|
expandedKeys={expandedKeys}
|
||||||
|
onExpand={(keys) => setExpandedKeys(keys as string[])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 }}>
|
||||||
|
<Button onClick={handleCancel}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
style={{ background: '#2a8c3a' }}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(checkedKeys);
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
|
Применить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,4 +1,20 @@
|
|||||||
import { api } from "../../api/client";
|
import { api, apiFetchBlob } from '../../api/client';
|
||||||
import { IntercompanyMatrixResponse } from "../../types/chess/chess";
|
import { ChessManualRequest, IntercompanyMatrixResponse } from '../../types/chess/chess';
|
||||||
|
|
||||||
export const fetchChessConsolidations = (id: string) => api.get<IntercompanyMatrixResponse>(`/consolidations/versions/${id}/intercompany-matrix/all`);
|
export const fetchChessConsolidations = (id: string) =>
|
||||||
|
api.get<IntercompanyMatrixResponse>(`/consolidations/versions/${id}/intercompany-matrix/all`);
|
||||||
|
|
||||||
|
export const fetchChessConsolidationsManual = (versionId: string, data: ChessManualRequest): Promise<IntercompanyMatrixResponse> => {
|
||||||
|
return api.post<IntercompanyMatrixResponse>(`/consolidations/versions/${versionId}/intercompany-matrix/manual`, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportChessTableAll = (versionId: string) =>
|
||||||
|
apiFetchBlob(`/consolidations/versions/${versionId}/intercompany-matrix/all/export`);
|
||||||
|
export const exportChessTableManual = (versionId: string, data: ChessManualRequest) =>
|
||||||
|
apiFetchBlob(`/consolidations/versions/${versionId}/intercompany-matrix/manual/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|||||||
@ -9,9 +9,18 @@ export interface MatrixCell {
|
|||||||
amount_a: string;
|
amount_a: string;
|
||||||
amount_b: string;
|
amount_b: string;
|
||||||
variance_amount: string;
|
variance_amount: string;
|
||||||
|
exceeds_threshold:boolean
|
||||||
|
one_sided:boolean
|
||||||
|
variance_percent:string| null
|
||||||
|
skipped_osv_entry_count:number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntercompanyMatrixResponse {
|
export interface IntercompanyMatrixResponse {
|
||||||
companies: MatrixCompany[];
|
companies: MatrixCompany[];
|
||||||
cells: MatrixCell[];
|
cells: MatrixCell[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChessManualRequest = {
|
||||||
|
debit_account_ids: string[];
|
||||||
|
credit_account_ids: string[];
|
||||||
|
};
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
.empty-cell {
|
|
||||||
background-color: #f2f4f7;
|
|
||||||
}
|
|
||||||
.ant-table-tbody > tr:hover > td.empty-cell {
|
|
||||||
background-color: #f2f4f7 !important;
|
|
||||||
}
|
|
||||||
@ -1,15 +1,22 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { Table, Typography } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import { FullscreenOutlined, FullscreenExitOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { fetchChessConsolidations } from '../../services/consodilation/chess';
|
import {
|
||||||
import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess';
|
exportChessTableAll,
|
||||||
|
exportChessTableManual,
|
||||||
|
fetchChessConsolidations,
|
||||||
|
fetchChessConsolidationsManual,
|
||||||
|
} from '../../services/consodilation/chess';
|
||||||
|
import type { IntercompanyMatrixResponse } from '../../types/chess/chess';
|
||||||
import { useNotificationApi } from '../../utils/useNotification';
|
import { useNotificationApi } from '../../utils/useNotification';
|
||||||
|
import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable';
|
||||||
|
import { ConsolidationChessTableFilter } from '../ConsolidationChessTable/ConsolidationChessTableFilter';
|
||||||
|
import { saveBlob } from '../../utils/download';
|
||||||
|
|
||||||
|
|
||||||
import './ConsolidatedChessContent.css';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
export const ChessContent = () => {
|
export const ChessContent = () => {
|
||||||
const { version_id } = useParams<{ version_id: string }>();
|
const { version_id } = useParams<{ version_id: string }>();
|
||||||
@ -17,103 +24,147 @@ export const ChessContent = () => {
|
|||||||
|
|
||||||
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
|
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [expoerLoading, setExportLoading] = useState(false);
|
||||||
|
const [companies, setCompanies] = useState<string[]>([]);
|
||||||
|
const [decimals, setDecimals] = useState(2);
|
||||||
|
const [units, setUnits] = useState('thousand');
|
||||||
|
const [debit, setDebit] = useState<string[]>([]);
|
||||||
|
const [credit, setCredit] = useState<string[]>([]);
|
||||||
|
|
||||||
const loadData = async (vId: string) => {
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const toggleFullscreen = async () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await containerRef.current.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = async (vId: string, dIds: string[], cIds: string[]) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetchChessConsolidations(vId);
|
let res;
|
||||||
|
if (dIds?.length && cIds?.length) {
|
||||||
|
res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds });
|
||||||
|
} else {
|
||||||
|
res = await fetchChessConsolidations(vId);
|
||||||
|
}
|
||||||
setData(res);
|
setData(res);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
notification.error({ description: e.message });
|
notification.error({ description: e.message });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const download = async () => {
|
||||||
|
if (!version_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExportLoading(true);
|
||||||
|
try {
|
||||||
|
if (debit?.length && credit?.length) {
|
||||||
|
const { blob, filename } = await exportChessTableManual(version_id, { debit_account_ids: debit, credit_account_ids: credit });
|
||||||
|
saveBlob(blob, filename ?? 'Шахматка');
|
||||||
|
} else {
|
||||||
|
const { blob, filename } = await exportChessTableAll(version_id);
|
||||||
|
saveBlob(blob, filename ?? 'Шахматка');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
notification.error({ description: e.message });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setExportLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version_id) return;
|
if (!version_id) return;
|
||||||
loadData(version_id);
|
loadData(version_id, debit, credit);
|
||||||
}, [version_id]);
|
}, [version_id, debit, credit]);
|
||||||
|
|
||||||
const matrixData = useMemo(() => {
|
useEffect(() => {
|
||||||
if (!data?.cells) return {};
|
const handleFullscreenChange = () => {
|
||||||
const result: Record<string, Record<string, MatrixCell>> = {};
|
setIsFullscreen(!!document.fullscreenElement);
|
||||||
|
|
||||||
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 } }),
|
|
||||||
};
|
};
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Внутри companyColumns
|
const filteredData = useMemo(() => {
|
||||||
const companyColumns = data.companies.map((company) => ({
|
if (!data) return null;
|
||||||
title: company.name,
|
if (companies.length === 0) return data;
|
||||||
dataIndex: company.id,
|
|
||||||
key: company.id,
|
|
||||||
width: 150,
|
|
||||||
align: 'right' as const,
|
|
||||||
|
|
||||||
onCell: (row: MatrixCompany) => {
|
const filteredCompanies = data.companies.filter((c) => companies.includes(c.id));
|
||||||
const cell = matrixData[row.id]?.[company.id];
|
const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id));
|
||||||
if (!cell) {
|
|
||||||
// Возвращаем класс через onCell, это работает надежнее
|
|
||||||
return { className: 'empty-cell' };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
|
|
||||||
render: (_: unknown, row: MatrixCompany) => {
|
return { companies: filteredCompanies, cells: filteredCells };
|
||||||
const cell = matrixData[row.id]?.[company.id];
|
}, [data, companies]);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Table<MatrixCompany>
|
<div
|
||||||
dataSource={data?.companies || []}
|
ref={containerRef}
|
||||||
columns={columns}
|
style={{
|
||||||
rowKey='id'
|
backgroundColor: isFullscreen ? '#fff' : 'transparent',
|
||||||
pagination={false}
|
padding: isFullscreen ? 24 : 0,
|
||||||
loading={loading}
|
display: 'flex',
|
||||||
scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }}
|
flexDirection: 'column',
|
||||||
size='small'
|
height: isFullscreen ? '100vh' : 'auto',
|
||||||
bordered
|
overflow: isFullscreen ? 'hidden' : 'visible',
|
||||||
/>
|
}}>
|
||||||
|
<ConsolidationChessTable
|
||||||
|
data={filteredData}
|
||||||
|
loading={loading}
|
||||||
|
decimals={decimals}
|
||||||
|
units={units}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
toolbar={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 16,
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: '1 1 auto' }}>
|
||||||
|
<ConsolidationChessTableFilter
|
||||||
|
companies={companies}
|
||||||
|
onCompaniesChange={setCompanies}
|
||||||
|
decimals={decimals}
|
||||||
|
onDecimalsChange={setDecimals}
|
||||||
|
units={units}
|
||||||
|
onUnitsChange={setUnits}
|
||||||
|
debit={debit}
|
||||||
|
onDebitChange={setDebit}
|
||||||
|
credit={credit}
|
||||||
|
onCreditChange={setCredit}
|
||||||
|
allCompanies={data?.companies || []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button loading={expoerLoading} onClick={() => download()} icon={<DownloadOutlined />}>
|
||||||
|
Экспорт
|
||||||
|
</Button>
|
||||||
|
<Button onClick={toggleFullscreen} icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
|
||||||
|
{isFullscreen ? 'Свернуть' : 'На весь экран'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,19 @@
|
|||||||
|
.empty-cell {
|
||||||
|
background-color: #f2f4f7;
|
||||||
|
}
|
||||||
|
.ant-table-tbody > tr:hover > td.empty-cell {
|
||||||
|
background-color: #f2f4f7 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-table-summary{
|
||||||
|
border-end-end-radius: 8px !important;
|
||||||
|
border-end-start-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.ant-table-cell-fix {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
.resRow{
|
||||||
|
background-color:#e6f4ea !important ;
|
||||||
|
}
|
||||||
@ -0,0 +1,183 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { Table, Typography } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess';
|
||||||
|
import './ConsolidatedChessTable.css';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface ConsolidationChessTableI {
|
||||||
|
data: IntercompanyMatrixResponse | null;
|
||||||
|
loading: boolean;
|
||||||
|
decimals: number;
|
||||||
|
units: string;
|
||||||
|
toolbar?: React.ReactNode;
|
||||||
|
isFullscreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableRow = MatrixCompany & { isTotal?: boolean };
|
||||||
|
|
||||||
|
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
decimals,
|
||||||
|
units,
|
||||||
|
toolbar,
|
||||||
|
isFullscreen = false,
|
||||||
|
}) => {
|
||||||
|
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 formatValue = (val: string | number) => {
|
||||||
|
if (val === undefined || val === null || val === '') return '';
|
||||||
|
let num = typeof val === 'string' ? parseFloat(val) : val;
|
||||||
|
if (isNaN(num)) return String(val);
|
||||||
|
|
||||||
|
if (units === 'thousand') {
|
||||||
|
num /= 1000;
|
||||||
|
} else if (units === 'million') {
|
||||||
|
num /= 1000000;
|
||||||
|
} else if (units === 'billion') {
|
||||||
|
num /= 1_000_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return num.toFixed(decimals).replace('.', ',');
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseAmount = (val: string | null | undefined): number => {
|
||||||
|
if (!val) return 0;
|
||||||
|
|
||||||
|
const isNegative: boolean = val.startsWith('(') && val.endsWith(')');
|
||||||
|
const cleanVal = isNegative ? val.slice(1, -1) : val;
|
||||||
|
|
||||||
|
const num = parseFloat(cleanVal);
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isNegative ? -num : num;
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<TableRow> = useMemo(() => {
|
||||||
|
if (!data?.companies) return [];
|
||||||
|
|
||||||
|
const firstColumn: ColumnsType<TableRow>[number] = {
|
||||||
|
title: 'ДБ / КТ',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
fixed: 'left',
|
||||||
|
width: 200,
|
||||||
|
render: (text, row) => <Text strong={row.isTotal}>{text}</Text>,
|
||||||
|
onCell: (row) => ({
|
||||||
|
style: {
|
||||||
|
background: row.isTotal ? '#e6f4ea' : '#fff',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const companyColumns = data.companies.map((company) => ({
|
||||||
|
title: company.name,
|
||||||
|
dataIndex: company.id,
|
||||||
|
key: company.id,
|
||||||
|
width: 150,
|
||||||
|
align: 'right' as const,
|
||||||
|
onCell: (row: TableRow) => {
|
||||||
|
if (row.isTotal) return { style: { background: '#e6f4ea' } };
|
||||||
|
|
||||||
|
const cell = matrixData[row.id]?.[company.id];
|
||||||
|
if (!cell) return { className: 'empty-cell' };
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
render: (_: unknown, row: TableRow) => {
|
||||||
|
if (row.isTotal) {
|
||||||
|
const totalVal = (row as any)[company.id];
|
||||||
|
return <Text strong>{formatValue(totalVal)}</Text>;
|
||||||
|
}
|
||||||
|
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>{formatValue(cell.amount_a)}</span>
|
||||||
|
<span>{formatValue(cell.amount_b)}</span>
|
||||||
|
<span style={{ color: cell.exceeds_threshold ? 'red' : 'inherit' }}>{formatValue(cell.variance_amount)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [firstColumn, ...companyColumns];
|
||||||
|
}, [data?.companies, matrixData, decimals, units]);
|
||||||
|
|
||||||
|
const totalRowData = useMemo(() => {
|
||||||
|
if (!data?.companies) return null;
|
||||||
|
|
||||||
|
const totalRow: Record<string, any> = {
|
||||||
|
id: 'total-row',
|
||||||
|
name: 'Итого',
|
||||||
|
isTotal: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
data.companies.forEach((colCompany) => {
|
||||||
|
let colTotal = 0;
|
||||||
|
|
||||||
|
data.companies.forEach((rowCompany) => {
|
||||||
|
const cell = matrixData[rowCompany.id]?.[colCompany.id];
|
||||||
|
if (cell?.variance_amount) {
|
||||||
|
colTotal += parseAmount(cell.variance_amount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalRow[colCompany.id] = colTotal;
|
||||||
|
});
|
||||||
|
|
||||||
|
return totalRow as TableRow;
|
||||||
|
}, [data?.companies, matrixData]);
|
||||||
|
|
||||||
|
const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table<TableRow>
|
||||||
|
dataSource={data?.companies || []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey='id'
|
||||||
|
pagination={false}
|
||||||
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content', y: scrollY }}
|
||||||
|
size='middle'
|
||||||
|
bordered
|
||||||
|
title={() => toolbar}
|
||||||
|
summary={() => {
|
||||||
|
if (!totalRowData) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Summary fixed >
|
||||||
|
<Table.Summary.Row className='resRow'>
|
||||||
|
<Table.Summary.Cell className='resRow' index={0} align='left'>
|
||||||
|
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
|
||||||
|
<Text strong>Итого</Text>
|
||||||
|
</div>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
|
||||||
|
{data?.companies.map((company, index) => (
|
||||||
|
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
|
||||||
|
<div>
|
||||||
|
<Text strong>{formatValue((totalRowData as any)[company.id])}</Text>
|
||||||
|
</div>
|
||||||
|
</Table.Summary.Cell>
|
||||||
|
))}
|
||||||
|
</Table.Summary.Row>
|
||||||
|
</Table.Summary>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,253 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Space, Select, Button } from 'antd';
|
||||||
|
import type { MatrixCompany } from '../../types/chess/chess';
|
||||||
|
import { OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown';
|
||||||
|
import { api, ApiError } from '../../api/client';
|
||||||
|
import { useNotificationApi } from '../../utils/useNotification';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
row_type: string;
|
||||||
|
is_off_balance: boolean;
|
||||||
|
is_debit: boolean;
|
||||||
|
is_credit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsolidationChessTableFilterI {
|
||||||
|
companies: string[];
|
||||||
|
onCompaniesChange: (val: string[]) => void;
|
||||||
|
decimals: number;
|
||||||
|
onDecimalsChange: (val: number) => void;
|
||||||
|
debit: string[];
|
||||||
|
onDebitChange: (val: string[]) => void;
|
||||||
|
credit: string[];
|
||||||
|
onCreditChange: (val: string[]) => void;
|
||||||
|
allCompanies: MatrixCompany[];
|
||||||
|
units: string;
|
||||||
|
onUnitsChange: (val: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildTree = (items: Account[]): OptionType[] => {
|
||||||
|
const map: Record<string, any> = {};
|
||||||
|
const roots: OptionType[] = [];
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
map[item.id] = {
|
||||||
|
title: `${item.code} - ${item.name}`,
|
||||||
|
key: item.id,
|
||||||
|
code: item.code,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.parent_id && map[item.parent_id]) {
|
||||||
|
map[item.parent_id].children.push(map[item.id]);
|
||||||
|
} else {
|
||||||
|
roots.push(map[item.id]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanUp = (nodes: OptionType[]): OptionType[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return { ...node, children: cleanUp(node.children) };
|
||||||
|
}
|
||||||
|
return { title: node.title, key: node.key, code: node.code };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return cleanUp(roots);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilterI> = ({
|
||||||
|
companies,
|
||||||
|
onCompaniesChange,
|
||||||
|
decimals,
|
||||||
|
onDecimalsChange,
|
||||||
|
debit,
|
||||||
|
onDebitChange,
|
||||||
|
credit,
|
||||||
|
onCreditChange,
|
||||||
|
allCompanies,
|
||||||
|
units,
|
||||||
|
onUnitsChange,
|
||||||
|
}) => {
|
||||||
|
const [companyOpen, setCompanyOpen] = useState(false);
|
||||||
|
const [debitOpen, setDebitOpen] = useState(false);
|
||||||
|
const [creditOpen, setCreditOpen] = useState(false);
|
||||||
|
|
||||||
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
|
const [debitTreeData, setDebitTreeData] = useState<OptionType[]>([]);
|
||||||
|
const [creditTreeData, setCreditTreeData] = useState<OptionType[]>([]);
|
||||||
|
|
||||||
|
const notification = useNotificationApi();
|
||||||
|
|
||||||
|
const companyOptions = allCompanies.map((c) => ({ label: c.name, value: c.id }));
|
||||||
|
const companyTreeData = allCompanies.map((c) => ({ title: c.name, key: c.id }));
|
||||||
|
|
||||||
|
const UNIT_OPTIONS = [
|
||||||
|
{ value: 'thousand', label: 'тыс. руб.' },
|
||||||
|
{ value: 'million', label: 'млн. руб.' },
|
||||||
|
{ value: 'billion', label: 'млрд руб.' },
|
||||||
|
];
|
||||||
|
const DECIMALS_OPTIONS = [
|
||||||
|
{ value: 0, label: '0' },
|
||||||
|
{ value: 1, label: '1' },
|
||||||
|
{ value: 2, label: '2' },
|
||||||
|
{ value: 3, label: '3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function getOptionHandler() {
|
||||||
|
setOptionsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<Account[]>('/directories/accounts');
|
||||||
|
|
||||||
|
const debitAccounts = res.filter((acc) => acc.is_debit);
|
||||||
|
const creditAccounts = res.filter((acc) => acc.is_credit);
|
||||||
|
|
||||||
|
setDebitTreeData(buildTree(debitAccounts));
|
||||||
|
setCreditTreeData(buildTree(creditAccounts));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
notification.error({ description: e.message });
|
||||||
|
} else {
|
||||||
|
notification.error({ description: 'Не удалось загрузить счета' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setOptionsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropFiltersHandler() {
|
||||||
|
onCompaniesChange([]);
|
||||||
|
onDebitChange([]);
|
||||||
|
onCreditChange([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getOptionHandler();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
<Space size='middle' wrap>
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Год</span>
|
||||||
|
<Select disabled style={{ minWidth: 150 }} value={null} options={[]} variant='underlined' />
|
||||||
|
</Space>
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Квартал</span>
|
||||||
|
<Select disabled style={{ minWidth: 150 }} value={null} options={[]} variant='underlined' />
|
||||||
|
</Space>
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Компании</span>
|
||||||
|
<Select
|
||||||
|
mode='multiple'
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
placeholder='Все компании'
|
||||||
|
options={companyOptions}
|
||||||
|
value={companies}
|
||||||
|
onChange={onCompaniesChange}
|
||||||
|
maxTagCount='responsive'
|
||||||
|
open={companyOpen}
|
||||||
|
onOpenChange={setCompanyOpen}
|
||||||
|
variant='underlined'
|
||||||
|
showSearch={false}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
popupRender={() => (
|
||||||
|
<TreeFilterDropdown
|
||||||
|
title='Компании'
|
||||||
|
placeholder='Введите название компании'
|
||||||
|
treeData={companyTreeData}
|
||||||
|
selectedKeys={companies}
|
||||||
|
onChange={onCompaniesChange}
|
||||||
|
onClose={() => setCompanyOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Дт</span>
|
||||||
|
<Select
|
||||||
|
mode='multiple'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
placeholder='Все счета'
|
||||||
|
value={debit}
|
||||||
|
onChange={onDebitChange}
|
||||||
|
maxTagCount='responsive'
|
||||||
|
variant='underlined'
|
||||||
|
open={debitOpen}
|
||||||
|
onOpenChange={setDebitOpen}
|
||||||
|
showSearch={false}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
options={debitTreeData.map((node) => ({ label: node.code, value: node.key }))}
|
||||||
|
popupRender={() => (
|
||||||
|
<TreeFilterDropdown
|
||||||
|
title='Дебетовые счета'
|
||||||
|
placeholder='Введите номер или название счета'
|
||||||
|
treeData={debitTreeData}
|
||||||
|
selectedKeys={debit}
|
||||||
|
onChange={onDebitChange}
|
||||||
|
onClose={() => setDebitOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
loading={optionsLoading}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Кт</span>
|
||||||
|
<Select
|
||||||
|
mode='multiple'
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
placeholder='Все счета'
|
||||||
|
value={credit}
|
||||||
|
onChange={onCreditChange}
|
||||||
|
maxTagCount='responsive'
|
||||||
|
variant='underlined'
|
||||||
|
open={creditOpen}
|
||||||
|
onOpenChange={setCreditOpen}
|
||||||
|
showSearch={false}
|
||||||
|
popupMatchSelectWidth={false}
|
||||||
|
options={creditTreeData.map((node) => ({ label: node.code, value: node.key }))}
|
||||||
|
popupRender={() => (
|
||||||
|
<TreeFilterDropdown
|
||||||
|
title='Кредитовые счета'
|
||||||
|
placeholder='Введите номер или название счета'
|
||||||
|
treeData={creditTreeData}
|
||||||
|
selectedKeys={credit}
|
||||||
|
onChange={onCreditChange}
|
||||||
|
onClose={() => setCreditOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
loading={optionsLoading}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
{companies?.length || debit?.length || credit?.length ? (
|
||||||
|
<Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button>
|
||||||
|
) : null}
|
||||||
|
<Space style={{ borderLeft: '1px solid #4754672e', paddingLeft: '8px' }} orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Единицы</span>
|
||||||
|
<Select style={{ minWidth: 120 }} options={UNIT_OPTIONS} value={units} onChange={onUnitsChange} variant='underlined' />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space orientation='vertical' size={0}>
|
||||||
|
<span style={{ fontSize: 12, color: '#667085' }}>Знаков после запятой</span>
|
||||||
|
<Select
|
||||||
|
style={{ minWidth: 150 }}
|
||||||
|
value={decimals}
|
||||||
|
onChange={(v) => onDecimalsChange(v || 0)}
|
||||||
|
options={DECIMALS_OPTIONS}
|
||||||
|
variant='underlined'
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user