AURORA-1891: редактирование одним кликом и с клавиатуры, без лишних записей в аудит #290

Merged
aep merged 8 commits from feat/284-aurora-1891-cell-click-keyboard-edit into test 2026-11-02 21:05:58 +03:00
10 changed files with 577 additions and 43 deletions
Showing only changes of commit 8558670b46 - Show all commits

View File

@ -152,7 +152,6 @@ const CellComponent = ({
cell,
row,
column,
onClick,
globalFilter,
columnFilter,
backgroundColor,
@ -236,22 +235,12 @@ const CellComponent = ({
return <div style={LOCK_BADGE_STYLES}>🔒</div>;
}, [isLocked]);
const handleClick = useCallback(() => {
if (!isLocked && isEditable && onClick) {
onClick();
}
}, [isLocked, isEditable, onClick]);
// AURORA-1891: клик теперь обрабатывает muiTableBodyCellProps (RealtimeTable.jsx)
// — там же проверяется редактируемость/блокировка и открывается редактор одним
// кликом; onClick/handleClick здесь были мёртвым кодом (tableColumns.jsx никогда
// не передавал onClick в Cell).
const cellNode = (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div
type='button'
className='cell'
style={cellStyles}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='cell' style={cellStyles} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
{lockBadge}
</div>

View File

@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import { toast } from 'react-toastify';
import { nonNegativeColumnKeys } from '../../constants/validation';
import { useRealtimeActions } from '../../contexts/RealtimeContext';
import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils';
import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, normalizeNumericInput } from '../../utils/formulaUtils';
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
const EDITOR_STYLES = {
@ -35,15 +35,29 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
const { endEditing: contextEndEditing } = useRealtimeActions();
const storageKey = `formula_${tableId}_${cellId}`;
const [state, setState] = useState(() => ({
currentValue: value,
displayValue: value,
isFormulaMode: false,
}));
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
// подставляют этот символ (или пустую строку) вместо текущего значения —
// RealtimeTable хранит его в своём state и отдаёт через
// table.options.meta.getEditorInitialInput(), тот же приём, что уже
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
// Инициализатор useState вызывается один раз при монтировании — ровно
// тогда, когда этот редактор открылся именно так.
const [state, setState] = useState(() => {
const initialInput = table?.options?.meta?.getEditorInitialInput?.();
if (initialInput !== null && initialInput !== undefined) {
return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
}
return { currentValue: value, displayValue: value, isFormulaMode: false };
});
// Позиция ячейки
const [position, setPosition] = useState();
const textareaRef = useRef(null);
// AURORA-1891: <td> этой ячейки — чтобы после закрытия редактора вернуть
// туда фокус (иначе стрелки/клавиатурная навигация отваливаются после
// первого же редактирования, см. muiTableBodyCellProps.onKeyDown).
const tdRef = useRef(null);
const refFinished = useRef(false);
@ -77,6 +91,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
const tr = td?.offsetParent;
if (td && tr) {
tdRef.current = td;
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
@ -85,6 +100,15 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
}
}, [refCell]);
// AURORA-1891: при закрытии редактора (размонтирование) фокус возвращается
// в <td> этой ячейки — без этого фокус терялся бы на удалённом textarea,
// и стрелки/Tab/Enter перестают работать сразу после первого редактирования.
useEffect(() => {
return () => {
tdRef.current?.focus?.();
};
}, []);
// Auto-resize
useLayoutEffect(() => {
const el = textareaRef.current;
@ -105,15 +129,18 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
}, [state.currentValue]);
useEffect(() => {
// Небольшая задержка для гарантии, что DOM полностью готов
const timeoutId = setTimeout(() => {
// AURORA-1891: setTimeout(50) был произвольной задержкой "на всякий
// случай"; requestAnimationFrame ждёт ровно до следующего кадра
// отрисовки — DOM (textarea) к этому моменту уже готов, фокус приходит
// на кадр раньше и без угадывания длительности.
const frameId = requestAnimationFrame(() => {
if (textareaRef.current && !disabled) {
textareaRef.current.focus();
textareaRef.current.select();
}
}, 50);
});
return () => clearTimeout(timeoutId);
return () => cancelAnimationFrame(frameId);
}, [disabled]);
// AURORA-1892: мягкий запрет минуса для заданных колонок — предупреждаем,
@ -163,9 +190,27 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
}));
const normalizedValue = normalizeNumericInput(currentValue);
warnIfNegativeNotAllowed(normalizedValue);
// AURORA-1891: значение не изменилось — не шлём onChange (а значит и
// cell_updated): открытие и закрытие ячейки без правки не должно
// создавать запись в журнале аудита.
if (isSameCellValue(normalizedValue, value)) return;
onChange?.(normalizedValue);
}
}, [state, storageKey, onChange, warnIfNegativeNotAllowed]);
}, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
// table.getState().editingCell, а не cell.row/cell.column: к этому моменту
// handleSave (через onChange -> EditCellPortal.handleChange) уже мог
// вызвать table.setEditingCell(null), но реальным состоянием владеет
// RealtimeTable.jsx (контролируемый state.editingCell), так что
// table.getState().editingCell всё ещё возвращает ячейку, которую мы
// только что редактировали.
const endCurrentEditing = useCallback(() => {
const editingCell = table.getState().editingCell;
if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column);
}
}, [table, contextEndEditing]);
const finishEditing = useCallback(() => {
if (refFinished.current) return;
@ -173,11 +218,16 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
if (!disabled) {
handleSave();
}
const editingCell = table.getState().editingCell;
if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column);
}
}, [disabled, handleSave, table, contextEndEditing]);
endCurrentEditing();
}, [disabled, handleSave, endCurrentEditing]);
// AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого
// cell_updated), только завершение редактирования текущей ячейки.
const cancelEditing = useCallback(() => {
if (refFinished.current) return;
refFinished.current = true;
endCurrentEditing();
}, [endCurrentEditing]);
useEffect(() => {
const handleClickOutside = (event) => {
@ -201,12 +251,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
const handleKeyDown = useCallback(
(e) => {
if (refFinished.current) return;
// AURORA-1891: отмена — значение не сохраняется, cell_updated не шлётся.
if (e.key === 'Escape') {
e.preventDefault();
cancelEditing();
return;
}
// AURORA-1891: Tab/Shift+Tab — сохранить и перейти к следующей/предыдущей
// редактируемой ячейке в строке. Переход резолвит и прокручивает
// table.options.meta.openNextEditableCell (RealtimeTable.jsx); сама
// ячейка открывается позже, в эффекте, который ждёт cell_edit_end
// текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем.
if (e.key === 'Tab') {
e.preventDefault();
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right');
finishEditing();
return;
}
// Shift+Enter — перенос строки в textarea (стандартное поведение,
// e.preventDefault() не вызывается).
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
finishEditing();
}
},
[finishEditing],
[cancelEditing, finishEditing, table, cell],
);
const editorStyles = useMemo(

View File

@ -8,7 +8,7 @@ const EDITOR_STYLES = {
top: 0,
};
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table }) => {
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
const { endEditing: contextEndEditing } = useRealtimeActions();
const vspOptions = useVspOptions();
@ -17,6 +17,9 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
const [open, setOpen] = useState(false);
const refFinished = useRef(false);
// AURORA-1891: см. тот же приём в EditCell.jsx — фокус после закрытия
// редактора возвращается в <td>, чтобы клавиатурная навигация не отваливалась.
const tdRef = useRef(null);
useEffect(() => {
const timer = setTimeout(() => {
@ -32,6 +35,7 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
const tr = td?.offsetParent;
if (td && tr) {
tdRef.current = td;
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
@ -40,6 +44,12 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
}
}, [refCell]);
useEffect(() => {
return () => {
tdRef.current?.focus?.();
};
}, []);
const finishEditing = useCallback(() => {
if (refFinished.current) return;
refFinished.current = true;
@ -50,6 +60,32 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
}
}, [table, contextEndEditing]);
// AURORA-1891: та же клавиатурная логика, что в EditCell.jsx — Esc уже
// работает через onClose MUI Select (reason !== 'selectOption' вызывает
// finishEditing без onChange, т.е. без cell_updated — отмена). Здесь
// добираем Tab/Shift+Tab и Enter: сохранить (для dropdown «сохранить» —
// это просто закрыть без выбора, onChange уже случился в handleSelectChange,
// если пользователь кликнул опцию) и перейти к следующей редактируемой ячейке.
const handleKeyDown = useCallback(
(event) => {
if (refFinished.current) return;
if (event.key === 'Tab') {
event.preventDefault();
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, event.shiftKey ? 'left' : 'right');
finishEditing();
return;
}
if (event.key === 'Enter') {
event.preventDefault();
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
finishEditing();
}
},
[cell, finishEditing, table],
);
const handleSelectChange = useCallback(
(e) => {
const val = e.target.value;
@ -92,6 +128,7 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
<Select
value={selectedValue}
onChange={handleSelectChange}
onKeyDown={handleKeyDown}
open={open}
onClose={handleSelectClose}
displayEmpty

View File

@ -18,7 +18,7 @@ import { SelectVspModal } from './Modals/SelectVspModal';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useLockedCellsStore, useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useColumnSettings } from './hooks/useColumnSettings';
import { useHeaderPortal } from './hooks/useHeaderPortal';
import { useTableScale } from './hooks/useTableScale';
@ -26,6 +26,8 @@ import { useBudgetYear } from './hooks/useBudgetYear';
import { useValidationRules } from './hooks/useValidationRules';
import { getRowId } from './utils/rowUtils';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
import { DictVspApi } from '../../api/dict-vsp';
import { useAuth } from '../../app/context/AuthProvider';
@ -97,6 +99,20 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
rowSelectionRef.current = rowSelection;
const [, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null);
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной ячейке,
// открывают редактор и подставляют этот символ (или '') вместо текущего
// значения — EditCell читает его через table.options.meta.getEditorInitialInput
// (ref-обёртка ниже — тот же приём, что у selectedColumnIdRef, чтобь не
// пересоздавать tableMeta на каждое нажатие клавиши).
const [editorInitialInput, setEditorInitialInput] = useState(null);
const editorInitialInputRef = useRef(editorInitialInput);
editorInitialInputRef.current = editorInitialInput;
// AURORA-1891: «отложенная цель» для Tab/Enter — следующая ячейка
// открывается не сразу (сервер ещё не закончил cell_edit_end текущей), а
// в эффекте ниже, когда dataEditingCells (и вместе с ним editingCell)
// станет null. См. ruling в PR про порядок cell_updated -> cell_edit_end
// -> cell_edit_start следующей.
const pendingEditTargetRef = useRef(null);
const [columnsConfig, setColumnsConfig] = useState(null);
const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
@ -143,6 +159,45 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
addProject: contextAddProject,
setVspOptions,
} = useRealtimeActions();
// AURORA-1891: нужен сам стор (не подписка на конкретную ячейку, как
// useCellLock) — для имперактивной проверки «не заблокирована ли эта
// ячейка» внутри onClick/onKeyDown на td (muiTableBodyCellProps ниже).
const lockedCellsStore = useLockedCellsStore();
const isAdminUser = userRoleId === ROLES_NAME_ID.admin;
const stageColumnsSet = useMemo(() => new Set(columnsCurStage), [columnsCurStage]);
// AURORA-1891: та же проверка, что isCellEditable/isEditable в
// tableColumns.jsx, нужна здесь отдельно — у клика/клавиатуры на ячейке
// (и у Tab/Enter перехода) нет доступа к замыканию processColumns.
const cellIsEditable = useCallback(
(row, columnId) =>
isCellEditable({
rowType: row.original?.row_type,
columnId,
isAdmin: isAdminUser,
stageColumns: stageColumnsSet,
}),
[isAdminUser, stageColumnsSet],
);
// AURORA-1891: cellIsEditable(row, columnId) не знает про сам column.def —
// для номера строки (accessorKey 'sort_order') enableEditing жёстко false
// (tableColumns.jsx), а cellIsEditable для INPUT-строки у админа сказала
// бы true. Без этой сверки клик/Tab у админа мог бы попытаться открыть
// (несуществующий) редактор для номера строки вместо выделения строки
// или перехода дальше. Используется и в muiTableBodyCellProps, и в
// openNextEditableCell (Tab/Enter не должны переходить на такую колонку).
const isColumnActuallyEditable = useCallback(
(row, column) => {
if (!cellIsEditable(row, column.id)) return false;
const enableEditing = column.columnDef?.enableEditing;
if (typeof enableEditing === 'function') return enableEditing(row);
if (typeof enableEditing === 'boolean') return enableEditing;
return true;
},
[cellIsEditable],
);
const {
columnSizing,
@ -297,12 +352,23 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const selectedColumnIdRef = useRef(selectedColumnId);
selectedColumnIdRef.current = selectedColumnId;
// AURORA-1891: сама функция объявляется ниже (после создания table — ей
// нужны getOrderedVisibleColumns(table)/table.getRowModel().flatRows), а
// tableMeta собирается до table. Тот же приём, что у selectedColumnIdRef:
// стабильная обёртка в tableMeta, реальная реализация — в ref, который
// обновляется на каждом рендере ниже.
const openNextEditableCellRef = useRef(null);
const tableMeta = useMemo(
() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
// AURORA-1892: человекочитаемый заголовок колонки для предупреждения о запрете минуса.
getColumnTitle: (columnId) => headerPaths[columnId]?.filter(Boolean).join(' / ') || columnId,
// AURORA-1891: «начальный ввод» для EditCell при открытии редактора цифрой/Backspace/Delete.
getEditorInitialInput: () => editorInitialInputRef.current,
// AURORA-1891: Tab/Shift+Tab/Enter из EditCell.jsx/VspDropdownEditCell.jsx.
openNextEditableCell: (row, column, direction) => openNextEditableCellRef.current?.(row, column, direction),
}),
[handleUpdateCell, headerPaths],
);
@ -343,6 +409,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
contextStartEditing,
globalFilterFn,
activeSearchMatch,
isColumnActuallyEditable,
lockedCellsStore,
setEditorInitialInput,
}) => ({
...BASE_TABLE_CONFIG,
globalFilterFn,
@ -354,7 +423,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
columnVirtualizerInstanceRef: columnVirtualizerRef,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
onEditingCellChange: (cell) => {
if (cell && !editingCell) {
// AURORA-1891: защита на случай, если что-то кроме нашего собственного
// onClick/onKeyDown откроет редактор для нередактируемой ячейки —
// cell_edit_start на сервер не уйдёт, блокировка (🔒 у коллег) не возьмётся.
if (cell && !editingCell && isColumnActuallyEditable(cell.row, cell.column)) {
contextStartEditing?.(cell.row, cell.column);
}
},
@ -398,11 +470,54 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
sx: { boxSizing: 'border-box' },
},
muiTableBodyCellProps: (props) => {
const { cell, column, row, table } = props;
const cellProps = getTableBodyCellProps(props);
const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id;
const isActiveMatch = activeSearchMatch?.rowId === row.id && activeSearchMatch?.columnId === column.id;
const cellKey = `${row.id}_${column.id}`;
// AURORA-1891: открыть редактор можно только если ячейка редактируема
// (isColumnActuallyEditable — см. определение выше про колонку
// номера строки), не занята коллегой (lockedCellsStore) и сейчас
// вообще ничего не редактируется. Иначе клик/клавиша не трогают td —
// клик всплывает на строку (её onClick выделяет строку — п.7
// тикета), клавиша просто игнорируется.
const canOpenEditor = () => !editingCell && isColumnActuallyEditable(row, column) && !lockedCellsStore.hasCell(cellKey);
return {
...cellProps,
onClick: (event) => {
if (!canOpenEditor()) return;
// Не даём клику дойти до muiTableBodyRowProps.onClick — иначе
// открытие редактора попутно выделило/сняло бы выделение строки.
event.stopPropagation();
table.setEditingCell(cell);
},
onKeyDown: (event) => {
if (!canOpenEditor()) return;
// Enter/F2 — открыть редактор с текущим значением.
if (event.key === 'Enter' || event.key === 'F2') {
event.preventDefault();
table.setEditingCell(cell);
return;
}
// Delete/Backspace — открыть редактор с пустым значением.
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
setEditorInitialInput('');
table.setEditingCell(cell);
return;
}
// Печатный символ без модификаторов — «начальный ввод»: открыть
// редактор и подставить его вместо текущего значения.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
setEditorInitialInput(event.key);
table.setEditingCell(cell);
}
},
sx: {
...cellProps.sx,
...(isActiveMatch && {
@ -464,6 +579,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
contextStartEditing,
globalFilterFn,
activeSearchMatch,
isColumnActuallyEditable,
lockedCellsStore,
setEditorInitialInput,
}),
[
columns,
@ -482,6 +600,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
expanded,
globalFilterFn,
activeSearchMatch,
isColumnActuallyEditable,
lockedCellsStore,
setEditorInitialInput,
],
);
@ -518,6 +639,60 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
[columnPinning, containerRef, table],
);
// AURORA-1891: Tab/Shift+Tab (direction 'right'/'left', в строке) и Enter
// ('down', по столбцу) — вызывается из EditCell.jsx/VspDropdownEditCell.jsx
// через table.options.meta.openNextEditableCell. Сам обход уже готового
// списка — в чистых findNextEditableColumnId/findNextEditableRowId
// (utils/cellNavigation.js); здесь — только резолв реальных MRT
// row/column (flatRows, не .rows — см. рулинг AURORA-1893 выше в
// handleNavigateToError) и прокрутка к цели, по образцу navigateToSearchMatch.
//
// Открытие целевой ячейки происходит не здесь: сервер должен сначала
// закончить cell_edit_end текущей, поэтому мы только помечаем цель в
// pendingEditTargetRef — реальный table.setEditingCell для неё вызывается
// в эффекте ниже, когда dataEditingCells (а с ним editingCell) станет null.
const openNextEditableCell = useCallback(
(row, column, direction) => {
let targetRowId = row.id;
let targetColumnId = column.id;
if (direction === 'right' || direction === 'left') {
const columns = getOrderedVisibleColumns(table);
const nextColumnId = findNextEditableColumnId({
columns,
currentColumnId: column.id,
step: direction === 'right' ? 1 : -1,
isEditable: (candidateColumn) => isColumnActuallyEditable(row, candidateColumn),
});
if (!nextColumnId) return;
targetColumnId = nextColumnId;
} else if (direction === 'down' || direction === 'up') {
const rows = table.getRowModel().flatRows;
const nextRowId = findNextEditableRowId({
rows,
currentRowId: row.id,
step: direction === 'down' ? 1 : -1,
isEditable: (candidateRow) => isColumnActuallyEditable(candidateRow, column),
});
if (!nextRowId) return;
targetRowId = nextRowId;
} else {
return;
}
pendingEditTargetRef.current = { rowId: targetRowId, columnId: targetColumnId };
handleNavigateToColumn(targetColumnId);
requestAnimationFrame(() => {
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === targetRowId);
if (rowIndex < 0) return;
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
});
},
[table, isColumnActuallyEditable, handleNavigateToColumn],
);
openNextEditableCellRef.current = openNextEditableCell;
// AURORA-1893: переход от плашки «Контроль ошибок» к конкретной ячейке (строка + колонка),
// а не только к колонке, как раньше. Строим по образцу navigateToSearchMatch ниже.
//
@ -643,6 +818,27 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
useEffect(() => {
if (!dataEditingCells?.line_id) {
setEditingCell(null);
// AURORA-1891: Tab/Enter из EditCell.jsx пометили следующую ячейку в
// pendingEditTargetRef (openNextEditableCell выше) — сервер только
// что закончил cell_edit_end текущей (editingCell/dataEditingCells
// стали null), теперь можно открыть следующую, тем же путём, что и
// обычное открытие по клику/double-click (table.setEditingCell
// триггерит onEditingCellChange -> contextStartEditing -> cell_edit_start).
const pendingTarget = pendingEditTargetRef.current;
if (pendingTarget) {
pendingEditTargetRef.current = null;
const animationFrameId = requestAnimationFrame(() => {
try {
const row = table.getRow(pendingTarget.rowId);
const cell = row?.getVisibleCells().find((c) => c.column.id === pendingTarget.columnId);
if (cell) table.setEditingCell(cell);
} catch (error) {
console.error(error);
}
});
return () => cancelAnimationFrame(animationFrameId);
}
return;
}
if (editingCell) return;
@ -660,6 +856,17 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
return () => cancelAnimationFrame(animationFrameId);
}, [dataEditingCells, editingCell, table]);
// AURORA-1891: «начальный ввод» (цифра/Backspace/Delete) нужен только для
// самого первого монтирования EditCell этой ячейки (useState-инициализатор
// там читает его один раз) — как только редактор открылся, очищаем, чтобы
// следующее открытие той же ячейки другим способом не подхватило старое
// значение.
useEffect(() => {
if (editingCell && editorInitialInputRef.current !== null) {
setEditorInitialInput(null);
}
}, [editingCell]);
const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]);
const handleAddRow = useCallback(() => {

View File

@ -78,6 +78,13 @@ export const useCellLock = (cellKey) => {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
};
// AURORA-1891: имперактивная проверка lockedCellsStore.hasCell(key) из
// обработчика клика/клавиатуры на ячейке (muiTableBodyCellProps в
// RealtimeTable.jsx) — там нужен сам стор (не подписка на конкретную ячейку,
// как useCellLock выше), чтобы в момент события синхронно узнать, занята ли
// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
export const useRealtime = () => {
const actions = useRealtimeActions();
const connection = useRealtimeConnection();

View File

@ -134,10 +134,16 @@ export const getTableColumns = ({
// пересборку колонок, а не на каждую ячейку.
const stageColumns = new Set(columnsCurStage);
const isEditable = (row, column) =>
// AURORA-1891: раньше принимала объект колонки (column.id); теперь — голый
// columnId, чтобы один и тот же вызов годился и для настоящих MRT-колонок
// (getCachedCellProps/getCachedEditProps, column.id), и для листовых
// колонок конфига внутри processColumns, у которых своего column.id ещё
// нет — есть только col.accessorKey (то же самое значение после того, как
// MRT построит из него колонку).
const isEditable = (row, columnId) =>
isCellEditable({
rowType: row.original?.row_type,
columnId: column.id,
columnId,
isAdmin: userRoleId === ROLES_NAME_ID.admin,
stageColumns,
});
@ -150,7 +156,7 @@ export const getTableColumns = ({
const cellErrors = getCellErrors?.(column.id, value, row.original) || [];
const isInvalid = cellErrors.length > 0;
const invalidMessage = cellErrors.join('. ');
const isEditableCell = isEditable(row, column);
const isEditableCell = isEditable(row, column.id);
const backgroundColor = getColorCell(columnColors, column, row);
if (!cellPropsCache.has(row)) {
cellPropsCache.set(row, {});
@ -207,7 +213,7 @@ export const getTableColumns = ({
}
const props = {
isEditable: isEditable(row, column),
isEditable: isEditable(row, column.id),
};
rowCache[key] = props;
@ -219,6 +225,9 @@ export const getTableColumns = ({
for (const col of columns) {
col.header = normalizeMonthHeader(col.header);
const usesVspOptions = col.editType === 'vsp_dropdown';
// Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit
// по строкам сами — enableEditing им не нужен, в отличие от листовых.
const isLeafColumn = !col.columns?.length;
col.Cell = ({ cell, table, column, row }) => {
const props = getCachedCellProps(row, column, table);
@ -247,8 +256,15 @@ export const getTableColumns = ({
return editComponent;
};
if (col.editType === 'vsp_dropdown') {
col.enableEditing = (row) => row?.original?.row_type === 'INPUT';
// AURORA-1891: раньше enableEditing выставлялся только для editType
// 'vsp_dropdown' (частный случай) — для остальных листовых колонок MRT
// считал строку INPUT редактируемой всегда, даже вычисляемые поля и
// колонки вне текущего этапа; от этого двойной клик открывал редактор
// (пусть и disabled) и сервер всё равно брал блокировку (🔒 у коллег).
// Теперь единая проверка isCellEditable для каждой листовой колонки —
// MRT не откроет редактор ни по двойному клику, ни программно.
if (isLeafColumn) {
col.enableEditing = (row) => isEditable(row, col.accessorKey);
}
if (col.columns?.length) {

View File

@ -0,0 +1,46 @@
/**
* AURORA-1891: Tab/Shift+Tab и Enter в EditCell.jsx/VspDropdownEditCell.jsx
* вызывают table.options.meta.openNextEditableCell(row, column, direction)
* (RealtimeTable.jsx), который резолвит реальные MRT-строки/колонки
* (getOrderedVisibleColumns, table.getRowModel().flatRows — не .rows, см.
* рулинг AURORA-1893 про expandRows()/paginateExpandedRows: .rows уже
* отфильтрован состоянием свёрнутости групп, и строка в свёрнутой группе
* там попросту не нашлась бы) и предикат isCellEditable, а эти две функции
* делают только сам обход уже готового списка — без знания про MRT.
*/
/**
* Следующая (step: 1, Tab) или предыдущая (step: -1, Shift+Tab) редактируемая
* колонка в той же строке. columns — видимые листовые колонки в порядке
* getOrderedVisibleColumns (скрытые туда не попадают — для этой функции они
* просто не существуют). isEditable(column) — редактируема ли эта колонка
* ИМЕННО в текущей строке (вызывающая сторона уже знает строку).
* Возвращает id следующей редактируемой колонки или null, если дошли до края.
*/
export const findNextEditableColumnId = ({ columns, currentColumnId, step, isEditable }) => {
const startIndex = columns.findIndex((column) => column.id === currentColumnId);
if (startIndex < 0) return null;
for (let index = startIndex + step; index >= 0 && index < columns.length; index += step) {
if (isEditable(columns[index])) return columns[index].id;
}
return null;
};
/**
* Следующая (step: 1, Enter) или предыдущая (step: -1) строка, где заданная
* колонка редактируема. rows — table.getRowModel().flatRows (весь порядок
* строк независимо от текущего состояния свёрнутости групп). isEditable(row)
* — редактируема ли заданная колонка в этой строке (пропускает итоговые
* строки ROOT/GROUP/ITEM, у которых row_type !== 'INPUT').
* Возвращает id следующей строки или null, если дошли до края таблицы.
*/
export const findNextEditableRowId = ({ rows, currentRowId, step, isEditable }) => {
const startIndex = rows.findIndex((row) => row.id === currentRowId);
if (startIndex < 0) return null;
for (let index = startIndex + step; index >= 0 && index < rows.length; index += step) {
if (isEditable(rows[index])) return rows[index].id;
}
return null;
};

View File

@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { findNextEditableColumnId, findNextEditableRowId } from './cellNavigation.js';
// AURORA-1891: Tab/Shift+Tab (в строке) и Enter (по столбцу, вниз/вверх) —
// EditCell.jsx/VspDropdownEditCell.jsx знают только текущую строку/колонку,
// а сам обход видимых листовых колонок (getOrderedVisibleColumns) и строк
// (table.getRowModel().flatRows) и предикат редактируемости (isCellEditable)
// собирает вызывающая сторона (RealtimeTable.jsx meta.openNextEditableCell).
// Здесь — только чистый обход уже готового списка.
// columns — то, что вернул бы getOrderedVisibleColumns: скрытая колонка
// конфига сюда вообще не попала бы, поэтому «пропуск скрытых» проверяется
// самим фактом, что функция работает только с тем, что ей передали, и про
// скрытые колонки ничего не знает.
const COLUMNS = [
{ id: 'a', editable: true },
{ id: 'b', editable: false }, // не редактируется в этой строке (напр. вычисляемая)
{ id: 'c', editable: true },
{ id: 'd', editable: false },
];
const isColumnEditable = (column) => column.editable;
test('Tab: moves right to the next editable column, skipping non-editable ones', () => {
const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'a', step: 1, isEditable: isColumnEditable });
assert.equal(next, 'c');
});
test('Shift+Tab: moves left to the previous editable column', () => {
const prev = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'c', step: -1, isEditable: isColumnEditable });
assert.equal(prev, 'a');
});
test('Tab at the end of the row returns null (конец строки)', () => {
const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'c', step: 1, isEditable: isColumnEditable });
assert.equal(next, null);
});
test('Shift+Tab at the start of the row returns null', () => {
const prev = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'a', step: -1, isEditable: isColumnEditable });
assert.equal(prev, null);
});
test('an unknown current column id returns null instead of throwing', () => {
const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'missing', step: 1, isEditable: isColumnEditable });
assert.equal(next, null);
});
// rows — table.getRowModel().flatRows: включают итоговые строки (GROUP/ITEM/
// ROOT), которые isCellEditable делает нередактируемыми независимо от
// колонки (rowType !== 'INPUT'); здесь это просто row.editable === false.
const ROWS = [
{ id: 'row-1', editable: true },
{ id: 'row-2', editable: false }, // итоговая строка (GROUP)
{ id: 'row-3', editable: true },
{ id: 'row-4', editable: false }, // итоговая строка (ROOT)
];
const isRowEditable = (row) => row.editable;
test('Enter: moves down to the next row where the column is editable, skipping summary rows', () => {
const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-1', step: 1, isEditable: isRowEditable });
assert.equal(next, 'row-3');
});
test('Enter at the last row returns null (конец таблицы)', () => {
const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-3', step: 1, isEditable: isRowEditable });
assert.equal(next, null);
});
test('moving up (step: -1) finds the previous editable row', () => {
const prev = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-3', step: -1, isEditable: isRowEditable });
assert.equal(prev, 'row-1');
});
test('an unknown current row id returns null instead of throwing', () => {
const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'missing', step: 1, isEditable: isRowEditable });
assert.equal(next, null);
});

View File

@ -81,6 +81,48 @@ export const evaluateCellValue = (value) => {
}
};
// AURORA-1891: сравнение "было/стало" для handleSave (EditCell.jsx) — если
// значение фактически не изменилось, cell_updated не шлём (иначе лишняя
// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
// Формула — особый случай: даже текстуально совпадающая формула всегда
// считается «изменённой», чтобы handleSave по-прежнему всегда её
// пересчитывал и писал в localStorage (так было до этой задачи).
const normalizeComparableValue = (value) => {
if (value === null || value === undefined) return '';
if (typeof value === 'number') return String(value);
if (typeof value !== 'string') return String(value);
const trimmed = value.trim();
if (trimmed === '') return '';
// "1 500,0" -> "1500,0": обычный и неразрывный пробел между цифрами —
// разделитель тысяч. normalizeNumericInput сам такие пробелы не убирает,
// он только меняет десятичную запятую на точку.
const withoutThousandsSeparators = trimmed.replace(/(\d)[  ](?=\d)/g, '$1');
const normalized = normalizeNumericInput(withoutThousandsSeparators);
if (typeof normalized === 'string' && /^[+-]?\d+(\.\d+)?$/.test(normalized)) {
const asNumber = Number(normalized);
if (Number.isFinite(asNumber)) return String(asNumber);
}
return trimmed;
};
/**
* Сравнивает старое и новое значение ячейки «по смыслу», а не строго: число
* и его строковое представление (в т.ч. с разделителем тысяч и запятой вместо
* точки) считаются равными, как и пустая строка с null/undefined. Формула
* (строка, начинающаяся с "=") никогда не считается неизменной.
* @param {string|number|null|undefined} a
* @param {string|number|null|undefined} b
* @returns {boolean}
*/
export const isSameCellValue = (a, b) => {
if (isFormula(a) || isFormula(b)) return false;
return normalizeComparableValue(a) === normalizeComparableValue(b);
};
/**
* Форматирует число для отображения
* @param {number} value - Число для форматирования

View File

@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSameCellValue } from './formulaUtils.js';
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
// открытии и закрытии ячейки без правки.
test('a numeric string equals the same number', () => {
assert.equal(isSameCellValue('1500', 1500), true);
});
test('a thousands-separated, comma-decimal value equals the plain number', () => {
assert.equal(isSameCellValue('1 500,0', 1500), true);
});
test('empty string and null both mean "no value"', () => {
assert.equal(isSameCellValue('', null), true);
assert.equal(isSameCellValue(null, ''), true);
assert.equal(isSameCellValue(undefined, ''), true);
assert.equal(isSameCellValue(undefined, null), true);
});
test('a formula is never treated as unchanged, even against identical formula text', () => {
assert.equal(isSameCellValue('=1+2', '=1+2'), false);
assert.equal(isSameCellValue('=1+1', 2), false);
assert.equal(isSameCellValue(2, '=1+1'), false);
});
test('different values (numeric or text) are not the same', () => {
assert.equal(isSameCellValue('1500', 1501), false);
assert.equal(isSameCellValue('abc', 'abd'), false);
assert.equal(isSameCellValue('1500', ''), false);
});
test('non-numeric text compares as-is after trimming surrounding whitespace', () => {
assert.equal(isSameCellValue(' текст ', 'текст'), true);
});