add-svod-projects #187

Merged
PotapovaA merged 3 commits from add-svod-projects into test 2026-10-01 18:27:04 +03:00
12 changed files with 915 additions and 602 deletions

View File

@ -38,6 +38,12 @@ export const ProjectsApi = {
getSummary: (id) => {
return api.get(`/projects/${id}/summary`).then((r) => r.data);
},
getAggregateSummary: (kind, year, branchIds = [], projectType = '') => {
const params = { year };
if (branchIds.length) params.branch_id = branchIds.join(',');
if (projectType) params.project_type = projectType;
return api.get(`/projects/${kind}-summary`, { params }).then((r) => r.data);
},
export: (id, year, sheetName) => {
return api.get(`/export/project/${id}/report/${year}/${sheetName}`).then((r) => r.data);
},

View File

@ -9,6 +9,7 @@ import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.j
import LoginPage from '../pages/LoginPage';
import NewFormTablePage from '../pages/NewFormTablePage.jsx';
import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx';
import ProjectSummariesPage from '../pages/ProjectPages/ProjectSummariesPage.jsx';
import Svod2Page from '../pages/Svod2Page/Svod2Page.jsx';
import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx';
import SvodPage from '../pages/SvodPage/SvodPage.jsx';
@ -38,6 +39,7 @@ export const AppRoutes = () => {
<Route path='/' element={<TasksPage />} />
<Route path='/tasks' element={<TasksPage />} />
<Route path='/projects' element={<NavigationProjectPage />} />
<Route path='/projects/summaries' element={<ProjectSummariesPage />} />
<Route path='/task/:taskId' element={<TaskPage />} />
<Route path='/project/:projectId' element={<TaskPage />} />
<Route path='/admin_panel/users' element={<UsersPage />} />

View File

@ -4,43 +4,7 @@ import { FormsSheetApi } from '../../../api/form_sheet';
import { ProjectsApi } from '../../../api/projects';
import { useRealtimeActions, useRealtimeConnection } from '../contexts/RealtimeContext';
import { addRootRow, deleteRow, insertCell, insertProjectRow, updateCells } from '../utils/cellUtils';
import { getParentId, isVspNewRow } from '../utils/rowUtils';
const ROW_TYPE_LEVELS = {
ROOT: 0,
GROUP: 1,
ITEM: 2,
SUB_ITEM: 3,
INPUT: 4,
};
const buildHierarchy = (items) => {
const result = [];
const stack = [];
for (const item of items) {
const currentLevel = ROW_TYPE_LEVELS[item.row_type] ?? 999;
while (stack.length > 0 && (ROW_TYPE_LEVELS[stack.at(-1).row_type] ?? 999) >= currentLevel) {
stack.pop();
}
const parent = stack.at(-1);
const newNode = { ...item, subRows: [] };
if (parent) {
parent.subRows.push(newNode);
} else {
result.push(newNode);
}
if (item.row_type !== 'INPUT') {
stack.push(newNode);
}
}
return result;
};
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const [data, setData] = useState([]);
@ -91,7 +55,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
return;
}
const hierarchicalData = buildHierarchy(data);
const hierarchicalData = buildRowHierarchy(data);
setData(hierarchicalData);
} catch (err) {
if (!isCancelled) console.error('Failed to load initial data:', err);

View File

@ -1,3 +1,34 @@
const ROW_TYPE_LEVELS = {
ROOT: 0,
GROUP: 1,
ITEM: 2,
SUB_ITEM: 3,
INPUT: 4,
};
export function buildRowHierarchy(items, mapItem = (item) => item) {
const result = [];
const stack = [];
for (const [index, item] of items.entries()) {
const currentLevel = ROW_TYPE_LEVELS[item.row_type] ?? 999;
while (stack.length > 0 && (ROW_TYPE_LEVELS[stack.at(-1).row_type] ?? 999) >= currentLevel) {
stack.pop();
}
const parent = stack.at(-1);
const newNode = { ...mapItem(item, index), subRows: [] };
if (parent) parent.subRows.push(newNode);
else result.push(newNode);
if (item.row_type !== 'INPUT') stack.push(newNode);
}
return result;
}
export function getDataRowId(data) {
return data?.id || data?.line_id;
}

View File

@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRowHierarchy } from './rowUtils.js';
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
const rows = [
{ row_type: 'ROOT', depth: 0, id: 'root' },
{ row_type: 'ITEM', depth: 2, id: 'item-1' },
{ row_type: 'INPUT', depth: 4, id: 'input-1' },
{ row_type: 'INPUT', depth: 4, id: 'input-2' },
{ row_type: 'ITEM', depth: 2, id: 'item-2' },
{ row_type: 'ROOT', depth: 0, id: 'next-root' },
];
const tree = buildRowHierarchy(rows);
assert.deepEqual(tree.map(({ id }) => id), ['root', 'next-root']);
assert.deepEqual(tree[0].subRows.map(({ id }) => id), ['item-1', 'item-2']);
assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']);
assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []);
});

View File

@ -462,9 +462,16 @@ const NavigationProjectPage = () => {
selectedStatus={selectedStatus}
onStatusChange={handleStatusChange}
/>
<PrimaryButton onClick={() => setCreateModalOpen(true)} startIcon={<WhitePlus />} sx={{ height: '2.5rem' }}>
Создать проект
</PrimaryButton>
<Box sx={{ display: 'flex', gap: '0.75rem' }}>
<PrimaryButton
onClick={() => navigate(branchIdsParam ? `/projects/summaries?branch_ids=${branchIdsParam}` : '/projects/summaries')}
sx={{ height: '2.5rem', whiteSpace: 'nowrap' }}>
Перейти к сводам
</PrimaryButton>
<PrimaryButton onClick={() => setCreateModalOpen(true)} startIcon={<WhitePlus />} sx={{ height: '2.5rem', whiteSpace: 'nowrap' }}>
Создать проект
</PrimaryButton>
</Box>
</Box>
<ProjectsTable

View File

@ -0,0 +1,261 @@
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { Alert, Box, Button, MenuItem, Paper, Tab, Tabs, TextField, Typography } from '@mui/material';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ProjectsApi } from '../../api/projects';
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
import { PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
import { PROJECT_TYPES } from '../../constants/constants';
import { SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from '../SvodPage/SummaryTable';
import { SvodFilters, useSummaryOrganizations } from '../SvodPage/SvodFilters';
const SHEETS = [
{ value: 'plan', label: 'План' },
{ value: 'fact', label: 'Факт' },
{ value: 'detail', label: 'Детализация' },
];
const QUARTERS = [
{ key: 'q1', label: 'I кв.' },
{ key: 'q2', label: 'II кв.' },
{ key: 'q3', label: 'III кв.' },
{ key: 'q4', label: 'IV кв.' },
{ key: 'year', label: 'Год' },
];
const DETAIL_FIELDS = [
{ key: 'adj_by_items', label: 'Корректировка по статьям' },
{ key: 'adj_increase', label: 'Увеличение' },
{ key: 'actual_m1', label: 'Факт, 1 месяц' },
{ key: 'actual_m2', label: 'Факт, 2 месяц' },
{ key: 'actual_m3', label: 'Факт, 3 месяц' },
{ key: 'quarter', label: 'Итого за квартал' },
{ key: 'residual', label: 'Остаток' },
];
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
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 valueColumn = (id, header, getValue) => ({
id,
header,
accessorFn: getValue,
Cell: ({ cell }) => formatValue(cell.getValue()),
size: 112,
muiTableBodyCellProps: { align: 'right' },
muiTableHeadCellProps: { align: 'right' },
});
const headerColumns = [
{ id: 'section_code', header: '№ п/п', accessorFn: (row) => row.data?.header?.section_code, size: 92 },
{
id: 'name',
header: 'Перечень расходов и затрат',
accessorFn: (row) => row.data?.header?.name,
size: 330,
Cell: ({ cell, row }) => (
<Box sx={{ pl: `${row.original.depth * 1.125}rem`, fontWeight: row.original.depth < 2 ? 600 : 400 }}>
{cell.getValue() || 'Без названия'}
</Box>
),
},
];
const metricColumns = (metric, label, periods) => ({
id: metric,
header: label,
columns: [
{ key: 'limit', label: 'Лимит' },
{ key: 'current', label: 'Текущие расходы' },
{ key: 'total', label: 'Итого' },
].map((group) => ({
id: `${metric}.${group.key}`,
header: group.label,
columns: periods.map((period) =>
valueColumn(
`${metric}.${group.key}.${period.key}`,
period.label,
(row) => row.data?.[metric]?.[group.key]?.[period.key],
),
),
})),
});
const createColumns = (sheet) => {
if (sheet === 'plan') {
return [
...headerColumns,
metricColumns('base', 'Базовый план', QUARTERS),
metricColumns('correction', 'Корректировка плана', QUARTERS.filter(({ key }) => key !== 'q1')),
];
}
if (sheet === 'fact') {
return [...headerColumns, metricColumns('fact', 'Факт', QUARTERS)];
}
return [
...headerColumns,
...QUARTERS.filter(({ key }) => key !== 'year').map((quarter) => ({
id: `detail.${quarter.key}`,
header: quarter.label,
columns: DETAIL_FIELDS.map((field) =>
valueColumn(
`detail.${quarter.key}.${field.key}`,
field.label,
(row) => row.data?.detail?.[quarter.key]?.[field.key],
),
),
})),
valueColumn('detail.year', 'Факт за год', (row) => row.data?.detail?.year),
];
};
const parseBranchIds = (value) =>
value
.split(',')
.map(Number)
.filter((id) => Number.isInteger(id) && id > 0);
const COLUMNS_BY_SHEET = Object.fromEntries(SHEETS.map(({ value }) => [value, createColumns(value)]));
const EMPTY_SUMMARY = { rows: [], tree: [] };
const createCacheKey = (year, branchIds, projectType) => JSON.stringify([year, branchIds.map(String).sort(), projectType]);
const getSheetCache = (cache, sheet) => {
if (!cache.has(sheet)) cache.set(sheet, new Map());
return cache.get(sheet);
};
export default function ProjectSummariesPage() {
const [searchParams] = useSearchParams();
const initialBranchIds = useMemo(() => parseBranchIds(searchParams.get('branch_ids') || ''), [searchParams]);
const { organizations } = useSummaryOrganizations();
const [sheet, setSheet] = useState('plan');
const [filters, setFilters] = useState({
year: new Date().getFullYear(),
selectedOrganizations: [],
search: '',
});
const [projectType, setProjectType] = useState('');
const [summary, setSummary] = useState(EMPTY_SUMMARY);
const { rows, tree } = summary;
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const requestRef = useRef(0);
const summaryCacheRef = useRef(new Map());
const initialBranchesAppliedRef = useRef(false);
const { year, selectedOrganizations, search } = filters;
const branchIds = useMemo(
() => selectedOrganizations.map(({ id }) => id),
[selectedOrganizations],
);
const updateFilters = useCallback((changes) => setFilters((current) => ({ ...current, ...changes })), []);
useEffect(
() => () => {
summaryCacheRef.current.clear();
},
[],
);
useEffect(() => {
if (initialBranchesAppliedRef.current || !organizations.length) return;
initialBranchesAppliedRef.current = true;
if (initialBranchIds.length) {
setFilters((current) => ({
...current,
selectedOrganizations: organizations.filter(({ id }) => initialBranchIds.includes(id)),
}));
}
}, [initialBranchIds, organizations]);
const getSummary = useCallback((kind, selectedYear, selectedBranchIds, selectedProjectType) => {
const sheetCache = getSheetCache(summaryCacheRef.current, kind);
const cacheKey = createCacheKey(selectedYear, selectedBranchIds, selectedProjectType);
const cachedRequest = sheetCache.get(cacheKey);
if (cachedRequest) return cachedRequest;
const request = ProjectsApi.getAggregateSummary(kind, selectedYear, selectedBranchIds, selectedProjectType)
.then((response) => {
const rows = [...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const preparedSummary = { rows, tree: buildSummaryTree(rows) };
if (sheetCache.get(cacheKey) === request) sheetCache.set(cacheKey, Promise.resolve(preparedSummary));
return preparedSummary;
})
.catch((requestError) => {
if (sheetCache.get(cacheKey) === request) sheetCache.delete(cacheKey);
throw requestError;
});
sheetCache.set(cacheKey, request);
return request;
}, []);
const loadSummary = useCallback(async () => {
const requestId = ++requestRef.current;
setLoading(true);
setError('');
try {
const requestedBranchIds = initialBranchesAppliedRef.current ? branchIds : initialBranchIds;
const response = await getSummary(sheet, year, requestedBranchIds, projectType);
if (requestId !== requestRef.current) return;
setSummary(response);
} catch (requestError) {
if (requestId !== requestRef.current) return;
setSummary(EMPTY_SUMMARY);
setError(requestError.response?.data?.detail || 'Не удалось загрузить свод проектов. Попробуйте ещё раз.');
} finally {
if (requestId === requestRef.current) setLoading(false);
}
}, [sheet, year, branchIds, initialBranchIds, projectType, getSummary]);
const refreshSummary = useCallback(() => {
const requestedBranchIds = initialBranchesAppliedRef.current ? branchIds : initialBranchIds;
summaryCacheRef.current.get(sheet)?.delete(createCacheKey(year, requestedBranchIds, projectType));
loadSummary();
}, [sheet, year, branchIds, initialBranchIds, projectType, loadSummary]);
useEffect(() => {
loadSummary();
return () => { requestRef.current += 1; };
}, [loadSummary]);
const filteredRows = useMemo(() => {
const query = search.trim().toLowerCase();
return query ? filterSummaryTree(tree, query) : tree;
}, [tree, search]);
const columns = COLUMNS_BY_SHEET[sheet];
const filteredCount = 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)} sx={{ px: '1rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
{SHEETS.map(({ value, label }) => <Tab key={value} value={value} label={label} sx={{ textTransform: 'none', minHeight: '3rem' }} />)}
</Tabs>
<SvodFilters
year={year}
selectedOrganizations={selectedOrganizations}
search={search}
onChange={updateFilters}
onReset={() => setProjectType('')}
searchPlaceholder='Код или статья расходов'
additionalFilters={
<TextField select size='small' label='Тип проекта' value={projectType} onChange={(event) => setProjectType(event.target.value)} sx={{ minWidth: '13rem' }}>
<MenuItem value=''>Все типы</MenuItem>
{PROJECT_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
</TextField>
}
>
{!loading && filteredCount !== rows.length && (
<Typography variant='caption' color='text.secondary'>
{`Найдено ${filteredCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
</Typography>
)}
<PrimaryOutlinedButton startIcon={<RefreshRoundedIcon />} disabled={loading} onClick={refreshSummary} sx={{ ml: 'auto', 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} colorBySource />
)}
</Box>
);
}

View File

@ -3,6 +3,7 @@ import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material'
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { MRT_Localization_RU } from 'material-react-table/locales/ru';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
@ -355,6 +356,7 @@ const matchesSearch = (row, query) => {
};
export default function Svod2Page() {
const navigate = useNavigate();
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
const [sheet, setSheet] = useState('GO');
const [tableStateBySheet, setTableStateBySheet] = useState(createInitialTableState);
@ -436,6 +438,11 @@ export default function Svod2Page() {
invalidateSummary(sheet, year, selectedOrganizationIds);
loadSummary();
}, [invalidateSummary, loadSummary, selectedOrganizationIds, sheet, year]);
const openFilteredTasks = useCallback(() => {
const params = new URLSearchParams({ year: String(year), form_type: 'FORM_2' });
if (selectedOrganizationIds.length) params.set('org_units', selectedOrganizationIds.join(','));
navigate(`/tasks?${params.toString()}`);
}, [navigate, selectedOrganizationIds, year]);
const filteredRows = useMemo(() => {
if (!hasCurrentData) return [];
@ -571,9 +578,9 @@ export default function Svod2Page() {
<Tabs
value={sheet}
onChange={(_event, value) => setSheet(value)}
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
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', minHeight: '3rem' }} />
<Tab key={item.value} value={item.value} label={item.label} sx={{ textTransform: 'none', alignItems: 'flex-start', textAlign: 'left', minHeight: '3rem' }} />
))}
</Tabs>
<SvodFilters
@ -589,6 +596,9 @@ export default function Svod2Page() {
{`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
</Typography>
)}
<PrimaryButton variant='contained' onClick={openFilteredTasks} sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
Перейти к задачам
</PrimaryButton>
{hasCurrentData ? (
<Button
variant='outlined'
@ -596,7 +606,7 @@ export default function Svod2Page() {
startIcon={<RefreshRoundedIcon />}
disabled={loading || selectedOrganizations.length === 0}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
sx={{ height: '2.5rem', flexShrink: 0 }}>
Обновить
</Button>
) : (
@ -605,7 +615,7 @@ export default function Svod2Page() {
color='default'
disabled={loading || selectedOrganizations.length === 0}
onClick={refreshSummary}
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
sx={{ height: '2.5rem', flexShrink: 0 }}>
Загрузить
</PrimaryButton>
)}

View File

@ -0,0 +1,154 @@
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { memo } from 'react';
import { blueColumn, greenColumn, orangeColumn } from '../../components/RealtimeTable/constants/columnColors';
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
import { buildRowHierarchy } from '../../components/RealtimeTable/utils/rowUtils';
const tableSize = (rem) => rem * 16;
const ROW_VIRTUALIZER_OPTIONS = {
overscan: 8,
estimateSize: () => 37,
measureElement: (element) => element?.offsetHeight || 37,
};
const SOURCE_COLORS = { ahr: greenColumn, oper: blueColumn, cap: orangeColumn };
const getSummaryRowColor = (row, colorBySource) => {
const rowType = row.original?.row_type || row.row_type;
if (colorBySource) {
const source = row.original?.data?.header?.source?.toLowerCase();
if (SOURCE_COLORS[source]) return SOURCE_COLORS[source][rowType];
}
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';
};
export const buildSummaryTree = (sourceRows) =>
buildRowHierarchy(sourceRows, (row, index) => ({
...row,
_rowId: `${row.sort_order ?? index}-${row.data?.section_code ?? row.data?.header?.section_code ?? index}`,
}));
export const filterSummaryTree = (tree, query) =>
tree.flatMap((row) => {
const matches = [row.data?.section_code, row.data?.name, row.data?.header?.section_code, row.data?.header?.name].some((value) =>
String(value || '')
.toLowerCase()
.includes(query),
);
if (matches) return [row];
const subRows = filterSummaryTree(row.subRows, query);
return subRows.length ? [{ ...row, subRows }] : [];
});
export const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0);
export const SummaryTable = memo(function SummaryTable({ columns, data, loading, colorBySource = false }) {
const table = useMaterialReactTable({
columns,
data,
enableExpanding: true,
enableExpandAll: true,
getSubRows: (row) => row.subRows,
getRowId: (row) => row._rowId,
enableColumnActions: false,
enableColumnFilters: false,
enableDensityToggle: false,
enableFullScreenToggle: false,
enableHiding: false,
enablePagination: false,
enableRowVirtualization: true,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
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, colorBySource);
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 <MaterialReactTable table={table} />;
});

View File

@ -53,6 +53,8 @@ export function SvodFilters({
searchPlaceholder = 'Код или статья расходов',
emptyOrganizationsPlaceholder = 'Все доступные',
allowSelectAll = false,
onReset,
additionalFilters,
children,
}) {
const { organizations, organizationsLoading } = useSummaryOrganizations();
@ -147,10 +149,12 @@ export function SvodFilters({
},
}}
/>
{additionalFilters}
<ResetFiltersButton
onClick={() => {
setSearchInput('');
onChange({ year: new Date().getFullYear(), selectedOrganizations: [], search: '' });
onReset?.();
}}
/>
{children}

View File

@ -1,14 +1,12 @@
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
import { blueColumn } from '../../components/RealtimeTable/constants/columnColors';
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
import { SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from './SummaryTable';
import { useSvod } from './SvodContext';
import { SvodFilters } from './SvodFilters';
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
const SHEETS = [
{ value: 'MAIN', label: 'Общий свод' },
@ -162,60 +160,6 @@ const createColumns = (sheet) => {
];
};
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);
export default function SvodPage() {
const navigate = useNavigate();
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
@ -286,104 +230,13 @@ export default function SvodPage() {
navigate(query ? `/projects?${query}` : '/projects');
}, [navigate, selectedOrganizations]);
const tree = useMemo(() => buildSummaryTree(rows), [rows]);
const filteredRows = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
const tree = buildSummaryTree(rows);
return normalizedSearch ? filterSummaryTree(tree, normalizedSearch) : tree;
}, [rows, search]);
}, [tree, 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
@ -406,9 +259,9 @@ export default function SvodPage() {
onChange={(_event, value) => setSheet(value)}
variant='scrollable'
scrollButtons='auto'
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
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', minHeight: '3rem' }} />
<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}>
@ -448,7 +301,7 @@ export default function SvodPage() {
{error}
</Alert>
) : (
<MaterialReactTable table={table} />
<SummaryTable columns={columns} data={filteredRows} loading={loading} />
)}
</Box>
);