fix-style #154

Merged
PotapovaA merged 3 commits from fix-style into test 2026-09-11 15:23:46 +03:00
23 changed files with 509 additions and 278 deletions
Showing only changes of commit 9f19695db4 - Show all commits

View File

@ -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);

View File

@ -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`

View File

@ -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>
);

View File

@ -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) => {

View File

@ -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',

View 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>
);

View File

@ -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'>

View File

@ -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'>

View File

@ -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>
);

View File

@ -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;

View File

@ -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}

View 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;

View File

@ -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>
</>
);
};

View File

@ -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;

View 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);
}
`;

View File

@ -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}

View File

@ -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}

View File

@ -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',

View File

@ -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 } : {}),
},

View File

@ -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>
);

View File

@ -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 ||

View 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;
};

View 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; }
};