diff --git a/web/src/api/summary.js b/web/src/api/summary.js
new file mode 100644
index 0000000..b4d27f0
--- /dev/null
+++ b/web/src/api/summary.js
@@ -0,0 +1,8 @@
+import api from './client';
+
+export const SummaryApi = {
+ get(sheet, year, orgIds = []) {
+ const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
+ return api.get(`/svod/1/${sheet}/${year}`, { params }).then((response) => response.data);
+ },
+};
diff --git a/web/src/components/RealtimeTable/constants/columnColors.js b/web/src/components/RealtimeTable/constants/columnColors.js
index 817be52..7d6c016 100644
--- a/web/src/components/RealtimeTable/constants/columnColors.js
+++ b/web/src/components/RealtimeTable/constants/columnColors.js
@@ -35,9 +35,9 @@ export const redColumn = {
};
export const blueColumn = {
- ROOT: "#538DD4",
- GROUP: "#8DB3E2",
- ITEM: "#C5D8F0",
+ ROOT: "#96c2f9",
+ GROUP: "#bad5f5",
+ ITEM: "#ddecfe",
SUB_ITEM: "#C5D8F0",
INPUT: "#FFFFFF",
COLOR: "#000000",
diff --git a/web/src/pages/ProjectsPage/ProjectCard.jsx b/web/src/pages/ProjectsPage/ProjectCard.jsx
index 156deb5..48fb12c 100644
--- a/web/src/pages/ProjectsPage/ProjectCard.jsx
+++ b/web/src/pages/ProjectsPage/ProjectCard.jsx
@@ -285,7 +285,7 @@ export const ProjectCardComponent = ({
}}>
}>{years.join(', ') || 'Год не указан'}
- }>{org_unit_name || 'Организация не указана'}
+ }>{org_unit_name || 'ССП/РФ не указана'}
{exportMode &&
{isChecked ? : }
}
diff --git a/web/src/pages/SvodPage/SvodPage.jsx b/web/src/pages/SvodPage/SvodPage.jsx
index 66e9ae8..51b18b9 100644
--- a/web/src/pages/SvodPage/SvodPage.jsx
+++ b/web/src/pages/SvodPage/SvodPage.jsx
@@ -1,35 +1,359 @@
-import ConstructionOutlinedIcon from '@mui/icons-material/ConstructionOutlined';
-import { Box, Paper, Typography } from '@mui/material';
+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, useState } from 'react';
+import { toast } from 'react-toastify';
+import { SspApi } from '../../api/ssp';
+import { SummaryApi } from '../../api/summary';
+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';
-const SvodPage = () => (
- <>
-
- 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 [sheet, setSheet] = useState('MAIN');
+ const [year, setYear] = useState(new Date().getFullYear());
+ const [organizations, setOrganizations] = useState([]);
+ const [selectedOrganizations, setSelectedOrganizations] = useState([]);
+ const [rows, setRows] = useState([]);
+ const [search, setSearch] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [organizationsLoading, setOrganizationsLoading] = useState(false);
+ const [error, setError] = useState('');
+ const [requestVersion, setRequestVersion] = useState(0);
+
+ 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 () => {
+ setLoading(true);
+ setError('');
+ try {
+ const response = await SummaryApi.get(sheet, year, selectedOrganizations.map(({ id }) => id));
+ setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)));
+ } catch (loadError) {
+ setRows([]);
+ setError(loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.');
+ } finally {
+ setLoading(false);
+ }
+ }, [selectedOrganizations, sheet, year]);
+
+ useEffect(() => { loadSummary(); }, [loadSummary, requestVersion]);
+
+ 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',
- alignItems: 'center',
- justifyContent: 'center',
- p: 3,
- }}>
-
-
- Страница находится в разработке
+ 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', borderBottom: '0.0625rem solid #dfe3e7',
+ color: '#4a5565', fontSize: '0.75rem', fontWeight: 700, lineHeight: 1.2, px: '0.75rem', py: '0.5rem', whiteSpace: 'nowrap',
+ },
+ },
+ 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) => )}
+
+
+ setYear(Number(event.target.value))} sx={{ width: '7.5rem' }}>
+ {getAvailableYears().map((availableYear) => )}
+
+ option.id === value.id}
+ getOptionLabel={(option) => option.title || ''} onChange={(_event, value) => setSelectedOrganizations(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}>,
+ }} />}
+ />
+ setSearch(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={() => setRequestVersion((version) => version + 1)}
+ sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
+ Обновить
+
+
+ {error ? (
+ setRequestVersion((version) => version + 1)}>Повторить}>{error}
+ ) : }
- >
-
-);
-
-export default SvodPage;
+ );
+}