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