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, если доступно. */
|
||||
export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> {
|
||||
const response = await send(path, {});
|
||||
export async function apiFetchBlob(
|
||||
path: string,
|
||||
init: RequestInit = {} // Добавили init
|
||||
): Promise<{ blob: Blob; filename: string | null }> {
|
||||
const response = await send(path, init);
|
||||
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 {
|
||||
|
||||
@ -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 { IntercompanyMatrixResponse } from "../../types/chess/chess";
|
||||
import { api, apiFetchBlob } from '../../api/client';
|
||||
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_b: string;
|
||||
variance_amount: string;
|
||||
exceeds_threshold:boolean
|
||||
one_sided:boolean
|
||||
variance_percent:string| null
|
||||
skipped_osv_entry_count:number
|
||||
}
|
||||
|
||||
export interface IntercompanyMatrixResponse {
|
||||
companies: MatrixCompany[];
|
||||
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 { Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from 'antd';
|
||||
import { FullscreenOutlined, FullscreenExitOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { fetchChessConsolidations } from '../../services/consodilation/chess';
|
||||
import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/chess/chess';
|
||||
import {
|
||||
exportChessTableAll,
|
||||
exportChessTableManual,
|
||||
fetchChessConsolidations,
|
||||
fetchChessConsolidationsManual,
|
||||
} from '../../services/consodilation/chess';
|
||||
import type { IntercompanyMatrixResponse } from '../../types/chess/chess';
|
||||
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 = () => {
|
||||
const { version_id } = useParams<{ version_id: string }>();
|
||||
@ -17,103 +24,147 @@ export const ChessContent = () => {
|
||||
|
||||
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
|
||||
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);
|
||||
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);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
notification.error({ description: e.message });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
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(() => {
|
||||
if (!version_id) return;
|
||||
loadData(version_id);
|
||||
}, [version_id]);
|
||||
loadData(version_id, debit, credit);
|
||||
}, [version_id, debit, credit]);
|
||||
|
||||
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 } }),
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Внутри companyColumns
|
||||
const companyColumns = data.companies.map((company) => ({
|
||||
title: company.name,
|
||||
dataIndex: company.id,
|
||||
key: company.id,
|
||||
width: 150,
|
||||
align: 'right' as const,
|
||||
const filteredData = useMemo(() => {
|
||||
if (!data) return null;
|
||||
if (companies.length === 0) return data;
|
||||
|
||||
onCell: (row: MatrixCompany) => {
|
||||
const cell = matrixData[row.id]?.[company.id];
|
||||
if (!cell) {
|
||||
// Возвращаем класс через onCell, это работает надежнее
|
||||
return { className: 'empty-cell' };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
const filteredCompanies = data.companies.filter((c) => companies.includes(c.id));
|
||||
const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id));
|
||||
|
||||
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 { companies: filteredCompanies, cells: filteredCells };
|
||||
}, [data, companies]);
|
||||
|
||||
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
|
||||
/>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
backgroundColor: isFullscreen ? '#fff' : 'transparent',
|
||||
padding: isFullscreen ? 24 : 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
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