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/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index f92fbd3..f224226 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -7,7 +7,6 @@ const BASE_CELL_STYLES = { height: '100%', display: 'flex', alignItems: 'center', - justifyContent: 'center', boxSizing: 'border-box', position: 'absolute', top: 0, @@ -98,6 +97,7 @@ const formatNumber = (value, asInteger = false) => { if (value === null || value === undefined || value === '') return String(value || ''); const num = Number(value); if (Number.isNaN(num)) return String(value); + if (num === 0) return '-'; return num.toLocaleString('ru-RU', { minimumFractionDigits: asInteger ? 0 : 1, maximumFractionDigits: asInteger ? 0 : 1, @@ -141,9 +141,10 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, const isLocked = useCellLock(cellKey); const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown'; + const nameIndent = column.id === 'data.header.name' ? 8 + row.depth * 16 : 8; // Мемоизация значения - const { rawValue, displayValue } = useMemo(() => { + const { rawValue, displayValue, isNumeric } = useMemo(() => { const value = cell.getValue(); const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)); let display = String(value || ''); @@ -177,13 +178,16 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, const cellStyles = useMemo( () => ({ ...BASE_CELL_STYLES, + paddingLeft: nameIndent, + justifyContent: isNumeric ? 'flex-end' : 'flex-start', + textAlign: isNumeric ? 'right' : 'left', backgroundColor: isLocked ? '#f5f5f5' : backgroundColor, color: textColor, ...(isLocked ? LOCKED_STYLES : {}), ...(isInvalid ? INVALID_STYLES : {}), ...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES), }), - [backgroundColor, isInvalid, isLocked, textColor, isEditable], + [backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent], ); const lockBadge = useMemo(() => { @@ -211,6 +215,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => { return ( prevProps.cell.getValue() === nextProps.cell.getValue() && prevProps.row.id === nextProps.row.id && + prevProps.row.depth === nextProps.row.depth && prevProps.column.id === nextProps.column.id && prevProps.globalFilter === nextProps.globalFilter && prevProps.columnFilter === nextProps.columnFilter && diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index 66ce216..acdf758 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,6 +1,6 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useRealtimeActions } from '../../contexts/RealtimeContext'; -import { calculateFormula, extractFormula, formatNumber, isFormula } from '../../utils/formulaUtils'; +import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils'; import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; const EDITOR_STYLES = { @@ -144,7 +144,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta displayValue: currentValue, isFormulaMode: false, })); - onChange?.(currentValue); + onChange?.(normalizeNumericInput(currentValue)); } }, [state, storageKey, onChange]); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 827babb..e03d971 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -21,8 +21,10 @@ import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; import { useTableScale } from './hooks/useTableScale'; +import { useBudgetYear } from './hooks/useBudgetYear'; import { useValidationRules } from './hooks/useValidationRules'; import { getRowId } from './utils/rowUtils'; +import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; @@ -39,24 +41,6 @@ const ROW_VIRTUALIZER_OPTIONS = { measureElement: (element) => element?.offsetHeight || TABLE_ROW_HEIGHT, }; -const getColumnVirtualizerOptions = ({ table }) => { - const orderedVisibleColumns = table.getVisibleLeafColumns(); - const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150; - const scrollPaddingStart = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); - const scrollPaddingEnd = table.getRightVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); - - return { - overscan: 10, - scrollPaddingStart, - scrollPaddingEnd, - estimateSize: getColumnSize, - measureElement: (element) => { - if (!element) return 150; - return getColumnSize(Number(element.getAttribute('data-index'))); - }, - }; -}; - const hasVspDropdown = (columns) => columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns))); @@ -91,6 +75,7 @@ const loadingOverlayStyle = { }; const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { + const budgetYear = useBudgetYear(formId, formType, year); const { user } = useAuth(); const vspOptions = useVspOptions(); const userRoleId = user?.role_id; @@ -372,6 +357,20 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { columnVirtualizerOptions: getColumnVirtualizerOptions, enableRowSelection: true, onRowSelectionChange: setRowSelection, + muiTableBodyRowProps: ({ row }) => ({ + onClick: () => setRowSelection({ [row.id]: true }), + hover: false, + sx: { + '& > td::after': { + content: row.getIsSelected() ? '""' : 'none', + position: 'absolute', + inset: 0, + backgroundColor: 'rgba(0, 0, 0, 0.08)', + pointerEvents: 'none', + zIndex: 11, + }, + }, + }), onColumnSizingChange: setColumnSizing, onColumnPinningChange: setColumnPinning, onExpandedChange: setExpanded, @@ -493,7 +492,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const pinnedIds = new Set([...(columnPinning?.left || []), ...(columnPinning?.right || [])]); if (pinnedIds.has(columnId)) return; - const visibleColumns = table.getVisibleLeafColumns(); + const visibleColumns = getOrderedVisibleColumns(table); const columnIndex = visibleColumns.findIndex((column) => column.id === columnId); if (columnIndex < 0) return; @@ -766,6 +765,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return ( <> { {headerPortalRef.current && createPortal( {columns ? ( { return ( { - return [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns(), ...table.getRightVisibleLeafColumns()]; -}; +import { formatHeaderYear } from '../utils/headerYearUtils'; +import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils'; const getColumnPinnedStyles = (column) => { const pinningPosition = column.getIsPinned(); @@ -16,7 +14,7 @@ const getColumnPinnedStyles = (column) => { }; const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => { - const sortedColumns = getSortedColumns(table); + const sortedColumns = getOrderedVisibleColumns(table); const tableState = table.getState(); const filteredColumns = sortedColumns.filter( (col) => !(col.id in tableState.columnVisibility) || (col.id in tableState.columnVisibility && tableState.columnVisibility[col.id]), @@ -104,7 +102,7 @@ const getColorBrightness = (hexColor) => { return 0.299 * r + 0.587 * g + 0.114 * b; }; -const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth }) => { +const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear }) => { const column = header.column; const headerSize = header.getSize(); const isPinned = Boolean(pinningPosition); @@ -174,7 +172,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth }) maxWidth: 'calc(100% - 1rem)', boxSizing: 'border-box', }}> - {header.isPlaceholder ? null : column.columnDef.header} + {header.isPlaceholder ? null : formatHeaderYear(column.columnDef.header, budgetYear)}
@@ -183,7 +181,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth }) }; const FilterRow = ({ table }) => { - const sortedColumns = getSortedColumns(table); + const sortedColumns = getOrderedVisibleColumns(table); const tableState = table.getState(); const filteredColumns = sortedColumns.filter( @@ -319,7 +317,7 @@ const FilterCell = ({ column }) => { ); }; -const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData }) => { +const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear }) => { const handleHeaderClick = (header) => { if (!header.subHeaders?.length && header.column?.id) { onColumnSelect?.(header.column.id); @@ -356,6 +354,7 @@ const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeW {headerSections.flatMap(({ groups, pinningPosition }) => (groups[rowIndex]?.headers || []).map((header) => ( { 'data-col-id': column.id, sx: { ...getTableHeadCellStyles().sx, + fontSize: '1rem', ...(isPinned && { zIndex: 5 }), }, }; diff --git a/web/src/components/RealtimeTable/hooks/useBudgetYear.js b/web/src/components/RealtimeTable/hooks/useBudgetYear.js new file mode 100644 index 0000000..7af351a --- /dev/null +++ b/web/src/components/RealtimeTable/hooks/useBudgetYear.js @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import { TasksApi } from '../../../api/tasks'; +import { parseBudgetYear } from '../utils/headerYearUtils'; + +export const useBudgetYear = (formId, formType, routeYear) => { + const year = parseBudgetYear(routeYear); + const [loadedYear, setLoadedYear] = useState(null); + + useEffect(() => { + if (year !== null || formType === 'PROJECT' || !formId) return; + let cancelled = false; + TasksApi.getById(formId) + .then(({ result }) => { + if (!cancelled) setLoadedYear({ formId, year: parseBudgetYear(result?.year) }); + }) + .catch(() => { + if (!cancelled) toast.error('Не удалось загрузить год сметы'); + }); + return () => { + cancelled = true; + }; + }, [formId, formType, year]); + + return year ?? (formType !== 'PROJECT' && loadedYear?.formId === formId ? loadedYear.year : null); +}; diff --git a/web/src/components/RealtimeTable/utils/columnLayoutUtils.js b/web/src/components/RealtimeTable/utils/columnLayoutUtils.js new file mode 100644 index 0000000..3a541ce --- /dev/null +++ b/web/src/components/RealtimeTable/utils/columnLayoutUtils.js @@ -0,0 +1,32 @@ +export const getOrderedVisibleColumns = (table) => [ + ...table.getLeftVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getRightVisibleLeafColumns(), +]; + +export const getColumnVirtualizerOptions = ({ table }) => { + const columns = getOrderedVisibleColumns(table); + const leftColumns = table.getLeftVisibleLeafColumns(); + const rightColumns = table.getRightVisibleLeafColumns(); + const getColumnSize = (index) => columns[index]?.getSize() ?? 150; + + return { + overscan: 10, + scrollPaddingStart: leftColumns.reduce((width, column) => width + column.getSize(), 0), + scrollPaddingEnd: rightColumns.reduce((width, column) => width + column.getSize(), 0), + estimateSize: getColumnSize, + getItemKey: (index) => columns[index].id, + measureElement: (element) => element ? getColumnSize(Number(element.getAttribute('data-index'))) : 150, + // Pinned indices must use visible positions: hidden pinned columns leave + // gaps in column.getPinnedIndex(), which MRT uses by default. + rangeExtractor: ({ startIndex, endIndex, overscan, count }) => { + const indexes = new Set(); + for (let index = 0; index < leftColumns.length; index++) indexes.add(index); + for (let index = Math.max(0, startIndex - overscan); index <= Math.min(count - 1, endIndex + overscan); index++) { + indexes.add(index); + } + for (let index = count - rightColumns.length; index < count; index++) indexes.add(index); + return [...indexes].sort((a, b) => a - b); + }, + }; +}; diff --git a/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js b/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js new file mode 100644 index 0000000..c1ddf81 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/columnLayoutUtils.test.js @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createTable, getCoreRowModel } from '@tanstack/table-core'; +import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './columnLayoutUtils.js'; + +const createFixture = () => createTable({ + data: [{ number: 1, amount: 100, name: 'Наименование', total: 200 }], + columns: [ + { accessorKey: 'number', size: 50 }, + { accessorKey: 'amount', size: 150 }, + { accessorKey: 'name', size: 400 }, + { accessorKey: 'total', size: 200 }, + ], + state: { columnPinning: { left: [], right: [] }, columnSizing: {}, columnOrder: [], columnVisibility: {} }, + getCoreRowModel: getCoreRowModel(), +}); + +const updateState = (table, state) => table.setOptions((options) => ({ ...options, state: { ...options.state, ...state } })); + +const assertLayoutMatchesBody = (table) => { + const cells = table.getRowModel().rows[0].getVisibleCells(); + const columns = getOrderedVisibleColumns(table); + const virtualizer = getColumnVirtualizerOptions({ table }); + assert.deepEqual(columns.map((column) => column.id), cells.map((cell) => cell.column.id)); + for (const [index, cell] of cells.entries()) { + assert.equal(virtualizer.getItemKey(index), cell.column.id); + assert.equal(virtualizer.estimateSize(index), cell.column.getSize()); + assert.equal(virtualizer.measureElement({ getAttribute: () => String(index) }), cell.column.getSize()); + } +}; + +test('pinning a wide column keeps virtual widths in the same order as body cells', () => { + const table = createFixture(); + assertLayoutMatchesBody(table); + updateState(table, { columnPinning: { left: ['name'], right: ['amount'] } }); + assertLayoutMatchesBody(table); + assert.deepEqual(getOrderedVisibleColumns(table).map((column) => column.id), ['name', 'number', 'total', 'amount']); + updateState(table, { columnPinning: { left: [], right: [] } }); + assertLayoutMatchesBody(table); +}); + +test('hiding and resizing pinned columns keeps offsets and visible ranges consistent', () => { + const table = createFixture(); + updateState(table, { + columnPinning: { left: ['number', 'name'], right: ['total'] }, + columnVisibility: { number: false }, + columnSizing: { name: 480 }, + }); + assertLayoutMatchesBody(table); + const virtualizer = getColumnVirtualizerOptions({ table }); + assert.equal(virtualizer.scrollPaddingStart, 480); + assert.equal(virtualizer.scrollPaddingEnd, 200); + assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 1, endIndex: 1, overscan: 0, count: 3 }), [0, 1, 2]); +}); + +test('scrolling to the far edge keeps both pinned sides rendered', () => { + const table = createFixture(); + updateState(table, { columnPinning: { left: ['name'], right: ['amount'] } }); + const virtualizer = getColumnVirtualizerOptions({ table }); + assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 2, endIndex: 2, overscan: 0, count: 4 }), [0, 2, 3]); + assert.deepEqual(virtualizer.rangeExtractor({ startIndex: 0, endIndex: 0, overscan: 0, count: 4 }), [0, 3]); +}); diff --git a/web/src/components/RealtimeTable/utils/formulaUtils.js b/web/src/components/RealtimeTable/utils/formulaUtils.js index 863601a..b36151c 100644 --- a/web/src/components/RealtimeTable/utils/formulaUtils.js +++ b/web/src/components/RealtimeTable/utils/formulaUtils.js @@ -8,6 +8,13 @@ export const isFormula = (value) => { return value.startsWith('='); }; +export const normalizeNumericInput = (value) => { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!/^[+-]?(?:\d+(?:[.,]\d*)?|[.,]\d+)$/.test(trimmed)) return value; + return trimmed.replace(',', '.'); +}; + /** * Извлекает формулу из строки (удаляет знак =) * @param {string} value - Значение ячейки @@ -27,8 +34,8 @@ export const extractFormula = (value) => { */ export const calculateFormula = (expression) => { try { - // Удаляем пробелы - const cleanExpr = expression.replace(/\s/g, ''); + // Удаляем пробелы и приводим десятичную запятую к точке + const cleanExpr = expression.replace(/\s/g, '').replace(/,/g, '.'); // Проверяем на допустимые символы (только цифры, операторы, скобки, точка) const validChars = /^[\d+\-*/%().]+$/; diff --git a/web/src/components/RealtimeTable/utils/headerYearUtils.js b/web/src/components/RealtimeTable/utils/headerYearUtils.js new file mode 100644 index 0000000..ad0ddfd --- /dev/null +++ b/web/src/components/RealtimeTable/utils/headerYearUtils.js @@ -0,0 +1,15 @@ +// {year}, {year+1}, {year+2}; :short keeps the two-digit year format. +export const parseBudgetYear = (value) => { + const year = Number(value); + return Number.isInteger(year) && year >= 1000 && year <= 9999 ? year : null; +}; + +export const formatHeaderYear = (header, budgetYear) => { + if (typeof header !== 'string') return header; + const year = parseBudgetYear(budgetYear); + return header.replace(/\{year([+-]\d+)?(:short)?\}/g, (_token, offset, short) => { + if (year === null) return offset ? `(год сметы${offset})` : 'год сметы'; + const result = String(year + Number(offset || 0)); + return short ? result.slice(-2) : result; + }); +}; diff --git a/web/src/components/RealtimeTable/utils/headerYearUtils.test.js b/web/src/components/RealtimeTable/utils/headerYearUtils.test.js new file mode 100644 index 0000000..deb5b2b --- /dev/null +++ b/web/src/components/RealtimeTable/utils/headerYearUtils.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { formatHeaderYear, parseBudgetYear } from './headerYearUtils.js'; + +test('headers follow the estimate year and retain relative payment years', () => { + const header = 'План {year}; платежи {year+1} и {year+2}'; + assert.equal(formatHeaderYear(header, '2026'), 'План 2026; платежи 2027 и 2028'); + assert.equal(formatHeaderYear(header, 2029), 'План 2029; платежи 2030 и 2031'); +}); + +test('short year labels handle a century boundary', () => { + assert.equal(formatHeaderYear('1 кв. {year:short}; {year+1:short}', 2099), '1 кв. 99; 00'); +}); + +test('missing route years have a readable fallback without guessing a year', () => { + for (const year of [undefined, null, '', 'undefined', 'null', 'invalid', 2026.5]) { + assert.equal(parseBudgetYear(year), null); + assert.equal(formatHeaderYear('План {year}', year), 'План год сметы'); + } +}); + +test('ordinary labels and non-string headers are preserved', () => { + assert.equal(formatHeaderYear('Наименование', 2026), 'Наименование'); + assert.equal(formatHeaderYear('', 2026), ''); + assert.equal(formatHeaderYear(null, 2026), null); +}); 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/CollapsibleTree.jsx b/web/src/components/common/CollapsibleTree.jsx index afb3194..05f6357 100644 --- a/web/src/components/common/CollapsibleTree.jsx +++ b/web/src/components/common/CollapsibleTree.jsx @@ -4,10 +4,12 @@ import { Box, Checkbox, InputAdornment, Popover, TextField, Typography } from '@ import { SimpleTreeView, TreeItem } from '@mui/x-tree-view'; import { memo, useMemo, useState } from 'react'; import { filterColumnTree, getColumnTreeLeafKeys } from '../RealtimeTable/utils/columnUtils'; +import { formatHeaderYear } from '../RealtimeTable/utils/headerYearUtils'; const EMPTY_IDS = []; const CollapsibleTree = ({ + budgetYear, columnTree, excludeColumnKeys = EMPTY_IDS, placeholder = 'Скрыть столбцы', @@ -110,7 +112,7 @@ const CollapsibleTree = ({ checkedIcon={} indeterminateIcon={} /> - {node.columnDef?.header || node.name || node.header || 'Без названия'} + {formatHeaderYear(node.columnDef?.header || node.name || node.header || 'Без названия', budgetYear)} }> {node.columns && node.columns.length > 0 && renderTree(node.columns)} @@ -121,7 +123,7 @@ const CollapsibleTree = ({ const treeElements = useMemo(() => { return visibleColumnTree.length > 0 ? renderTree(visibleColumnTree) : null; - }, [visibleColumnTree, selectedNodes]); + }, [visibleColumnTree, selectedNodes, budgetYear]); const handleOpen = (event) => { setAnchorEl(event.currentTarget); 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 9cfde52..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); @@ -14,10 +16,12 @@ const TablesList = ({ filteredTables, query, basePath = '/table', formInfo, isPr if (!formInfo) { return; } - const path = `${basePath}/form/${formInfo.id}/form-type/${isProject ? 'PROJECT' : formInfo.form_type_code}/${table.sheet}/${table?.direction}/${table?.year}`; + const tableYear = isProject ? table.year : formInfo.year; + const path = `${basePath}/form/${formInfo.id}/form-type/${isProject ? 'PROJECT' : formInfo.form_type_code}/${table.sheet}/${table?.direction}/${tableYear}`; 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/constants/constants.jsx b/web/src/constants/constants.jsx index a52d90c..a6bce19 100644 --- a/web/src/constants/constants.jsx +++ b/web/src/constants/constants.jsx @@ -64,7 +64,7 @@ export const ORG_UNIT_TYPE_OPTIONS = [ export const SHEET_NAME = { AHR: 'АХР', - CAP: 'КВП', + CAP: 'КВ', OPER: 'Операц', AHR_LIMIT: 'АХР Лимиты', AHR_RENT: 'АХР Аренда', 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/Svod2Page/Svod2Page.jsx b/web/src/pages/Svod2Page/Svod2Page.jsx index 81af2e4..59db4b6 100644 --- a/web/src/pages/Svod2Page/Svod2Page.jsx +++ b/web/src/pages/Svod2Page/Svod2Page.jsx @@ -72,7 +72,7 @@ const CARRYOVER_HEADERS = { const ESTIMATE_TYPE_STYLES = { АХР: { backgroundColor: '#ecf9f0', borderColor: '#a7d9b8', color: '#08783e' }, Операц: { backgroundColor: '#eff6ff', borderColor: '#93c5fd', color: '#1d4ed8' }, - КВП: { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' }, + КВ: { backgroundColor: '#fff7ed', borderColor: '#fdba74', color: '#c2410c' }, }; const createCorrectionColumns = (quarter) => ({ 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; } +};