309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
||
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
||
import { SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from './SummaryTable';
|
||
import { useSvod } from './SvodContext';
|
||
import { SvodFilters } from './SvodFilters';
|
||
|
||
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 FORM_3_METRIC_LABELS = {
|
||
plan: 'Расходы, планируемые РФ',
|
||
approved: 'Утвержденные базовые расходы',
|
||
fact: 'Фактические расходы',
|
||
corrected: 'Скорректированная смета расходов',
|
||
};
|
||
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 FORM_3_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);
|
||
if (numericValue === 0) return '-';
|
||
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 = { 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 createForm3MetricColumns = (metric) => ({
|
||
id: metric.key,
|
||
header: FORM_3_METRIC_LABELS[metric.key],
|
||
columns: FORM_3_QUARTERS.filter(({ key }) =>
|
||
valueAt({ data: getShapeForSheet('FORM_3') }, [metric.key, 'development', key]) !== undefined,
|
||
).map((period) => ({
|
||
...valueColumn(metric, { key: 'development' }, period),
|
||
id: metric.key + '.' + period.key,
|
||
})),
|
||
});
|
||
|
||
const createColumns = (sheet) => {
|
||
let visibleDirections = DIRECTIONS;
|
||
if (sheet === 'FORM_2') visibleDirections = DIRECTIONS.filter(({ key }) => key === 'support');
|
||
if (['FORM_3', 'FORM_4'].includes(sheet)) visibleDirections = DIRECTIONS.filter(({ key }) => key === 'development');
|
||
const shape = getShapeForSheet(sheet);
|
||
if (sheet === 'FORM_3') {
|
||
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>
|
||
),
|
||
},
|
||
...METRICS.map(createForm3MetricColumns),
|
||
];
|
||
}
|
||
const metricColumns = METRICS.map((metric) => ({
|
||
id: metric.key,
|
||
header: sheet === 'FORM_3' ? FORM_3_METRIC_LABELS[metric.key] : 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,
|
||
];
|
||
};
|
||
|
||
export default function SvodPage() {
|
||
const navigate = useNavigate();
|
||
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
||
const [sheet, setSheet] = useState('MAIN');
|
||
const [rows, setRows] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const latestSummaryRequestRef = useRef(0);
|
||
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
||
const updateCurrentSheetFilters = useCallback(
|
||
(changes) => {
|
||
updateSheetFilters(sheet, changes);
|
||
},
|
||
[sheet, updateSheetFilters],
|
||
);
|
||
|
||
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 openFilteredTasks = useCallback(() => {
|
||
const params = new URLSearchParams({ year: String(year) });
|
||
const organizationIds = selectedOrganizations.map(({ id }) => id);
|
||
if (organizationIds.length > 0) params.set('org_units', organizationIds.join(','));
|
||
if (sheet !== 'MAIN') params.set('form_type', sheet);
|
||
navigate(`/tasks?${params.toString()}`);
|
||
}, [navigate, selectedOrganizations, sheet, year]);
|
||
|
||
const openFilteredProjects = useCallback(() => {
|
||
const params = new URLSearchParams();
|
||
const organizationIds = selectedOrganizations.map(({ id }) => id);
|
||
if (organizationIds.length > 0) params.set('branch_ids', organizationIds.join(','));
|
||
const query = params.toString();
|
||
navigate(query ? `/projects?${query}` : '/projects');
|
||
}, [navigate, selectedOrganizations]);
|
||
|
||
const tree = useMemo(() => buildSummaryTree(rows), [rows]);
|
||
const filteredRows = useMemo(() => {
|
||
const normalizedSearch = search.trim().toLowerCase();
|
||
return normalizedSearch ? filterSummaryTree(tree, normalizedSearch) : tree;
|
||
}, [tree, search]);
|
||
const columns = useMemo(() => createColumns(sheet), [sheet]);
|
||
const filteredRowsCount = useMemo(() => countSummaryTree(filteredRows), [filteredRows]);
|
||
|
||
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: '1rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
|
||
{SHEETS.map((item) => (
|
||
<Tab key={item.value} value={item.value} label={item.label} sx={{ textTransform: 'none', alignItems: 'flex-start', textAlign: 'left', minHeight: '3rem' }} />
|
||
))}
|
||
</Tabs>
|
||
<SvodFilters year={year} selectedOrganizations={selectedOrganizations} search={search} onChange={updateCurrentSheetFilters}>
|
||
{!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>
|
||
)}
|
||
{sheet === 'FORM_3' ? (
|
||
<PrimaryButton variant='contained' onClick={openFilteredProjects} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||
Перейти к проектам
|
||
</PrimaryButton>
|
||
) : (
|
||
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||
Перейти к задачам
|
||
</PrimaryButton>
|
||
)}
|
||
<PrimaryOutlinedButton
|
||
variant='outlined'
|
||
color='default'
|
||
startIcon={<RefreshRoundedIcon />}
|
||
disabled={loading}
|
||
onClick={refreshSummary}
|
||
sx={{ height: '2.5rem', flexShrink: 0 }}>
|
||
Обновить
|
||
</PrimaryOutlinedButton>
|
||
</SvodFilters>
|
||
</Paper>
|
||
{error ? (
|
||
<Alert
|
||
severity='error'
|
||
action={
|
||
<Button color='inherit' size='small' onClick={refreshSummary}>
|
||
Повторить
|
||
</Button>
|
||
}>
|
||
{error}
|
||
</Alert>
|
||
) : (
|
||
<SummaryTable columns={columns} data={filteredRows} loading={loading} />
|
||
)}
|
||
</Box>
|
||
);
|
||
}
|