From 9f19695db4ca83410f5140b422bbdcfe2e8c1eac Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 11 Sep 2026 15:22:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8E?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=D0=B4=D0=BD=D0=B8=D1=85=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D1=85=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=20=D0=B8=20=D0=BB=D0=B8=D1=81=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/app/context/AuthProvider.jsx | 2 + web/src/components/HeaderMenu/HeaderMenu.jsx | 110 ++++++++--------- .../components/HeaderMenu/HeaderSwitch.jsx | 44 ++++--- web/src/components/Stages/SettingModal.jsx | 39 ------ web/src/components/Stages/constants.js | 2 +- web/src/components/common/LastOpenedBadge.jsx | 14 +++ web/src/components/common/PaginatedList.jsx | 8 +- web/src/components/common/Skeleton.jsx | 34 ++++++ .../components/common/TableCard/TableCard.jsx | 49 +++++--- .../common/TableCard/TableCard.styled.jsx | 3 +- .../components/common/TableList/TableList.jsx | 3 + web/src/components/common/TaskBreadcrumbs.jsx | 34 ++++++ .../components/common/TaskCardComponent.jsx | 114 +++++------------- web/src/components/styles/StyledChip.jsx | 4 +- web/src/components/styles/TaskCard.style.jsx | 39 ++++++ web/src/pages/NewFormTablePage.jsx | 45 +++++-- .../ProjectPages/NavigationProjectPage.jsx | 84 +++++++++---- web/src/pages/ProjectPages/columns.js | 10 +- .../ProjectsTable/ProjectsTable.jsx | 32 ++++- web/src/pages/TaskPage/TaskPage.jsx | 9 +- web/src/pages/TasksPage/TasksPage.jsx | 59 +++++++-- web/src/utils/listNavigation.js | 34 ++++++ web/src/utils/sheetNavigation.js | 15 +++ 23 files changed, 509 insertions(+), 278 deletions(-) create mode 100644 web/src/components/common/LastOpenedBadge.jsx create mode 100644 web/src/components/common/TaskBreadcrumbs.jsx create mode 100644 web/src/components/styles/TaskCard.style.jsx create mode 100644 web/src/utils/listNavigation.js create mode 100644 web/src/utils/sheetNavigation.js diff --git a/web/src/app/context/AuthProvider.jsx b/web/src/app/context/AuthProvider.jsx index 964ee0f..2904de2 100644 --- a/web/src/app/context/AuthProvider.jsx +++ b/web/src/app/context/AuthProvider.jsx @@ -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); diff --git a/web/src/components/HeaderMenu/HeaderMenu.jsx b/web/src/components/HeaderMenu/HeaderMenu.jsx index 45241c9..ced34eb 100644 --- a/web/src/components/HeaderMenu/HeaderMenu.jsx +++ b/web/src/components/HeaderMenu/HeaderMenu.jsx @@ -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 ( - - - - - - {user ? getInitials() : ''} - - -
{user?.full_name}
-
{user?.role}
-
+ return ( + + + + + + {user ? getInitials() : ''} + + +
{user?.full_name}
+
{user?.role}
+
- {/* на стенде прода не показываем кнопку выхода */} - {isTestMode && } - {isTestMode && isMenuOpen && ( - - Выйти - - )} -
-
- ); + {/* на стенде прода не показываем кнопку выхода */} + {isTestMode && } + {isTestMode && isMenuOpen && ( + + Выйти + + )} +
+
+ ); } const HeaderMenuTag = styled.div` diff --git a/web/src/components/HeaderMenu/HeaderSwitch.jsx b/web/src/components/HeaderMenu/HeaderSwitch.jsx index 7a29ae1..cec6291 100644 --- a/web/src/components/HeaderMenu/HeaderSwitch.jsx +++ b/web/src/components/HeaderMenu/HeaderSwitch.jsx @@ -68,21 +68,26 @@ export function HeaderSwitch() { Задачи - -
- -
- Проекты -
+ {(user.is_executor_rf || user.role_id === ROLES_NAME_ID.admin || user.role_id === ROLES_NAME_ID.executor_durrs) && - location.pathname.startsWith(path)) ? 'active' : ''}> -
- location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} /> -
- Своды -
+ +
+ +
+ Проекты +
+ } + {(user.role_id === ROLES_NAME_ID.admin || user.role_id === ROLES_NAME_ID.executor_dfip) && + location.pathname.startsWith(path)) ? 'active' : ''}> +
+ location.pathname.startsWith(path)) ? '#258141' : '#99A1AF'} /> +
+ Своды +
+ } {user.role_id === ROLES_NAME_ID.admin && ( +
@@ -90,12 +95,15 @@ export function HeaderSwitch() { Администрирование )} - -
- -
- Справочники -
+ {(user.is_executor_rf || user.role_id === ROLES_NAME_ID.admin) && + +
+ +
+ Справочники +
+ } + ); diff --git a/web/src/components/Stages/SettingModal.jsx b/web/src/components/Stages/SettingModal.jsx index 32a59b7..39578f8 100644 --- a/web/src/components/Stages/SettingModal.jsx +++ b/web/src/components/Stages/SettingModal.jsx @@ -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) => { diff --git a/web/src/components/Stages/constants.js b/web/src/components/Stages/constants.js index 044e6f3..1979f6b 100644 --- a/web/src/components/Stages/constants.js +++ b/web/src/components/Stages/constants.js @@ -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', diff --git a/web/src/components/common/LastOpenedBadge.jsx b/web/src/components/common/LastOpenedBadge.jsx new file mode 100644 index 0000000..5316cc4 --- /dev/null +++ b/web/src/components/common/LastOpenedBadge.jsx @@ -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 = {} }) => ( + {children} +); diff --git a/web/src/components/common/PaginatedList.jsx b/web/src/components/common/PaginatedList.jsx index dec9530..25b644f 100644 --- a/web/src/components/common/PaginatedList.jsx +++ b/web/src/components/common/PaginatedList.jsx @@ -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({ {/* Список элементов */} {loading ? ( - + (skeletonVariant === 'task' ? : ) ) : items.length === 0 ? ( diff --git a/web/src/components/common/Skeleton.jsx b/web/src/components/common/Skeleton.jsx index c7419df..2ee7c7b 100644 --- a/web/src/components/common/Skeleton.jsx +++ b/web/src/components/common/Skeleton.jsx @@ -1,3 +1,37 @@ +import { Box, Skeleton } from '@mui/material'; +import { TaskCard } from '../styles/TaskCard.style'; + +export function TaskCardsSkeleton({ count = 20 }) { + return ( + + {Array.from({ length: count }, (_, index) => ( + + ))} + + ); +} + export function LinesSkeleton({ lines = 20 }) { return (
diff --git a/web/src/components/common/TableCard/TableCard.jsx b/web/src/components/common/TableCard/TableCard.jsx index 9f08245..07caf4e 100644 --- a/web/src/components/common/TableCard/TableCard.jsx +++ b/web/src/components/common/TableCard/TableCard.jsx @@ -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 ( -
-
- -
-
-
- -

{table.name}

-

{DIRECTION_TRANSLATE[table?.direction] || ''}

- {/* -
-
*/} -
-
-
event.stopPropagation()}>{action || }
+
+
+ + {isLastOpened && } + + +
+
+
+ + +

{table.name}

+

{DIRECTION_TRANSLATE[table?.direction] || ''}

+
+ + + + {action ?
event.stopPropagation()}>{action}
: } +
+ +
+ + + +
); diff --git a/web/src/components/common/TableCard/TableCard.styled.jsx b/web/src/components/common/TableCard/TableCard.styled.jsx index c3b44f2..4652a9b 100644 --- a/web/src/components/common/TableCard/TableCard.styled.jsx +++ b/web/src/components/common/TableCard/TableCard.styled.jsx @@ -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; diff --git a/web/src/components/common/TableList/TableList.jsx b/web/src/components/common/TableList/TableList.jsx index 2cac9e7..748dd7f 100644 --- a/web/src/components/common/TableList/TableList.jsx +++ b/web/src/components/common/TableList/TableList.jsx @@ -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 ( handleNavigate(path)} table={table} diff --git a/web/src/components/common/TaskBreadcrumbs.jsx b/web/src/components/common/TaskBreadcrumbs.jsx new file mode 100644 index 0000000..9507f12 --- /dev/null +++ b/web/src/components/common/TaskBreadcrumbs.jsx @@ -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 ( + + {isProject ? 'Проекты' : 'Задачи'} + {sheetName ? ( + {taskLabel} + ) : ( + {taskLabel} + )} + {sheetName && {currentSheetLabel}} + + ); +}; + +export default TaskBreadcrumbs; diff --git a/web/src/components/common/TaskCardComponent.jsx b/web/src/components/common/TaskCardComponent.jsx index 382a360..fdbc4d3 100644 --- a/web/src/components/common/TaskCardComponent.jsx +++ b/web/src/components/common/TaskCardComponent.jsx @@ -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 ( <> { 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 = ({ ID: {id} - {/* TODO: вернется, когда будут реализованы статусы на бэке */} - {/* */} - - {!isForm && !exportMode && } - {/* */} - + {isLastOpened && Последняя открытая} - {currentTitle} + {org_unit?.title || 'Подразделение не указано'} }>{year} - }> - {org_unit.title} + }> + {currentTypeCode} - {/* }> - Таблиц: {tables_count} - */} + + {!isForm && !exportMode && } {exportMode &&
{isChecked ? : }
}
- - - - - - }> -
- - {isForm ? 'Название формы' : 'Название задачи'} - setEditedTitle(e.target.value)} - autoFocus - /> - -
-
); }; diff --git a/web/src/components/styles/StyledChip.jsx b/web/src/components/styles/StyledChip.jsx index 71022a2..364113e 100644 --- a/web/src/components/styles/StyledChip.jsx +++ b/web/src/components/styles/StyledChip.jsx @@ -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; diff --git a/web/src/components/styles/TaskCard.style.jsx b/web/src/components/styles/TaskCard.style.jsx new file mode 100644 index 0000000..7e10dd5 --- /dev/null +++ b/web/src/components/styles/TaskCard.style.jsx @@ -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); + } +`; diff --git a/web/src/pages/NewFormTablePage.jsx b/web/src/pages/NewFormTablePage.jsx index f89dc21..858db1d 100644 --- a/web/src/pages/NewFormTablePage.jsx +++ b/web/src/pages/NewFormTablePage.jsx @@ -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 = ( - - {sheetName && ( - - {tableName} - {directionName ? ` — ${directionName}` : ''} - - )} + ); @@ -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 && } + {formId && } {user && ( { 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 = () => { { - 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 }) => ( + + {cell.getValue()} + {row.depth === 0 && String(row.original.id) === lastOpenedId && ( + Последний открытый + )} + + ), size: 200, filterFn: 'contains', filterVariant: 'text', diff --git a/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx b/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx index a56ae40..144ffd2 100644 --- a/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx +++ b/web/src/pages/ProjectPages/components/ProjectsTable/ProjectsTable.jsx @@ -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 } : {}), }, diff --git a/web/src/pages/TaskPage/TaskPage.jsx b/web/src/pages/TaskPage/TaskPage.jsx index b12c007..bd346b9 100644 --- a/web/src/pages/TaskPage/TaskPage.jsx +++ b/web/src/pages/TaskPage/TaskPage.jsx @@ -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 = ( - - {isProject ? 'Проект' : FORM_TYPE_TRANSLATE[task.form_type_code] || ''} + ); diff --git a/web/src/pages/TasksPage/TasksPage.jsx b/web/src/pages/TasksPage/TasksPage.jsx index 1dc2c01..28d01eb 100644 --- a/web/src/pages/TasksPage/TasksPage.jsx +++ b/web/src/pages/TasksPage/TasksPage.jsx @@ -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() { } + renderItem={(task, exportProps) => } getItemId={(task) => task.id} getItemTitle={(task) => task.title || diff --git a/web/src/utils/listNavigation.js b/web/src/utils/listNavigation.js new file mode 100644 index 0000000..fdb8b58 --- /dev/null +++ b/web/src/utils/listNavigation.js @@ -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; +}; diff --git a/web/src/utils/sheetNavigation.js b/web/src/utils/sheetNavigation.js new file mode 100644 index 0000000..40d0137 --- /dev/null +++ b/web/src/utils/sheetNavigation.js @@ -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; } +};