Andrey Epifancev 5115704f63 AURORA-1757: итоги шахматки из API, фильтр компаний на сервере (#224)
- клиент на единый POST …/intercompany-matrix и …/export
- матрица: колонка «Итого Дт» и строка «Итого Кт» из total_debit/total_credit,
  клиентское сложение убрано
- фильтр «Компании» уходит в запрос (legal_entity_ids), варианты — весь
  периметр из ответа без выбора; выгрузка с тем же выбором
- результирующая таблица без строки «Итого», колонка «Итого по группе»
- change intercompany-matrix-totals-ui

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:08:28 +04:00

313 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, ConfigProvider } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../../api/client';
import {
exportChessConsolidationsResult,
exportIntercompanyMatrix,
fetchChessConsolidationsResult,
fetchIntercompanyMatrix,
} from '../../../services/api/intercompanyMatrix';
import type {
IntercompanyMatrixRequest,
IntercompanyMatrixResponse,
IntercompanyMatrixResultResponse,
MatrixCompany,
} from '../../../types/api';
import type { VersionCard } from '../../../types/consolidations/version_members';
import { chessMatrixMode } from '../../../utils/chessMatrixMode';
import { chessMatrixRequest, isWholePerimeter } from '../../../utils/chessMatrixRequest';
import { saveBlob, xlsxFilename } from '../../../utils/download';
import { useNotificationApi } from '../../../utils/useNotification';
import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter';
import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable';
import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable';
import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter';
export const ChessContent = ({ card }: { card: VersionCard | null }) => {
const { version_id } = useParams<{ version_id: string }>();
const notification = useNotificationApi();
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
const [loading, setLoading] = useState(false);
const [matrixExportLoading, setMatrixExportLoading] = useState(false);
const [resExportLoading, setResExportLoading] = useState(false);
const [companies, setCompanies] = useState<string[]>([]);
// варианты фильтра «Компании» — весь периметр версии; при выборе компаний ответ сужен и их не меняет
const [perimeter, setPerimeter] = useState<MatrixCompany[]>([]);
const [decimals, setDecimals] = useState(2);
const [units, setUnits] = useState('thousand');
const [debit, setDebit] = useState<string[]>([]);
const [credit, setCredit] = useState<string[]>([]);
const [chessResData, setChessResData] = useState<IntercompanyMatrixResultResponse | null>(null);
const [chessResUnits, setChessResUnits] = useState('thousand');
const [chessResDecimals, setChessResDecimals] = useState(2);
const [chessResLoading, setChessResLoading] = useState(false);
const chessTableRef = useRef<HTMLDivElement>(null);
const resTableRef = useRef<HTMLDivElement>(null);
const [isChessFullscreen, setIsChessFullscreen] = useState(false);
const [isResFullscreen, setIsResFullscreen] = useState(false);
const toggleFullscreen = async (ref: React.RefObject<HTMLDivElement>, setFullscreen: (val: boolean) => void) => {
if (!ref.current) return;
try {
if (!document.fullscreenElement) {
await ref.current.requestFullscreen();
setFullscreen(true);
} else {
await document.exitFullscreen();
setFullscreen(false);
}
} catch (e) {
notification.error({
description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим',
});
}
};
useEffect(() => {
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
setIsChessFullscreen(false);
setIsResFullscreen(false);
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const matrixMode = chessMatrixMode(debit, credit);
const isHalfFiltered = matrixMode === 'half';
const loadData = useCallback(
async (vId: string, request: IntercompanyMatrixRequest, isActual: () => boolean) => {
setLoading(true);
try {
const res = await fetchIntercompanyMatrix(vId, request);
if (isActual()) {
setData(res);
if (isWholePerimeter(request)) {
setPerimeter(res.companies);
}
}
} catch (e) {
if (isActual() && e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
if (isActual()) {
setLoading(false);
}
}
},
[notification],
);
const loadResData = useCallback(
async (vid: string) => {
setChessResLoading(true);
try {
const res = await fetchChessConsolidationsResult(vid);
setChessResData(res);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setChessResLoading(false);
}
},
[notification],
);
const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
const download = async () => {
if (!version_id) {
return;
}
setMatrixExportLoading(true);
try {
// выгрузка по тем же счетам и компаниям, что на экране: итоги в xlsx совпадают с таблицей
const { blob, filename } = await exportIntercompanyMatrix(version_id, chessMatrixRequest(debit, credit, companies));
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setMatrixExportLoading(false);
}
};
const downloadResTable = async () => {
if (!version_id) {
return;
}
setResExportLoading(true);
try {
const { blob, filename } = await exportChessConsolidationsResult(version_id);
saveBlob(blob, filename ?? xlsxFilename('Результирующая_таблица_ВГО', versionInfo));
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setResExportLoading(false);
}
};
useEffect(() => {
// выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку
if (!version_id || isHalfFiltered) return;
let isMounted = true;
const isActual = () => isMounted;
loadData(version_id, chessMatrixRequest(debit, credit, companies), isActual);
return () => {
isMounted = false;
setLoading(false);
};
}, [version_id, debit, credit, companies, isHalfFiltered, loadData]);
useEffect(() => {
if (!version_id) return;
loadResData(version_id);
}, [version_id, loadResData]);
return (
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
<div
style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
overflowX: 'hidden',
gap: 24,
paddingBottom: 32,
width: '100%',
}}>
<div
ref={chessTableRef}
style={{
backgroundColor: isChessFullscreen ? '#fff' : 'transparent',
padding: isChessFullscreen ? 6 : 0,
height: isChessFullscreen ? '100vh' : 'auto',
overflow: isChessFullscreen ? 'hidden' : 'visible',
display: 'flex',
flexDirection: 'column',
}}>
<ConsolidationChessTable
data={data}
loading={loading}
decimals={decimals}
units={units}
isFullscreen={isChessFullscreen}
isHalfFiltered={isHalfFiltered}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<ConsolidationChessTableFilter
companies={companies}
onCompaniesChange={setCompanies}
debit={debit}
onDebitChange={setDebit}
credit={credit}
onCreditChange={setCredit}
allCompanies={perimeter}
year={card?.year}
quarter={card?.quarter}
/>
<div
style={{
width: 1,
height: 50,
backgroundColor: '#e5e7eb',
}}
/>
<ConsolidationChessFormatTableFilter
decimals={decimals}
units={units}
onDecimalsChange={setDecimals}
onUnitsChange={setUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button disabled={isHalfFiltered} loading={matrixExportLoading} onClick={() => download()} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button
onClick={() => toggleFullscreen(chessTableRef, setIsChessFullscreen)}
icon={isChessFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isChessFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
<div
ref={resTableRef}
style={{
backgroundColor: isResFullscreen ? '#fff' : 'transparent',
padding: isResFullscreen ? 6 : 0,
height: isResFullscreen ? '100vh' : 'auto',
overflow: isResFullscreen ? 'hidden' : 'visible',
display: 'flex',
flexDirection: 'column',
}}>
<ConsolidationChessResTable
data={chessResData}
loading={chessResLoading}
decimals={chessResDecimals}
units={chessResUnits}
isFullscreen={isResFullscreen}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<ConsolidationChessFormatTableFilter
decimals={chessResDecimals}
units={chessResUnits}
onDecimalsChange={setChessResDecimals}
onUnitsChange={setChessResUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button loading={resExportLoading} onClick={() => downloadResTable()} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button
onClick={() => toggleFullscreen(resTableRef, setIsResFullscreen)}
icon={isResFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isResFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
</div>
</ConfigProvider>
);
};