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 }) => ( {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 { 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 ( setSheet(value)} variant='scrollable' scrollButtons='auto' sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}> {SHEETS.map((item) => )} updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}> {getAvailableYears().map((availableYear) => {availableYear})} 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 ; }).concat(value.length > 2 ? [] : [])} renderInput={(params) => {organizationsLoading && }{params.InputProps?.endAdornment}, }} />} /> updateCurrentSheetFilters({ search: 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')} строк`} )} {error ? ( Повторить}>{error} ) : } ); }