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) => {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')} строк`} + + )} + + + {error ? ( + setRequestVersion((version) => version + 1)}>Повторить}>{error} + ) : } - - -); - -export default SvodPage; + ); +}