381 lines
15 KiB
JavaScript
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 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 }) => (
<Box sx={{ pl: `${row.original.depth * 1.125}rem`, fontWeight: row.original.depth < 2 ? 600 : 400 }}>
{cell.getValue() || 'Без названия'}
</Box>
),
},
...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 (
<Box sx={{ px: '2rem', pt: '1rem', pb: '1.5rem', height: '100%', minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<HeaderSwitch />
<Paper elevation={0} sx={{ border: '0.0625rem solid #e5e7eb', borderRadius: '0.75rem', overflow: 'hidden', flexShrink: 0 }}>
<Tabs value={sheet} onChange={(_event, value) => setSheet(value)} variant='scrollable' scrollButtons='auto'
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
{SHEETS.map((item) => <Tab key={item.value} value={item.value} label={item.label}
sx={{ textTransform: 'none', minHeight: '3rem' }} />)}
</Tabs>
<Box sx={{ p: '1rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<TextField select size='small' label='Год' value={year}
onChange={(event) => updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}>
{getAvailableYears().map((availableYear) => <MenuItem key={availableYear} value={availableYear}>{availableYear}</MenuItem>)}
</TextField>
<Autocomplete
multiple disableCloseOnSelect size='small' options={organizations} loading={organizationsLoading}
value={selectedOrganizations} isOptionEqualToValue={(option, value) => 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 <Chip {...tagProps} key={option.id} label={option.title} size='small' />;
}).concat(value.length > 2 ? [<Chip key='more' label={`+${value.length - 2}`} size='small' />] : [])}
renderInput={(params) => <TextField {...params} label='ССП/РФ'
placeholder={selectedOrganizations.length ? '' : 'Все доступные'}
InputProps={{
...(params.InputProps || {}),
endAdornment: <>{organizationsLoading && <CircularProgress size='1.125rem' />}{params.InputProps?.endAdornment}</>,
}} />}
/>
<TextField size='small' value={search} onChange={(event) => updateCurrentSheetFilters({ search: event.target.value })}
placeholder='Код или статья расходов' sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }}
InputProps={{ startAdornment: <InputAdornment position='start'><SearchRoundedIcon sx={{ color: '#99a1af' }} /></InputAdornment> }} />
{!loading && filteredRowsCount !== rows.length && (
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
{`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
</Typography>
)}
<Button
variant='outlined'
color='default'
startIcon={<RefreshRoundedIcon />}
disabled={loading}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Обновить
</Button>
</Box>
</Paper>
{error ? (
<Alert severity='error' action={<Button color='inherit' size='small'
onClick={refreshSummary}>Повторить</Button>}>{error}</Alert>
) : <MaterialReactTable table={table} />}
</Box>
);
}