svod2-ui #141
@ -1,8 +1,8 @@
|
|||||||
import api from './client';
|
import api from './client';
|
||||||
|
|
||||||
export const SummaryApi = {
|
export const SummaryApi = {
|
||||||
get(sheet, year, orgIds = []) {
|
get(summaryNumber, sheet, year, orgIds = []) {
|
||||||
const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
|
const params = orgIds.length > 0 ? { org_ids: orgIds.join(',') } : {};
|
||||||
return api.get(`/svod/1/${sheet}/${year}`, { params }).then((response) => response.data);
|
return api.get(`/svod/${summaryNumber}/${sheet}/${year}`, { params }).then((response) => response.data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,11 +8,11 @@ import DictsNavigatorPage from '../pages/DictsNavigatorPage/DictsNavigatorPage.j
|
|||||||
import FormsPage from '../pages/FormsPage';
|
import FormsPage from '../pages/FormsPage';
|
||||||
import LoginPage from '../pages/LoginPage';
|
import LoginPage from '../pages/LoginPage';
|
||||||
import NewFormTablePage from '../pages/NewFormTablePage.jsx';
|
import NewFormTablePage from '../pages/NewFormTablePage.jsx';
|
||||||
import NewTablePage from '../pages/NewTablePage.jsx';
|
|
||||||
import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx';
|
import NavigationProjectPage from '../pages/ProjectPages/NavigationProjectPage.jsx';
|
||||||
import ProjectsPage from '../pages/ProjectsPage/ProjectsPage.jsx';
|
import Svod2Page from '../pages/Svod2Page/Svod2Page.jsx';
|
||||||
import SvodPage from '../pages/SvodPage/SvodPage.jsx';
|
|
||||||
import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx';
|
import { SvodProvider } from '../pages/SvodPage/SvodContext.jsx';
|
||||||
|
import SvodPage from '../pages/SvodPage/SvodPage.jsx';
|
||||||
|
import SvodsNavigatorPage from '../pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx';
|
||||||
import TablePage from '../pages/TablePage';
|
import TablePage from '../pages/TablePage';
|
||||||
import TableTempPage from '../pages/TableTempPage/TableTempPage';
|
import TableTempPage from '../pages/TableTempPage/TableTempPage';
|
||||||
import TablesTest from '../pages/TablesTest.jsx';
|
import TablesTest from '../pages/TablesTest.jsx';
|
||||||
@ -51,12 +51,27 @@ export const AppRoutes = () => {
|
|||||||
<Route path='/admin_panel/audit-log' element={<AuditLogsPage />} />
|
<Route path='/admin_panel/audit-log' element={<AuditLogsPage />} />
|
||||||
<Route path='/dicts/dict-vsp' element={<DictVspPage />} />
|
<Route path='/dicts/dict-vsp' element={<DictVspPage />} />
|
||||||
<Route path='/dicts-navigator' element={<DictsNavigatorPage />} />
|
<Route path='/dicts-navigator' element={<DictsNavigatorPage />} />
|
||||||
<Route path='/new-table' element={<NewTablePage />} />
|
|
||||||
<Route path='/tables-new' element={<TablesTest />} />
|
<Route path='/tables-new' element={<TablesTest />} />
|
||||||
<Route path='/tables/:form/:sheetName' element={<NewFormTablePage />} />
|
<Route path='/tables/:form/:sheetName' element={<NewFormTablePage />} />
|
||||||
<Route path='/table/form/:formId/form-type/:formType/:sheetName/:direction/:year' element={<NewFormTablePage />} />
|
<Route path='/table/form/:formId/form-type/:formType/:sheetName/:direction/:year' element={<NewFormTablePage />} />
|
||||||
<Route path='/project-mock-summary' element={<NavigationProjectPage />} />
|
<Route path='/project-mock-summary' element={<NavigationProjectPage />} />
|
||||||
<Route path='/svod' element={<SvodProvider><SvodPage /></SvodProvider>} />
|
<Route path='/svods-navigator' element={<SvodsNavigatorPage />} />
|
||||||
|
<Route
|
||||||
|
path='/svod/2'
|
||||||
|
element={
|
||||||
|
<SvodProvider summaryNumber={2} sheetValues={['GO', 'RF']}>
|
||||||
|
<Svod2Page />
|
||||||
|
</SvodProvider>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path='/svod'
|
||||||
|
element={
|
||||||
|
<SvodProvider>
|
||||||
|
<SvodPage />
|
||||||
|
</SvodProvider>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export function HeaderSwitch() {
|
|||||||
const formsTasksPaths = ['/tasks', '/forms'];
|
const formsTasksPaths = ['/tasks', '/forms'];
|
||||||
const adminPathsPrefix = '/admin_panel';
|
const adminPathsPrefix = '/admin_panel';
|
||||||
const dictPaths = '/dicts-navigator';
|
const dictPaths = '/dicts-navigator';
|
||||||
const svodPaths = '/svod';
|
const svodPaths = ['/svod', '/svods-navigator'];
|
||||||
const projectsPaths = '/projects';
|
const projectsPaths = '/projects';
|
||||||
|
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@ -75,9 +75,9 @@ export function HeaderSwitch() {
|
|||||||
<span>Проекты</span>
|
<span>Проекты</span>
|
||||||
</StyledNavLink>
|
</StyledNavLink>
|
||||||
|
|
||||||
<StyledNavLink to='/svod' className={location.pathname.startsWith(svodPaths) ? 'active' : ''}>
|
<StyledNavLink to='/svods-navigator' className={svodPaths.some((path) => location.pathname.startsWith(path)) ? 'active' : ''}>
|
||||||
<div className='img'>
|
<div className='img'>
|
||||||
<SummarySvg fill={location.pathname.startsWith(svodPaths) ? '#258141' : '#99A1AF'} />
|
<SummarySvg fill={svodPaths.some((path) => location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} />
|
||||||
</div>
|
</div>
|
||||||
<span>Своды</span>
|
<span>Своды</span>
|
||||||
</StyledNavLink>
|
</StyledNavLink>
|
||||||
@ -103,4 +103,4 @@ export function HeaderSwitch() {
|
|||||||
if (targetElement) {
|
if (targetElement) {
|
||||||
return createPortal(portalContent, targetElement);
|
return createPortal(portalContent, targetElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { getRowId } from './utils/rowUtils';
|
|||||||
|
|
||||||
import { DictVspApi } from '../../api/dict-vsp';
|
import { DictVspApi } from '../../api/dict-vsp';
|
||||||
import { useAuth } from '../../app/context/AuthProvider';
|
import { useAuth } from '../../app/context/AuthProvider';
|
||||||
|
import { ROLES_NAME_ID } from '../../constants/constants';
|
||||||
import { FormsNoCanAddRow } from './constants/formConfig';
|
import { FormsNoCanAddRow } from './constants/formConfig';
|
||||||
|
|
||||||
const CONFIG_CACHE = new Map();
|
const CONFIG_CACHE = new Map();
|
||||||
@ -121,6 +122,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const [createModalType, setCreateModalType] = useState(null);
|
const [createModalType, setCreateModalType] = useState(null);
|
||||||
|
|
||||||
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
||||||
|
const canModifyRows = userRoleId === ROLES_NAME_ID.admin || columnsCurStage.length > 0;
|
||||||
|
|
||||||
const isVspAdditionRow = useMemo(() => {
|
const isVspAdditionRow = useMemo(() => {
|
||||||
if (!formType || !sheetName) return false;
|
if (!formType || !sheetName) return false;
|
||||||
@ -664,6 +666,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
}, [getSelectedRow]);
|
}, [getSelectedRow]);
|
||||||
|
|
||||||
const handleDeleteRow = useCallback(() => {
|
const handleDeleteRow = useCallback(() => {
|
||||||
|
if (!canModifyRows) {
|
||||||
|
toast.error('Удаление строк недоступно: нет доступных для редактирования столбцов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = getSelectedRow();
|
const row = getSelectedRow();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
toast.error('Выделите строку для удаления');
|
toast.error('Выделите строку для удаления');
|
||||||
@ -671,9 +677,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
}
|
}
|
||||||
contextDeleteRow(getRowId(row));
|
contextDeleteRow(getRowId(row));
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
}, [contextDeleteRow, getSelectedRow]);
|
}, [canModifyRows, contextDeleteRow, getSelectedRow]);
|
||||||
|
|
||||||
const addRow = useCallback(() => {
|
const addRow = useCallback(() => {
|
||||||
|
if (!canModifyRows) {
|
||||||
|
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isVspAdditionRow) {
|
if (isVspAdditionRow) {
|
||||||
return handleOpenModalSelectVsp();
|
return handleOpenModalSelectVsp();
|
||||||
}
|
}
|
||||||
@ -681,13 +691,21 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
return handleOpenModalSelectExpenseItem();
|
return handleOpenModalSelectExpenseItem();
|
||||||
}
|
}
|
||||||
return handleAddRow();
|
return handleAddRow();
|
||||||
}, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]);
|
}, [canModifyRows, isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]);
|
||||||
|
|
||||||
const addProgram = useCallback(() => {
|
const addProgram = useCallback(() => {
|
||||||
|
if (!canModifyRows) {
|
||||||
|
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreateModalType('program');
|
setCreateModalType('program');
|
||||||
}, []);
|
}, [canModifyRows]);
|
||||||
|
|
||||||
const addProject = useCallback(() => {
|
const addProject = useCallback(() => {
|
||||||
|
if (!canModifyRows) {
|
||||||
|
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = getSelectedRow();
|
const row = getSelectedRow();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
toast.error('Выделите строку для вставки');
|
toast.error('Выделите строку для вставки');
|
||||||
@ -698,7 +716,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCreateModalType('project');
|
setCreateModalType('project');
|
||||||
}, [getSelectedRow]);
|
}, [canModifyRows, getSelectedRow]);
|
||||||
|
|
||||||
const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []);
|
const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []);
|
||||||
const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []);
|
const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []);
|
||||||
@ -777,6 +795,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
direction={direction}
|
direction={direction}
|
||||||
year={year}
|
year={year}
|
||||||
isFormNoCanAddRow={isFormNoCanAddRow}
|
isFormNoCanAddRow={isFormNoCanAddRow}
|
||||||
|
canModifyRows={canModifyRows}
|
||||||
formType={formType}
|
formType={formType}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
onNavigateToColumn={handleNavigateToColumn}
|
onNavigateToColumn={handleNavigateToColumn}
|
||||||
|
|||||||
@ -90,6 +90,7 @@ const SettingPanel = ({
|
|||||||
direction,
|
direction,
|
||||||
year,
|
year,
|
||||||
isFormNoCanAddRow,
|
isFormNoCanAddRow,
|
||||||
|
canModifyRows,
|
||||||
formType,
|
formType,
|
||||||
validationErrors = [],
|
validationErrors = [],
|
||||||
onNavigateToColumn,
|
onNavigateToColumn,
|
||||||
@ -178,7 +179,7 @@ const SettingPanel = ({
|
|||||||
|
|
||||||
<Divider orientation='vertical' flexItem />
|
<Divider orientation='vertical' flexItem />
|
||||||
<GroupByObject title='Строки'>
|
<GroupByObject title='Строки'>
|
||||||
{!isFormNoCanAddRow && (
|
{!isFormNoCanAddRow && canModifyRows && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title='Добавить строку'>
|
<Tooltip title='Добавить строку'>
|
||||||
<IconButton onClick={onAddRow} variant='outlined' sx={controlSx}>
|
<IconButton onClick={onAddRow} variant='outlined' sx={controlSx}>
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Box, CircularProgress, Stack } from '@mui/material';
|
import { Box, CircularProgress, Stack } from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useAuth } from '../../app/context/AuthProvider';
|
||||||
import { useTaskStages } from '../../hooks/useTaskStages';
|
import { useTaskStages } from '../../hooks/useTaskStages';
|
||||||
import { useProjectStages } from '../../hooks/useProjectStages';
|
import { useProjectStages } from '../../hooks/useProjectStages';
|
||||||
import { SHEET_NAME } from '../../constants/constants';
|
import { ROLES_NAME_ID, SHEET_NAME } from '../../constants/constants';
|
||||||
import { PrimaryButton } from '../common/Buttons/Buttons';
|
import { PrimaryButton } from '../common/Buttons/Buttons';
|
||||||
import Modal from '../common/Modal/Modal';
|
import Modal from '../common/Modal/Modal';
|
||||||
import { EditContainer } from '../common/Modal/ModalStyled';
|
import { EditContainer } from '../common/Modal/ModalStyled';
|
||||||
@ -23,6 +24,8 @@ export const SettingModal = ({
|
|||||||
formTypeCode = '',
|
formTypeCode = '',
|
||||||
sheets,
|
sheets,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.role_id === ROLES_NAME_ID.admin;
|
||||||
const taskStages = useTaskStages(taskId);
|
const taskStages = useTaskStages(taskId);
|
||||||
const projectStages = useProjectStages(projectId, year, reportType);
|
const projectStages = useProjectStages(projectId, year, reportType);
|
||||||
const stages = mode === 'project' ? projectStages : taskStages;
|
const stages = mode === 'project' ? projectStages : taskStages;
|
||||||
@ -137,11 +140,13 @@ export const SettingModal = ({
|
|||||||
<>
|
<>
|
||||||
<Modal open={isOpen} onClose={onClose} title={modalTitle} customSize={50}>
|
<Modal open={isOpen} onClose={onClose} title={modalTitle} customSize={50}>
|
||||||
<EditContainer style={{ margin: 0 }}>
|
<EditContainer style={{ margin: 0 }}>
|
||||||
<Stack direction='row' sx={{ width: '100%', justifyContent: 'flex-end' }}>
|
{isAdmin && (
|
||||||
<PrimaryButton onClick={handleAddStage} startIcon={<WhitePlus />}>
|
<Stack direction='row' sx={{ width: '100%', justifyContent: 'flex-end' }}>
|
||||||
Добавить этап
|
<PrimaryButton onClick={handleAddStage} startIcon={<WhitePlus />}>
|
||||||
</PrimaryButton>
|
Добавить этап
|
||||||
</Stack>
|
</PrimaryButton>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
{stagesInfo.isLoading ? (
|
{stagesInfo.isLoading ? (
|
||||||
<Box
|
<Box
|
||||||
@ -159,20 +164,22 @@ export const SettingModal = ({
|
|||||||
onDelete={!isTaskMode ? handleDeleteStage : undefined}
|
onDelete={!isTaskMode ? handleDeleteStage : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AddStagesModal
|
{isAdmin && (
|
||||||
isOpen={isAddEditModalOpen}
|
<AddStagesModal
|
||||||
onClose={() => {
|
isOpen={isAddEditModalOpen}
|
||||||
setIsAddEditModalOpen(false);
|
onClose={() => {
|
||||||
}}
|
setIsAddEditModalOpen(false);
|
||||||
stage={stageForEdit}
|
}}
|
||||||
createStage={createStage}
|
stage={stageForEdit}
|
||||||
updateStage={updateStage}
|
createStage={createStage}
|
||||||
isTaskMode={isTaskMode}
|
updateStage={updateStage}
|
||||||
isSaving={isSaving}
|
isTaskMode={isTaskMode}
|
||||||
sheetOptions={sheetOptions}
|
isSaving={isSaving}
|
||||||
formType={formTypeCode}
|
sheetOptions={sheetOptions}
|
||||||
defaultSheet={mode === 'project' ? reportType : ''}
|
formType={formTypeCode}
|
||||||
/>
|
defaultSheet={mode === 'project' ? reportType : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!isTaskMode && (
|
{!isTaskMode && (
|
||||||
<DeleteStageModal
|
<DeleteStageModal
|
||||||
isOpen={isDeleteModalOpen}
|
isOpen={isDeleteModalOpen}
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const TableCard = ({ table, onNavigate, action }) => {
|
|||||||
</div>
|
</div>
|
||||||
<SectionTitle>
|
<SectionTitle>
|
||||||
<h2>{table.name}</h2>
|
<h2>{table.name}</h2>
|
||||||
<h2>{DIRECTION_TRANSLATE[table?.direction]}</h2>
|
<h2>{DIRECTION_TRANSLATE[table?.direction] || ''}</h2>
|
||||||
{/* <UsersList>
|
{/* <UsersList>
|
||||||
<div className="img-user"></div>
|
<div className="img-user"></div>
|
||||||
</UsersList> */}
|
</UsersList> */}
|
||||||
|
|||||||
@ -167,14 +167,16 @@ export const EditUserModal = ({ isOpen, onClose, onConfirm, user, onEditSsp }) =
|
|||||||
variant='outlined'
|
variant='outlined'
|
||||||
size='small'
|
size='small'
|
||||||
autoFocus
|
autoFocus
|
||||||
InputProps={{
|
slotProps={{
|
||||||
endAdornment: (
|
input: {
|
||||||
<InputAdornment position='end'>
|
endAdornment: (
|
||||||
<IconButton edge='end' onClick={() => toggleEdit(field)} size='small' sx={{ padding: '4px' }}>
|
<InputAdornment position='end'>
|
||||||
<EditOutlinedIcon fontSize='small' />
|
<IconButton edge='end' onClick={() => toggleEdit(field)} size='small' sx={{ padding: '4px' }}>
|
||||||
</IconButton>
|
<EditOutlinedIcon fontSize='small' />
|
||||||
</InputAdornment>
|
</IconButton>
|
||||||
),
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { DIRECTION_TRANSLATE, SHEET_NAME } from '../constants/constants';
|
|||||||
const TableInfo = ({ formId, sheetName, direction, isProject = false }) => {
|
const TableInfo = ({ formId, sheetName, direction, isProject = false }) => {
|
||||||
const path = (isProject ? '/project/' : '/task/') + formId;
|
const path = (isProject ? '/project/' : '/task/') + formId;
|
||||||
const tableName = SHEET_NAME[sheetName] || sheetName;
|
const tableName = SHEET_NAME[sheetName] || sheetName;
|
||||||
const directionName = DIRECTION_TRANSLATE[direction] || direction;
|
const directionName = DIRECTION_TRANSLATE[direction] || '';
|
||||||
const portalContent = (
|
const portalContent = (
|
||||||
<TaskInfoContainer>
|
<TaskInfoContainer>
|
||||||
<BackButton to={path} />
|
<BackButton to={path} />
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style';
|
|
||||||
|
|
||||||
import RealtimeTable from '../components/RealtimeTable';
|
|
||||||
|
|
||||||
const TableInfo = () => {
|
|
||||||
const portalContent = (
|
|
||||||
<TaskInfoContainer>
|
|
||||||
{/* <BackButton to={`/task/${table.task_id}`} />
|
|
||||||
<NameTask>{table.name}</NameTask> */}
|
|
||||||
</TaskInfoContainer>
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetElement = document.getElementById('TableInfo');
|
|
||||||
|
|
||||||
if (targetElement) {
|
|
||||||
return createPortal(portalContent, targetElement);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NewTablePage() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RealtimeTable />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -93,21 +93,23 @@ const TableFilters = ({
|
|||||||
onBranchChange(newValue || '');
|
onBranchChange(newValue || '');
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => {
|
renderInput={(params) => {
|
||||||
const { InputProps, ...rest } = params;
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
{...rest}
|
{...params}
|
||||||
placeholder='Региональные филиалы'
|
placeholder='Региональные филиалы'
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
size='small'
|
size='small'
|
||||||
InputProps={{
|
slotProps={{
|
||||||
...InputProps,
|
...params.slotProps,
|
||||||
endAdornment: (
|
input: {
|
||||||
<>
|
...params.slotProps.input,
|
||||||
{isLoading && <CircularProgress color='inherit' size={20} />}
|
endAdornment: (
|
||||||
{InputProps?.endAdornment}
|
<>
|
||||||
</>
|
{isLoading && <CircularProgress color='inherit' size={20} />}
|
||||||
),
|
{params.slotProps.input.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
570
web/src/pages/Svod2Page/Svod2Page.jsx
Normal file
570
web/src/pages/Svod2Page/Svod2Page.jsx
Normal file
@ -0,0 +1,570 @@
|
|||||||
|
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 { MRT_Localization_RU } from 'material-react-table/locales/ru';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||||||
|
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
|
||||||
|
import { useSvod } from '../SvodPage/SvodContext';
|
||||||
|
import { SvodFilters } from '../SvodPage/SvodFilters';
|
||||||
|
|
||||||
|
const SHEETS = [
|
||||||
|
{ value: 'GO', label: 'ССП' },
|
||||||
|
{ value: 'RF', label: 'РФ' },
|
||||||
|
];
|
||||||
|
const QUARTERS = [1, 2, 3, 4];
|
||||||
|
const tableSize = (rem) => rem * 16;
|
||||||
|
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
|
||||||
|
const ROW_HEIGHT = 37;
|
||||||
|
const ROW_VIRTUALIZER_OPTIONS = {
|
||||||
|
overscan: 8,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
measureElement: (element) => element?.offsetHeight || ROW_HEIGHT,
|
||||||
|
};
|
||||||
|
const COLUMN_BAND_COLORS = {
|
||||||
|
head: ['#f8f9fa', '#f1f7f3'],
|
||||||
|
body: ['#ffffff', '#f8fbf9'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueAt = (row, path) => path.reduce((value, key) => value?.[key], row.data);
|
||||||
|
|
||||||
|
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 textColumn = (id, header, path, size = 10, options = {}) => ({
|
||||||
|
id,
|
||||||
|
header,
|
||||||
|
accessorFn: (row) => valueAt(row, path),
|
||||||
|
Cell: ({ cell }) => cell.getValue() || '—',
|
||||||
|
size: tableSize(size),
|
||||||
|
enableColumnFilter: false,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
const numericColumn = (id, header, path, size = 7.5) => ({
|
||||||
|
id,
|
||||||
|
header,
|
||||||
|
accessorFn: (row) => valueAt(row, path),
|
||||||
|
Cell: ({ cell }) => formatValue(cell.getValue()),
|
||||||
|
size: tableSize(size),
|
||||||
|
enableColumnFilter: false,
|
||||||
|
muiTableBodyCellProps: { align: 'right' },
|
||||||
|
muiTableHeadCellProps: { align: 'right' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const MONTHS_BY_QUARTER = {
|
||||||
|
1: ['Январь', 'Февраль', 'Март'],
|
||||||
|
2: ['Апрель', 'Май', 'Июнь'],
|
||||||
|
3: ['Июль', 'Август', 'Сентябрь'],
|
||||||
|
4: ['Октябрь', 'Ноябрь', 'Декабрь'],
|
||||||
|
};
|
||||||
|
const CARRYOVER_HEADERS = {
|
||||||
|
1: 'Корректировка из 1 квартала во 2–4 кварталы',
|
||||||
|
2: 'Корректировка из 2 квартала в 3–4 кварталы',
|
||||||
|
3: 'Корректировка из 3 квартала в 4 квартал',
|
||||||
|
};
|
||||||
|
const ESTIMATE_TYPE_STYLES = {
|
||||||
|
АХР: { backgroundColor: '#ecf9f0', borderColor: '#a7d9b8', color: '#08783e' },
|
||||||
|
Операц: { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' },
|
||||||
|
КВП: { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCorrectionColumns = (quarter) => ({
|
||||||
|
id: `q${quarter}.correction`,
|
||||||
|
header: 'Корректировка внутри квартала',
|
||||||
|
columns: [
|
||||||
|
numericColumn(`q${quarter}.adj_current`, 'Текущие корректировки (= 0)', [`q${quarter}`, 'adj_current'], 12),
|
||||||
|
numericColumn(`q${quarter}.adj_ssp`, 'Корректировки с ССП / сметой развития', [`q${quarter}`, 'adj_ssp'], 15),
|
||||||
|
numericColumn(`q${quarter}.adj_rf`, 'Корректировки с РФ', [`q${quarter}`, 'adj_rf'], 11),
|
||||||
|
numericColumn(`q${quarter}.adj_reserve`, 'Корректировки из резерва', [`q${quarter}`, 'adj_reserve'], 12),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPaymentColumns = (quarter) => ({
|
||||||
|
id: `q${quarter}.payment`,
|
||||||
|
header: 'Платеж и предоставление акта',
|
||||||
|
columns: [
|
||||||
|
textColumn(`q${quarter}.payment_date`, 'Дата платежа', [`q${quarter}`, 'payment_date'], 9),
|
||||||
|
numericColumn(`q${quarter}.payment_amount`, 'Сумма, тыс. руб. (без НДС)', [`q${quarter}`, 'payment_amount'], 12),
|
||||||
|
numericColumn(`q${quarter}.payment_amount_ho`, 'в т.ч. сумма (без НДС) ГО', [`q${quarter}`, 'payment_amount_ho'], 12),
|
||||||
|
numericColumn(`q${quarter}.payment_amount_rf`, 'в т.ч. сумма (без НДС) РФ', [`q${quarter}`, 'payment_amount_rf'], 12),
|
||||||
|
textColumn(`q${quarter}.payment_act`, 'Предоставление акта (др. подтверждения)', [`q${quarter}`, 'payment_act'], 16),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createActualColumns = (quarter) => ({
|
||||||
|
id: `q${quarter}.actual`,
|
||||||
|
header: 'Фактические расходы за квартал',
|
||||||
|
columns: [
|
||||||
|
numericColumn(`q${quarter}.booking`, `Бронь ${quarter} кв.`, [`q${quarter}`, 'booking_amount'], 9),
|
||||||
|
...MONTHS_BY_QUARTER[quarter].map((month, index) =>
|
||||||
|
numericColumn(`q${quarter}.actual_m${index + 1}`, `Факт ${month}`, [`q${quarter}`, `actual_m${index + 1}`], 9),
|
||||||
|
),
|
||||||
|
...(quarter === 4 ? [numericColumn('q4.actual_spod', 'Факт СПОД', ['q4', 'actual_spod'], 9)] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCarryoverColumns = (quarter) => {
|
||||||
|
const nextQuarter = quarter + 1;
|
||||||
|
const transferColumns = QUARTERS.filter((targetQuarter) => targetQuarter > quarter).map((targetQuarter) =>
|
||||||
|
numericColumn(
|
||||||
|
`q${quarter}.transfer_to_q${targetQuarter}`,
|
||||||
|
`Перенос в ${targetQuarter} кв.`,
|
||||||
|
[`q${quarter}`, `transfer_to_q${targetQuarter}`],
|
||||||
|
9,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: `q${quarter}.carryover`,
|
||||||
|
header: CARRYOVER_HEADERS[quarter],
|
||||||
|
columns: [
|
||||||
|
...transferColumns,
|
||||||
|
numericColumn(`q${quarter}.transfer_to_economy`, 'Перенос в фонд экономии', [`q${quarter}`, 'transfer_to_economy'], 12),
|
||||||
|
numericColumn(
|
||||||
|
`q${nextQuarter}.plan_revision_eco_change`,
|
||||||
|
'Изменение целевого назначения перенесенной экономии (= 0)',
|
||||||
|
[`q${nextQuarter}`, 'plan_revision_eco_change'],
|
||||||
|
16,
|
||||||
|
),
|
||||||
|
numericColumn(
|
||||||
|
`q${nextQuarter}.plan_revision_item_adj`,
|
||||||
|
'Корректировка статей базового плана (= 0)',
|
||||||
|
[`q${nextQuarter}`, 'plan_revision_item_adj'],
|
||||||
|
15,
|
||||||
|
),
|
||||||
|
numericColumn(
|
||||||
|
`q${nextQuarter}.plan_revision_increase`,
|
||||||
|
'Увеличение базового плана (> 0)',
|
||||||
|
[`q${nextQuarter}`, 'plan_revision_increase'],
|
||||||
|
13,
|
||||||
|
),
|
||||||
|
numericColumn(
|
||||||
|
`q${nextQuarter}.plan_revision_sequester`,
|
||||||
|
'Секвестр базового плана (< 0)',
|
||||||
|
[`q${nextQuarter}`, 'plan_revision_sequester'],
|
||||||
|
13,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createColumns = (sheet) => [
|
||||||
|
textColumn('smeta_type', 'Вид сметы', ['header', 'smeta_type'], 8, {
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterVariant: 'select',
|
||||||
|
Cell: ({ cell }) => {
|
||||||
|
const value = cell.getValue();
|
||||||
|
if (!value) return '—';
|
||||||
|
const colors = ESTIMATE_TYPE_STYLES[value] || {
|
||||||
|
backgroundColor: '#f3f4f6',
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
color: '#4b5563',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component='span'
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
px: '0.625rem',
|
||||||
|
py: '0.25rem',
|
||||||
|
border: '0.0625rem solid',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...colors,
|
||||||
|
}}>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
textColumn('smeta_direction', 'Направление', ['header', 'smeta_direction'], 10, { enableColumnFilter: true, filterVariant: 'select' }),
|
||||||
|
textColumn('org_name', 'ССП', ['header', 'org_name'], 14, {
|
||||||
|
enableColumnFilter: true,
|
||||||
|
filterVariant: 'select',
|
||||||
|
Cell: ({ cell }) => {
|
||||||
|
const value = cell.getValue();
|
||||||
|
if (!value) return '—';
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component='span'
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
maxWidth: '100%',
|
||||||
|
px: '0.625rem',
|
||||||
|
py: '0.25rem',
|
||||||
|
border: '0.0625rem solid #a7d9b8',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
backgroundColor: '#ecf9f0',
|
||||||
|
color: '#08783e',
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{value}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
textColumn('name', 'Наименование статьи', ['header', 'name'], 20),
|
||||||
|
|
||||||
|
{
|
||||||
|
id: 'estimate',
|
||||||
|
header: sheet === 'GO' ? 'Смета расходов ГО в разрезе договоров' : 'Смета расходов РФ в разрезе договоров',
|
||||||
|
columns: [
|
||||||
|
textColumn('item_id', 'ID статьи', ['header', 'item_id'], 7),
|
||||||
|
textColumn('num_group', 'ID группы номенклатуры', ['header', 'num_group'], 10),
|
||||||
|
textColumn('justification', 'Конкретный вид расхода', ['header', 'justification'], 18),
|
||||||
|
...QUARTERS.map((quarter) => numericColumn(`plan.q${quarter}`, `${quarter} кв.`, ['plan', `plan_q${quarter}`])),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'allocation',
|
||||||
|
header: 'Аллокация расходов',
|
||||||
|
columns: [
|
||||||
|
textColumn('allocation.internal_order', 'Внутренний заказ', ['allocation', 'internal_order'], 12),
|
||||||
|
textColumn('allocation.property_object', 'Объект недвижимости', ['allocation', 'property_object'], 14),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sequestration',
|
||||||
|
header: 'Секвестирование ДФиП',
|
||||||
|
columns: QUARTERS.map((quarter) =>
|
||||||
|
numericColumn(`sequestration.q${quarter}`, `${quarter} кв.`, ['sequestration', 'DFIP', `adj_q${quarter}`]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reserve',
|
||||||
|
header: 'Отнесение в резерв',
|
||||||
|
columns: QUARTERS.map((quarter) => numericColumn(`reserve.q${quarter}`, `${quarter} кв.`, ['reserve', `amount_q${quarter}`])),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approved',
|
||||||
|
header: 'Итоговая смета расходов',
|
||||||
|
columns: QUARTERS.map((quarter) => numericColumn(`approved.q${quarter}`, `${quarter} кв.`, ['approved', `approved_q${quarter}`])),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contract',
|
||||||
|
header: 'Договор',
|
||||||
|
columns: [
|
||||||
|
textColumn('contract.counterparty', 'Контрагент', ['contract_detail', 'counterparty'], 14),
|
||||||
|
textColumn('contract.reference', 'Договор (реквизиты)', ['contract_detail', 'reference'], 12),
|
||||||
|
textColumn('contract.subject', 'Предмет', ['contract_detail', 'subject'], 18),
|
||||||
|
textColumn('contract.currency', 'Валюта договора', ['contract_detail', 'currency'], 9),
|
||||||
|
numericColumn('contract.ceiling', 'Предельная ст-ть, тыс. руб. (без НДС)', ['contract_detail', 'ceiling_amount'], 14),
|
||||||
|
...QUARTERS.map((quarter) =>
|
||||||
|
numericColumn(
|
||||||
|
`contract.expenses_q${quarter}`,
|
||||||
|
`Расходы ${quarter} кв., тыс. руб. (без НДС)`,
|
||||||
|
['contract_detail', `expenses_q${quarter}`],
|
||||||
|
13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textColumn('contract.vat_rate', 'Ставка НДС (%)', ['contract_detail', 'vat_rate'], 9),
|
||||||
|
textColumn('contract.deadline', 'Срок', ['contract_detail', 'deadline'], 9),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
createCorrectionColumns(1),
|
||||||
|
createPaymentColumns(1),
|
||||||
|
createActualColumns(1),
|
||||||
|
createCarryoverColumns(1),
|
||||||
|
createCorrectionColumns(2),
|
||||||
|
createPaymentColumns(2),
|
||||||
|
createActualColumns(2),
|
||||||
|
createCarryoverColumns(2),
|
||||||
|
createCorrectionColumns(3),
|
||||||
|
createPaymentColumns(3),
|
||||||
|
createActualColumns(3),
|
||||||
|
createCarryoverColumns(3),
|
||||||
|
createCorrectionColumns(4),
|
||||||
|
createPaymentColumns(4),
|
||||||
|
createActualColumns(4),
|
||||||
|
];
|
||||||
|
|
||||||
|
const createColumnBandMap = (columns) => {
|
||||||
|
const bands = new Map();
|
||||||
|
const addColumn = (column, band) => {
|
||||||
|
bands.set(column.id, band);
|
||||||
|
column.columns?.forEach((childColumn) => addColumn(childColumn, band));
|
||||||
|
};
|
||||||
|
columns.forEach((column, index) => addColumn(column, index % 2));
|
||||||
|
return bands;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createColumnGroupEndSet = (columns) => {
|
||||||
|
const groupEnds = new Set();
|
||||||
|
const getLastLeafId = (column) => {
|
||||||
|
if (!column.columns?.length) return column.id;
|
||||||
|
return getLastLeafId(column.columns.at(-1));
|
||||||
|
};
|
||||||
|
columns.forEach((column) => {
|
||||||
|
if (!column.columns?.length) return;
|
||||||
|
groupEnds.add(column.id);
|
||||||
|
groupEnds.add(getLastLeafId(column));
|
||||||
|
});
|
||||||
|
return groupEnds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableGroupColumnFilters = (columns) =>
|
||||||
|
columns.map((column) => {
|
||||||
|
if (!column.columns?.length) return column;
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
enableColumnFilter: false,
|
||||||
|
columns: disableGroupColumnFilters(column.columns),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchesSearch = (row, query) => {
|
||||||
|
const searchableData = {
|
||||||
|
...row.data?.header,
|
||||||
|
...row.data?.contract_summary,
|
||||||
|
...row.data?.contract_detail,
|
||||||
|
};
|
||||||
|
return Object.values(searchableData).some((value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Svod2Page() {
|
||||||
|
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
||||||
|
const [sheet, setSheet] = useState('GO');
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loadedQueryKey, setLoadedQueryKey] = useState('');
|
||||||
|
const latestSummaryRequestRef = useRef(0);
|
||||||
|
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
||||||
|
const selectedOrganizationIds = useMemo(
|
||||||
|
() => (selectedOrganizations.some(({ id }) => id === '__all__') ? [] : selectedOrganizations.map(({ id }) => id)),
|
||||||
|
[selectedOrganizations],
|
||||||
|
);
|
||||||
|
const currentQueryKey = useMemo(
|
||||||
|
() => JSON.stringify([sheet, year, selectedOrganizations.map(({ id }) => String(id)).sort()]),
|
||||||
|
[sheet, year, selectedOrganizations],
|
||||||
|
);
|
||||||
|
const hasCurrentData = loadedQueryKey === currentQueryKey;
|
||||||
|
const updateCurrentSheetFilters = useCallback((changes) => updateSheetFilters(sheet, changes), [sheet, updateSheetFilters]);
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
const requestId = latestSummaryRequestRef.current + 1;
|
||||||
|
latestSummaryRequestRef.current = requestId;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await getSummary(sheet, year, selectedOrganizationIds);
|
||||||
|
if (latestSummaryRequestRef.current !== requestId) return;
|
||||||
|
setRows(response.result || []);
|
||||||
|
setLoadedQueryKey(currentQueryKey);
|
||||||
|
} catch (loadError) {
|
||||||
|
if (latestSummaryRequestRef.current !== requestId) return;
|
||||||
|
setRows([]);
|
||||||
|
setError(loadError.response?.data?.detail || 'Не удалось загрузить свод. Попробуйте ещё раз.');
|
||||||
|
} finally {
|
||||||
|
if (latestSummaryRequestRef.current === requestId) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentQueryKey, getSummary, selectedOrganizationIds, sheet, year]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
latestSummaryRequestRef.current += 1;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSummary = useCallback(() => {
|
||||||
|
invalidateSummary(sheet, year, selectedOrganizationIds);
|
||||||
|
loadSummary();
|
||||||
|
}, [invalidateSummary, loadSummary, selectedOrganizationIds, sheet, year]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
if (!hasCurrentData) return [];
|
||||||
|
const normalizedSearch = search.trim().toLowerCase();
|
||||||
|
return normalizedSearch ? rows.filter((row) => matchesSearch(row, normalizedSearch)) : rows;
|
||||||
|
}, [hasCurrentData, rows, search]);
|
||||||
|
const columns = useMemo(() => disableGroupColumnFilters(createColumns(sheet)), [sheet]);
|
||||||
|
const columnBands = useMemo(() => createColumnBandMap(columns), [columns]);
|
||||||
|
const columnGroupEnds = useMemo(() => createColumnGroupEndSet(columns), [columns]);
|
||||||
|
const table = useMaterialReactTable({
|
||||||
|
columns,
|
||||||
|
data: filteredRows,
|
||||||
|
getRowId: (row) => `${row.data?.header?.source_form}-${row.data?.header?.source_id}-${row.data?.line_id}`,
|
||||||
|
enableColumnActions: false,
|
||||||
|
enableColumnFilters: true,
|
||||||
|
enableFacetedValues: true,
|
||||||
|
enableDensityToggle: false,
|
||||||
|
enableFullScreenToggle: false,
|
||||||
|
enableHiding: false,
|
||||||
|
enablePagination: false,
|
||||||
|
enableRowVirtualization: true,
|
||||||
|
enableTopToolbar: false,
|
||||||
|
enableBottomToolbar: false,
|
||||||
|
enableSorting: false,
|
||||||
|
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
|
||||||
|
state: { isLoading: loading },
|
||||||
|
initialState: {
|
||||||
|
columnPinning: { left: ['smeta_type', 'smeta_direction', 'org_name', 'name'] },
|
||||||
|
showColumnFilters: 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
muiTableHeadProps: {
|
||||||
|
sx: {
|
||||||
|
'& tr:not(:last-of-type) > th > .MuiCollapse-root': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
muiTableHeadCellProps: ({ column }) => ({
|
||||||
|
sx: {
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
boxShadow: 'none',
|
||||||
|
backgroundColor: COLUMN_BAND_COLORS.head[columnBands.get(column.id) ?? 0],
|
||||||
|
borderRight: columnGroupEnds.has(column.id) ? '0.1875rem solid #9eafa3' : '0.0625rem solid #e5e7eb',
|
||||||
|
color: '#4a5565',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
px: '0.75rem',
|
||||||
|
py: '0.5rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: column.getIsPinned() ? 5 : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
muiTableHeadRowProps: { sx: { boxShadow: 'none' } },
|
||||||
|
muiTableBodyCellProps: ({ column }) => ({
|
||||||
|
sx: {
|
||||||
|
backgroundColor: COLUMN_BAND_COLORS.body[columnBands.get(column.id) ?? 0],
|
||||||
|
borderRight: columnGroupEnds.has(column.id) ? '0.1875rem solid #9eafa3' : '0.0625rem solid #edf0f2',
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
px: '0.75rem',
|
||||||
|
py: '0.5rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: 1,
|
||||||
|
zIndex: column.getIsPinned() ? 2 : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
localization: {
|
||||||
|
...MRT_Localization_RU,
|
||||||
|
noRecordsToDisplay: 'Нет данных для выбранных параметров',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let content = <MaterialReactTable table={table} />;
|
||||||
|
if (!hasCurrentData && !loading && !error) {
|
||||||
|
content = (
|
||||||
|
<Alert severity='info'>
|
||||||
|
{selectedOrganizations.length === 0
|
||||||
|
? 'Выберите одно или несколько ССП/РФ либо пункт «Все ССП/РФ», затем нажмите «Загрузить».'
|
||||||
|
: 'Нажмите «Загрузить», чтобы получить данные по выбранным параметрам.'}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
content = (
|
||||||
|
<Alert
|
||||||
|
severity='error'
|
||||||
|
action={
|
||||||
|
<Button color='inherit' size='small' onClick={refreshSummary}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
sx={{ px: '0.5rem', 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' }} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
<SvodFilters
|
||||||
|
year={year}
|
||||||
|
selectedOrganizations={selectedOrganizations}
|
||||||
|
search={search}
|
||||||
|
onChange={updateCurrentSheetFilters}
|
||||||
|
searchPlaceholder='ССП/РФ, код, статья или договор'
|
||||||
|
emptyOrganizationsPlaceholder='Выберите ССП/РФ'
|
||||||
|
allowSelectAll>
|
||||||
|
{hasCurrentData && !loading && filteredRows.length !== rows.length && (
|
||||||
|
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||||
|
{`Найдено ${filteredRows.length.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{hasCurrentData ? (
|
||||||
|
<Button
|
||||||
|
variant='outlined'
|
||||||
|
color='default'
|
||||||
|
startIcon={<RefreshRoundedIcon />}
|
||||||
|
disabled={loading || selectedOrganizations.length === 0}
|
||||||
|
onClick={refreshSummary}
|
||||||
|
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<PrimaryButton
|
||||||
|
variant='outlined'
|
||||||
|
color='default'
|
||||||
|
disabled={loading || selectedOrganizations.length === 0}
|
||||||
|
onClick={refreshSummary}
|
||||||
|
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||||||
|
Загрузить
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
</SvodFilters>
|
||||||
|
</Paper>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { SummaryApi } from '../../api/summary';
|
|||||||
|
|
||||||
const SvodContext = createContext(null);
|
const SvodContext = createContext(null);
|
||||||
|
|
||||||
const SHEET_VALUES = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'];
|
const DEFAULT_SHEET_VALUES = ['MAIN', 'FORM_1', 'FORM_2', 'FORM_3', 'FORM_4'];
|
||||||
|
|
||||||
const createDefaultFilters = () => ({
|
const createDefaultFilters = () => ({
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
@ -11,7 +11,7 @@ const createDefaultFilters = () => ({
|
|||||||
search: '',
|
search: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const createInitialFilters = () => Object.fromEntries(SHEET_VALUES.map((sheet) => [sheet, createDefaultFilters()]));
|
const createInitialFilters = (sheetValues) => Object.fromEntries(sheetValues.map((sheet) => [sheet, createDefaultFilters()]));
|
||||||
|
|
||||||
const createCacheKey = (year, organizationIds) => JSON.stringify([year, organizationIds.map(String).sort()]);
|
const createCacheKey = (year, organizationIds) => JSON.stringify([year, organizationIds.map(String).sort()]);
|
||||||
|
|
||||||
@ -20,8 +20,8 @@ const getSheetCache = (cache, sheet) => {
|
|||||||
return cache.get(sheet);
|
return cache.get(sheet);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SvodProvider = ({ children }) => {
|
export const SvodProvider = ({ children, summaryNumber = 1, sheetValues = DEFAULT_SHEET_VALUES }) => {
|
||||||
const [filtersBySheet, setFiltersBySheet] = useState(createInitialFilters);
|
const [filtersBySheet, setFiltersBySheet] = useState(() => createInitialFilters(sheetValues));
|
||||||
const summaryCacheRef = useRef(new Map());
|
const summaryCacheRef = useRef(new Map());
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@ -43,29 +43,32 @@ export const SvodProvider = ({ children }) => {
|
|||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getSummary = useCallback((sheet, year, organizationIds = []) => {
|
const getSummary = useCallback(
|
||||||
const sheetCache = getSheetCache(summaryCacheRef.current, sheet);
|
(sheet, year, organizationIds = []) => {
|
||||||
const cacheKey = createCacheKey(year, organizationIds);
|
const sheetCache = getSheetCache(summaryCacheRef.current, sheet);
|
||||||
const cachedRequest = sheetCache.get(cacheKey);
|
const cacheKey = createCacheKey(year, organizationIds);
|
||||||
if (cachedRequest) return cachedRequest;
|
const cachedRequest = sheetCache.get(cacheKey);
|
||||||
|
if (cachedRequest) return cachedRequest;
|
||||||
|
|
||||||
const request = SummaryApi.get(sheet, year, organizationIds)
|
const request = SummaryApi.get(summaryNumber, sheet, year, organizationIds)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (sheetCache.get(cacheKey) === request) {
|
if (sheetCache.get(cacheKey) === request) {
|
||||||
sheetCache.set(cacheKey, Promise.resolve(response));
|
sheetCache.set(cacheKey, Promise.resolve(response));
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (sheetCache.get(cacheKey) === request) {
|
if (sheetCache.get(cacheKey) === request) {
|
||||||
sheetCache.delete(cacheKey);
|
sheetCache.delete(cacheKey);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
sheetCache.set(cacheKey, request);
|
sheetCache.set(cacheKey, request);
|
||||||
return request;
|
return request;
|
||||||
}, []);
|
},
|
||||||
|
[summaryNumber],
|
||||||
|
);
|
||||||
|
|
||||||
const invalidateSummary = useCallback((sheet, year, organizationIds = []) => {
|
const invalidateSummary = useCallback((sheet, year, organizationIds = []) => {
|
||||||
summaryCacheRef.current.get(sheet)?.delete(createCacheKey(year, organizationIds));
|
summaryCacheRef.current.get(sheet)?.delete(createCacheKey(year, organizationIds));
|
||||||
|
|||||||
136
web/src/pages/SvodPage/SvodFilters.jsx
Normal file
136
web/src/pages/SvodPage/SvodFilters.jsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||||
|
import { Autocomplete, Box, Chip, CircularProgress, InputAdornment, MenuItem, TextField } from '@mui/material';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import { SspApi } from '../../api/ssp';
|
||||||
|
import { useAuth } from '../../app/context/AuthProvider';
|
||||||
|
import { ROLES_NAME_ID } from '../../constants/constants';
|
||||||
|
|
||||||
|
const SELECT_ALL_OPTION = { id: '__all__', title: 'Все ССП/РФ' };
|
||||||
|
|
||||||
|
export const getAvailableYears = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
return Array.from({ length: 7 }, (_, index) => currentYear + 1 - index);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSummaryOrganizations = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [organizations, setOrganizations] = useState([]);
|
||||||
|
const [organizationsLoading, setOrganizationsLoading] = useState(false);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
return { organizations, organizationsLoading };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SvodFilters({
|
||||||
|
year,
|
||||||
|
selectedOrganizations,
|
||||||
|
search,
|
||||||
|
onChange,
|
||||||
|
searchPlaceholder = 'Код или статья расходов',
|
||||||
|
emptyOrganizationsPlaceholder = 'Все доступные',
|
||||||
|
allowSelectAll = false,
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
const { organizations, organizationsLoading } = useSummaryOrganizations();
|
||||||
|
const organizationOptions = allowSelectAll ? [SELECT_ALL_OPTION, ...organizations] : organizations;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: '1rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size='small'
|
||||||
|
label='Год'
|
||||||
|
value={year}
|
||||||
|
onChange={(event) => onChange({ year: Number(event.target.value) })}
|
||||||
|
sx={{ width: '7.5rem' }}>
|
||||||
|
{getAvailableYears().map((availableYear) => (
|
||||||
|
<MenuItem key={availableYear} value={availableYear}>
|
||||||
|
{availableYear}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Autocomplete
|
||||||
|
multiple
|
||||||
|
disableCloseOnSelect
|
||||||
|
size='small'
|
||||||
|
options={organizationOptions}
|
||||||
|
loading={organizationsLoading}
|
||||||
|
value={selectedOrganizations}
|
||||||
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
||||||
|
getOptionLabel={(option) => option.title || ''}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
const selectedAll = value.some(({ id }) => id === SELECT_ALL_OPTION.id);
|
||||||
|
onChange({ selectedOrganizations: selectedAll ? [SELECT_ALL_OPTION] : value });
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: '18.75rem', flex: '1 1 22.5rem', maxWidth: '32.5rem' }}
|
||||||
|
renderValue={(value, getItemProps) =>
|
||||||
|
value
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((option, index) => {
|
||||||
|
const { key, ...itemProps } = getItemProps({ index });
|
||||||
|
return <Chip {...itemProps} key={option.id || key} label={option.title} size='small' />;
|
||||||
|
})
|
||||||
|
.concat(value.length > 2 ? [<Chip key='more' label={`+${value.length - 2}`} size='small' />] : [])
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label='ССП/РФ'
|
||||||
|
placeholder={selectedOrganizations.length ? '' : emptyOrganizationsPlaceholder}
|
||||||
|
slotProps={{
|
||||||
|
...params.slotProps,
|
||||||
|
input: {
|
||||||
|
...params.slotProps.input,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{organizationsLoading && <CircularProgress size='1.125rem' />}
|
||||||
|
{params.slotProps.input.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size='small'
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => onChange({ search: event.target.value })}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position='start'>
|
||||||
|
<SearchRoundedIcon sx={{ color: '#99a1af' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,23 +1,16 @@
|
|||||||
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
|
||||||
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
import { Alert, Box, Button, Paper, Tab, Tabs, Typography } from '@mui/material';
|
||||||
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 { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||||||
import { blueColumn } from '../../components/RealtimeTable/constants/columnColors';
|
import { blueColumn } from '../../components/RealtimeTable/constants/columnColors';
|
||||||
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
|
import { sectionCodeColor } from '../../components/RealtimeTable/constants/columnConfig';
|
||||||
import { ROLES_NAME_ID } from '../../constants/constants';
|
|
||||||
import { useSvod } from './SvodContext';
|
import { useSvod } from './SvodContext';
|
||||||
|
import { SvodFilters } from './SvodFilters';
|
||||||
|
|
||||||
const SHEETS = [
|
const SHEETS = [
|
||||||
{ value: 'MAIN', label: 'Общий свод' },
|
{ value: 'MAIN', label: 'Общий свод' },
|
||||||
{ value: 'FORM_1', label: 'Смета ГО' },
|
{ value: 'FORM_1', label: 'Смета ССП' },
|
||||||
{ value: 'FORM_2', label: 'Смета РФ' },
|
{ value: 'FORM_2', label: 'Смета РФ' },
|
||||||
{ value: 'FORM_3', label: 'Проекты РФ Развитие' },
|
{ value: 'FORM_3', label: 'Проекты РФ Развитие' },
|
||||||
{ value: 'FORM_4', label: 'Проектная деятельность' },
|
{ value: 'FORM_4', label: 'Проектная деятельность' },
|
||||||
@ -33,8 +26,10 @@ const DIRECTIONS = [
|
|||||||
{ key: 'development', label: 'Развитие' },
|
{ key: 'development', label: 'Развитие' },
|
||||||
];
|
];
|
||||||
const QUARTERS = [
|
const QUARTERS = [
|
||||||
{ key: 'q1', label: 'I кв.' }, { key: 'q2', label: 'II кв.' },
|
{ key: 'q1', label: 'I кв.' },
|
||||||
{ key: 'q3', label: 'III кв.' }, { key: 'q4', label: 'IV кв.' },
|
{ key: 'q2', label: 'II кв.' },
|
||||||
|
{ key: 'q3', label: 'III кв.' },
|
||||||
|
{ key: 'q4', label: 'IV кв.' },
|
||||||
{ key: 'year', label: 'Год' },
|
{ key: 'year', label: 'Год' },
|
||||||
];
|
];
|
||||||
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
|
const numberFormatter = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 });
|
||||||
@ -49,7 +44,7 @@ const formatValue = (value) => {
|
|||||||
|
|
||||||
const getShapeForSheet = (sheet) => {
|
const getShapeForSheet = (sheet) => {
|
||||||
const directionShape = { q1: 0, q2: 0, q3: 0, q4: 0, year: 0 };
|
const directionShape = { q1: 0, q2: 0, q3: 0, q4: 0, year: 0 };
|
||||||
const correctedShape = sheet === 'FORM_4' ? directionShape : { q2: 0, q3: 0, q4: 0 };
|
const correctedShape = { q2: 0, q3: 0, q4: 0 };
|
||||||
return {
|
return {
|
||||||
plan: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
|
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 } : {}) },
|
approved: { support: directionShape, development: directionShape, ...(['FORM_1', 'MAIN'].includes(sheet) ? { total_year: 0 } : {}) },
|
||||||
@ -69,39 +64,50 @@ const valueColumn = (metric, direction, period) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createColumns = (sheet) => {
|
const createColumns = (sheet) => {
|
||||||
const visibleDirections = sheet === 'FORM_2'
|
let visibleDirections = DIRECTIONS;
|
||||||
? DIRECTIONS.filter(({ key }) => key === 'support')
|
if (sheet === 'FORM_2') visibleDirections = DIRECTIONS.filter(({ key }) => key === 'support');
|
||||||
: ['FORM_3', 'FORM_4'].includes(sheet)
|
if (['FORM_3', 'FORM_4'].includes(sheet)) visibleDirections = DIRECTIONS.filter(({ key }) => key === 'development');
|
||||||
? DIRECTIONS.filter(({ key }) => key === 'development')
|
|
||||||
: DIRECTIONS;
|
|
||||||
const shape = getShapeForSheet(sheet);
|
const shape = getShapeForSheet(sheet);
|
||||||
const metricColumns = METRICS.map((metric) => ({
|
const metricColumns = METRICS.map((metric) => ({
|
||||||
id: metric.key,
|
id: metric.key,
|
||||||
header: metric.label,
|
header: metric.label,
|
||||||
columns: visibleDirections.map((direction) => ({
|
columns: visibleDirections
|
||||||
id: `${metric.key}.${direction.key}`,
|
.map((direction) => ({
|
||||||
header: direction.label,
|
id: `${metric.key}.${direction.key}`,
|
||||||
columns: QUARTERS
|
header: direction.label,
|
||||||
.filter(({ key }) => valueAt({ data: shape }, [metric.key, direction.key, key]) !== undefined)
|
columns: QUARTERS.filter(({ key }) => valueAt({ data: shape }, [metric.key, direction.key, key]) !== undefined).map((period) =>
|
||||||
.map((period) => valueColumn(metric, direction, period)),
|
valueColumn(metric, direction, period),
|
||||||
})).concat(shape[metric.key].total_year === undefined ? [] : [{
|
),
|
||||||
id: `${metric.key}.total`,
|
}))
|
||||||
header: 'Итого',
|
.concat(
|
||||||
columns: [{
|
shape[metric.key].total_year === undefined
|
||||||
id: `${metric.key}.total_year`,
|
? []
|
||||||
header: 'Год',
|
: [
|
||||||
accessorFn: (row) => valueAt(row, [metric.key, 'total_year']),
|
{
|
||||||
Cell: ({ cell }) => formatValue(cell.getValue()),
|
id: `${metric.key}.total`,
|
||||||
size: tableSize(8.25),
|
header: 'Итого',
|
||||||
muiTableBodyCellProps: { align: 'right' },
|
columns: [
|
||||||
muiTableHeadCellProps: { align: 'right' },
|
{
|
||||||
}],
|
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 [
|
return [
|
||||||
{ id: 'section_code', header: 'Код', accessorFn: (row) => row.data?.section_code, size: tableSize(5.75) },
|
{ 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),
|
id: 'name',
|
||||||
|
header: 'Статья расходов',
|
||||||
|
accessorFn: (row) => row.data?.name,
|
||||||
|
size: tableSize(20.625),
|
||||||
Cell: ({ cell, row }) => (
|
Cell: ({ cell, row }) => (
|
||||||
<Box sx={{ pl: `${row.original.depth * 1.125}rem`, fontWeight: row.original.depth < 2 ? 600 : 400 }}>
|
<Box sx={{ pl: `${row.original.depth * 1.125}rem`, fontWeight: row.original.depth < 2 ? 600 : 400 }}>
|
||||||
{cell.getValue() || 'Без названия'}
|
{cell.getValue() || 'Без названия'}
|
||||||
@ -150,58 +156,36 @@ const buildSummaryTree = (sourceRows) => {
|
|||||||
return roots;
|
return roots;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterSummaryTree = (tree, query) => tree.flatMap((row) => {
|
const filterSummaryTree = (tree, query) =>
|
||||||
const matches = [row.data?.section_code, row.data?.name]
|
tree.flatMap((row) => {
|
||||||
.some((value) => String(value || '').toLowerCase().includes(query));
|
const matches = [row.data?.section_code, row.data?.name].some((value) =>
|
||||||
|
String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query),
|
||||||
|
);
|
||||||
|
|
||||||
if (matches) return [row];
|
if (matches) return [row];
|
||||||
|
|
||||||
const subRows = filterSummaryTree(row.subRows, query);
|
const subRows = filterSummaryTree(row.subRows, query);
|
||||||
return subRows.length ? [{ ...row, subRows }] : [];
|
return subRows.length ? [{ ...row, subRows }] : [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const countSummaryTree = (tree) => tree.reduce((count, row) => count + 1 + countSummaryTree(row.subRows), 0);
|
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() {
|
export default function SvodPage() {
|
||||||
const { user } = useAuth();
|
|
||||||
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
const { getSheetFilters, updateSheetFilters, getSummary, invalidateSummary } = useSvod();
|
||||||
const [sheet, setSheet] = useState('MAIN');
|
const [sheet, setSheet] = useState('MAIN');
|
||||||
const [organizations, setOrganizations] = useState([]);
|
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [organizationsLoading, setOrganizationsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const latestSummaryRequestRef = useRef(0);
|
const latestSummaryRequestRef = useRef(0);
|
||||||
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
const { year, selectedOrganizations, search } = getSheetFilters(sheet);
|
||||||
const updateCurrentSheetFilters = useCallback((changes) => {
|
const updateCurrentSheetFilters = useCallback(
|
||||||
updateSheetFilters(sheet, changes);
|
(changes) => {
|
||||||
}, [sheet, updateSheetFilters]);
|
updateSheetFilters(sheet, changes);
|
||||||
|
},
|
||||||
useEffect(() => {
|
[sheet, updateSheetFilters],
|
||||||
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 loadSummary = useCallback(async () => {
|
||||||
const requestId = latestSummaryRequestRef.current + 1;
|
const requestId = latestSummaryRequestRef.current + 1;
|
||||||
@ -209,7 +193,11 @@ export default function SvodPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const response = await getSummary(sheet, year, selectedOrganizations.map(({ id }) => id));
|
const response = await getSummary(
|
||||||
|
sheet,
|
||||||
|
year,
|
||||||
|
selectedOrganizations.map(({ id }) => id),
|
||||||
|
);
|
||||||
if (latestSummaryRequestRef.current !== requestId) return;
|
if (latestSummaryRequestRef.current !== requestId) return;
|
||||||
setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)));
|
setRows([...(response.result || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)));
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
@ -229,7 +217,11 @@ export default function SvodPage() {
|
|||||||
}, [loadSummary]);
|
}, [loadSummary]);
|
||||||
|
|
||||||
const refreshSummary = useCallback(() => {
|
const refreshSummary = useCallback(() => {
|
||||||
invalidateSummary(sheet, year, selectedOrganizations.map(({ id }) => id));
|
invalidateSummary(
|
||||||
|
sheet,
|
||||||
|
year,
|
||||||
|
selectedOrganizations.map(({ id }) => id),
|
||||||
|
);
|
||||||
loadSummary();
|
loadSummary();
|
||||||
}, [invalidateSummary, loadSummary, selectedOrganizations, sheet, year]);
|
}, [invalidateSummary, loadSummary, selectedOrganizations, sheet, year]);
|
||||||
|
|
||||||
@ -297,8 +289,15 @@ export default function SvodPage() {
|
|||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
backgroundColor: '#f8f9fa', borderRight: '0.0625rem solid #e5e7eb',
|
backgroundColor: '#f8f9fa',
|
||||||
color: '#4a5565', fontSize: '0.75rem', fontWeight: 700, lineHeight: 1.2, px: '0.75rem', py: '0.5rem', whiteSpace: 'nowrap',
|
borderRight: '0.0625rem solid #e5e7eb',
|
||||||
|
color: '#4a5565',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
px: '0.75rem',
|
||||||
|
py: '0.5rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
muiTableHeadRowProps: {
|
muiTableHeadRowProps: {
|
||||||
@ -310,9 +309,13 @@ export default function SvodPage() {
|
|||||||
const backgroundColor = getSummaryRowColor(row);
|
const backgroundColor = getSummaryRowColor(row);
|
||||||
return {
|
return {
|
||||||
sx: {
|
sx: {
|
||||||
backgroundColor, borderRight: '0.0625rem solid #edf0f2', color: getTextColor(backgroundColor),
|
backgroundColor,
|
||||||
fontSize: '0.8125rem', px: column.id === 'mrt-row-expand' ? '0.25rem' : '0.75rem',
|
borderRight: '0.0625rem solid #edf0f2',
|
||||||
py: column.id === 'mrt-row-expand' ? 0 : '0.5rem', whiteSpace: 'nowrap',
|
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',
|
textAlign: ['section_code', 'name'].includes(column.id) ? 'left' : 'right',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -322,39 +325,32 @@ export default function SvodPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ px: '2rem', pt: '1rem', pb: '1.5rem', height: '100%', minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: '2rem',
|
||||||
|
pt: '1rem',
|
||||||
|
pb: '1.5rem',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
}}>
|
||||||
<HeaderSwitch />
|
<HeaderSwitch />
|
||||||
|
|
||||||
<Paper elevation={0} sx={{ border: '0.0625rem solid #e5e7eb', borderRadius: '0.75rem', overflow: 'hidden', flexShrink: 0 }}>
|
<Paper elevation={0} sx={{ border: '0.0625rem solid #e5e7eb', borderRadius: '0.75rem', overflow: 'hidden', flexShrink: 0 }}>
|
||||||
<Tabs value={sheet} onChange={(_event, value) => setSheet(value)} variant='scrollable' scrollButtons='auto'
|
<Tabs
|
||||||
|
value={sheet}
|
||||||
|
onChange={(_event, value) => setSheet(value)}
|
||||||
|
variant='scrollable'
|
||||||
|
scrollButtons='auto'
|
||||||
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
|
sx={{ px: '0.5rem', minHeight: '3rem', borderBottom: '0.0625rem solid #edf0f2' }}>
|
||||||
{SHEETS.map((item) => <Tab key={item.value} value={item.value} label={item.label}
|
{SHEETS.map((item) => (
|
||||||
sx={{ textTransform: 'none', minHeight: '3rem' }} />)}
|
<Tab key={item.value} value={item.value} label={item.label} sx={{ textTransform: 'none', minHeight: '3rem' }} />
|
||||||
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Box sx={{ p: '1rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<SvodFilters year={year} selectedOrganizations={selectedOrganizations} search={search} onChange={updateCurrentSheetFilters}>
|
||||||
<TextField select size='small' label='Год' value={year}
|
|
||||||
onChange={(event) => updateCurrentSheetFilters({ year: Number(event.target.value) })} sx={{ width: '7.5rem' }}>
|
|
||||||
{getAvailableYears().map((availableYear) => <MenuItem key={availableYear} value={availableYear}>{availableYear}</MenuItem>)}
|
|
||||||
</TextField>
|
|
||||||
<Autocomplete
|
|
||||||
multiple disableCloseOnSelect size='small' options={organizations} loading={organizationsLoading}
|
|
||||||
value={selectedOrganizations} isOptionEqualToValue={(option, value) => 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 <Chip {...tagProps} key={option.id} label={option.title} size='small' />;
|
|
||||||
}).concat(value.length > 2 ? [<Chip key='more' label={`+${value.length - 2}`} size='small' />] : [])}
|
|
||||||
renderInput={(params) => <TextField {...params} label='ССП/РФ'
|
|
||||||
placeholder={selectedOrganizations.length ? '' : 'Все доступные'}
|
|
||||||
InputProps={{
|
|
||||||
...(params.InputProps || {}),
|
|
||||||
endAdornment: <>{organizationsLoading && <CircularProgress size='1.125rem' />}{params.InputProps?.endAdornment}</>,
|
|
||||||
}} />}
|
|
||||||
/>
|
|
||||||
<TextField size='small' value={search} onChange={(event) => updateCurrentSheetFilters({ search: event.target.value })}
|
|
||||||
placeholder='Код или статья расходов' sx={{ minWidth: '16.25rem', flex: '1 1 17.5rem', maxWidth: '23.75rem' }}
|
|
||||||
InputProps={{ startAdornment: <InputAdornment position='start'><SearchRoundedIcon sx={{ color: '#99a1af' }} /></InputAdornment> }} />
|
|
||||||
{!loading && filteredRowsCount !== rows.length && (
|
{!loading && filteredRowsCount !== rows.length && (
|
||||||
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
<Typography variant='caption' color='text.secondary' sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}>
|
||||||
{`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
|
{`Найдено ${filteredRowsCount.toLocaleString('ru-RU')} из ${rows.length.toLocaleString('ru-RU')} строк`}
|
||||||
@ -369,12 +365,21 @@ export default function SvodPage() {
|
|||||||
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
sx={{ ml: 'auto', height: '2.5rem', flexShrink: 0 }}>
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</SvodFilters>
|
||||||
</Paper>
|
</Paper>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert severity='error' action={<Button color='inherit' size='small'
|
<Alert
|
||||||
onClick={refreshSummary}>Повторить</Button>}>{error}</Alert>
|
severity='error'
|
||||||
) : <MaterialReactTable table={table} />}
|
action={
|
||||||
|
<Button color='inherit' size='small' onClick={refreshSummary}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<MaterialReactTable table={table} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
139
web/src/pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx
Normal file
139
web/src/pages/SvodsNavigatorPage/SvodsNavigatorPage.jsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { Stack } from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { HeaderSwitch } from '../../components/HeaderMenu/HeaderSwitch';
|
||||||
|
import { PrimaryButton } from '../../components/common/Buttons/Buttons';
|
||||||
|
import { TransitionSvg } from '../../components/common/icons/icons';
|
||||||
|
import { PageContainer } from '../StyledForPage';
|
||||||
|
|
||||||
|
const CardsContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Card = styled.div`
|
||||||
|
background: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 115rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardContent = styled.div`
|
||||||
|
padding: 2.5rem;
|
||||||
|
width: 51rem;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardTitle = styled.h2`
|
||||||
|
font-size: 1.13rem;
|
||||||
|
line-height: 150%;
|
||||||
|
color: #2d3748;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CardDescription = styled.p`
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
line-height: 160%;
|
||||||
|
color: #4a5565;
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FeatureList = styled.ul`
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FeatureItem = styled.li`
|
||||||
|
color: #4a5565;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 157%;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: '•';
|
||||||
|
color: #4a5565;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function SvodsNavigatorPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderSwitch />
|
||||||
|
<PageContainer>
|
||||||
|
<CardsContainer>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<CardTitle>Свод 1</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Консолидированное представление плановых и фактических показателей по формам бюджетного планирования.
|
||||||
|
</CardDescription>
|
||||||
|
<FeatureList>
|
||||||
|
<FeatureItem>Просмотр общего свода и данных по формам</FeatureItem>
|
||||||
|
<FeatureItem>Фильтрация по году и подразделениям</FeatureItem>
|
||||||
|
<FeatureItem>Поиск по статьям расходов</FeatureItem>
|
||||||
|
</FeatureList>
|
||||||
|
<PrimaryButton sx={{ height: '2.75rem' }} onClick={() => navigate('/svod')}>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
gap: '.25rem',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<TransitionSvg />
|
||||||
|
<p>Открыть свод</p>
|
||||||
|
</Stack>
|
||||||
|
</PrimaryButton>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<CardTitle>Свод 2</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Детализированное представление договорных строк по головному офису и региональной филиальной сети.
|
||||||
|
</CardDescription>
|
||||||
|
<FeatureList>
|
||||||
|
<FeatureItem>Просмотр данных по ГО и РФ</FeatureItem>
|
||||||
|
<FeatureItem>Фильтрация по году и подразделениям</FeatureItem>
|
||||||
|
<FeatureItem>Поиск по статьям и реквизитам договоров</FeatureItem>
|
||||||
|
</FeatureList>
|
||||||
|
<PrimaryButton sx={{ height: '2.75rem' }} onClick={() => navigate('/svod/2')}>
|
||||||
|
<Stack
|
||||||
|
sx={{
|
||||||
|
gap: '.25rem',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<TransitionSvg />
|
||||||
|
<p>Открыть свод</p>
|
||||||
|
</Stack>
|
||||||
|
</PrimaryButton>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</CardsContainer>
|
||||||
|
</PageContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SvodsNavigatorPage;
|
||||||
Loading…
x
Reference in New Issue
Block a user