import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import {
Alert, Autocomplete, Box, Button, Chip, CircularProgress, InputAdornment,
MenuItem, Paper, Tab, Tabs, TextField, Typography,
} from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { SspApi } from '../../api/ssp';
import { useAuth } from '../../app/context/AuthProvider';
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
import { blueColumn } from '../../components/RealtimeTable/constants/columnColors';
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
import { ROLES_NAME_ID } from '../../constants/constants';
import { useSvod } from './SvodContext';
const SHEETS = [
{ value: 'MAIN', label: 'Общий свод' },
{ value: 'FORM_1', label: 'Смета ГО' },
{ value: 'FORM_2', label: 'Смета РФ' },
{ value: 'FORM_3', label: 'Проекты РФ Развитие' },
{ value: 'FORM_4', label: 'Проектная деятельность' },
];
const METRICS = [
{ key: 'plan', label: 'План' },
{ key: 'approved', label: 'Утверждено' },
{ key: 'fact', label: 'Факт' },
{ key: 'corrected', label: 'Скорректированный план' },
];
const DIRECTIONS = [
{ key: 'support', label: 'Поддержка' },
{ key: 'development', label: 'Развитие' },
];
const QUARTERS = [
{ key: 'q1', label: 'I кв.' }, { key: 'q2', label: 'II кв.' },
{ key: 'q3', label: 'III кв.' }, { key: 'q4', label: 'IV кв.' },
{ key: 'year', label: 'Год' },
];
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data);
const tableSize = (rem) => rem * 16;
const formatValue = (value) => {
if (value === null || value === undefined || value === '') return '—';
const numericValue = typeof value === 'number' ? value : Number(value);
return Number.isNaN(numericValue) ? value : numberFormatter.format(numericValue);
};
const getShapeForSheet = (sheet) => {
const directionShape = { q1: 0, q2: 0, q3: 0, q4: 0, year: 0 };
const correctedShape = sheet === 'FORM_4' ? directionShape : { q2: 0, q3: 0, q4: 0 };
return {
plan: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
approved: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
fact: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
corrected: { support: correctedShape, development: correctedShape },
};
};
const valueColumn = (metric, direction, period) => ({
id: `${metric.key}.${direction.key}.${period.key}`,
header: period.label,
accessorFn: (row) => valueAt(row, [metric.key, direction.key, period.key]),
Cell: ({ cell }) => formatValue(cell.getValue()),
size: tableSize(period.key === 'year' ? 8 : 6.75),
muiTableBodyCellProps: { align: 'right' },
muiTableHeadCellProps: { align: 'right' },
});
const createColumns = (sheet) => {
const visibleDirections = sheet === 'FORM_2'
? DIRECTIONS.filter(({ key }) => key === 'support')
: ['FORM_3', 'FORM_4'].includes(sheet)
? DIRECTIONS.filter(({ key }) => key === 'development')
: DIRECTIONS;
const shape = getShapeForSheet(sheet);
const metricColumns = METRICS.map((metric) => ({
id: metric.key,
header: metric.label,
columns: visibleDirections.map((direction) => ({
id: `${metric.key}.${direction.key}`,
header: direction.label,
columns: QUARTERS
.filter(({ key }) => valueAt({ data: shape }, [metric.key, direction.key, key]) !== undefined)
.map((period) => valueColumn(metric, direction, period)),
})).concat(shape[metric.key].total_year === undefined ? [] : [{
id: `${metric.key}.total`,
header: 'Итого',
columns: [{
id: `${metric.key}.total_year`,
header: 'Год',
accessorFn: (row) => valueAt(row, [metric.key, 'total_year']),
Cell: ({ cell }) => formatValue(cell.getValue()),
size: tableSize(8.25),
muiTableBodyCellProps: { align: 'right' },
muiTableHeadCellProps: { align: 'right' },
}],
}]),
}));
return [
{ id: 'section_code', header: 'Код', accessorFn: (row) => row.data?.section_code, size: tableSize(5.75) },
{
id: 'name', header: 'Статья расходов', accessorFn: (row) => row.data?.name, size: tableSize(20.625),
Cell: ({ cell, row }) => (
{cell.getValue() || 'Без названия'}
),
},
...metricColumns,
];
};
const getSummaryRowColor = (row) => {
const rowType = row.original?.row_type || row.row_type;
const sectionCodeValue = row.original?.data?.section_code ?? row.original?.data?.header?.section_code;
if (sectionCodeValue === null || sectionCodeValue === undefined || sectionCodeValue === '') return undefined;
const sectionCode = String(sectionCodeValue)[0];
return (sectionCodeColor[sectionCode] || blueColumn)[rowType];
};
const getTextColor = (backgroundColor) => {
if (!backgroundColor?.startsWith('#') || backgroundColor.length !== 7) return '#000';
const red = Number.parseInt(backgroundColor.slice(1, 3), 16);
const green = Number.parseInt(backgroundColor.slice(3, 5), 16);
const blue = Number.parseInt(backgroundColor.slice(5, 7), 16);
return 0.299 * red + 0.587 * green + 0.114 * blue < 128 ? '#fff' : '#000';
};
const buildSummaryTree = (sourceRows) => {
const roots = [];
const parents = [];
for (const [index, sourceRow] of sourceRows.entries()) {
const row = {
...sourceRow,
_rowId: `${sourceRow.sort_order ?? index}-${sourceRow.data?.section_code ?? index}`,
subRows: [],
};
while (parents.length > row.depth) parents.pop();
if (row.depth > 0 && parents[row.depth - 1]) parents[row.depth - 1].subRows.push(row);
else roots.push(row);
parents[row.depth] = row;
parents.length = row.depth + 1;
}
return roots;
};
const filterSummaryTree = (tree, query) => tree.flatMap((row) => {
const matches = [row.data?.section_code, row.data?.name]
.some((value) => String(value || '').toLowerCase().includes(query));
if (matches) return [row];
const subRows = filterSummaryTree(row.subRows, query);
return subRows.length ? [{ ...row, subRows }] : [];
});
const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0);
const getAvailableYears = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 7 }, (_, index) => currentYear + 1 - index);
};
export default function SvodPage() {
const { user } = useAuth();
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
const [sheet, setSheet] = useState('MAIN');
const [organizations, setOrganizations] = useState([]);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [organizationsLoading, setOrganizationsLoading] = useState(false);
const [error, setError] = useState('');
const latestSummaryRequestRef = useRef(0);
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
const updateCurrentSheetFilters = useCallback((changes) => {
updateSheetFilters(sheet, changes);
}, [sheet, updateSheetFilters]);
useEffect(() => {
if (!user) return;
const loadOrganizations = async () => {
setOrganizationsLoading(true);
try {
if (user.role_id === ROLES_NAME_ID.admin) {
const response = await SspApi.getAll({ is_active: true, limit: 100 });
setOrganizations(response.result || []);
} else {
setOrganizations(user.org_units || []);
}
} catch (_error) {
setOrganizations(user.org_units || []);
toast.error('Не удалось загрузить список ССП/РФ');
} finally {
setOrganizationsLoading(false);
}
};
loadOrganizations();
}, [user]);
const loadSummary = useCallback(async () => {
const requestId = latestSummaryRequestRef.current + 1;
latestSummaryRequestRef.current = requestId;
setLoading(true);
setError('');
try {
const response = await getSummary(sheet, year, selectedOrganizations.map(({ id }) => id));
if (latestSummaryRequestRef.current !== requestId) return;
setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)));
} catch (loadError) {
if (latestSummaryRequestRef.current !== requestId) return;
setRows([]);
setError(loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.');
} finally {
if (latestSummaryRequestRef.current === requestId) setLoading(false);
}
}, [getSummary, selectedOrganizations, sheet, year]);
useEffect(() => {
loadSummary();
return () => {
latestSummaryRequestRef.current += 1;
};
}, [loadSummary]);
const refreshSummary = useCallback(() => {
invalidateSummary(sheet, year, selectedOrganizations.map(({ id }) => id));
loadSummary();
}, [invalidateSummary, loadSummary, selectedOrganizations, sheet, year]);
const filteredRows = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
const tree = buildSummaryTree(rows);
return normalizedSearch ? filterSummaryTree(tree, normalizedSearch) : tree;
}, [rows, search]);
const columns = useMemo(() => createColumns(sheet), [sheet]);
const filteredRowsCount = useMemo(() => countSummaryTree(filteredRows), [filteredRows]);
const table = useMaterialReactTable({
columns,
data: filteredRows,
enableExpanding: true,
enableExpandAll: true,
getSubRows: (row) => row.subRows,
getRowId: (row) => row._rowId,
enableColumnActions: false,
enableColumnFilters: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
enableHiding: false,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableSorting: false,
displayColumnDefOptions: {
'mrt-row-expand': {
size: tableSize(3.75),
minSize: tableSize(3.75),
maxSize: tableSize(3.75),
grow: false,
},
},
state: { isLoading: loading },
initialState: { columnPinning: { left: ['mrt-row-expand', 'section_code', 'name'] }, expanded: true },
muiTablePaperProps: {
elevation: 0,
sx: {
border: '0.0625rem solid #e5e7eb',
borderRadius: '0.75rem',
overflow: 'hidden',
flex: 1,
minHeight: 0,
width: '100%',
display: 'flex',
flexDirection: 'column',
},
},
muiTableContainerProps: {
sx: {
flex: 1,
overflow: 'auto',
minHeight: 0,
height: '100%',
'& thead': {
position: 'sticky',
top: 0,
zIndex: 10,
},
},
},
muiTableHeadCellProps: {
sx: {
position: 'sticky',
top: 0,
boxShadow: 'none',
backgroundColor: '#f8f9fa', borderRight: '0.0625rem solid #e5e7eb',
color: '#4a5565', fontSize: '0.75rem', fontWeight: 700, lineHeight: 1.2, px: '0.75rem', py: '0.5rem', whiteSpace: 'nowrap',
},
},
muiTableHeadRowProps: {
sx: {
boxShadow: 'none',
},
},
muiTableBodyCellProps: ({ row, column }) => {
const backgroundColor = getSummaryRowColor(row);
return {
sx: {
backgroundColor, borderRight: '0.0625rem solid #edf0f2', color: getTextColor(backgroundColor),
fontSize: '0.8125rem', px: column.id === 'mrt-row-expand' ? '0.25rem' : '0.75rem',
py: column.id === 'mrt-row-expand' ? 0 : '0.5rem', whiteSpace: 'nowrap',
textAlign: ['section_code', 'name'].includes(column.id) ? 'left' : 'right',
},
};
},
muiExpandButtonProps: { sx: { width: '1.25rem', height: '1.25rem', p: 0, '& .MuiSvgIcon-root': { fontSize: '1.125rem' } } },
localization: { noRecordsToDisplay: 'Нет данных для выбранных параметров' },
});
return (
setSheet(value)} variant='scrollable' scrollButtons='auto'
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
{SHEETS.map((item) => )}
updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}>
{getAvailableYears().map((availableYear) => )}
option.id === value.id}
getOptionLabel={(option) => option.title || ''} onChange={(_event, value) => updateCurrentSheetFilters({ selectedOrganizations: value })}
sx={{ minWidth: '18.75rem', flex: '1 1 22.5rem', maxWidth: '32.5rem' }}
renderTags={(value, getTagProps) => value.slice(0, 2).map((option, index) => {
const tagProps = getTagProps({ index });
return ;
}).concat(value.length > 2 ? [] : [])}
renderInput={(params) => {organizationsLoading && }{params.InputProps?.endAdornment}>,
}} />}
/>
updateCurrentSheetFilters({ search: event.target.value })}
placeholder='Код или статья расходов' sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }}
InputProps={{ startAdornment: }} />
{!loading && filteredRowsCount !== rows.length && (
{`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
)}
}
disabled={loading}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Обновить
{error ? (
Повторить}>{error}
) : }
);
}