fix-style #154
@ -22,6 +22,8 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
try {
|
||||
const userData = await UsersApi.me();
|
||||
const is_executor_rf = userData?.org_units?.[0]?.is_ssp === false || false;
|
||||
userData.is_executor_rf = is_executor_rf;
|
||||
setUser(userData);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки пользователя:', error);
|
||||
|
||||
@ -5,68 +5,68 @@ import { useAuth } from '../../app/context/AuthProvider';
|
||||
import { isTestMode } from '../../conf/config';
|
||||
|
||||
export function HeaderMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
setIsMenuOpen(false);
|
||||
}, [logout, navigate]);
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
setIsMenuOpen(false);
|
||||
}, [logout, navigate]);
|
||||
|
||||
const getInitials = useCallback(() => {
|
||||
if (user) {
|
||||
const names = user.username.split(' ');
|
||||
return names
|
||||
.map((name) => name[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
}, [user]);
|
||||
const getInitials = useCallback(() => {
|
||||
if (user) {
|
||||
const names = user.username.split(' ');
|
||||
return names
|
||||
.map((name) => name[0])
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setIsMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
const toggleMenu = useCallback(() => {
|
||||
setIsMenuOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Закрытие меню при клике вне его области
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
// Закрытие меню при клике вне его области
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||
setIsMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<HeaderMenuTag>
|
||||
<Logo />
|
||||
<TableInfo id='TableInfo' />
|
||||
<UserInfo ref={menuRef}>
|
||||
<UserAvatar>
|
||||
<UserInitials>{user ? getInitials() : ''}</UserInitials>
|
||||
</UserAvatar>
|
||||
<UserNameAndRole>
|
||||
<div className='username'>{user?.full_name}</div>
|
||||
<div className='role'>{user?.role}</div>
|
||||
</UserNameAndRole>
|
||||
return (
|
||||
<HeaderMenuTag>
|
||||
<Logo />
|
||||
<TableInfo id='TableInfo' />
|
||||
<UserInfo ref={menuRef}>
|
||||
<UserAvatar>
|
||||
<UserInitials>{user ? getInitials() : ''}</UserInitials>
|
||||
</UserAvatar>
|
||||
<UserNameAndRole>
|
||||
<div className='username'>{user?.full_name}</div>
|
||||
<div className='role'>{user?.role}</div>
|
||||
</UserNameAndRole>
|
||||
|
||||
{/* на стенде прода не показываем кнопку выхода */}
|
||||
{isTestMode && <DownButton onClick={toggleMenu} isOpen={isMenuOpen} />}
|
||||
{isTestMode && isMenuOpen && (
|
||||
<DropdownMenu>
|
||||
<LogoutButton onClick={handleLogout}>Выйти</LogoutButton>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</UserInfo>
|
||||
</HeaderMenuTag>
|
||||
);
|
||||
{/* на стенде прода не показываем кнопку выхода */}
|
||||
{isTestMode && <DownButton onClick={toggleMenu} isOpen={isMenuOpen} />}
|
||||
{isTestMode && isMenuOpen && (
|
||||
<DropdownMenu>
|
||||
<LogoutButton onClick={handleLogout}>Выйти</LogoutButton>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</UserInfo>
|
||||
</HeaderMenuTag>
|
||||
);
|
||||
}
|
||||
|
||||
const HeaderMenuTag = styled.div`
|
||||
|
||||
@ -68,21 +68,26 @@ export function HeaderSwitch() {
|
||||
</div>
|
||||
<span>Задачи</span>
|
||||
</StyledNavLink>
|
||||
<StyledNavLink to='/projects' className={location.pathname.startsWith(projectsPaths) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<ProjectSvg fill={location.pathname.startsWith(projectsPaths) ? '#258141' : '#99A1AF'} />
|
||||
</div>
|
||||
<span>Проекты</span>
|
||||
</StyledNavLink>
|
||||
{(user.is_executor_rf || user.role_id === ROLES_NAME_ID.admin || user.role_id === ROLES_NAME_ID.executor_durrs) &&
|
||||
|
||||
<StyledNavLink to='/svods-navigator' className={svodPaths.some((path) => location.pathname.startsWith(path)) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<SummarySvg fill={svodPaths.some((path) => location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} />
|
||||
</div>
|
||||
<span>Своды</span>
|
||||
</StyledNavLink>
|
||||
<StyledNavLink to='/projects' className={location.pathname.startsWith(projectsPaths) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<ProjectSvg fill={location.pathname.startsWith(projectsPaths) ? '#258141' : '#99A1AF'} />
|
||||
</div>
|
||||
<span>Проекты</span>
|
||||
</StyledNavLink>
|
||||
}
|
||||
{(user.role_id === ROLES_NAME_ID.admin || user.role_id === ROLES_NAME_ID.executor_dfip) &&
|
||||
|
||||
<StyledNavLink to='/svods-navigator' className={svodPaths.some((path) => location.pathname.startsWith(path)) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<SummarySvg fill={svodPaths.some((path) => location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} />
|
||||
</div>
|
||||
<span>Своды</span>
|
||||
</StyledNavLink>
|
||||
}
|
||||
{user.role_id === ROLES_NAME_ID.admin && (
|
||||
|
||||
<StyledNavLink to='/admin_panel/users' className={location.pathname.startsWith(adminPathsPrefix) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<AdminPanelSvg fill={location.pathname.startsWith(adminPathsPrefix) ? '#258141' : '#99A1AF'} />
|
||||
@ -90,12 +95,15 @@ export function HeaderSwitch() {
|
||||
<span>Администрирование</span>
|
||||
</StyledNavLink>
|
||||
)}
|
||||
<StyledNavLink to='/dicts-navigator' className={location.pathname.startsWith(dictPaths) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<DictSvg fill={location.pathname.startsWith(dictPaths) ? '#258141' : '#99A1AF'} />
|
||||
</div>
|
||||
<span>Справочники</span>
|
||||
</StyledNavLink>
|
||||
{(user.is_executor_rf || user.role_id === ROLES_NAME_ID.admin) &&
|
||||
<StyledNavLink to='/dicts-navigator' className={location.pathname.startsWith(dictPaths) ? 'active' : ''}>
|
||||
<div className='img'>
|
||||
<DictSvg fill={location.pathname.startsWith(dictPaths) ? '#258141' : '#99A1AF'} />
|
||||
</div>
|
||||
<span>Справочники</span>
|
||||
</StyledNavLink>
|
||||
}
|
||||
|
||||
</Buttons>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@ -70,45 +70,6 @@ export const SettingModal = ({
|
||||
setIsAddEditModalOpen(true);
|
||||
};
|
||||
const transformToSheetOptions = (data) => {
|
||||
// TO DO пока не добавили direction для этапов
|
||||
|
||||
// const sheetMap = new Map();
|
||||
|
||||
// data.forEach(item => {
|
||||
// const key = item.direction ? `${item.sheet}_${item.direction}` : item.sheet;
|
||||
|
||||
// if (!sheetMap.has(key)) {
|
||||
// sheetMap.set(key, {
|
||||
// sheet: item.sheet,
|
||||
// direction: item.direction,
|
||||
// names: []
|
||||
// });
|
||||
// }
|
||||
|
||||
// const entry = sheetMap.get(key);
|
||||
// if (!entry.names.includes(item.name)) {
|
||||
// entry.names.push(item.name);
|
||||
// }
|
||||
// });
|
||||
|
||||
// const sheetOptions = [];
|
||||
|
||||
// sheetMap.forEach((value) => {
|
||||
// let label = value.names.join(' / ');
|
||||
|
||||
// if (value.direction) {
|
||||
// label += ` (${value.direction})`;
|
||||
// }
|
||||
|
||||
// sheetOptions.push({
|
||||
// value: value.direction ? `${value.sheet}_${value.direction}` : value.sheet,
|
||||
// label: label,
|
||||
// sheet: value.sheet,
|
||||
// direction: value.direction
|
||||
// });
|
||||
// });
|
||||
|
||||
// return sheetOptions;
|
||||
|
||||
const sheetOptions = [];
|
||||
data.forEach((value) => {
|
||||
|
||||
@ -5,7 +5,7 @@ export const stageColumnsHiddenFromPicker = [
|
||||
'data.header.item_id',
|
||||
'data.header.num_group',
|
||||
'data.header.num_group_id',
|
||||
'data.header.name',
|
||||
// 'data.header.name',
|
||||
'data.header.internal_order',
|
||||
// 'data.header.vsp_id',
|
||||
'data.header.vsp_address',
|
||||
|
||||
14
web/src/components/common/LastOpenedBadge.jsx
Normal file
14
web/src/components/common/LastOpenedBadge.jsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Chip } from '../styles/StyledChip';
|
||||
|
||||
const badgeSx = {
|
||||
whiteSpace: 'nowrap',
|
||||
width: 'max-content',
|
||||
mb: '1rem',
|
||||
background: '#eef5e9',
|
||||
border: 'none',
|
||||
color: '#77886c',
|
||||
};
|
||||
|
||||
export const LastOpenedBadge = ({ children = 'Последний открытый', sx = {} }) => (
|
||||
<Chip sx={[badgeSx, ...(Array.isArray(sx) ? sx : [sx])]}>{children}</Chip>
|
||||
);
|
||||
@ -1,9 +1,8 @@
|
||||
import { Box, MenuItem, Pagination, PaginationItem, Select, Stack, Typography } from '@mui/material';
|
||||
// src/components/common/PaginatedList/PaginatedList.jsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import { exportBulkForms, exportSingleForm } from '../../utils/exportFile';
|
||||
import { ExportExitButton, ExportWithTextButton } from './Buttons/ButtonsActions';
|
||||
import { CardsSkeleton } from './Skeleton';
|
||||
import { CardsSkeleton, TaskCardsSkeleton } from './Skeleton';
|
||||
|
||||
/**
|
||||
*
|
||||
@ -42,9 +41,10 @@ export function PaginatedList({
|
||||
entityName = 'элементов',
|
||||
emptyMessage = 'Элементы не найдены',
|
||||
skeletonCount = 20,
|
||||
skeletonVariant = 'default',
|
||||
extraActions = null,
|
||||
enableExport = true,
|
||||
limitOptions = [10, 20, 50, 100, 200],
|
||||
limitOptions = [15, 25, 50, 100, 200],
|
||||
maxHeight = 'calc(100vh - 300px)',
|
||||
enableScroll = true,
|
||||
filterActions = null,
|
||||
@ -152,7 +152,7 @@ export function PaginatedList({
|
||||
{/* Список элементов */}
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
{loading ? (
|
||||
<CardsSkeleton count={skeletonCount} />
|
||||
(skeletonVariant === 'task' ? <TaskCardsSkeleton count={skeletonCount} /> : <CardsSkeleton count={skeletonCount} />)
|
||||
) : items.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', py: 4, width: '100%' }}>
|
||||
<Typography variant='body1' color='textSecondary'>
|
||||
|
||||
@ -1,3 +1,37 @@
|
||||
import { Box, Skeleton } from '@mui/material';
|
||||
import { TaskCard } from '../styles/TaskCard.style';
|
||||
|
||||
export function TaskCardsSkeleton({ count = 20 }) {
|
||||
return (
|
||||
<Box role='status' aria-label='Загрузка задач' sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', width: '100%' }}>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<TaskCard key={index} $fluidWidth data-loading='true' aria-hidden='true' style={{ cursor: 'default' }}>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Skeleton variant='rounded' width={52} height={16} />
|
||||
<Box sx={{ mt: '0.375rem' }}>
|
||||
<Skeleton variant='text' width='85%' sx={{ fontSize: '1rem', lineHeight: 1.4 }} />
|
||||
<Skeleton variant='text' width='60%' sx={{ fontSize: '1rem', lineHeight: 1.4 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 'auto', display: 'flex', alignItems: 'flex-end', gap: '0.5rem', width: '100%' }}>
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Skeleton variant='rounded' width={16} height={16} />
|
||||
<Skeleton variant='rounded' width={40} height={16} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Skeleton variant='rounded' width={16} height={16} />
|
||||
<Skeleton variant='rounded' width='65%' height='1.38rem' sx={{ borderRadius: '0.5rem' }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Skeleton variant='rounded' width={24} height={24} sx={{ flexShrink: 0 }} />
|
||||
</Box>
|
||||
</TaskCard>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinesSkeleton({ lines = 20 }) {
|
||||
return (
|
||||
<div className='animate-pulse space-y-2'>
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import { DIRECTION_TRANSLATE } from '../../../constants/constants';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import { LastOpenedBadge } from '../LastOpenedBadge';
|
||||
import { Right, Section, SectionStartPart, SectionTitle } from './TableCard.styled';
|
||||
|
||||
const TableCard = ({ table, onNavigate, action }) => {
|
||||
const TableCard = ({ table, onNavigate, action, isLastOpened = false }) => {
|
||||
const cardRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (isLastOpened) cardRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isLastOpened]);
|
||||
const handleClick = () => {
|
||||
if (onNavigate) {
|
||||
onNavigate(table.id);
|
||||
@ -9,21 +16,31 @@ const TableCard = ({ table, onNavigate, action }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={table.sheet + '_' + table?.direction}>
|
||||
<Section onClick={handleClick}>
|
||||
<SectionStartPart>
|
||||
<div className='for-img-table'>
|
||||
<div className='img-table'></div>
|
||||
</div>
|
||||
<SectionTitle>
|
||||
<h2>{table.name}</h2>
|
||||
<h2>{DIRECTION_TRANSLATE[table?.direction] || ''}</h2>
|
||||
{/* <UsersList>
|
||||
<div className="img-user"></div>
|
||||
</UsersList> */}
|
||||
</SectionTitle>
|
||||
</SectionStartPart>
|
||||
<div onClick={(event) => event.stopPropagation()}>{action || <Right />}</div>
|
||||
<div key={`${table.sheet}_${table?.direction}`}>
|
||||
<Section ref={cardRef} onClick={handleClick} >
|
||||
<Stack sx={{ flexDirection: 'column', width: '100%', justifyContent: 'baseline' }}>
|
||||
{isLastOpened && <LastOpenedBadge />}
|
||||
<Stack sx={{ flexDirection: 'row', width: '100%' }}>
|
||||
<SectionStartPart>
|
||||
<div className='for-img-table'>
|
||||
<div className='img-table' />
|
||||
</div>
|
||||
<SectionTitle>
|
||||
|
||||
<h2>{table.name}</h2>
|
||||
<h2>{DIRECTION_TRANSLATE[table?.direction] || ''}</h2>
|
||||
</SectionTitle>
|
||||
</SectionStartPart>
|
||||
<Box sx={{ display: 'flex', width: '100%', flexDirection: 'column', alignItems: 'flex-end', gap: '0.5rem', ml: '1rem' }}>
|
||||
<Stack sx={{ flexDirection: 'row', alignItems: 'center', gap: '5.5rem' }}>
|
||||
{action ? <div onClick={(event) => event.stopPropagation()}>{action}</div> : <Right />}
|
||||
</Stack>
|
||||
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
</Stack>
|
||||
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -20,8 +20,9 @@ export const SectionStartPart = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
align-items: end;
|
||||
gap: 1.5rem;
|
||||
min-width: max-content;
|
||||
|
||||
.for-img-table {
|
||||
width: 2.25rem;
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import TableCard from '../TableCard/TableCard';
|
||||
import { getSheetVisitId, readSheetVisit } from '../../../utils/sheetNavigation';
|
||||
|
||||
const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isProject = false, renderTableAction }) => {
|
||||
const navigate = useNavigate();
|
||||
const lastSheet = readSheetVisit(isProject ? 'projects' : 'tasks', formInfo?.id);
|
||||
|
||||
const handleNavigate = (path) => {
|
||||
navigate(path);
|
||||
@ -19,6 +21,7 @@ const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isPr
|
||||
|
||||
return (
|
||||
<TableCard
|
||||
isLastOpened={lastSheet === getSheetVisitId(table.sheet, table.direction, isProject ? table.year : undefined)}
|
||||
key={`${table.sheet}_${table?.direction}_${table?.year}`}
|
||||
onNavigate={() => handleNavigate(path)}
|
||||
table={table}
|
||||
|
||||
34
web/src/components/common/TaskBreadcrumbs.jsx
Normal file
34
web/src/components/common/TaskBreadcrumbs.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Breadcrumbs, Link, Typography } from '@mui/material';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { FORM_TYPE_TRANSLATE } from '../../constants/constants';
|
||||
import { getListReturnUrl } from '../../utils/listNavigation';
|
||||
|
||||
export const getTaskLabel = (task) => [
|
||||
FORM_TYPE_TRANSLATE[task?.form_type_code] || task?.form_type_code,
|
||||
task?.year,
|
||||
task?.org_unit?.title
|
||||
? `${task.org_unit.title}`
|
||||
: null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const TaskBreadcrumbs = ({ task, taskId, sheetName, isProject = false, year }) => {
|
||||
const projectYears = [...new Set(task?.years || task?.reports?.map((report) => report.year) || [])]
|
||||
.filter(Boolean).sort((a, b) => Number(a) - Number(b)).join(', ');
|
||||
const projectLabel = [task?.name || `Проект №${taskId}`, task?.org_unit_name || task?.org_unit?.title, !sheetName && projectYears]
|
||||
.filter(Boolean).join(' · ');
|
||||
const taskLabel = isProject ? projectLabel : getTaskLabel(task) || `Задача №${taskId}`;
|
||||
const currentSheetLabel = isProject && /^\d{4}$/.test(String(year)) ? `${sheetName} · ${year}` : sheetName;
|
||||
return (
|
||||
<Breadcrumbs aria-label={isProject ? 'Навигация по проекту' : 'Навигация по задаче'} sx={{ fontSize: '0.875rem', '& .MuiBreadcrumbs-ol': { rowGap: '0.25rem' } }}>
|
||||
<Link component={RouterLink} to={getListReturnUrl(isProject ? 'projects' : 'tasks', taskId)} color='inherit' underline='hover'>{isProject ? 'Проекты' : 'Задачи'}</Link>
|
||||
{sheetName ? (
|
||||
<Link component={RouterLink} to={`/${isProject ? 'project' : 'task'}/${taskId}`} color='inherit' underline='hover'>{taskLabel}</Link>
|
||||
) : (
|
||||
<Typography color='text.primary' fontSize='inherit' aria-current='page'>{taskLabel}</Typography>
|
||||
)}
|
||||
{sheetName && <Typography color='text.primary' fontSize='inherit' aria-current='page'>{currentSheetLabel}</Typography>}
|
||||
</Breadcrumbs>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskBreadcrumbs;
|
||||
@ -1,54 +1,21 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { Box, Button, FormControl, FormLabel, Stack, TextField } from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { rememberListVisit } from '../../utils/listNavigation';
|
||||
import { toast } from 'react-toastify';
|
||||
import { FormsApi } from '../../api/forms';
|
||||
import { TasksApi } from '../../api/tasks';
|
||||
import { FORM_TYPE_TRANSLATE } from '../../constants/constants';
|
||||
import { ExportButton } from '../common/Buttons/ButtonsActions';
|
||||
import { Chip } from '../styles/StyledChip';
|
||||
import { LastOpenedBadge } from './LastOpenedBadge';
|
||||
import { IconWithContent } from './IconWithContent';
|
||||
import Modal from './Modal/Modal';
|
||||
import { CheckBoxCheckSvg, CheckBoxUncheckSvg, DateIcon, UserIcon } from './icons/icons';
|
||||
import { CheckBoxCheckSvg, CheckBoxUncheckSvg, DateIcon, TableIcon } from './icons/icons';
|
||||
|
||||
const TaskCard = styled.div`
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? 'calc((100% - 2.25rem) / 4)' : '26.8rem')};
|
||||
min-height: 10rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02),
|
||||
0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02);
|
||||
background: var(--White, rgba(255, 255, 255, 1));
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
import { TaskCard } from '../styles/TaskCard.style';
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? 'calc((100% - 1.5rem) / 3)' : '26.8rem')};
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? 'calc((100% - 0.75rem) / 2)' : '26.8rem')};
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? '100%' : '26.8rem')};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
`;
|
||||
|
||||
const CardHeader = styled.div`
|
||||
width: 100%;
|
||||
@ -66,7 +33,8 @@ const TaskId = styled.div`
|
||||
`;
|
||||
|
||||
const CardBody = styled.div`
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
@ -90,13 +58,19 @@ export const TaskCardComponent = ({
|
||||
isLoadingFile = false,
|
||||
onAddForExport,
|
||||
onExportClick,
|
||||
isLastOpened = false,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const cardRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (isLastOpened) cardRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isLastOpened]);
|
||||
const { id, title, org_unit, form_type_code, year } = taskForm;
|
||||
|
||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState('');
|
||||
const [currentTitle, setCurrentTitle] = useState(FORM_TYPE_TRANSLATE[form_type_code] || '');
|
||||
const [currentTypeCode, setCurrentTypeCode] = useState(FORM_TYPE_TRANSLATE[form_type_code] || '');
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -105,7 +79,7 @@ export const TaskCardComponent = ({
|
||||
|
||||
const handleOpenEdit = (event) => {
|
||||
event.stopPropagation();
|
||||
setEditedTitle(currentTitle);
|
||||
setEditedTitle(currentTypeCode);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
@ -131,7 +105,7 @@ export const TaskCardComponent = ({
|
||||
const updateTask = async (nextTitle) => {
|
||||
try {
|
||||
await TasksApi.editTask(id, { title: nextTitle });
|
||||
setCurrentTitle(nextTitle);
|
||||
setCurrentTypeCode(nextTitle);
|
||||
setEditModalOpen(false);
|
||||
toast.success('Название задачи обновлено');
|
||||
} catch (error) {
|
||||
@ -142,7 +116,7 @@ export const TaskCardComponent = ({
|
||||
const updateForm = async (nextTitle) => {
|
||||
try {
|
||||
await FormsApi.updateForm(id, { title: nextTitle });
|
||||
setCurrentTitle(nextTitle);
|
||||
setCurrentTypeCode(nextTitle);
|
||||
setEditModalOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error?.response?.data?.detail || 'Не удалось обновить название формы');
|
||||
@ -155,7 +129,7 @@ export const TaskCardComponent = ({
|
||||
toast.error('Название не может быть пустым');
|
||||
return;
|
||||
}
|
||||
if (nextTitle === currentTitle) {
|
||||
if (nextTitle === currentTypeCode) {
|
||||
setEditModalOpen(false);
|
||||
return;
|
||||
}
|
||||
@ -171,11 +145,13 @@ export const TaskCardComponent = ({
|
||||
return (
|
||||
<>
|
||||
<TaskCard
|
||||
ref={cardRef}
|
||||
$fluidWidth={fluidWidth}
|
||||
onClick={(event) => {
|
||||
if (exportMode) {
|
||||
handleCheckboxClick(event);
|
||||
} else {
|
||||
if (!isForm && location.pathname === '/tasks') rememberListVisit('tasks', id, `${location.pathname}${location.search}`);
|
||||
navigate(`/${isForm ? 'form' : 'task'}/${id}`);
|
||||
}
|
||||
}}
|
||||
@ -183,65 +159,33 @@ export const TaskCardComponent = ({
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<CardHeader>
|
||||
<TaskId>ID: {id}</TaskId>
|
||||
{/* TODO: вернется, когда будут реализованы статусы на бэке */}
|
||||
{/* <StatusBadge status={status} /> */}
|
||||
<Stack direction={'row'} spacing={'.5rem'}>
|
||||
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
|
||||
{/* <Edit onClick={handleOpenEdit} /> */}
|
||||
</Stack>
|
||||
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
|
||||
</CardHeader>
|
||||
|
||||
<TaskTitle>{currentTitle}</TaskTitle>
|
||||
<TaskTitle>{org_unit?.title || 'Подразделение не указано'}</TaskTitle>
|
||||
</Box>
|
||||
|
||||
<Stack
|
||||
direction='row'
|
||||
sx={{
|
||||
marginTop: 'auto',
|
||||
gap: '0.5rem',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
alignItems: 'flex-end',
|
||||
}}>
|
||||
<CardBody>
|
||||
<IconWithContent icon={<DateIcon />}>{year}</IconWithContent>
|
||||
<IconWithContent icon={<UserIcon />}>
|
||||
<Chip>{org_unit.title}</Chip>
|
||||
<IconWithContent icon={<TableIcon />}>
|
||||
<Chip sx={{ background: 'transparent' }}>{currentTypeCode}</Chip>
|
||||
</IconWithContent>
|
||||
{/* <IconWithContent icon={<TableIcon />}>
|
||||
Таблиц: {tables_count}
|
||||
</IconWithContent> */}
|
||||
|
||||
</CardBody>
|
||||
|
||||
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
|
||||
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
|
||||
</Stack>
|
||||
</TaskCard>
|
||||
|
||||
<Modal
|
||||
open={isEditModalOpen}
|
||||
onClose={handleCloseEdit}
|
||||
title={isForm ? 'Редактировать форму' : 'Редактировать задачу'}
|
||||
actions={
|
||||
<>
|
||||
<Button variant='grey' onClick={handleCloseEdit}>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button variant='contained' onClick={handleSaveTitle}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<FormControl fullWidth>
|
||||
<FormLabel required>{isForm ? 'Название формы' : 'Название задачи'}</FormLabel>
|
||||
<TextField
|
||||
size='small'
|
||||
placeholder={isForm ? 'Укажите название формы' : 'Укажите название задачи'}
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
export const Chip = styled.div`
|
||||
export const Chip = styled('div')`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
39
web/src/components/styles/TaskCard.style.jsx
Normal file
39
web/src/components/styles/TaskCard.style.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export const TaskCard = styled.div`
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? 'calc((100% - 3rem) / 5)' : '26.8rem')};
|
||||
min-height: 10rem;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
border: 0.06rem solid var(--Stroke, rgba(0, 0, 0, 0.1));
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0rem 2rem 4rem -2rem rgba(0, 0, 0, 0.02),
|
||||
0rem 4rem 6rem -1rem rgba(0, 0, 0, 0.02);
|
||||
background: var(--White, rgba(255, 255, 255, 1));
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? 'calc((100% - 1.5rem) / 3)' : '26.8rem')};
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? 'calc((100% - 0.75rem) / 2)' : '26.8rem')};
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
width: ${({ $fluidWidth }) => ($fluidWidth ? '100%' : '26.8rem')};
|
||||
}
|
||||
|
||||
&:not([data-loading="true"]):hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
`;
|
||||
@ -1,25 +1,24 @@
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { TasksApi } from '../api/tasks';
|
||||
import { rememberSheetVisit } from '../utils/sheetNavigation';
|
||||
import { ProjectsApi } from '../api/projects';
|
||||
import { useAuth } from '../app/context/AuthProvider';
|
||||
import RealtimeTable from '../components/RealtimeTable';
|
||||
import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext';
|
||||
import { BackButton } from '../components/common/Buttons/BackButton';
|
||||
import { NameTask, TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style';
|
||||
import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style';
|
||||
import { DIRECTION_TRANSLATE, SHEET_NAME } from '../constants/constants';
|
||||
import TaskBreadcrumbs from '../components/common/TaskBreadcrumbs';
|
||||
|
||||
const TableInfo = ({ formId, sheetName, direction, isProject = false }) => {
|
||||
const path = (isProject ? '/project/' : '/task/') + formId;
|
||||
const TableInfo = ({ formId, sheetName, direction, task, year, isProject = false }) => {
|
||||
const tableName = SHEET_NAME[sheetName] || sheetName;
|
||||
const directionName = DIRECTION_TRANSLATE[direction] || '';
|
||||
const sheetLabel = [tableName, directionName].filter(Boolean).join(' — ');
|
||||
const portalContent = (
|
||||
<TaskInfoContainer>
|
||||
<BackButton to={path} />
|
||||
{sheetName && (
|
||||
<NameTask>
|
||||
{tableName}
|
||||
{directionName ? ` — ${directionName}` : ''}
|
||||
</NameTask>
|
||||
)}
|
||||
<TaskBreadcrumbs task={task} taskId={formId} sheetName={sheetLabel} isProject={isProject} year={year} />
|
||||
</TaskInfoContainer>
|
||||
);
|
||||
|
||||
@ -35,10 +34,32 @@ export default function NewTablePage() {
|
||||
const { user } = useAuth();
|
||||
const isProject = formType === 'PROJECT';
|
||||
const normalizedDirection = direction === 'null' ? null : direction;
|
||||
const [loadedTask, setLoadedTask] = useState(null);
|
||||
const task = loadedTask?.formId === formId && loadedTask?.isProject === isProject ? loadedTask.data : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId || !sheetName || !user) return;
|
||||
rememberSheetVisit(isProject ? 'projects' : 'tasks', formId, sheetName, normalizedDirection, isProject ? year : undefined);
|
||||
}, [formId, sheetName, normalizedDirection, isProject, year, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId || !user) return;
|
||||
let cancelled = false;
|
||||
(isProject ? ProjectsApi.get(formId) : TasksApi.getById(formId))
|
||||
.then(({ result }) => {
|
||||
if (!cancelled) setLoadedTask({ formId, isProject, data: result });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) toast.error(`Не удалось загрузить информацию о ${isProject ? 'проекте' : 'задаче'}`);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [formId, isProject, user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{formId && <TableInfo formId={formId} sheetName={sheetName} direction={normalizedDirection} isProject={isProject} />}
|
||||
{formId && <TableInfo formId={formId} sheetName={sheetName} direction={normalizedDirection} task={task} isProject={isProject} year={year} />}
|
||||
{user && (
|
||||
<RealtimeProvider
|
||||
formId={formId}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { findItemPage, positiveInteger, readListVisit, rememberListVisit } from '../../utils/listNavigation';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ProjectsApi } from '../../api/projects';
|
||||
import { SspApi } from '../../api/ssp';
|
||||
@ -21,6 +22,9 @@ import { handleNavigateClick } from './utils/tableHandlers';
|
||||
const NavigationProjectPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const [lastVisit] = useState(() => readListVisit('projects'));
|
||||
const restoreVisit = useRef(lastVisit?.url === `${location.pathname}${location.search}` ? lastVisit : null);
|
||||
const branchIdsParam = searchParams.get('branch_ids') || '';
|
||||
const branchIds = useMemo(
|
||||
() =>
|
||||
@ -52,14 +56,33 @@ const NavigationProjectPage = () => {
|
||||
|
||||
// Состояния для фильтров
|
||||
const [selectedBranches, setSelectedBranches] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState('');
|
||||
const searchQuery = searchParams.get('search') || '';
|
||||
const selectedStatus = searchParams.get('status') || '';
|
||||
const [archivedData, setArchivedData] = useState([]);
|
||||
const [archivedTotalCount, setArchivedTotalCount] = useState(0);
|
||||
const [archivedPagination, setArchivedPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const archivedPagination = {
|
||||
pageIndex: positiveInteger(searchParams.get('page'), 1) - 1,
|
||||
pageSize: positiveInteger(searchParams.get('limit'), 10),
|
||||
};
|
||||
const setArchivedPagination = (updater) => {
|
||||
restoreVisit.current = null;
|
||||
const next = typeof updater === 'function' ? updater(archivedPagination) : updater;
|
||||
setSearchParams((params) => {
|
||||
const result = new URLSearchParams(params);
|
||||
result.set('page', String(next.pageIndex + 1));
|
||||
result.set('limit', String(next.pageSize));
|
||||
return result;
|
||||
});
|
||||
};
|
||||
const updateListFilter = (name, value) => {
|
||||
restoreVisit.current = null;
|
||||
setSearchParams((params) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(name, value); else next.delete(name);
|
||||
next.set('page', '1');
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isArchiveMode = selectedStatus === 'archived';
|
||||
|
||||
@ -110,7 +133,7 @@ const NavigationProjectPage = () => {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!user || isLoading) return undefined;
|
||||
if (!user || isLoading || isArchiveMode) return undefined;
|
||||
|
||||
const loadProjects = async () => {
|
||||
setIsProjectsLoading(true);
|
||||
@ -128,7 +151,7 @@ const NavigationProjectPage = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [user, isLoading, projectsReloadKey]);
|
||||
}, [user, isLoading, isArchiveMode, projectsReloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@ -142,11 +165,30 @@ const NavigationProjectPage = () => {
|
||||
status_in: 'archived',
|
||||
offset,
|
||||
limit: archivedPagination.pageSize,
|
||||
...(selectedBranches.length ? { branch_ids: selectedBranches.map(({ id }) => id).join(',') } : {}),
|
||||
...(branchIdsParam ? { branch_ids: branchIdsParam } : {}),
|
||||
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
|
||||
};
|
||||
|
||||
const response = await ProjectsApi.listWithReports(params);
|
||||
if (!active) return;
|
||||
const visit = restoreVisit.current;
|
||||
restoreVisit.current = null;
|
||||
if (visit && !(response.result || []).some((item) => String(item.id) === visit.id)) {
|
||||
const foundPage = await findItemPage({
|
||||
id: visit.id, currentPage: archivedPagination.pageIndex + 1,
|
||||
pageSize: archivedPagination.pageSize, count: response.count,
|
||||
fetchPage: async (page) => (await ProjectsApi.listWithReports({ ...params, offset: (page - 1) * archivedPagination.pageSize })).result || [],
|
||||
isCancelled: () => !active,
|
||||
});
|
||||
if (!active) return;
|
||||
if (foundPage) {
|
||||
const savedUrl = new URL(visit.url, window.location.origin);
|
||||
savedUrl.searchParams.set('page', String(foundPage));
|
||||
rememberListVisit('projects', visit.id, `${savedUrl.pathname}${savedUrl.search}`);
|
||||
setSearchParams(savedUrl.searchParams, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
setArchivedData(response.result || []);
|
||||
@ -172,7 +214,7 @@ const NavigationProjectPage = () => {
|
||||
user,
|
||||
isLoading,
|
||||
isArchiveMode,
|
||||
selectedBranches,
|
||||
branchIdsParam,
|
||||
searchQuery,
|
||||
archivedPagination.pageIndex,
|
||||
archivedPagination.pageSize,
|
||||
@ -272,9 +314,10 @@ const NavigationProjectPage = () => {
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(row) => {
|
||||
rememberListVisit('projects', row.original.id || row.original.project, `${location.pathname}${location.search}`);
|
||||
handleNavigateClick(row, navigate);
|
||||
},
|
||||
[navigate],
|
||||
[navigate, location.pathname, location.search],
|
||||
);
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
@ -363,25 +406,17 @@ const NavigationProjectPage = () => {
|
||||
setSelectedRow(null);
|
||||
setSummaryData([]);
|
||||
setSelectedBranches(value);
|
||||
setSearchParams((currentParams) => {
|
||||
const nextParams = new URLSearchParams(currentParams);
|
||||
if (value.length > 0) nextParams.set('branch_ids', value.map(({ id }) => id).join(','));
|
||||
else nextParams.delete('branch_ids');
|
||||
return nextParams;
|
||||
});
|
||||
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
updateListFilter('branch_ids', value.map(({ id }) => id).join(','));
|
||||
};
|
||||
|
||||
const handleSearchChange = (value) => {
|
||||
setSearchQuery(value);
|
||||
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
updateListFilter('search', value);
|
||||
};
|
||||
|
||||
const handleStatusChange = (value) => {
|
||||
setSelectedRow(null);
|
||||
setSummaryData([]);
|
||||
setSelectedStatus(value);
|
||||
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
|
||||
updateListFilter('status', value);
|
||||
};
|
||||
|
||||
const handleCreateProject = async (formData) => {
|
||||
@ -433,6 +468,7 @@ const NavigationProjectPage = () => {
|
||||
</Box>
|
||||
|
||||
<ProjectsTable
|
||||
lastOpenedId={lastVisit?.id}
|
||||
data={filteredData}
|
||||
isLoading={isLoading || isProjectsLoading}
|
||||
isArchiveMode={isArchiveMode}
|
||||
|
||||
@ -5,7 +5,7 @@ import StatusBadge from './components/StatusBadge/StatusBadge';
|
||||
import { DEVELOMENT_BLOCK, FUNDING_BY_DECISION } from './constants';
|
||||
|
||||
export const createFirstColumns = (handlers) => {
|
||||
const { onDelete, onEdit, onNavigate, orgUnitNames = {} } = handlers;
|
||||
const { onDelete, onEdit, onNavigate, orgUnitNames = {}, lastOpenedId } = handlers;
|
||||
return [
|
||||
{
|
||||
id: 'project',
|
||||
@ -15,6 +15,14 @@ export const createFirstColumns = (handlers) => {
|
||||
return orgUnitName ? `${projectName} / ${orgUnitName}` : projectName;
|
||||
},
|
||||
header: 'Проект',
|
||||
Cell: ({ cell, row }) => (
|
||||
<Box>
|
||||
{cell.getValue()}
|
||||
{row.depth === 0 && String(row.original.id) === lastOpenedId && (
|
||||
<Box component='span' sx={{ display: 'block', fontSize: '0.7rem', color: '#527342' }}>Последний открытый</Box>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
size: 200,
|
||||
filterFn: 'contains',
|
||||
filterVariant: 'text',
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { Paper } from '@mui/material';
|
||||
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { createFirstColumns } from '../../columns';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
const tableHeadCellStyles = {
|
||||
fontWeight: 700,
|
||||
@ -30,6 +31,7 @@ const tableHeadCellStyles = {
|
||||
};
|
||||
|
||||
const ProjectsTable = ({
|
||||
lastOpenedId,
|
||||
data,
|
||||
isLoading,
|
||||
isArchiveMode,
|
||||
@ -43,9 +45,20 @@ const ProjectsTable = ({
|
||||
onNavigate,
|
||||
orgUnitNames,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const sorting = useMemo(() => {
|
||||
try {
|
||||
const value = JSON.parse(searchParams.get('sorting') || '[]');
|
||||
return Array.isArray(value) ? value.filter((item) => typeof item?.id === 'string' && typeof item.desc === 'boolean') : [];
|
||||
} catch { return []; }
|
||||
}, [searchParams]);
|
||||
const lastOpenedRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (!isLoading) lastOpenedRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [isLoading, data, lastOpenedId]);
|
||||
const columns = useMemo(
|
||||
() => createFirstColumns({ onDelete, onEdit, onNavigate, orgUnitNames }),
|
||||
[onDelete, onEdit, onNavigate, orgUnitNames],
|
||||
() => createFirstColumns({ onDelete, onEdit, onNavigate, orgUnitNames, lastOpenedId }),
|
||||
[onDelete, onEdit, onNavigate, orgUnitNames, lastOpenedId],
|
||||
);
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
@ -56,6 +69,14 @@ const ProjectsTable = ({
|
||||
enablePagination: isArchiveMode,
|
||||
manualPagination: isArchiveMode,
|
||||
enableSorting: true,
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSearchParams((params) => {
|
||||
const result = new URLSearchParams(params);
|
||||
if (next.length) result.set('sorting', JSON.stringify(next)); else result.delete('sorting');
|
||||
return result;
|
||||
});
|
||||
},
|
||||
enableBottomToolbar: isArchiveMode,
|
||||
enableTopToolbar: false,
|
||||
enableExpanding: true,
|
||||
@ -74,12 +95,16 @@ const ProjectsTable = ({
|
||||
},
|
||||
},
|
||||
muiTableBodyRowProps: ({ row }) => {
|
||||
const isLastOpened = row.depth === 0 && String(row.original.id) === lastOpenedId;
|
||||
const isSelected = selectedRow && row.id === selectedRow.id;
|
||||
const isChild = selectedRow && row.parentId === selectedRow.id;
|
||||
|
||||
return {
|
||||
ref: isLastOpened ? lastOpenedRef : undefined,
|
||||
title: isLastOpened ? 'Последний открытый проект' : undefined,
|
||||
sx: {
|
||||
backgroundColor: '#ffffff',
|
||||
...(isLastOpened && { backgroundColor: '#eef5e9', scrollMarginTop: '6rem' }),
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgb(248, 250, 252)',
|
||||
},
|
||||
@ -167,6 +192,7 @@ const ProjectsTable = ({
|
||||
}
|
||||
: {}),
|
||||
state: {
|
||||
sorting,
|
||||
isLoading,
|
||||
...(isArchiveMode ? { pagination: archivedPagination } : {}),
|
||||
},
|
||||
|
||||
@ -6,24 +6,23 @@ import { toast } from 'react-toastify';
|
||||
import { ProjectsApi } from '../../api/projects'; // Добавьте импорт API для проектов
|
||||
import { TasksApi } from '../../api/tasks';
|
||||
import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal';
|
||||
import { BackButton } from '../../components/common/Buttons/BackButton';
|
||||
import { DangerOutlinedButton, PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
||||
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
|
||||
import { IconWithContent } from '../../components/common/IconWithContent';
|
||||
import SearchComponent from '../../components/common/SearchComponent';
|
||||
import Modal from '../../components/common/Modal/Modal';
|
||||
import { NameTask, TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
|
||||
import { TaskInfoContainer } from '../../components/common/SwitchFormTask/SwitchFormTask.style';
|
||||
import TablesList from '../../components/common/TableList/TableList';
|
||||
import TaskBreadcrumbs from '../../components/common/TaskBreadcrumbs';
|
||||
import { TableIcon } from '../../components/common/icons/icons';
|
||||
import { Chip } from '../../components/styles/StyledChip';
|
||||
import { FORM_TYPE_TRANSLATE, SHEET_NAME } from '../../constants/constants';
|
||||
import { SHEET_NAME } from '../../constants/constants';
|
||||
import { exportProject, exportSingleForm } from '../../utils/exportFile';
|
||||
|
||||
const TaskInfo = ({ task, isProject }) => {
|
||||
const portalContent = (
|
||||
<TaskInfoContainer>
|
||||
<BackButton to={isProject ? '/projects' : '/tasks'} />
|
||||
<NameTask>{isProject ? 'Проект' : FORM_TYPE_TRANSLATE[task.form_type_code] || ''}</NameTask>
|
||||
<TaskBreadcrumbs task={task} taskId={task.id} isProject={isProject} />
|
||||
</TaskInfoContainer>
|
||||
);
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Autocomplete, Box, Button, FormControl, FormLabel, MenuItem, Select, TextField } from '@mui/material';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { findItemPage, positiveInteger, readListVisit, rememberListVisit } from '../../utils/listNavigation';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { SspApi } from '../../api/ssp';
|
||||
@ -49,9 +50,17 @@ export default function TasksPage() {
|
||||
[orgUnitsFilter],
|
||||
);
|
||||
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const page = positiveInteger(searchParams.get('page'), 1);
|
||||
const limit = [15, 25, 50, 100, 200].includes(Number(searchParams.get('limit'))) ? Number(searchParams.get('limit')) : 25;
|
||||
const [lastVisit] = useState(() => readListVisit('tasks'));
|
||||
const listUrl = `/tasks${searchParams.size ? `?${searchParams}` : ''}`;
|
||||
const restoreVisit = useRef(lastVisit?.url === listUrl ? lastVisit : null);
|
||||
const requestId = useRef(0);
|
||||
const setPage = (value) => setSearchParams((params) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set('page', String(value));
|
||||
return next;
|
||||
});
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
const [sspList, setSspList] = useState([]);
|
||||
@ -86,8 +95,10 @@ export default function TasksPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const updateFilter = (name, value) => {
|
||||
restoreVisit.current = null;
|
||||
setSearchParams((currentParams) => {
|
||||
const nextParams = new URLSearchParams(currentParams);
|
||||
nextParams.set('page', '1');
|
||||
if (value) {
|
||||
nextParams.set(name, value);
|
||||
} else {
|
||||
@ -164,6 +175,7 @@ export default function TasksPage() {
|
||||
|
||||
const loadTasks = useCallback(
|
||||
async (currentPage, currentLimit) => {
|
||||
const currentRequest = ++requestId.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const skip = (currentPage - 1) * currentLimit;
|
||||
@ -177,26 +189,44 @@ export default function TasksPage() {
|
||||
|
||||
const res = await TasksApi.list(params);
|
||||
const data = res.result;
|
||||
if (currentRequest !== requestId.current) return;
|
||||
const visit = restoreVisit.current;
|
||||
restoreVisit.current = null;
|
||||
if (visit && !data.some((item) => String(item.id) === visit.id)) {
|
||||
const foundPage = await findItemPage({
|
||||
id: visit.id, currentPage, pageSize: currentLimit, count: res.count,
|
||||
fetchPage: async (nextPage) => (await TasksApi.list({ ...params, offset: (nextPage - 1) * currentLimit })).result,
|
||||
isCancelled: () => currentRequest !== requestId.current,
|
||||
});
|
||||
if (currentRequest !== requestId.current) return;
|
||||
if (foundPage) {
|
||||
const savedUrl = new URL(visit.url, window.location.origin);
|
||||
savedUrl.searchParams.set('page', String(foundPage));
|
||||
rememberListVisit('tasks', visit.id, `${savedUrl.pathname}${savedUrl.search}`);
|
||||
setSearchParams(savedUrl.searchParams, { replace: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setItems(data);
|
||||
setTotalCount(res.count);
|
||||
} catch (_error) {
|
||||
toast.error('Ошибка загрузки задач');
|
||||
if (currentRequest === requestId.current) toast.error('Ошибка загрузки задач');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (currentRequest === requestId.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[yearFilter, formTypeFilter, orgUnitIdsFilter],
|
||||
[yearFilter, formTypeFilter, orgUnitIdsFilter, setSearchParams],
|
||||
);
|
||||
|
||||
const handlePageChange = (_event, value) => {
|
||||
restoreVisit.current = null;
|
||||
setPage(value);
|
||||
};
|
||||
|
||||
const handleLimitChange = (event) => {
|
||||
const newLimit = event.target.value;
|
||||
setLimit(newLimit);
|
||||
setPage(1);
|
||||
updateFilter('limit', newLimit);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -216,17 +246,20 @@ export default function TasksPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks(page, limit);
|
||||
return () => {
|
||||
requestId.current++;
|
||||
};
|
||||
}, [page, limit, loadTasks]);
|
||||
|
||||
const handleFilterChange = (name, value) => {
|
||||
setPage(1);
|
||||
updateFilter(name, value);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setPage(1);
|
||||
restoreVisit.current = null;
|
||||
setSearchParams((currentParams) => {
|
||||
const nextParams = new URLSearchParams(currentParams);
|
||||
nextParams.set('page', '1');
|
||||
nextParams.delete('year');
|
||||
nextParams.delete('form_type');
|
||||
nextParams.delete('org_units');
|
||||
@ -241,6 +274,8 @@ export default function TasksPage() {
|
||||
<PageContainer>
|
||||
<HeaderSwitch />
|
||||
<PaginatedList
|
||||
skeletonVariant='task'
|
||||
skeletonCount={limit}
|
||||
items={items}
|
||||
loading={loading}
|
||||
totalCount={totalCount}
|
||||
@ -251,7 +286,7 @@ export default function TasksPage() {
|
||||
onLimitChange={handleLimitChange}
|
||||
entityName='задач'
|
||||
emptyMessage='Задачи не найдены'
|
||||
renderItem={(task, exportProps) => <TaskCardComponent key={task.id} taskForm={task} fluidWidth {...exportProps} />}
|
||||
renderItem={(task, exportProps) => <TaskCardComponent key={task.id} taskForm={task} isLastOpened={String(task.id) === lastVisit?.id} fluidWidth {...exportProps} />}
|
||||
getItemId={(task) => task.id}
|
||||
getItemTitle={(task) =>
|
||||
task.title ||
|
||||
|
||||
34
web/src/utils/listNavigation.js
Normal file
34
web/src/utils/listNavigation.js
Normal file
@ -0,0 +1,34 @@
|
||||
export const readListVisit = (kind, id) => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(`list-visit:${kind}:${id ?? 'last'}`) || 'null');
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
export const rememberListVisit = (kind, id, url) => {
|
||||
const visit = { id: String(id), url };
|
||||
try {
|
||||
for (const key of [String(id), 'last']) sessionStorage.setItem(`list-visit:${kind}:${key}`, JSON.stringify(visit));
|
||||
} catch { /* Navigation remains available when browser storage is disabled. */ }
|
||||
};
|
||||
|
||||
export const getListReturnUrl = (kind, id) => {
|
||||
const visit = readListVisit(kind, id);
|
||||
const base = `/${kind}`;
|
||||
return visit?.url === base || visit?.url?.startsWith(`${base}?`) ? visit.url : base;
|
||||
};
|
||||
|
||||
export const positiveInteger = (value, fallback) => {
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number > 0 ? number : fallback;
|
||||
};
|
||||
|
||||
export const findItemPage = async ({ id, currentPage, pageSize, count, fetchPage, isCancelled = () => false }) => {
|
||||
for (let page = 1; page <= Math.ceil(count / pageSize); page++) {
|
||||
if (isCancelled()) return null;
|
||||
if (page === currentPage) continue;
|
||||
const result = await fetchPage(page);
|
||||
if (isCancelled()) return null;
|
||||
if (result.some((item) => String(item.id) === String(id))) return page;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
15
web/src/utils/sheetNavigation.js
Normal file
15
web/src/utils/sheetNavigation.js
Normal file
@ -0,0 +1,15 @@
|
||||
const normalize = (value) => value == null || value === 'null' || value === 'undefined' ? '' : String(value);
|
||||
|
||||
export const getSheetVisitId = (sheet, direction, year) => JSON.stringify([sheet, normalize(direction), normalize(year)]);
|
||||
|
||||
export const rememberSheetVisit = (kind, parentId, sheet, direction, year) => {
|
||||
try {
|
||||
sessionStorage.setItem(`last-sheet:${kind}:${parentId}`, getSheetVisitId(sheet, direction, year));
|
||||
} catch { /* The sheet remains available when browser storage is disabled. */ }
|
||||
};
|
||||
|
||||
export const readSheetVisit = (kind, parentId) => {
|
||||
try {
|
||||
return sessionStorage.getItem(`last-sheet:${kind}:${parentId}`);
|
||||
} catch { return null; }
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user