своды для проектов
This commit is contained in:
parent
02a24954e5
commit
633c36787d
@ -38,6 +38,12 @@ export const ProjectsApi = {
|
|||||||
getSummary: (id) => {
|
getSummary: (id) => {
|
||||||
return api.get(`/projects/${id}/summary`).then((r) => r.data);
|
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) => {
|
export: (id, year, sheetName) => {
|
||||||
return api.get(`/export/project/${id}/report/${year}/${sheetName}`, {
|
return api.get(`/export/project/${id}/report/${year}/${sheetName}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.j
|
|||||||
import LoginPage from '../pages/LoginPage';
|
import LoginPage from '../pages/LoginPage';
|
||||||
import NewFormTablePage from '../pages/NewFormTablePage.jsx';
|
import NewFormTablePage from '../pages/NewFormTablePage.jsx';
|
||||||
import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx';
|
import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx';
|
||||||
|
import ProjectSummariesPage from '../pages/ProjectPages/ProjectSummariesPage.jsx';
|
||||||
import Svod2Page from '../pages/Svod2Page/Svod2Page.jsx';
|
import Svod2Page from '../pages/Svod2Page/Svod2Page.jsx';
|
||||||
import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx';
|
import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx';
|
||||||
import SvodPage from '../pages/SvodPage/SvodPage.jsx';
|
import SvodPage from '../pages/SvodPage/SvodPage.jsx';
|
||||||
@ -38,6 +39,7 @@ export const AppRoutes = () => {
|
|||||||
<Route path='/' element={<TasksPage />} />
|
<Route path='/' element={<TasksPage />} />
|
||||||
<Route path='/tasks' element={<TasksPage />} />
|
<Route path='/tasks' element={<TasksPage />} />
|
||||||
<Route path='/projects' element={<NavigationProjectPage />} />
|
<Route path='/projects' element={<NavigationProjectPage />} />
|
||||||
|
<Route path='/projects/summaries' element={<ProjectSummariesPage />} />
|
||||||
<Route path='/task/:taskId' element={<TaskPage />} />
|
<Route path='/task/:taskId' element={<TaskPage />} />
|
||||||
<Route path='/project/:projectId' element={<TaskPage />} />
|
<Route path='/project/:projectId' element={<TaskPage />} />
|
||||||
<Route path='/admin_panel/users' element={<UsersPage />} />
|
<Route path='/admin_panel/users' element={<UsersPage />} />
|
||||||
|
|||||||
@ -462,9 +462,16 @@ const NavigationProjectPage = () => {
|
|||||||
selectedStatus={selectedStatus}
|
selectedStatus={selectedStatus}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
<PrimaryButton onClick={() => setCreateModalOpen(true)} startIcon={<WhitePlus />} sx={{ height: '2.5rem' }}>
|
<Box sx={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
Создать проект
|
<PrimaryButton
|
||||||
</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>
|
</Box>
|
||||||
|
|
||||||
<ProjectsTable
|
<ProjectsTable
|
||||||
|
|||||||
261
web/src/pages/ProjectPages/ProjectSummariesPage.jsx
Normal file
261
web/src/pages/ProjectPages/ProjectSummariesPage.jsx
Normal 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: '0.5rem', 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} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
web/src/pages/SvodPage/SummaryTable.jsx
Normal file
163
web/src/pages/SvodPage/SummaryTable.jsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { blueColumn } from '../../components/RealtimeTable/constants/columnColors';
|
||||||
|
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
|
||||||
|
|
||||||
|
const tableSize = (rem) => rem * 16;
|
||||||
|
const ROW_VIRTUALIZER_OPTIONS = {
|
||||||
|
overscan: 8,
|
||||||
|
estimateSize: () => 37,
|
||||||
|
measureElement: (element) => element?.offsetHeight || 37,
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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 ?? sourceRow.data?.header?.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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
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);
|
||||||
|
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} />;
|
||||||
|
});
|
||||||
@ -53,6 +53,8 @@ export function SvodFilters({
|
|||||||
searchPlaceholder = 'Код или статья расходов',
|
searchPlaceholder = 'Код или статья расходов',
|
||||||
emptyOrganizationsPlaceholder = 'Все доступные',
|
emptyOrganizationsPlaceholder = 'Все доступные',
|
||||||
allowSelectAll = false,
|
allowSelectAll = false,
|
||||||
|
onReset,
|
||||||
|
additionalFilters,
|
||||||
children,
|
children,
|
||||||
}) {
|
}) {
|
||||||
const { organizations, organizationsLoading } = useSummaryOrganizations();
|
const { organizations, organizationsLoading } = useSummaryOrganizations();
|
||||||
@ -147,10 +149,12 @@ export function SvodFilters({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{additionalFilters}
|
||||||
<ResetFiltersButton
|
<ResetFiltersButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchInput('');
|
setSearchInput('');
|
||||||
onChange({ year: new Date().getFullYear(), selectedOrganizations: [], search: '' });
|
onChange({ year: new Date().getFullYear(), selectedOrganizations: [], search: '' });
|
||||||
|
onReset?.();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
||||||
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
|
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||||||
import { blueColumn } from '../../components/RealtimeTable/constants/columnColors';
|
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
||||||
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
|
import { SummaryTable, buildSummaryTree, countSummaryTree, filterSummaryTree } from './SummaryTable';
|
||||||
import { useSvod } from './SvodContext';
|
import { useSvod } from './SvodContext';
|
||||||
import { SvodFilters } from './SvodFilters';
|
import { SvodFilters } from './SvodFilters';
|
||||||
import { PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
|
||||||
|
|
||||||
const SHEETS = [
|
const SHEETS = [
|
||||||
{ value: 'MAIN', label: 'Общий свод' },
|
{ 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() {
|
export default function SvodPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
||||||
@ -286,104 +230,13 @@ export default function SvodPage() {
|
|||||||
navigate(query ? `/projects?${query}` : '/projects');
|
navigate(query ? `/projects?${query}` : '/projects');
|
||||||
}, [navigate, selectedOrganizations]);
|
}, [navigate, selectedOrganizations]);
|
||||||
|
|
||||||
|
const tree = useMemo(() => buildSummaryTree(rows), [rows]);
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
const normalizedSearch = search.trim().toLowerCase();
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
const tree = buildSummaryTree(rows);
|
|
||||||
return normalizedSearch ? filterSummaryTree(tree, normalizedSearch) : tree;
|
return normalizedSearch ? filterSummaryTree(tree, normalizedSearch) : tree;
|
||||||
}, [rows, search]);
|
}, [tree, search]);
|
||||||
const columns = useMemo(() => createColumns(sheet), [sheet]);
|
const columns = useMemo(() => createColumns(sheet), [sheet]);
|
||||||
const filteredRowsCount = useMemo(() => countSummaryTree(filteredRows), [filteredRows]);
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@ -448,7 +301,7 @@ export default function SvodPage() {
|
|||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<MaterialReactTable table={table} />
|
<SummaryTable columns={columns} data={filteredRows} loading={loading} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user