добавить навигацию и отметки последних открытых задач и листов
This commit is contained in:
parent
e82999f2a6
commit
9f19695db4
@ -22,6 +22,8 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const userData = await UsersApi.me();
|
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);
|
setUser(userData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки пользователя:', error);
|
console.error('Ошибка загрузки пользователя:', error);
|
||||||
|
|||||||
@ -5,68 +5,68 @@ import { useAuth } from '../../app/context/AuthProvider';
|
|||||||
import { isTestMode } from '../../conf/config';
|
import { isTestMode } from '../../conf/config';
|
||||||
|
|
||||||
export function HeaderMenu() {
|
export function HeaderMenu() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
}, [logout, navigate]);
|
}, [logout, navigate]);
|
||||||
|
|
||||||
const getInitials = useCallback(() => {
|
const getInitials = useCallback(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
const names = user.username.split(' ');
|
const names = user.username.split(' ');
|
||||||
return names
|
return names
|
||||||
.map((name) => name[0])
|
.map((name) => name[0])
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const toggleMenu = useCallback(() => {
|
const toggleMenu = useCallback(() => {
|
||||||
setIsMenuOpen((prev) => !prev);
|
setIsMenuOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Закрытие меню при клике вне его области
|
// Закрытие меню при клике вне его области
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event) => {
|
const handleClickOutside = (event) => {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
if (menuRef.current && !menuRef.current.contains(event.target)) {
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderMenuTag>
|
<HeaderMenuTag>
|
||||||
<Logo />
|
<Logo />
|
||||||
<TableInfo id='TableInfo' />
|
<TableInfo id='TableInfo' />
|
||||||
<UserInfo ref={menuRef}>
|
<UserInfo ref={menuRef}>
|
||||||
<UserAvatar>
|
<UserAvatar>
|
||||||
<UserInitials>{user ? getInitials() : ''}</UserInitials>
|
<UserInitials>{user ? getInitials() : ''}</UserInitials>
|
||||||
</UserAvatar>
|
</UserAvatar>
|
||||||
<UserNameAndRole>
|
<UserNameAndRole>
|
||||||
<div className='username'>{user?.full_name}</div>
|
<div className='username'>{user?.full_name}</div>
|
||||||
<div className='role'>{user?.role}</div>
|
<div className='role'>{user?.role}</div>
|
||||||
</UserNameAndRole>
|
</UserNameAndRole>
|
||||||
|
|
||||||
{/* на стенде прода не показываем кнопку выхода */}
|
{/* на стенде прода не показываем кнопку выхода */}
|
||||||
{isTestMode && <DownButton onClick={toggleMenu} isOpen={isMenuOpen} />}
|
{isTestMode && <DownButton onClick={toggleMenu} isOpen={isMenuOpen} />}
|
||||||
{isTestMode && isMenuOpen && (
|
{isTestMode && isMenuOpen && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<LogoutButton onClick={handleLogout}>Выйти</LogoutButton>
|
<LogoutButton onClick={handleLogout}>Выйти</LogoutButton>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</UserInfo>
|
</UserInfo>
|
||||||
</HeaderMenuTag>
|
</HeaderMenuTag>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeaderMenuTag = styled.div`
|
const HeaderMenuTag = styled.div`
|
||||||
|
|||||||
@ -68,21 +68,26 @@ export function HeaderSwitch() {
|
|||||||
</div>
|
</div>
|
||||||
<span>Задачи</span>
|
<span>Задачи</span>
|
||||||
</StyledNavLink>
|
</StyledNavLink>
|
||||||
<StyledNavLink to='/projects' className={location.pathname.startsWith(projectsPaths) ? 'active' : ''}>
|
{(user.is_executor_rf || user.role_id === ROLES_NAME_ID.admin || user.role_id === ROLES_NAME_ID.executor_durrs) &&
|
||||||
<div className='img'>
|
|
||||||
<ProjectSvg fill={location.pathname.startsWith(projectsPaths) ? '#258141' : '#99A1AF'} />
|
|
||||||
</div>
|
|
||||||
<span>Проекты</span>
|
|
||||||
</StyledNavLink>
|
|
||||||
|
|
||||||
<StyledNavLink to='/svods-navigator' className={svodPaths.some((path) => location.pathname.startsWith(path)) ? 'active' : ''}>
|
<StyledNavLink to='/projects' className={location.pathname.startsWith(projectsPaths) ? 'active' : ''}>
|
||||||
<div className='img'>
|
<div className='img'>
|
||||||
<SummarySvg fill={svodPaths.some((path) => location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} />
|
<ProjectSvg fill={location.pathname.startsWith(projectsPaths) ? '#258141' : '#99A1AF'} />
|
||||||
</div>
|
</div>
|
||||||
<span>Своды</span>
|
<span>Проекты</span>
|
||||||
</StyledNavLink>
|
</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 && (
|
{user.role_id === ROLES_NAME_ID.admin && (
|
||||||
|
|
||||||
<StyledNavLink to='/admin_panel/users' className={location.pathname.startsWith(adminPathsPrefix) ? 'active' : ''}>
|
<StyledNavLink to='/admin_panel/users' className={location.pathname.startsWith(adminPathsPrefix) ? 'active' : ''}>
|
||||||
<div className='img'>
|
<div className='img'>
|
||||||
<AdminPanelSvg fill={location.pathname.startsWith(adminPathsPrefix) ? '#258141' : '#99A1AF'} />
|
<AdminPanelSvg fill={location.pathname.startsWith(adminPathsPrefix) ? '#258141' : '#99A1AF'} />
|
||||||
@ -90,12 +95,15 @@ export function HeaderSwitch() {
|
|||||||
<span>Администрирование</span>
|
<span>Администрирование</span>
|
||||||
</StyledNavLink>
|
</StyledNavLink>
|
||||||
)}
|
)}
|
||||||
<StyledNavLink to='/dicts-navigator' className={location.pathname.startsWith(dictPaths) ? 'active' : ''}>
|
{(user.is_executor_rf || user.role_id === ROLES_NAME_ID.admin) &&
|
||||||
<div className='img'>
|
<StyledNavLink to='/dicts-navigator' className={location.pathname.startsWith(dictPaths) ? 'active' : ''}>
|
||||||
<DictSvg fill={location.pathname.startsWith(dictPaths) ? '#258141' : '#99A1AF'} />
|
<div className='img'>
|
||||||
</div>
|
<DictSvg fill={location.pathname.startsWith(dictPaths) ? '#258141' : '#99A1AF'} />
|
||||||
<span>Справочники</span>
|
</div>
|
||||||
</StyledNavLink>
|
<span>Справочники</span>
|
||||||
|
</StyledNavLink>
|
||||||
|
}
|
||||||
|
|
||||||
</Buttons>
|
</Buttons>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -70,45 +70,6 @@ export const SettingModal = ({
|
|||||||
setIsAddEditModalOpen(true);
|
setIsAddEditModalOpen(true);
|
||||||
};
|
};
|
||||||
const transformToSheetOptions = (data) => {
|
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 = [];
|
const sheetOptions = [];
|
||||||
data.forEach((value) => {
|
data.forEach((value) => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ export const stageColumnsHiddenFromPicker = [
|
|||||||
'data.header.item_id',
|
'data.header.item_id',
|
||||||
'data.header.num_group',
|
'data.header.num_group',
|
||||||
'data.header.num_group_id',
|
'data.header.num_group_id',
|
||||||
'data.header.name',
|
// 'data.header.name',
|
||||||
'data.header.internal_order',
|
'data.header.internal_order',
|
||||||
// 'data.header.vsp_id',
|
// 'data.header.vsp_id',
|
||||||
'data.header.vsp_address',
|
'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';
|
import { Box, MenuItem, Pagination, PaginationItem, Select, Stack, Typography } from '@mui/material';
|
||||||
// src/components/common/PaginatedList/PaginatedList.jsx
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { exportBulkForms, exportSingleForm } from '../../utils/exportFile';
|
import { exportBulkForms, exportSingleForm } from '../../utils/exportFile';
|
||||||
import { ExportExitButton, ExportWithTextButton } from './Buttons/ButtonsActions';
|
import { ExportExitButton, ExportWithTextButton } from './Buttons/ButtonsActions';
|
||||||
import { CardsSkeleton } from './Skeleton';
|
import { CardsSkeleton, TaskCardsSkeleton } from './Skeleton';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -42,9 +41,10 @@ export function PaginatedList({
|
|||||||
entityName = 'элементов',
|
entityName = 'элементов',
|
||||||
emptyMessage = 'Элементы не найдены',
|
emptyMessage = 'Элементы не найдены',
|
||||||
skeletonCount = 20,
|
skeletonCount = 20,
|
||||||
|
skeletonVariant = 'default',
|
||||||
extraActions = null,
|
extraActions = null,
|
||||||
enableExport = true,
|
enableExport = true,
|
||||||
limitOptions = [10, 20, 50, 100, 200],
|
limitOptions = [15, 25, 50, 100, 200],
|
||||||
maxHeight = 'calc(100vh - 300px)',
|
maxHeight = 'calc(100vh - 300px)',
|
||||||
enableScroll = true,
|
enableScroll = true,
|
||||||
filterActions = null,
|
filterActions = null,
|
||||||
@ -152,7 +152,7 @@ export function PaginatedList({
|
|||||||
{/* Список элементов */}
|
{/* Список элементов */}
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<CardsSkeleton count={skeletonCount} />
|
(skeletonVariant === 'task' ? <TaskCardsSkeleton count={skeletonCount} /> : <CardsSkeleton count={skeletonCount} />)
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 4, width: '100%' }}>
|
<Box sx={{ textAlign: 'center', py: 4, width: '100%' }}>
|
||||||
<Typography variant='body1' color='textSecondary'>
|
<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 }) {
|
export function LinesSkeleton({ lines = 20 }) {
|
||||||
return (
|
return (
|
||||||
<div className='animate-pulse space-y-2'>
|
<div className='animate-pulse space-y-2'>
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
import { DIRECTION_TRANSLATE } from '../../../constants/constants';
|
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';
|
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 = () => {
|
const handleClick = () => {
|
||||||
if (onNavigate) {
|
if (onNavigate) {
|
||||||
onNavigate(table.id);
|
onNavigate(table.id);
|
||||||
@ -9,21 +16,31 @@ const TableCard = ({ table, onNavigate, action }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={table.sheet + '_' + table?.direction}>
|
<div key={`${table.sheet}_${table?.direction}`}>
|
||||||
<Section onClick={handleClick}>
|
<Section ref={cardRef} onClick={handleClick} >
|
||||||
<SectionStartPart>
|
<Stack sx={{ flexDirection: 'column', width: '100%', justifyContent: 'baseline' }}>
|
||||||
<div className='for-img-table'>
|
{isLastOpened && <LastOpenedBadge />}
|
||||||
<div className='img-table'></div>
|
<Stack sx={{ flexDirection: 'row', width: '100%' }}>
|
||||||
</div>
|
<SectionStartPart>
|
||||||
<SectionTitle>
|
<div className='for-img-table'>
|
||||||
<h2>{table.name}</h2>
|
<div className='img-table' />
|
||||||
<h2>{DIRECTION_TRANSLATE[table?.direction] || ''}</h2>
|
</div>
|
||||||
{/* <UsersList>
|
<SectionTitle>
|
||||||
<div className="img-user"></div>
|
|
||||||
</UsersList> */}
|
<h2>{table.name}</h2>
|
||||||
</SectionTitle>
|
<h2>{DIRECTION_TRANSLATE[table?.direction] || ''}</h2>
|
||||||
</SectionStartPart>
|
</SectionTitle>
|
||||||
<div onClick={(event) => event.stopPropagation()}>{action || <Right />}</div>
|
</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>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,8 +20,9 @@ export const SectionStartPart = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: end;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
min-width: max-content;
|
||||||
|
|
||||||
.for-img-table {
|
.for-img-table {
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { Box, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import TableCard from '../TableCard/TableCard';
|
import TableCard from '../TableCard/TableCard';
|
||||||
|
import { getSheetVisitId, readSheetVisit } from '../../../utils/sheetNavigation';
|
||||||
|
|
||||||
const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isProject = false, renderTableAction }) => {
|
const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isProject = false, renderTableAction }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const lastSheet = readSheetVisit(isProject ? 'projects' : 'tasks', formInfo?.id);
|
||||||
|
|
||||||
const handleNavigate = (path) => {
|
const handleNavigate = (path) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
@ -19,6 +21,7 @@ const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCard
|
<TableCard
|
||||||
|
isLastOpened={lastSheet === getSheetVisitId(table.sheet, table.direction, isProject ? table.year : undefined)}
|
||||||
key={`${table.sheet}_${table?.direction}_${table?.year}`}
|
key={`${table.sheet}_${table?.direction}_${table?.year}`}
|
||||||
onNavigate={() => handleNavigate(path)}
|
onNavigate={() => handleNavigate(path)}
|
||||||
table={table}
|
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 styled from '@emotion/styled';
|
||||||
import { Box, Button, FormControl, FormLabel, Stack, TextField } from '@mui/material';
|
import { Box, Button, FormControl, FormLabel, Stack, TextField } from '@mui/material';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { rememberListVisit } from '../../utils/listNavigation';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { FormsApi } from '../../api/forms';
|
import { FormsApi } from '../../api/forms';
|
||||||
import { TasksApi } from '../../api/tasks';
|
import { TasksApi } from '../../api/tasks';
|
||||||
import { FORM_TYPE_TRANSLATE } from '../../constants/constants';
|
import { FORM_TYPE_TRANSLATE } from '../../constants/constants';
|
||||||
import { ExportButton } from '../common/Buttons/ButtonsActions';
|
import { ExportButton } from '../common/Buttons/ButtonsActions';
|
||||||
import { Chip } from '../styles/StyledChip';
|
import { Chip } from '../styles/StyledChip';
|
||||||
|
import { LastOpenedBadge } from './LastOpenedBadge';
|
||||||
import { IconWithContent } from './IconWithContent';
|
import { IconWithContent } from './IconWithContent';
|
||||||
import Modal from './Modal/Modal';
|
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`
|
import { TaskCard } from '../styles/TaskCard.style';
|
||||||
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;
|
|
||||||
|
|
||||||
@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`
|
const CardHeader = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -66,7 +33,8 @@ const TaskId = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const CardBody = styled.div`
|
const CardBody = styled.div`
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@ -90,13 +58,19 @@ export const TaskCardComponent = ({
|
|||||||
isLoadingFile = false,
|
isLoadingFile = false,
|
||||||
onAddForExport,
|
onAddForExport,
|
||||||
onExportClick,
|
onExportClick,
|
||||||
|
isLastOpened = false,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
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 { id, title, org_unit, form_type_code, year } = taskForm;
|
||||||
|
|
||||||
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
const [isEditModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [editedTitle, setEditedTitle] = useState('');
|
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);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -105,7 +79,7 @@ export const TaskCardComponent = ({
|
|||||||
|
|
||||||
const handleOpenEdit = (event) => {
|
const handleOpenEdit = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
setEditedTitle(currentTitle);
|
setEditedTitle(currentTypeCode);
|
||||||
setEditModalOpen(true);
|
setEditModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,7 +105,7 @@ export const TaskCardComponent = ({
|
|||||||
const updateTask = async (nextTitle) => {
|
const updateTask = async (nextTitle) => {
|
||||||
try {
|
try {
|
||||||
await TasksApi.editTask(id, { title: nextTitle });
|
await TasksApi.editTask(id, { title: nextTitle });
|
||||||
setCurrentTitle(nextTitle);
|
setCurrentTypeCode(nextTitle);
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
toast.success('Название задачи обновлено');
|
toast.success('Название задачи обновлено');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -142,7 +116,7 @@ export const TaskCardComponent = ({
|
|||||||
const updateForm = async (nextTitle) => {
|
const updateForm = async (nextTitle) => {
|
||||||
try {
|
try {
|
||||||
await FormsApi.updateForm(id, { title: nextTitle });
|
await FormsApi.updateForm(id, { title: nextTitle });
|
||||||
setCurrentTitle(nextTitle);
|
setCurrentTypeCode(nextTitle);
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error?.response?.data?.detail || 'Не удалось обновить название формы');
|
toast.error(error?.response?.data?.detail || 'Не удалось обновить название формы');
|
||||||
@ -155,7 +129,7 @@ export const TaskCardComponent = ({
|
|||||||
toast.error('Название не может быть пустым');
|
toast.error('Название не может быть пустым');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nextTitle === currentTitle) {
|
if (nextTitle === currentTypeCode) {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -171,11 +145,13 @@ export const TaskCardComponent = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TaskCard
|
<TaskCard
|
||||||
|
ref={cardRef}
|
||||||
$fluidWidth={fluidWidth}
|
$fluidWidth={fluidWidth}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (exportMode) {
|
if (exportMode) {
|
||||||
handleCheckboxClick(event);
|
handleCheckboxClick(event);
|
||||||
} else {
|
} else {
|
||||||
|
if (!isForm && location.pathname === '/tasks') rememberListVisit('tasks', id, `${location.pathname}${location.search}`);
|
||||||
navigate(`/${isForm ? 'form' : 'task'}/${id}`);
|
navigate(`/${isForm ? 'form' : 'task'}/${id}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -183,65 +159,33 @@ export const TaskCardComponent = ({
|
|||||||
<Box sx={{ width: '100%' }}>
|
<Box sx={{ width: '100%' }}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<TaskId>ID: {id}</TaskId>
|
<TaskId>ID: {id}</TaskId>
|
||||||
{/* TODO: вернется, когда будут реализованы статусы на бэке */}
|
{isLastOpened && <LastOpenedBadge sx={{ mb: 0 }}>Последняя открытая</LastOpenedBadge>}
|
||||||
{/* <StatusBadge status={status} /> */}
|
|
||||||
<Stack direction={'row'} spacing={'.5rem'}>
|
|
||||||
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
|
|
||||||
{/* <Edit onClick={handleOpenEdit} /> */}
|
|
||||||
</Stack>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<TaskTitle>{currentTitle}</TaskTitle>
|
<TaskTitle>{org_unit?.title || 'Подразделение не указано'}</TaskTitle>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
direction='row'
|
direction='row'
|
||||||
sx={{
|
sx={{
|
||||||
|
marginTop: 'auto',
|
||||||
|
gap: '0.5rem',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
}}>
|
}}>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<IconWithContent icon={<DateIcon />}>{year}</IconWithContent>
|
<IconWithContent icon={<DateIcon />}>{year}</IconWithContent>
|
||||||
<IconWithContent icon={<UserIcon />}>
|
<IconWithContent icon={<TableIcon />}>
|
||||||
<Chip>{org_unit.title}</Chip>
|
<Chip sx={{ background: 'transparent' }}>{currentTypeCode}</Chip>
|
||||||
</IconWithContent>
|
</IconWithContent>
|
||||||
{/* <IconWithContent icon={<TableIcon />}>
|
|
||||||
Таблиц: {tables_count}
|
|
||||||
</IconWithContent> */}
|
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|
||||||
|
{!isForm && !exportMode && <ExportButton onClick={handleExportTask} />}
|
||||||
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
|
{exportMode && <div>{isChecked ? <CheckBoxCheckSvg size='1.625rem' /> : <CheckBoxUncheckSvg size='1.625rem' />}</div>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</TaskCard>
|
</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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 { createPortal } from 'react-dom';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
import { useParams } from 'react-router-dom';
|
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 { useAuth } from '../app/context/AuthProvider';
|
||||||
import RealtimeTable from '../components/RealtimeTable';
|
import RealtimeTable from '../components/RealtimeTable';
|
||||||
import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext';
|
import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext';
|
||||||
import { BackButton } from '../components/common/Buttons/BackButton';
|
import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style';
|
||||||
import { NameTask, TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style';
|
|
||||||
import { DIRECTION_TRANSLATE, SHEET_NAME } from '../constants/constants';
|
import { DIRECTION_TRANSLATE, SHEET_NAME } from '../constants/constants';
|
||||||
|
import TaskBreadcrumbs from '../components/common/TaskBreadcrumbs';
|
||||||
|
|
||||||
const TableInfo = ({ formId, sheetName, direction, isProject = false }) => {
|
const TableInfo = ({ formId, sheetName, direction, task, year, isProject = false }) => {
|
||||||
const path = (isProject ? '/project/' : '/task/') + formId;
|
|
||||||
const tableName = SHEET_NAME[sheetName] || sheetName;
|
const tableName = SHEET_NAME[sheetName] || sheetName;
|
||||||
const directionName = DIRECTION_TRANSLATE[direction] || '';
|
const directionName = DIRECTION_TRANSLATE[direction] || '';
|
||||||
|
const sheetLabel = [tableName, directionName].filter(Boolean).join(' — ');
|
||||||
const portalContent = (
|
const portalContent = (
|
||||||
<TaskInfoContainer>
|
<TaskInfoContainer>
|
||||||
<BackButton to={path} />
|
<TaskBreadcrumbs task={task} taskId={formId} sheetName={sheetLabel} isProject={isProject} year={year} />
|
||||||
{sheetName && (
|
|
||||||
<NameTask>
|
|
||||||
{tableName}
|
|
||||||
{directionName ? ` — ${directionName}` : ''}
|
|
||||||
</NameTask>
|
|
||||||
)}
|
|
||||||
</TaskInfoContainer>
|
</TaskInfoContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -35,10 +34,32 @@ export default function NewTablePage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isProject = formType === 'PROJECT';
|
const isProject = formType === 'PROJECT';
|
||||||
const normalizedDirection = direction === 'null' ? null : direction;
|
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 (
|
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 && (
|
{user && (
|
||||||
<RealtimeProvider
|
<RealtimeProvider
|
||||||
formId={formId}
|
formId={formId}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Box, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { findItemPage, positiveInteger, readListVisit, rememberListVisit } from '../../utils/listNavigation';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { ProjectsApi } from '../../api/projects';
|
import { ProjectsApi } from '../../api/projects';
|
||||||
import { SspApi } from '../../api/ssp';
|
import { SspApi } from '../../api/ssp';
|
||||||
@ -21,6 +22,9 @@ import { handleNavigateClick } from './utils/tableHandlers';
|
|||||||
const NavigationProjectPage = () => {
|
const NavigationProjectPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
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 branchIdsParam = searchParams.get('branch_ids') || '';
|
||||||
const branchIds = useMemo(
|
const branchIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -52,14 +56,33 @@ const NavigationProjectPage = () => {
|
|||||||
|
|
||||||
// Состояния для фильтров
|
// Состояния для фильтров
|
||||||
const [selectedBranches, setSelectedBranches] = useState([]);
|
const [selectedBranches, setSelectedBranches] = useState([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const searchQuery = searchParams.get('search') || '';
|
||||||
const [selectedStatus, setSelectedStatus] = useState('');
|
const selectedStatus = searchParams.get('status') || '';
|
||||||
const [archivedData, setArchivedData] = useState([]);
|
const [archivedData, setArchivedData] = useState([]);
|
||||||
const [archivedTotalCount, setArchivedTotalCount] = useState(0);
|
const [archivedTotalCount, setArchivedTotalCount] = useState(0);
|
||||||
const [archivedPagination, setArchivedPagination] = useState({
|
const archivedPagination = {
|
||||||
pageIndex: 0,
|
pageIndex: positiveInteger(searchParams.get('page'), 1) - 1,
|
||||||
pageSize: 10,
|
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';
|
const isArchiveMode = selectedStatus === 'archived';
|
||||||
|
|
||||||
@ -110,7 +133,7 @@ const NavigationProjectPage = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
if (!user || isLoading) return undefined;
|
if (!user || isLoading || isArchiveMode) return undefined;
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
setIsProjectsLoading(true);
|
setIsProjectsLoading(true);
|
||||||
@ -128,7 +151,7 @@ const NavigationProjectPage = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [user, isLoading, projectsReloadKey]);
|
}, [user, isLoading, isArchiveMode, projectsReloadKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@ -142,11 +165,30 @@ const NavigationProjectPage = () => {
|
|||||||
status_in: 'archived',
|
status_in: 'archived',
|
||||||
offset,
|
offset,
|
||||||
limit: archivedPagination.pageSize,
|
limit: archivedPagination.pageSize,
|
||||||
...(selectedBranches.length ? { branch_ids: selectedBranches.map(({ id }) => id).join(',') } : {}),
|
...(branchIdsParam ? { branch_ids: branchIdsParam } : {}),
|
||||||
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
|
...(searchQuery.trim() ? { search: searchQuery.trim() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await ProjectsApi.listWithReports(params);
|
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) {
|
if (active) {
|
||||||
setArchivedData(response.result || []);
|
setArchivedData(response.result || []);
|
||||||
@ -172,7 +214,7 @@ const NavigationProjectPage = () => {
|
|||||||
user,
|
user,
|
||||||
isLoading,
|
isLoading,
|
||||||
isArchiveMode,
|
isArchiveMode,
|
||||||
selectedBranches,
|
branchIdsParam,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
archivedPagination.pageIndex,
|
archivedPagination.pageIndex,
|
||||||
archivedPagination.pageSize,
|
archivedPagination.pageSize,
|
||||||
@ -272,9 +314,10 @@ const NavigationProjectPage = () => {
|
|||||||
|
|
||||||
const onNavigate = useCallback(
|
const onNavigate = useCallback(
|
||||||
(row) => {
|
(row) => {
|
||||||
|
rememberListVisit('projects', row.original.id || row.original.project, `${location.pathname}${location.search}`);
|
||||||
handleNavigateClick(row, navigate);
|
handleNavigateClick(row, navigate);
|
||||||
},
|
},
|
||||||
[navigate],
|
[navigate, location.pathname, location.search],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteProject = async () => {
|
const handleDeleteProject = async () => {
|
||||||
@ -363,25 +406,17 @@ const NavigationProjectPage = () => {
|
|||||||
setSelectedRow(null);
|
setSelectedRow(null);
|
||||||
setSummaryData([]);
|
setSummaryData([]);
|
||||||
setSelectedBranches(value);
|
setSelectedBranches(value);
|
||||||
setSearchParams((currentParams) => {
|
updateListFilter('branch_ids', value.map(({ id }) => id).join(','));
|
||||||
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 }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSearchChange = (value) => {
|
const handleSearchChange = (value) => {
|
||||||
setSearchQuery(value);
|
updateListFilter('search', value);
|
||||||
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = (value) => {
|
const handleStatusChange = (value) => {
|
||||||
setSelectedRow(null);
|
setSelectedRow(null);
|
||||||
setSummaryData([]);
|
setSummaryData([]);
|
||||||
setSelectedStatus(value);
|
updateListFilter('status', value);
|
||||||
setArchivedPagination((current) => ({ ...current, pageIndex: 0 }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateProject = async (formData) => {
|
const handleCreateProject = async (formData) => {
|
||||||
@ -433,6 +468,7 @@ const NavigationProjectPage = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ProjectsTable
|
<ProjectsTable
|
||||||
|
lastOpenedId={lastVisit?.id}
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
isLoading={isLoading || isProjectsLoading}
|
isLoading={isLoading || isProjectsLoading}
|
||||||
isArchiveMode={isArchiveMode}
|
isArchiveMode={isArchiveMode}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import StatusBadge from './components/StatusBadge/StatusBadge';
|
|||||||
import { DEVELOMENT_BLOCK, FUNDING_BY_DECISION } from './constants';
|
import { DEVELOMENT_BLOCK, FUNDING_BY_DECISION } from './constants';
|
||||||
|
|
||||||
export const createFirstColumns = (handlers) => {
|
export const createFirstColumns = (handlers) => {
|
||||||
const { onDelete, onEdit, onNavigate, orgUnitNames = {} } = handlers;
|
const { onDelete, onEdit, onNavigate, orgUnitNames = {}, lastOpenedId } = handlers;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'project',
|
id: 'project',
|
||||||
@ -15,6 +15,14 @@ export const createFirstColumns = (handlers) => {
|
|||||||
return orgUnitName ? `${projectName} / ${orgUnitName}` : projectName;
|
return orgUnitName ? `${projectName} / ${orgUnitName}` : projectName;
|
||||||
},
|
},
|
||||||
header: 'Проект',
|
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,
|
size: 200,
|
||||||
filterFn: 'contains',
|
filterFn: 'contains',
|
||||||
filterVariant: 'text',
|
filterVariant: 'text',
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Paper } from '@mui/material';
|
import { Paper } from '@mui/material';
|
||||||
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
import { createFirstColumns } from '../../columns';
|
import { createFirstColumns } from '../../columns';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
const tableHeadCellStyles = {
|
const tableHeadCellStyles = {
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@ -30,6 +31,7 @@ const tableHeadCellStyles = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ProjectsTable = ({
|
const ProjectsTable = ({
|
||||||
|
lastOpenedId,
|
||||||
data,
|
data,
|
||||||
isLoading,
|
isLoading,
|
||||||
isArchiveMode,
|
isArchiveMode,
|
||||||
@ -43,9 +45,20 @@ const ProjectsTable = ({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
orgUnitNames,
|
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(
|
const columns = useMemo(
|
||||||
() => createFirstColumns({ onDelete, onEdit, onNavigate, orgUnitNames }),
|
() => createFirstColumns({ onDelete, onEdit, onNavigate, orgUnitNames, lastOpenedId }),
|
||||||
[onDelete, onEdit, onNavigate, orgUnitNames],
|
[onDelete, onEdit, onNavigate, orgUnitNames, lastOpenedId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useMaterialReactTable({
|
const table = useMaterialReactTable({
|
||||||
@ -56,6 +69,14 @@ const ProjectsTable = ({
|
|||||||
enablePagination: isArchiveMode,
|
enablePagination: isArchiveMode,
|
||||||
manualPagination: isArchiveMode,
|
manualPagination: isArchiveMode,
|
||||||
enableSorting: true,
|
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,
|
enableBottomToolbar: isArchiveMode,
|
||||||
enableTopToolbar: false,
|
enableTopToolbar: false,
|
||||||
enableExpanding: true,
|
enableExpanding: true,
|
||||||
@ -74,12 +95,16 @@ const ProjectsTable = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
muiTableBodyRowProps: ({ row }) => {
|
muiTableBodyRowProps: ({ row }) => {
|
||||||
|
const isLastOpened = row.depth === 0 && String(row.original.id) === lastOpenedId;
|
||||||
const isSelected = selectedRow && row.id === selectedRow.id;
|
const isSelected = selectedRow && row.id === selectedRow.id;
|
||||||
const isChild = selectedRow && row.parentId === selectedRow.id;
|
const isChild = selectedRow && row.parentId === selectedRow.id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
ref: isLastOpened ? lastOpenedRef : undefined,
|
||||||
|
title: isLastOpened ? 'Последний открытый проект' : undefined,
|
||||||
sx: {
|
sx: {
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
|
...(isLastOpened && { backgroundColor: '#eef5e9', scrollMarginTop: '6rem' }),
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
backgroundColor: 'rgb(248, 250, 252)',
|
backgroundColor: 'rgb(248, 250, 252)',
|
||||||
},
|
},
|
||||||
@ -167,6 +192,7 @@ const ProjectsTable = ({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
state: {
|
state: {
|
||||||
|
sorting,
|
||||||
isLoading,
|
isLoading,
|
||||||
...(isArchiveMode ? { pagination: archivedPagination } : {}),
|
...(isArchiveMode ? { pagination: archivedPagination } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -6,24 +6,23 @@ import { toast } from 'react-toastify';
|
|||||||
import { ProjectsApi } from '../../api/projects'; // Добавьте импорт API для проектов
|
import { ProjectsApi } from '../../api/projects'; // Добавьте импорт API для проектов
|
||||||
import { TasksApi } from '../../api/tasks';
|
import { TasksApi } from '../../api/tasks';
|
||||||
import { SettingModal as SettingStageModal } from '../../components/Stages/SettingModal';
|
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 { DangerOutlinedButton, PrimaryButton, PrimaryOutlinedButton } from '../../components/common/Buttons/Buttons';
|
||||||
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
|
import { ExportWithTextButton } from '../../components/common/Buttons/ButtonsActions';
|
||||||
import { IconWithContent } from '../../components/common/IconWithContent';
|
import { IconWithContent } from '../../components/common/IconWithContent';
|
||||||
import SearchComponent from '../../components/common/SearchComponent';
|
import SearchComponent from '../../components/common/SearchComponent';
|
||||||
import Modal from '../../components/common/Modal/Modal';
|
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 TablesList from '../../components/common/TableList/TableList';
|
||||||
|
import TaskBreadcrumbs from '../../components/common/TaskBreadcrumbs';
|
||||||
import { TableIcon } from '../../components/common/icons/icons';
|
import { TableIcon } from '../../components/common/icons/icons';
|
||||||
import { Chip } from '../../components/styles/StyledChip';
|
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';
|
import { exportProject, exportSingleForm } from '../../utils/exportFile';
|
||||||
|
|
||||||
const TaskInfo = ({ task, isProject }) => {
|
const TaskInfo = ({ task, isProject }) => {
|
||||||
const portalContent = (
|
const portalContent = (
|
||||||
<TaskInfoContainer>
|
<TaskInfoContainer>
|
||||||
<BackButton to={isProject ? '/projects' : '/tasks'} />
|
<TaskBreadcrumbs task={task} taskId={task.id} isProject={isProject} />
|
||||||
<NameTask>{isProject ? 'Проект' : FORM_TYPE_TRANSLATE[task.form_type_code] || ''}</NameTask>
|
|
||||||
</TaskInfoContainer>
|
</TaskInfoContainer>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Autocomplete, Box, Button, FormControl, FormLabel, MenuItem, Select, TextField } from '@mui/material';
|
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 { useSearchParams } from 'react-router-dom';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { SspApi } from '../../api/ssp';
|
import { SspApi } from '../../api/ssp';
|
||||||
@ -49,9 +50,17 @@ export default function TasksPage() {
|
|||||||
[orgUnitsFilter],
|
[orgUnitsFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pagination state
|
const page = positiveInteger(searchParams.get('page'), 1);
|
||||||
const [page, setPage] = useState(1);
|
const limit = [15, 25, 50, 100, 200].includes(Number(searchParams.get('limit'))) ? Number(searchParams.get('limit')) : 25;
|
||||||
const [limit, setLimit] = useState(20);
|
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 [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
const [sspList, setSspList] = useState([]);
|
const [sspList, setSspList] = useState([]);
|
||||||
@ -86,8 +95,10 @@ export default function TasksPage() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const updateFilter = (name, value) => {
|
const updateFilter = (name, value) => {
|
||||||
|
restoreVisit.current = null;
|
||||||
setSearchParams((currentParams) => {
|
setSearchParams((currentParams) => {
|
||||||
const nextParams = new URLSearchParams(currentParams);
|
const nextParams = new URLSearchParams(currentParams);
|
||||||
|
nextParams.set('page', '1');
|
||||||
if (value) {
|
if (value) {
|
||||||
nextParams.set(name, value);
|
nextParams.set(name, value);
|
||||||
} else {
|
} else {
|
||||||
@ -164,6 +175,7 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
const loadTasks = useCallback(
|
const loadTasks = useCallback(
|
||||||
async (currentPage, currentLimit) => {
|
async (currentPage, currentLimit) => {
|
||||||
|
const currentRequest = ++requestId.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const skip = (currentPage - 1) * currentLimit;
|
const skip = (currentPage - 1) * currentLimit;
|
||||||
@ -177,26 +189,44 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
const res = await TasksApi.list(params);
|
const res = await TasksApi.list(params);
|
||||||
const data = res.result;
|
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);
|
setItems(data);
|
||||||
setTotalCount(res.count);
|
setTotalCount(res.count);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error('Ошибка загрузки задач');
|
if (currentRequest === requestId.current) toast.error('Ошибка загрузки задач');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (currentRequest === requestId.current) setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[yearFilter, formTypeFilter, orgUnitIdsFilter],
|
[yearFilter, formTypeFilter, orgUnitIdsFilter, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePageChange = (_event, value) => {
|
const handlePageChange = (_event, value) => {
|
||||||
|
restoreVisit.current = null;
|
||||||
setPage(value);
|
setPage(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLimitChange = (event) => {
|
const handleLimitChange = (event) => {
|
||||||
const newLimit = event.target.value;
|
const newLimit = event.target.value;
|
||||||
setLimit(newLimit);
|
updateFilter('limit', newLimit);
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -216,17 +246,20 @@ export default function TasksPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTasks(page, limit);
|
loadTasks(page, limit);
|
||||||
|
return () => {
|
||||||
|
requestId.current++;
|
||||||
|
};
|
||||||
}, [page, limit, loadTasks]);
|
}, [page, limit, loadTasks]);
|
||||||
|
|
||||||
const handleFilterChange = (name, value) => {
|
const handleFilterChange = (name, value) => {
|
||||||
setPage(1);
|
|
||||||
updateFilter(name, value);
|
updateFilter(name, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearFilters = () => {
|
const handleClearFilters = () => {
|
||||||
setPage(1);
|
restoreVisit.current = null;
|
||||||
setSearchParams((currentParams) => {
|
setSearchParams((currentParams) => {
|
||||||
const nextParams = new URLSearchParams(currentParams);
|
const nextParams = new URLSearchParams(currentParams);
|
||||||
|
nextParams.set('page', '1');
|
||||||
nextParams.delete('year');
|
nextParams.delete('year');
|
||||||
nextParams.delete('form_type');
|
nextParams.delete('form_type');
|
||||||
nextParams.delete('org_units');
|
nextParams.delete('org_units');
|
||||||
@ -241,6 +274,8 @@ export default function TasksPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<HeaderSwitch />
|
<HeaderSwitch />
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
|
skeletonVariant='task'
|
||||||
|
skeletonCount={limit}
|
||||||
items={items}
|
items={items}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
totalCount={totalCount}
|
totalCount={totalCount}
|
||||||
@ -251,7 +286,7 @@ export default function TasksPage() {
|
|||||||
onLimitChange={handleLimitChange}
|
onLimitChange={handleLimitChange}
|
||||||
entityName='задач'
|
entityName='задач'
|
||||||
emptyMessage='Задачи не найдены'
|
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}
|
getItemId={(task) => task.id}
|
||||||
getItemTitle={(task) =>
|
getItemTitle={(task) =>
|
||||||
task.title ||
|
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