Merge pull request 'AURORA-1891: редактирование одним кликом и с клавиатуры, без лишних записей в аудит' (#290) from feat/284-aurora-1891-cell-click-keyboard-edit into test

This commit is contained in:
aep 2026-11-02 21:05:54 +03:00
commit 12dc8414d1
27 changed files with 1756 additions and 191 deletions

19
web/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.0.1",
"@mui/x-tree-view": "^9.1.0",
"axios": "^1.9.0",
@ -665,7 +666,6 @@
"resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.1.1.tgz",
"integrity": "sha512-OXhm9DajemStb58AumM06DuPhHTa3XD36TFD4yf6WtJyNRO5DfEZbbnHlBg/US2Y2oOXwM/XurMTBOD6L/YYZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.29.2"
},
@ -3170,23 +3170,6 @@
}
}
},
"node_modules/tailwindcss/node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",

View File

@ -11,6 +11,7 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.1.1",
"@mui/material": "^9.0.1",
"@mui/x-tree-view": "^9.1.0",
"axios": "^1.9.0",
@ -46,4 +47,4 @@
"@babel/helper-validator-identifier": "7.28.5",
"@babel/helpers": "7.28.4"
}
}
}

View File

@ -1,5 +1,7 @@
import React, { useMemo, useCallback } from 'react';
import { Tooltip } from '@mui/material';
import React, { useMemo, useCallback, useState } from 'react';
import { useCellLock } from '../../contexts/RealtimeContext';
import { formatNumber } from '../../utils/numberFormat';
import { TEXT_COLUMN_IDS } from '../../utils/moneyUtils';
const BASE_CELL_STYLES = {
@ -17,11 +19,12 @@ const BASE_CELL_STYLES = {
padding: '8px',
};
// AURORA-1890: занятая другим пользователем ячейка не должна читаться как
// «только чтение» (старый серый фон #f5f5f5 путался с нередактируемой
// ячейкой) — фон не меняем, отличаем пунктирной оранжевой рамкой и значком 🔒.
const LOCKED_STYLES = {
border: '2px solid #e0e0e0',
backgroundColor: '#f5f5f5',
border: '2px dashed #F0A030',
cursor: 'not-allowed',
opacity: 0.85,
};
const INVALID_STYLES = {
@ -30,11 +33,30 @@ const INVALID_STYLES = {
color: '#C60C0C',
};
// AURORA-1892: отрицательные суммы должны быть явно отличимы от нуля и обычных чисел,
// в том числе на тёмных фонах палитры (например ROOT красной палитры #933634).
const NEGATIVE_COLOR_ON_LIGHT = '#C62828';
const NEGATIVE_COLOR_ON_DARK = '#FFCDD2';
// AURORA-1890: редактируемая ячейка строки INPUT — белый фон и заметная
// синяя рамка (вместо прежней почти незаметной полупрозрачной 2px #d8e4ea94).
// Толщина рамки растёт при наведении (1px → 2px, см. isHovered в CellComponent), курсор — text.
const EDITABLE_STYLES = {
cursor: 'pointer',
border: '2px solid #d8e4ea94',
cursor: 'text',
backgroundColor: '#FFFFFF',
};
const EDITABLE_BORDER_COLOR = '#7FA7C9';
// AURORA-1890: нередактируемая ячейка строки INPUT (вычисляемая или вне
// текущего этапа) — приглушённый фон и текст, чтобы визуально отличаться от
// редактируемой белой ячейки без отдельной палитры цветов.
const NOT_EDITABLE_INPUT_STYLES = {
cursor: 'default',
backgroundColor: '#F4F5F7',
color: '#5F6B7A',
};
// Строки ITEM/GROUP/ROOT: вне скоупа AURORA-1890 — сохраняем прежнее поведение.
const NOT_EDITABLE_STYLES = {
cursor: 'default',
};
@ -102,17 +124,6 @@ const getColorBrightness = (hexColor) => {
return 0.299 * r + 0.587 * g + 0.114 * b;
};
const formatNumber = (value, asInteger = false) => {
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,
useGrouping: true,
});
};
// Оптимизированная функция подсветки
const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
const cleanQueries = searchQueries.filter(Boolean);
@ -144,31 +155,55 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
});
};
const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayFactor = 1 }) => {
const CellComponent = ({
cell,
row,
column,
globalFilter,
columnFilter,
backgroundColor,
isEditable,
isInvalid,
invalidMessage,
vspOptions,
displayFactor = 1,
}) => {
const cellKey = `${row.id}_${column.id}`;
const isLocked = useCellLock(cellKey);
// AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px).
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = useCallback(() => setIsHovered(true), []);
const handleMouseLeave = useCallback(() => setIsHovered(false), []);
const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown';
const isNameColumn = NAME_COLUMN_IDS.has(column.id);
const nameIndent = isNameColumn ? 8 + row.depth * 16 : 8;
const isOverLimit = isNameColumn && row.original?.data?.header?.is_over_limit === true;
// AURORA-1890: строки ITEM/GROUP/ROOT не получают серую "нередактируемую"
// заливку — она зарезервирована под строку ввода (та же проверка, что в
// tableColumns.jsx getColorCell).
const rowType = row?.original?.row_type || row?.row_type;
const isInputRow = rowType === 'INPUT';
const { rawValue, displayValue, isNumeric } = useMemo(() => {
const { rawValue, displayValue, isNumeric, isNegative } = useMemo(() => {
const value = cell.getValue();
const isNum = value !== null && value !== undefined && value !== '' && !Number.isNaN(Number(value)) && !TEXT_COLUMN_IDS.has(column.id);
let display = String(value || '');
let negative = false;
if (isNum) {
display = formatNumber(Number(display) * displayFactor, false);
const formatted = formatNumber(Number(display) * displayFactor, false);
display = formatted.text;
negative = formatted.isNegative;
}
if (isVspDropdown && value != null) {
const vsp = vspOptions?.find((v) => v.id === Number(value));
if (vsp) {
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false };
return { rawValue: value, displayValue: vsp.registration_number, isNumeric: false, isNegative: false };
}
}
return { rawValue: value, displayValue: display, isNumeric: isNum };
return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative };
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);
const highlightedContent = useMemo(() => {
@ -176,11 +211,17 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
return createHighlightedContent(rawValue, displayValue, searchQueries);
}, [rawValue, displayValue, globalFilter, columnFilter]);
// AURORA-1890: занятая коллегой ячейка больше не красится в серый —
// обычный brightness-based цвет текста, отличие только в рамке/бейдже.
const textColor = useMemo(() => {
if (isLocked) return '#999999';
const brightness = getColorBrightness(backgroundColor);
if (isNegative) return brightness < 128 ? NEGATIVE_COLOR_ON_DARK : NEGATIVE_COLOR_ON_LIGHT;
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor, isLocked]);
}, [backgroundColor, isNegative]);
// AURORA-1890: нередактируемый стиль зависит от типа строки (INPUT получает
// приглушённую заливку, ITEM/GROUP/ROOT — прежнее поведение без изменений).
const notEditableStyles = isInputRow ? NOT_EDITABLE_INPUT_STYLES : NOT_EDITABLE_STYLES;
const cellStyles = useMemo(
() => ({
@ -188,13 +229,14 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
paddingLeft: nameIndent,
justifyContent: isNumeric ? 'flex-end' : 'flex-start',
textAlign: isNumeric ? 'right' : 'left',
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
backgroundColor,
color: textColor,
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
...(isNegative ? { fontWeight: 600 } : {}),
...(isEditable ? { ...EDITABLE_STYLES, border: `${isHovered ? 2 : 1}px solid ${EDITABLE_BORDER_COLOR}` } : notEditableStyles),
...(isLocked ? LOCKED_STYLES : {}),
...(isInvalid ? INVALID_STYLES : {}),
}),
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent],
[backgroundColor, isInvalid, isLocked, textColor, isEditable, notEditableStyles, isNumeric, nameIndent, isNegative, isHovered],
);
const lockBadge = useMemo(() => {
@ -202,20 +244,29 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
return <div style={LOCK_BADGE_STYLES}>🔒</div>;
}, [isLocked]);
const handleClick = useCallback(() => {
if (!isLocked && isEditable && onClick) {
onClick();
}
}, [isLocked, isEditable, onClick]);
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
<div type='button' className='cell' style={cellStyles} onClick={handleClick}>
// AURORA-1891: клик теперь обрабатывает muiTableBodyCellProps (RealtimeTable.jsx)
// — там же проверяется редактируемость/блокировка и открывается редактор одним
// кликом; onClick/handleClick здесь были мёртвым кодом (tableColumns.jsx никогда
// не передавал onClick в Cell).
const cellNode = (
<div className='cell' style={cellStyles} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
{isOverLimit && <span style={OVER_LIMIT_CHIP_STYLES}>сверх лимита</span>}
{lockBadge}
</div>
);
// AURORA-1893: причина ошибки по наведению — только когда ячейка действительно
// невалидна и есть текст причины (invalidMessage собран useValidationRules'ом).
if (isInvalid && invalidMessage) {
return (
<Tooltip title={invalidMessage} arrow placement='top'>
{cellNode}
</Tooltip>
);
}
return cellNode;
};
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
@ -229,6 +280,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
prevProps.columnFilter === nextProps.columnFilter &&
prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isInvalid === nextProps.isInvalid &&
prevProps.invalidMessage === nextProps.invalidMessage &&
prevProps.isEditable === nextProps.isEditable &&
prevProps.vspOptions === nextProps.vspOptions &&
prevProps.displayFactor === nextProps.displayFactor &&

View File

@ -1,6 +1,8 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
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 = {
@ -29,19 +31,33 @@ const TEXTAREA_STYLES = {
fontSize: 'inherit',
};
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
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);
@ -75,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,
@ -83,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;
@ -103,17 +129,34 @@ 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: мягкий запрет минуса для заданных колонок — предупреждаем,
// но значение всё равно сохраняется (жёсткий запрет в этой задаче не делается).
const warnIfNegativeNotAllowed = useCallback(
(newValue) => {
const columnId = cell?.column?.id;
if (!columnId || !nonNegativeColumnKeys.includes(columnId)) return;
if (!Number.isFinite(Number(newValue)) || Number(newValue) >= 0) return;
const columnTitle = table?.options?.meta?.getColumnTitle?.(columnId) ?? columnId;
toast.warning(`В колонке «${columnTitle}» отрицательные суммы запрещены`);
},
[cell, table],
);
const handleSave = useCallback(() => {
const { currentValue } = state;
@ -128,6 +171,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
displayValue: formattedResult,
isFormulaMode: true,
}));
warnIfNegativeNotAllowed(formattedResult);
onChange?.(formattedResult);
} catch {
setState((prev) => ({
@ -144,9 +188,29 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
displayValue: currentValue,
isFormulaMode: false,
}));
onChange?.(normalizeNumericInput(currentValue));
const normalizedValue = normalizeNumericInput(currentValue);
warnIfNegativeNotAllowed(normalizedValue);
// AURORA-1891: значение не изменилось — не шлём onChange (а значит и
// cell_updated): открытие и закрытие ячейки без правки не должно
// создавать запись в журнале аудита.
if (isSameCellValue(normalizedValue, value)) return;
onChange?.(normalizedValue);
}
}, [state, storageKey, onChange]);
}, [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;
@ -154,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) => {
@ -182,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

@ -19,13 +19,15 @@ import { additionExpenseRowTable, additionVspRowTable } from './constants/adding
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
import { getActiveCellSx } from './utils/activeCellCss';
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useLockedCellsStore, useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useBudgetYear } from './hooks/useBudgetYear';
import { useColumnSettings } from './hooks/useColumnSettings';
import { useHeaderPortal } from './hooks/useHeaderPortal';
import { useTableScale } from './hooks/useTableScale';
import { useValidationRules } from './hooks/useValidationRules';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
import { DictVspApi } from '../../api/dict-vsp';
@ -83,6 +85,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const {
data,
columnsCurStage,
stageColumnDeadlines,
isLoading: isTableLoading,
editingCells: dataEditingCells,
} = useRealtimeData(formId, sheetName, direction, formType, year);
@ -97,8 +100,22 @@ 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, isCellInvalid } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
@ -143,6 +160,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,
@ -263,7 +319,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
userRoleId,
onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError,
isCellInvalid,
getCellErrors,
denomination,
sheetName,
});
@ -271,7 +327,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
console.error('Error creating columns:', error);
return [];
}
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination, sheetName]);
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination, sheetName]);
// MRT запоминает индексы pinned-колонок до завершения асинхронной
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
@ -298,12 +354,25 @@ 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],
[handleUpdateCell, headerPaths],
);
const vspRegistrationById = useMemo(
@ -342,6 +411,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
contextStartEditing,
globalFilterFn,
activeSearchMatch,
isColumnActuallyEditable,
lockedCellsStore,
setEditorInitialInput,
}) => ({
...BASE_TABLE_CONFIG,
globalFilterFn,
@ -353,7 +425,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);
}
},
@ -387,7 +462,54 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
muiTableHeadCellProps: {
sx: { boxSizing: 'border-box' },
},
muiTableBodyCellProps: ({ column }) => ({ 'data-col-id': column.id }),
muiTableBodyCellProps: ({ cell, column, row, table }) => {
const cellKey = `${row.id}_${column.id}`;
// AURORA-1891: открыть редактор можно только если ячейка редактируема
// (isColumnActuallyEditable — см. определение выше про колонку
// номера строки), не занята коллегой (lockedCellsStore) и сейчас
// вообще ничего не редактируется. Иначе клик/клавиша не трогают td —
// клик всплывает на строку (её onClick выделяет строку — п.7
// тикета), клавиша просто игнорируется.
const canOpenEditor = () => !editingCell && isColumnActuallyEditable(row, column) && !lockedCellsStore.hasCell(cellKey);
return {
'data-col-id': column.id,
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);
}
},
};
},
muiTableHeadProps: {
sx: {
display: 'table-header-group',
@ -435,6 +557,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
contextStartEditing,
globalFilterFn,
activeSearchMatch,
isColumnActuallyEditable,
lockedCellsStore,
setEditorInitialInput,
}),
[
columns,
@ -453,6 +578,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
expanded,
globalFilterFn,
activeSearchMatch,
isColumnActuallyEditable,
lockedCellsStore,
setEditorInitialInput,
],
);
@ -489,6 +617,111 @@ 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 ниже.
//
// Рулинг: ищем строку в table.getRowModel().flatRows, а не в table.getRowModel().rows —
// flatRows содержит все строки независимо от текущего состояния свёрнутости групп
// (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows
// целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть.
const handleNavigateToError = useCallback(
({ lineId, columnId } = {}) => {
if (lineId === undefined || lineId === null || !columnId) return;
const row = table.getRowModel().flatRows.find((candidate) => getRowId(candidate) === lineId);
if (!row) return;
const parentRows = row.getParentRows();
if (parentRows.length) {
setExpanded((current) => {
if (current === true) return true;
let changed = false;
const next = { ...current };
for (const parentRow of parentRows) {
if (next[parentRow.id] !== true) {
next[parentRow.id] = true;
changed = true;
}
}
return changed ? next : current;
});
}
// Рулинг: handleToggleColumnVisibility (которую называет тикет) принимает узел
// дерева колонок, а не columnId — здесь нужнее handleSetColumnsVisibility,
// у которой сигнатура {columnId: isVisible} и которая уже доступна в этом файле.
if (columnVisibility[columnId] === false) {
handleSetColumnsVisibility({ [columnId]: true });
}
setActiveSearchMatch({ rowId: row.id, columnId });
setRowSelection({ [row.id]: true });
handleNavigateToColumn(columnId);
requestAnimationFrame(() => {
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === row.id);
if (rowIndex < 0) return;
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
});
},
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn],
);
const searchMatches = useMemo(() => {
const query = normalizeFilterValue(globalFilter);
if (!query) return [];
@ -566,6 +799,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;
@ -583,6 +837,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(() => {
@ -783,7 +1048,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
canModifyRows={canModifyRows}
formType={formType}
validationErrors={validationErrors}
onNavigateToColumn={handleNavigateToColumn}
onNavigateToError={handleNavigateToError}
headerPaths={headerPaths}
expenseItemsVisibilityAction={expenseItemsVisibilityAction}
onToggleDepthVisibility={handleToggleDepthVisibility}
/>
@ -824,6 +1090,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
stageColumnDeadlines={stageColumnDeadlines}
userRoleId={userRoleId}
/>,
headerPortalRef.current,
)}

View File

@ -0,0 +1,55 @@
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
import { IconButton, Stack, Tooltip } from '@mui/material';
import { memo } from 'react';
// AURORA-1890: легенда рядом с плашкой "Контроль ошибок" — те же цвета,
// что в Cell.jsx (EDITABLE_STYLES / NOT_EDITABLE_INPUT_STYLES), без новых
// палитр колонок (columnColors.js не меняется).
const controlSx = {
minHeight: '2.5rem',
flex: '0 0 2.5rem',
};
const sampleBaseSx = {
display: 'inline-block',
width: '0.875rem',
height: '0.875rem',
borderRadius: '0.1875rem',
flexShrink: 0,
};
const editableSampleSx = {
...sampleBaseSx,
backgroundColor: '#FFFFFF',
border: '1px solid #7FA7C9',
};
const notEditableSampleSx = {
...sampleBaseSx,
backgroundColor: '#F4F5F7',
border: '1px solid rgba(0, 0, 0, 0.12)',
};
const legendRowSx = { alignItems: 'center', gap: 0.75 };
const CellLegend = () => (
<Tooltip
title={
<Stack direction='column' sx={{ gap: 0.75, py: 0.25 }}>
<Stack direction='row' sx={legendRowSx}>
<span style={editableSampleSx} />
<span>можно вводить</span>
</Stack>
<Stack direction='row' sx={legendRowSx}>
<span style={notEditableSampleSx} />
<span>расчёт или закрыто на этапе</span>
</Stack>
</Stack>
}>
<IconButton variant='outlined' sx={controlSx} aria-label='Пояснение цветов ячеек'>
<HelpOutlineIcon sx={{ fontSize: '1.15rem' }} />
</IconButton>
</Tooltip>
);
export default memo(CellLegend);

View File

@ -22,6 +22,7 @@ import {
VisibleExpenseItems,
} from '../../common/icons/icons';
import { collectLeafColumns } from '../utils/columnUtils';
import CellLegend from './CellLegend';
import { AHR_LIMIT_SHEET } from '../utils/moneyUtils';
import { GroupByObject } from './GroupByObject/GroupByObject';
import { Panel } from './SettingPanel.style';
@ -96,7 +97,8 @@ const SettingPanel = ({
canModifyRows,
formType,
validationErrors = [],
onNavigateToColumn,
onNavigateToError,
headerPaths,
expenseItemsVisibilityAction,
onToggleDepthVisibility,
denomination,
@ -165,7 +167,13 @@ const SettingPanel = ({
return (
<>
<Panel data-pin-panel>
<Stack direction='row' sx={{ gap: '1.25rem' }}>
<Stack direction='row' sx={{ gap: '1.25rem', alignItems: 'center' }}>
{validationErrors.length > 0 && (
<>
<ValidationErrorsAlert errors={validationErrors} onNavigateToError={onNavigateToError} headerPaths={headerPaths} />
<Divider orientation='vertical' flexItem />
</>
)}
<GroupByObject title='Отображение'>
{/* <ColorPickerButton onColorApply={addColorForCells} /> */}
@ -329,7 +337,9 @@ const SettingPanel = ({
/>
</GroupByObject>
</Stack>
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
<CellLegend />
</Stack>
</Panel>
</>
);

View File

@ -1,56 +1,99 @@
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import { Button, Menu, MenuItem, Stack } from '@mui/material';
import { memo, useEffect, useState } from 'react';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlineOutlined';
import { Button, IconButton, ListSubheader, Menu, MenuItem, Stack } from '@mui/material';
import { memo, useEffect, useMemo, useState } from 'react';
import { groupValidationErrors } from '../utils/validationUtils';
const warningColors = {
bg: '#fff7e6',
text: '#b54708',
border: '#FCD99C',
hover: '#fff1d6',
// AURORA-1893: «более выделенный формат контроля за ошибками» — красная плашка
// вместо прежней бледно-оранжевой.
const errorColors = {
bg: '#FDEDED',
text: '#D32F2F',
border: '#D32F2F',
hover: '#FBD5D5',
};
const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
const stepButtonSx = {
color: errorColors.text,
border: `1px solid ${errorColors.border}`,
borderRadius: '0.5rem',
'&:hover': { bgcolor: errorColors.hover },
'&.Mui-disabled': { color: errorColors.border, border: `1px solid ${errorColors.border}`, opacity: 0.5 },
};
// Внутри одной группы (правило + колонка) показываем не больше 20 строк,
// остальные — свёрнутой строкой «и ещё N».
const MAX_VISIBLE_CELLS_PER_GROUP = 20;
const getColumnTitle = (headerPaths, columnId) => headerPaths?.[columnId]?.filter(Boolean).join(' / ') || columnId;
const ValidationErrorsAlert = ({ errors = [], onNavigateToError, headerPaths }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
const isOpen = Boolean(anchorEl);
const groups = useMemo(() => groupValidationErrors(errors), [errors]);
useEffect(() => {
if (errors.length === 0) {
setAnchorEl(null);
setActiveIndex(0);
return;
}
}, [errors.length]);
if (activeIndex >= errors.length) {
setActiveIndex(0);
}
}, [errors, activeIndex]);
if (errors.length === 0) return null;
const handleNavigate = (columnId) => {
onNavigateToColumn?.(columnId);
const navigateToIndex = (index) => {
const error = errors[index];
if (!error) return;
setActiveIndex(index);
onNavigateToError?.({ lineId: error.lineId, columnId: error.columnId });
};
const handleStep = (direction) => {
const nextIndex = (activeIndex + direction + errors.length) % errors.length;
navigateToIndex(nextIndex);
};
const handleSelectCell = (cell) => {
const index = errors.findIndex((candidate) => candidate.id === cell.id);
navigateToIndex(index < 0 ? 0 : index);
setAnchorEl(null);
};
return (
<>
<Button
variant='outlined'
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{
ml: 2,
maxWidth: 360,
height: '2.5rem',
borderRadius: '0.5rem',
bgcolor: warningColors.bg,
color: warningColors.text,
borderColor: warningColors.border,
textTransform: 'none',
justifyContent: 'space-between',
px: 1.5,
gap: 1,
'&:hover': {
bgcolor: warningColors.hover,
borderColor: warningColors.border,
},
}}>
<Stack direction='row' sx={{ alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
<WarningAmberRoundedIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
<IconButton size='small' onClick={() => handleStep(-1)} aria-label='Предыдущая ошибка' sx={stepButtonSx}>
<ChevronLeftIcon fontSize='small' />
</IconButton>
<Button
variant='outlined'
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{
maxWidth: 360,
height: '2.5rem',
borderRadius: '0.5rem',
bgcolor: errorColors.bg,
color: errorColors.text,
borderColor: errorColors.border,
textTransform: 'none',
fontWeight: 600,
justifyContent: 'flex-start',
px: 1.5,
gap: 1,
'&:hover': {
bgcolor: errorColors.hover,
borderColor: errorColors.border,
},
}}>
<ErrorOutlineIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
<span
style={{
overflow: 'hidden',
@ -58,21 +101,14 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
whiteSpace: 'nowrap',
textAlign: 'left',
}}>
Контроль ошибок
Ошибок: {errors.length}
</span>
</Stack>
</Button>
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
<span>Подробнее</span>
<KeyboardArrowDownIcon
sx={{
fontSize: '1.25rem',
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
}}
/>
</Stack>
</Button>
<IconButton size='small' onClick={() => handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}>
<ChevronRightIcon fontSize='small' />
</IconButton>
</Stack>
<Menu
anchorEl={anchorEl}
@ -85,31 +121,61 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
'data-validation-errors-menu': true,
sx: {
mt: 0.5,
bgcolor: warningColors.bg,
color: warningColors.text,
border: `1px solid ${warningColors.border}`,
bgcolor: errorColors.bg,
color: errorColors.text,
border: `1px solid ${errorColors.border}`,
borderRadius: '0.5rem',
boxShadow: 'none',
minWidth: anchorEl?.offsetWidth,
maxWidth: 420,
maxHeight: 420,
},
},
}}>
{errors.map((error) => (
<MenuItem
key={error.id}
onClick={() => handleNavigate(error.columnId)}
sx={{
whiteSpace: 'normal',
color: warningColors.text,
cursor: 'pointer',
'&:hover': {
bgcolor: warningColors.hover,
},
}}>
{error.message}
</MenuItem>
))}
{groups.flatMap((group) => {
const columnTitle = getColumnTitle(headerPaths, group.columnId);
const visibleCells = group.cells.slice(0, MAX_VISIBLE_CELLS_PER_GROUP);
const hiddenCount = group.count - visibleCells.length;
return [
<ListSubheader
key={`${group.id}-header`}
sx={{
bgcolor: 'inherit',
color: errorColors.text,
fontWeight: 600,
lineHeight: 1.3,
whiteSpace: 'normal',
py: 0.5,
}}>
{group.message} · {columnTitle} ({group.count})
</ListSubheader>,
...visibleCells.map((cell) => (
<MenuItem
key={cell.id}
selected={errors[activeIndex]?.id === cell.id}
onClick={() => handleSelectCell(cell)}
sx={{
whiteSpace: 'normal',
color: errorColors.text,
cursor: 'pointer',
pl: 3,
'&:hover': {
bgcolor: errorColors.hover,
},
}}>
{cell.rowLabel}
</MenuItem>
)),
...(hiddenCount > 0
? [
<MenuItem key={`${group.id}-more`} disabled sx={{ pl: 3, color: errorColors.text, opacity: 0.7 }}>
и ещё {hiddenCount}
</MenuItem>,
]
: []),
];
})}
</Menu>
</>
);

View File

@ -1,8 +1,23 @@
import React, { useMemo } from 'react';
import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils';
import { formatDeadline } from '../utils/deadlineFormat';
import { ROLES_NAME_ID } from '../../../constants/constants';
import { formatHeaderUnit, formatHeaderYear } from '../utils/headerYearUtils';
import ColumnResizer from './ColumnResizer';
// AURORA-1890: срок ввода в заголовке колонки показывается только
// исполнителю (у админа колонка открыта всегда объединением всех фаз,
// метка ему ничего не даёт — см. тикет, п.5).
const DEADLINE_LABEL_STYLES = {
display: 'block',
fontSize: '0.5625rem',
fontWeight: 400,
lineHeight: 1.2,
marginTop: '2px',
whiteSpace: 'nowrap',
opacity: 0.8,
};
const getColumnPinnedStyles = (column) => {
const pinningPosition = column.getIsPinned();
@ -102,7 +117,7 @@ const getColorBrightness = (hexColor) => {
return 0.299 * r + 0.587 * g + 0.114 * b;
};
const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear, denomination }) => {
const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear, stageColumnDeadlines, userRoleId, denomination }) => {
const column = header.column;
const headerSize = header.getSize();
const isPinned = Boolean(pinningPosition);
@ -120,6 +135,15 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
return brightness < 128 ? '#ffffff' : '#000000';
}, [backgroundColor]);
// AURORA-1890: срок ввода колонки на текущем этапе исполнителя.
const deadlineLabel = useMemo(() => {
if (userRoleId === ROLES_NAME_ID.admin) return null;
const closesAt = stageColumnDeadlines?.get?.(column.id);
if (!closesAt) return null;
const formatted = formatDeadline(closesAt);
return formatted ? `✎ ${formatted}` : null;
}, [stageColumnDeadlines, column.id, userRoleId]);
const handleChangeWidth = (deltaWidth) => {
const size = header.getSize();
onChangeWidth(header, size + deltaWidth);
@ -173,6 +197,7 @@ const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, bu
boxSizing: 'border-box',
}}>
{header.isPlaceholder ? null : formatHeaderUnit(formatHeaderYear(column.columnDef.header, budgetYear), denomination)}
{deadlineLabel && <span style={DEADLINE_LABEL_STYLES}>{deadlineLabel}</span>}
</div>
<ColumnResizer reportDragDeltaX={handleChangeWidth} />
</div>
@ -317,7 +342,17 @@ const FilterCell = ({ column }) => {
);
};
const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear, denomination }) => {
const TableHeadComponent = ({
table,
selectedColumnId,
onColumnSelect,
onChangeWidth,
isLoadingData,
budgetYear,
stageColumnDeadlines,
userRoleId,
denomination,
}) => {
const handleHeaderClick = (header) => {
if (!header.subHeaders?.length && header.column?.id) {
onColumnSelect?.(header.column.id);
@ -362,6 +397,8 @@ const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeW
pinningPosition={pinningPosition}
onClick={handleHeaderClick}
onChangeWidth={onChangeWidth}
stageColumnDeadlines={stageColumnDeadlines}
userRoleId={userRoleId}
/>
)),
)}

View File

@ -84,6 +84,103 @@ const bookingLessBalanceRuleScope = {
includedScopes: [{ formType: "FORM_2" }],
};
// Требование AURORA-1892. Колонки, в которых отрицательное значение считается ошибкой.
// Перечень — из вложения Салмина М.П. к AURORA-1892 (AURORA-1892_Перечень_колонок.md),
// уточнённого кодовыми именами колонок (см. рулинги в PR). Корректировки и переносы
// (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) сюда не входят —
// там минус — нормальная операция.
export const nonNegativeColumnKeys = [
// Основные листы форм 1, 2, 4 (Операц/АХР/КВ): план по кварталам и за год.
"data.plan.q1",
"data.plan.q2",
"data.plan.q3",
"data.plan.q4",
"data.plan.year",
// Ввод брони на 2026 и 2027 годы (форма 2).
"data.booking.y2026.q1",
"data.booking.y2026.q2",
"data.booking.y2026.q3",
"data.booking.y2026.q4",
"data.booking.y2027.q1",
"data.booking.y2027.q2",
"data.booking.y2027.q3",
"data.booking.y2027.q4",
// Факт по месяцам и СПОД.
"data.q1.actual_m1",
"data.q1.actual_m2",
"data.q1.actual_m3",
"data.q2.actual_m1",
"data.q2.actual_m2",
"data.q2.actual_m3",
"data.q3.actual_m1",
"data.q3.actual_m2",
"data.q3.actual_m3",
"data.q4.actual_m1",
"data.q4.actual_m2",
"data.q4.actual_m3",
"data.q4.actual_spod",
// Сумма платежа.
"data.q1.pay_amount",
"data.q2.pay_amount",
"data.q3.pay_amount",
"data.q4.pay_amount",
// Предельная стоимость договора / ЦКК.
"data.contract.ceiling",
"data.contract_detail.ceiling",
"data.ckk.ceiling",
"data.contract_summary.total",
// Сателлиты формы 2 — АХР Аренда/Коммунальные услуги/Безопасность: факт по месяцам
// и квартальный итог (data.plan.qN уже учтён выше — ключ общий с основными листами).
"data.fact_q1.jan",
"data.fact_q1.feb",
"data.fact_q1.mar",
"data.fact_q1.total",
"data.fact_q2.apr",
"data.fact_q2.may",
"data.fact_q2.jun",
"data.fact_q2.total",
"data.fact_q3.jul",
"data.fact_q3.aug",
"data.fact_q3.sep",
"data.fact_q3.total",
"data.fact_q4.oct",
"data.fact_q4.nov",
"data.fact_q4.dec",
"data.fact_q4.total",
// АХР Лимиты — по вложению Салмина минусов не должно быть вообще во всех
// числовых показателях листа (шире, чем исходный набор qty_qN/amount_qN).
"data.qty_q1",
"data.qty_q2",
"data.qty_q3",
"data.qty_q4",
"data.amount_q1",
"data.amount_q2",
"data.amount_q3",
"data.amount_q4",
"data.limit_with_vat",
"data.limit_without_vat",
];
const isNegativeNumber = (value) =>
value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value)) && Number(value) < 0;
// Фабрика правила «минус запрещён» для заданного перечня колонок.
// appliesToRow намеренно не ограничивается — правило проверяет все строки.
export const nonNegativeRule = ({ id, columnKeys, message, includedScopes, excludedScopes } = {}) => ({
id,
columnKeys,
message,
...(includedScopes ? { includedScopes } : {}),
...(excludedScopes ? { excludedScopes } : {}),
isInvalid: isNegativeNumber,
});
export const validationRules = [
{
...remainingBookingRuleScope,
@ -210,4 +307,9 @@ export const validationRules = [
);
},
},
nonNegativeRule({
id: "non-negative",
columnKeys: nonNegativeColumnKeys,
message: "Отрицательное значение в этой колонке недопустимо",
}),
];

View File

@ -79,6 +79,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

@ -9,6 +9,10 @@ import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const [data, setData] = useState([]);
const [columnsCurStage, setColumnsCurStage] = useState([]);
// AURORA-1890: срок ввода (closes_at) для колонок текущего этапа —
// чтобы показать его в заголовке колонки. Ключи совпадают с columnsCurStage
// ("data.<column>"), closes_at отбрасывался раньше (useRealtimeData.js:48-51).
const [stageColumnDeadlines, setStageColumnDeadlines] = useState(new Map());
const [isLoading, setIsLoading] = useState(true);
const [editingCells, setEditingCells] = useState({});
@ -47,8 +51,10 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
const data = res.result.filter((d) => d.depth > -1);
const curStageColumns = res.result.filter((d) => d.depth === -1)[0];
const stageColumns = (curStageColumns?.data?.editable || []).map((column) => `data.${column.column}`);
const editableColumns = curStageColumns?.data?.editable || [];
const stageColumns = editableColumns.map((column) => `data.${column.column}`);
setColumnsCurStage(stageColumns);
setStageColumnDeadlines(new Map(editableColumns.map((column) => [`data.${column.column}`, column.closes_at])));
if (!data || data.length === 0) {
setData([]);
@ -233,6 +239,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
return {
data,
columnsCurStage,
stageColumnDeadlines,
isLoading,
editingCells,
};

View File

@ -19,34 +19,36 @@ export const useValidationRules = (data, columns, formType, sheetName) => {
const seen = new Set();
for (const rule of activeRules) {
collectInvalidErrors(safeData, rule, headerPaths, result, seen);
collectInvalidErrors(safeData, rule, result, seen);
}
return result;
}, [activeRules, headerPaths, safeData]);
}, [activeRules, safeData]);
// AURORA-1893: id теперь кодирует правило+колонку+строку и уникален для каждой
// ошибочной ЯЧЕЙКИ — сравнения по нему достаточно для стабильности ссылки.
const previousErrors = stableErrorsRef.current;
const errorsAreEqual =
previousErrors.length === calculatedErrors.length &&
previousErrors.every(
(error, index) =>
error.id === calculatedErrors[index].id &&
error.columnId === calculatedErrors[index].columnId &&
error.message === calculatedErrors[index].message,
);
previousErrors.length === calculatedErrors.length && previousErrors.every((error, index) => error.id === calculatedErrors[index].id);
if (!errorsAreEqual) {
stableErrorsRef.current = calculatedErrors;
}
const errors = stableErrorsRef.current;
const isCellInvalid = useCallback(
// AURORA-1893: причины ошибок на ячейке (для подсказки Cell) — список сообщений
// правил, которым эта ячейка не удовлетворяет.
const getCellErrors = useCallback(
(columnId, value, row) =>
activeRules.some(
(rule) =>
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
),
activeRules
.filter(
(rule) =>
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
)
.map((rule) => rule.message),
[activeRules],
);
return { errors, isCellInvalid };
const isCellInvalid = useCallback((columnId, value, row) => getCellErrors(columnId, value, row).length > 0, [getCellErrors]);
return { errors, isCellInvalid, getCellErrors, headerPaths };
};

View File

@ -7,6 +7,7 @@ import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUt
import { ROLES_NAME_ID } from '../../constants/constants';
import { blueColumn } from './constants/columnColors';
import { sectionCodeColor } from './constants/columnConfig';
import { isCellEditable } from './utils/cellEditability';
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
@ -118,7 +119,7 @@ export const getTableColumns = ({
userRoleId,
onCellUpdateError,
onCellNumberClick,
isCellInvalid,
getCellErrors,
denomination = 'thousand',
sheetName,
}) => {
@ -128,28 +129,52 @@ export const getTableColumns = ({
const cellPropsCache = new WeakMap();
const editPropsCache = new WeakMap();
// AURORA-1890: stageColumns ожидается как Set (сигнатура isCellEditable),
// columnsCurStage из useRealtimeData — массив; строим Set один раз на
// пересборку колонок, а не на каждую ячейку.
const stageColumns = new Set(columnsCurStage);
// factor === 1 означает «как в базе»: либо колонка не денежная, либо разрядность уже совпадает с базовой.
// На листе AHR_LIMIT нет селектора — значения всегда показываются как есть, независимо от сохранённой
// разрядности (там она по умолчанию 'thousand', а база листа — 'rub').
const getCellDisplayFactor = (column, moneyUnit = getMoneyUnit(column.columnDef, sheetName)) =>
const getCellDisplayFactor = (columnDef, moneyUnit = getMoneyUnit(columnDef, sheetName)) =>
moneyUnit && sheetName !== AHR_LIMIT_SHEET ? getDisplayFactor(moneyUnit, denomination) : 1;
// AURORA-1890/1891: принимает либо настоящий MRT column (column.id +
// column.columnDef), либо голый конфиг колонки из processColumns (col,
// у которого своего column.id ещё нет — только col.accessorKey, а сам col
// это и есть будущий columnDef). columnId уходит в isCellEditable
// (вычисляемые поля/этап/админ), columnDef — в getCellDisplayFactor
// (AURORA-1899: нельзя редактировать ячейку, когда выбранная разрядность
// искажает число относительно базовой единицы).
const isEditable = (row, column) => {
if (row.original?.row_type !== 'INPUT') return false;
const moneyUnit = getMoneyUnit(column.columnDef, sheetName);
const columnId = column.id ?? column.accessorKey;
if (
!isCellEditable({
rowType: row.original?.row_type,
columnId,
isAdmin: userRoleId === ROLES_NAME_ID.admin,
stageColumns,
})
) {
return false;
}
const columnDef = column.columnDef ?? column;
const moneyUnit = getMoneyUnit(columnDef, sheetName);
// Разрядность, отличная от базовой «тыс. руб.», искажает значение — редактирование запрещено,
// чтобы случайно не записать домноженное/разделённое число.
if (moneyUnit !== null && getCellDisplayFactor(column, moneyUnit) !== 1) return false;
if (userRoleId === ROLES_NAME_ID.admin) return true;
const keyCol = column.id;
if (columnsCurStage.includes(keyCol)) return true;
return false;
if (moneyUnit !== null && getCellDisplayFactor(columnDef, moneyUnit) !== 1) return false;
return true;
};
const getCachedCellProps = (row, column, table) => {
const key = `${row.id}_${column.id}`;
const value = row.getValue(column.id);
const isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
// AURORA-1893: причины ошибки для подсказки на ячейке; isInvalid — просто
// «есть хотя бы одна причина», чтобы не держать две параллельные проверки.
const cellErrors = getCellErrors?.(column.id, value, row.original) || [];
const isInvalid = cellErrors.length > 0;
const invalidMessage = cellErrors.join('. ');
const isEditableCell = isEditable(row, column);
const backgroundColor = getColorCell(columnColors, column, row);
const displayFactor = getCellDisplayFactor(column);
@ -169,6 +194,7 @@ export const getTableColumns = ({
cached.value === value &&
cached.displayFactor === displayFactor &&
cached.isInvalid === isInvalid &&
cached.invalidMessage === invalidMessage &&
cached.backgroundColor === backgroundColor
) {
return cached;
@ -184,9 +210,10 @@ export const getTableColumns = ({
isEditable: isEditableCell,
backgroundColor,
isInvalid,
invalidMessage,
value,
displayFactor,
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayFactor}`,
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`,
};
rowCache[key] = props;
@ -218,6 +245,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);
@ -246,8 +276,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);
}
if (col.columns?.length) {

View File

@ -0,0 +1,107 @@
// AURORA-1890: редактируемость ячейки — чистая функция, вынесенная из
// tableColumns.jsx. Используется tableColumns.jsx (isEditable внутри
// getTableColumns) и будет использоваться AURORA-1891 — сигнатуру без
// необходимости не менять.
//
// isComputedColumn повторяет часть серверной проверки из
// v3._apply_form_cell и v3._apply_form3_cell
// (api/alembic/versions/sql/0023_apply_cell_empty_string.sql; функция
// переопределяется CREATE OR REPLACE в более новых миграциях, но список
// computed_field/normative_field исключений не менялся): какие колонки
// сервер отвергает при попытке правки через _apply_form_cell/_apply_form3_cell,
// те же колонки фронт не должен показывать как редактируемые — даже
// администратору, у которого isEditable иначе был бы всегда true на
// строках INPUT.
//
// Это фронтовый список, зашитый в коде (а не синхронизированный с БД
// автоматически) — умышленно, см. AURORA-1890. Расхождения чинятся отдельным
// PR, а не здесь.
// 0023:416-420 — IF v_field IN (...) THEN RAISE EXCEPTION 'computed_field: %'
// применяется к полям scope q1..q4, плюс FORM_3 (_apply_form3_cell, 0023:515)
// называет те же по смыслу поля иначе (quarter_actual вместо actual_quarter,
// total_corr — отдельное суммарное поле без аналога в FORM_1/2/4).
const QUARTER_COMPUTED_FIELDS = new Set([
'corrected_plan',
'new_plan',
'actual_quarter',
'booking',
'rem_booking',
'rem_actual',
'residual_after_booking',
'residual_after_actual',
'economy',
'total',
// FORM_3 / PROJECT CURRENT_EXPENSES (_apply_form3_cell, 0023:515-517)
'quarter_actual',
'total_corr',
]);
// 0023:200-219 — header.* редактируется через v3.budget_line, остальные
// поля header.* (year/section/item_id/num_group/vsp_address) — computed_field.
const HEADER_EDITABLE_FIELDS = new Set(['name', 'internal_order', 'justification', 'vsp_id']);
// 0023:154-155 — атрибуты ВСП на сателлитах AHR_RENT/AHR_UTILITY/AHR_SECURITY
// правятся через справочник ВСП, не через эту ячейку.
const VSP_ATTRIBUTE_FIELDS = new Set(['address', 'object_type', 'rented_area']);
// 0023:115-127 — лист лимитов AHR_LIMIT: amount_qN вычисляется из
// qty × limit × period_factor, limit_with_vat/limit_without_vat — нормативный
// справочник (правится админом отдельно, не через ячейку формы).
const LIMIT_SHEET_COMPUTED_FIELDS = new Set(['amount_q1', 'amount_q2', 'amount_q3', 'amount_q4', 'limit_with_vat', 'limit_without_vat']);
const QUARTER_SCOPE_PATTERN = /^q[1-4]$/;
const FACT_QUARTER_SCOPE_PATTERN = /^fact_q[1-4]$/;
const DATA_PREFIX = 'data.';
/**
* Повторяет серверный список computed_field/normative_field исключений
* v3._apply_form_cell / v3._apply_form3_cell для набора колонок, который
* знает фронт (FORM_1/2/4, PROJECT, сателлиты AHR_LIMIT/RENT/UTILITY/SECURITY,
* OTCH9F). columnId — это React Table column.id в формате accessorKey, т.е.
* всегда с префиксом "data." для полей строки (например "data.q1.booking").
*/
export const isComputedColumn = (columnId) => {
if (typeof columnId !== 'string' || !columnId.startsWith(DATA_PREFIX)) return false;
const parts = columnId.slice(DATA_PREFIX.length).split('.');
const [scope, field] = parts;
// 0023:221-223 (FORM_1/2/4), 0023:515 (FORM_3) — approved.*/totals.* всегда вычисляемые.
if (scope === 'approved' || scope === 'totals') return true;
// 0023:200-219 — header.* кроме разрешённого списка.
if (scope === 'header') return !HEADER_EDITABLE_FIELDS.has(field);
if (parts.length === 2) {
// *.year — plan.year, seq_dfip.year, seq_ssp.year, reserve.year и т.п.
if (field === 'year') return true;
// qN.<field> из списка вычисляемых полей квартала.
if (QUARTER_SCOPE_PATTERN.test(scope) && QUARTER_COMPUTED_FIELDS.has(field)) return true;
// fact_qN.total — сателлиты AHR_RENT/AHR_UTILITY/AHR_SECURITY.
if (FACT_QUARTER_SCOPE_PATTERN.test(scope) && field === 'total') return true;
return false;
}
if (parts.length === 1) {
if (VSP_ATTRIBUTE_FIELDS.has(scope)) return true;
if (LIMIT_SHEET_COMPUTED_FIELDS.has(scope)) return true;
}
return false;
};
/**
* isCellEditable({ rowType, columnId, isAdmin, stageColumns })
*
* rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId))
*
* stageColumns — Set с ключами вида "data.<column>" (columnsCurStage из
* useRealtimeData, отражающий окно текущего этапа пользователя).
*/
export const isCellEditable = ({ rowType, columnId, isAdmin, stageColumns }) =>
rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId));

View File

@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isCellEditable, isComputedColumn } from './cellEditability.js';
// Контрольные примеры из тикета AURORA-1890.
test('INPUT row with a column open on the user stage is editable', () => {
const editable = isCellEditable({
rowType: 'INPUT',
columnId: 'data.plan.q1',
isAdmin: false,
stageColumns: new Set(['data.plan.q1']),
});
assert.equal(editable, true);
});
test('ITEM row is never editable, even for a stage column', () => {
const editable = isCellEditable({
rowType: 'ITEM',
columnId: 'data.plan.q1',
isAdmin: false,
stageColumns: new Set(['data.plan.q1']),
});
assert.equal(editable, false);
});
test('admin cannot edit computed columns on an INPUT row', () => {
const computedColumnIds = ['data.q1.corrected_plan', 'data.approved.q1', 'data.totals.fact_year', 'data.header.vsp_address'];
for (const columnId of computedColumnIds) {
const editable = isCellEditable({
rowType: 'INPUT',
columnId,
isAdmin: true,
stageColumns: new Set(),
});
assert.equal(editable, false, `${columnId} must not be editable even for admin`);
}
});
test('admin can edit a regular (non-computed) INPUT cell outside their own stage', () => {
const editable = isCellEditable({
rowType: 'INPUT',
columnId: 'data.plan.q1',
isAdmin: true,
stageColumns: new Set(),
});
assert.equal(editable, true);
});
test('executor cannot edit a column outside their current stage', () => {
const editable = isCellEditable({
rowType: 'INPUT',
columnId: 'data.plan.q1',
isAdmin: false,
stageColumns: new Set(['data.plan.q2']),
});
assert.equal(editable, false);
});
// isComputedColumn — повторяет v3._apply_form_cell / v3._apply_form3_cell
// (api/alembic/versions/sql/0023_apply_cell_empty_string.sql).
test('approved.* and totals.* are always computed (0023:221-223, 0023:515)', () => {
assert.equal(isComputedColumn('data.approved.q1'), true);
assert.equal(isComputedColumn('data.approved.year'), true);
assert.equal(isComputedColumn('data.totals.fact_year'), true);
assert.equal(isComputedColumn('data.totals.go_balance_only'), true);
});
test('header.* is computed except name/internal_order/justification/vsp_id (0023:200-219)', () => {
assert.equal(isComputedColumn('data.header.name'), false);
assert.equal(isComputedColumn('data.header.internal_order'), false);
assert.equal(isComputedColumn('data.header.justification'), false);
assert.equal(isComputedColumn('data.header.vsp_id'), false);
assert.equal(isComputedColumn('data.header.vsp_address'), true);
assert.equal(isComputedColumn('data.header.section'), true);
assert.equal(isComputedColumn('data.header.item_id'), true);
assert.equal(isComputedColumn('data.header.num_group'), true);
assert.equal(isComputedColumn('data.header.year'), true);
});
test('qN computed fields match the SQL exception list (0023:416-420)', () => {
const computedQuarterFields = [
'corrected_plan',
'new_plan',
'actual_quarter',
'booking',
'rem_booking',
'rem_actual',
'residual_after_booking',
'residual_after_actual',
'economy',
'total',
];
for (const field of computedQuarterFields) {
assert.equal(isComputedColumn(`data.q1.${field}`), true, `data.q1.${field} must be computed`);
assert.equal(isComputedColumn(`data.q4.${field}`), true, `data.q4.${field} must be computed`);
}
// обычные ненулевые поля квартала остаются редактируемыми
assert.equal(isComputedColumn('data.q1.adj_current'), false);
assert.equal(isComputedColumn('data.q1.pay_amount'), false);
});
test('FORM_3 (PROJECT/CURRENT_EXPENSES) uses different names for the same computed fields (_apply_form3_cell, 0023:515)', () => {
assert.equal(isComputedColumn('data.q1.quarter_actual'), true);
assert.equal(isComputedColumn('data.q2.total_corr'), true);
});
test('*.year columns are computed regardless of scope (plan.year, seq_dfip.year, seq_ssp.year, reserve.year)', () => {
assert.equal(isComputedColumn('data.plan.year'), true);
assert.equal(isComputedColumn('data.seq_dfip.year'), true);
assert.equal(isComputedColumn('data.seq_ssp.year'), true);
assert.equal(isComputedColumn('data.reserve.year'), true);
assert.equal(isComputedColumn('data.sequestration.year'), true);
});
test('satellite fact_qN.total is computed, other fact_qN fields are editable (0023:163-164)', () => {
assert.equal(isComputedColumn('data.fact_q1.total'), true);
assert.equal(isComputedColumn('data.fact_q1.jan'), false);
assert.equal(isComputedColumn('data.fact_q2.total'), true);
});
test('VSP attributes on AHR_RENT/UTILITY/SECURITY satellites are computed (0023:154-155)', () => {
assert.equal(isComputedColumn('data.address'), true);
assert.equal(isComputedColumn('data.object_type'), true);
assert.equal(isComputedColumn('data.rented_area'), true);
});
test('AHR_LIMIT sheet: amount_qN and limit_with/without_vat are computed (0023:120-124)', () => {
assert.equal(isComputedColumn('data.amount_q1'), true);
assert.equal(isComputedColumn('data.amount_q4'), true);
assert.equal(isComputedColumn('data.limit_with_vat'), true);
assert.equal(isComputedColumn('data.limit_without_vat'), true);
// qty_qN остаётся редактируемым полем листа лимитов
assert.equal(isComputedColumn('data.qty_q1'), false);
});
test('plain non-"data." column ids (row number, grouping headers) are never computed', () => {
assert.equal(isComputedColumn('sort_order'), false);
assert.equal(isComputedColumn(undefined), false);
assert.equal(isComputedColumn(null), false);
});

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

@ -0,0 +1,17 @@
// AURORA-1890: срок ввода в заголовке колонки — closes_at показывается в
// локальном времени браузера пользователя (ISO-строка/Date уже несёт
// смещение часового пояса, Date сам пересчитывает его в локальное при
// обращении к getDate/getHours и т.п.), без года: «до 31.10 18:00».
export const formatDeadline = (closesAt) => {
if (!closesAt) return null;
const date = closesAt instanceof Date ? closesAt : new Date(closesAt);
if (Number.isNaN(date.getTime())) return null;
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `до ${day}.${month} ${hours}:${minutes}`;
};

View File

@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatDeadline } from './deadlineFormat.js';
test('ISO closes_at is formatted as "до DD.MM HH:mm" in local time', () => {
const closesAt = new Date(2026, 9, 31, 18, 0, 0); // 31 октября, 18:00, локальное время
assert.equal(formatDeadline(closesAt.toISOString()), 'до 31.10 18:00');
});
test('single-digit day/month/hour/minute are zero-padded', () => {
const closesAt = new Date(2026, 0, 5, 9, 3, 0); // 5 января, 09:03
assert.equal(formatDeadline(closesAt.toISOString()), 'до 05.01 09:03');
});
test('a Date instance is accepted directly, not only an ISO string', () => {
const closesAt = new Date(2026, 11, 1, 0, 0, 0);
assert.equal(formatDeadline(closesAt), 'до 01.12 00:00');
});
test('falsy or invalid input returns null instead of throwing', () => {
assert.equal(formatDeadline(null), null);
assert.equal(formatDeadline(undefined), null);
assert.equal(formatDeadline(''), null);
assert.equal(formatDeadline('not-a-date'), 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);
});

View File

@ -0,0 +1,17 @@
// Форматирует число ячейки таблицы для отображения.
// Ноль по-прежнему выводится как «-» (привычный формат), а isNegative
// позволяет вызывающей стороне (Cell.jsx) подобрать более заметный цвет
// для отрицательных значений, не путая их с нулём.
export const formatNumber = (value, asInteger = false) => {
const num = Number(value);
if (Number.isNaN(num)) return { text: String(value), isNegative: false };
if (num === 0) return { text: '-', isNegative: false };
const text = num.toLocaleString('ru-RU', {
minimumFractionDigits: asInteger ? 0 : 1,
maximumFractionDigits: asInteger ? 0 : 1,
useGrouping: true,
});
return { text, isNegative: num < 0 };
};

View File

@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatNumber } from './numberFormat.js';
test('negative numbers are formatted ru-RU and flagged as negative', () => {
const result = formatNumber(-1234.5);
assert.equal(result.text, '-1 234,5');
assert.equal(result.isNegative, true);
});
test('zero is rendered as a dash and is never negative', () => {
const result = formatNumber(0);
assert.equal(result.text, '-');
assert.equal(result.isNegative, false);
});
test('positive numbers keep the familiar ru-RU grouping and are not negative', () => {
const result = formatNumber(1234.5);
assert.equal(result.text, '1 234,5');
assert.equal(result.isNegative, false);
});
test('asInteger drops the fractional digit', () => {
const integer = formatNumber(1234, true);
assert.equal(integer.text, '1 234');
assert.equal(integer.isNegative, false);
const negativeInteger = formatNumber(-1234, true);
assert.equal(negativeInteger.text, '-1 234');
assert.equal(negativeInteger.isNegative, true);
});
test('non-numeric input is returned as a string without crashing', () => {
const result = formatNumber('не число');
assert.equal(result.text, 'не число');
assert.equal(result.isNegative, false);
});

View File

@ -1,3 +1,5 @@
import { getDataRowId } from './rowUtils.js';
// Достаёт вложенное поле из объекта строки таблицы
export const getValueByPath = (row, path) => path.split('.').reduce((value, key) => value?.[key], row);
@ -31,28 +33,67 @@ export const buildColumnHeaderPaths = (columns = [], path = [], map = {}) => {
return map;
};
// Обходит строки и собирает ошибки по правилу валидации
export const collectInvalidErrors = (rows, rule, headerPaths, errors, seen) => {
// AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
// название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
const getRowLabel = (row) => {
const data = row?.data;
if (data?.header?.name) return data.header.name;
if (data?.name) return data.name;
if (row?.sort_order !== undefined && row?.sort_order !== null) return String(row.sort_order);
return String(getDataRowId(data) ?? '');
};
// Обходит строки и собирает ошибки по правилу валидации — по записи на каждую
// ошибочную ЯЧЕЙКУ (не одна запись на колонку, как было раньше), чтобы плашка
// могла показать счётчик и перейти к конкретной строке.
export const collectInvalidErrors = (rows, rule, errors, seen) => {
for (const row of rows) {
if (!rule.appliesToRow || rule.appliesToRow(row)) {
for (const columnKey of rule.errorColumnKeys || rule.columnKeys) {
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue;
const errorId = `${rule.id}:${columnKey}`;
const lineId = getDataRowId(row.data);
const errorId = `${rule.id}:${columnKey}:${lineId}`;
if (seen.has(errorId)) continue;
seen.add(errorId);
const columnPath = headerPaths[columnKey];
const columnMessage = columnPath?.length ? `Ошибка в колонке: ${columnPath.join(' => ')}` : null;
errors.push({
id: errorId,
ruleId: rule.id,
columnId: columnKey,
message: columnMessage ? `${rule.message}. ${columnMessage}` : rule.message,
lineId,
rowLabel: getRowLabel(row),
message: rule.message,
});
}
}
collectInvalidErrors(row.subRows || [], rule, headerPaths, errors, seen);
collectInvalidErrors(row.subRows || [], rule, errors, seen);
}
};
// AURORA-1893: группирует ошибки по «правило + колонка» (как раньше отображалось
// в плашке) и прикладывает к каждой группе список конкретных ячеек со счётчиком.
export const groupValidationErrors = (errors) => {
const groupsByKey = new Map();
for (const error of errors) {
const key = `${error.ruleId}:${error.columnId}`;
let group = groupsByKey.get(key);
if (!group) {
group = {
id: key,
ruleId: error.ruleId,
columnId: error.columnId,
message: error.message,
cells: [],
count: 0,
};
groupsByKey.set(key, group);
}
group.cells.push(error);
group.count += 1;
}
return [...groupsByKey.values()];
};

View File

@ -0,0 +1,214 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
import { collectInvalidErrors, groupValidationErrors, isValidationRuleEnabled } from './validationUtils.js';
test('nonNegativeRule flags negative numbers as invalid', () => {
const rule = nonNegativeRule({
id: 'non-negative-test',
columnKeys: ['data.plan.q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
});
assert.equal(rule.isInvalid(-1), true);
assert.equal(rule.isInvalid(-0.01), true);
});
test('nonNegativeRule does not flag zero, empty or positive values', () => {
const rule = nonNegativeRule({
id: 'non-negative-test',
columnKeys: ['data.plan.q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
});
assert.equal(rule.isInvalid(0), false);
assert.equal(rule.isInvalid(''), false);
assert.equal(rule.isInvalid(null), false);
assert.equal(rule.isInvalid(undefined), false);
assert.equal(rule.isInvalid(100), false);
});
test('nonNegativeRule does not restrict which rows it applies to', () => {
const rule = nonNegativeRule({
id: 'non-negative-test',
columnKeys: ['data.plan.q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
});
assert.equal(rule.appliesToRow, undefined);
});
test('a scoped rule is disabled outside its form/sheet scope', () => {
const rule = nonNegativeRule({
id: 'non-negative-scoped-test',
columnKeys: ['data.qty_q1'],
message: 'Отрицательное значение в этой колонке недопустимо',
includedScopes: [{ formType: 'FORM_2', sheetName: 'AHR_LIMIT' }],
});
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR_LIMIT' }), true);
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_1', sheetName: 'AHR' }), false);
assert.equal(isValidationRuleEnabled(rule, { formType: 'FORM_2', sheetName: 'AHR' }), false);
});
test('the shared nonNegativeColumnKeys list excludes correction and transfer columns', () => {
const excludedPrefixes = ['adj_', 'base_correction', 'revision_', 'seq_', 'target_change', 'transfer_'];
for (const key of nonNegativeColumnKeys) {
const segment = key.split('.').pop();
for (const prefix of excludedPrefixes) {
assert.equal(
segment.startsWith(prefix),
false,
`${key} looks like a correction/transfer column and must not be in nonNegativeColumnKeys`,
);
}
}
});
// AURORA-1893: по записи на каждую ошибочную ячейку (не по одной на колонку).
test('collectInvalidErrors produces one entry per invalid cell, with lineId and columnId', () => {
const rule = {
id: 'test-rule',
columnKeys: ['data.value'],
message: 'Значение недопустимо',
isInvalid: (value) => Number(value) < 0,
};
const rows = [
{ data: { line_id: 10, value: -1 }, subRows: [] },
{ data: { line_id: 11, value: 5 }, subRows: [] },
{
data: { line_id: 12, value: 1 },
subRows: [{ data: { line_id: 13, value: -2 }, subRows: [] }],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.equal(errors.length, 2);
assert.deepEqual(
errors.map((error) => ({ lineId: error.lineId, columnId: error.columnId })),
[
{ lineId: 10, columnId: 'data.value' },
{ lineId: 13, columnId: 'data.value' },
],
);
assert.equal(errors[0].id, 'test-rule:data.value:10');
assert.equal(errors[0].ruleId, 'test-rule');
assert.equal(errors[0].message, 'Значение недопустимо');
});
test('collectInvalidErrors does not duplicate the same cell when seen is reused', () => {
const rule = {
id: 'test-rule',
columnKeys: ['data.value'],
message: 'Значение недопустимо',
isInvalid: (value) => Number(value) < 0,
};
const rows = [{ data: { line_id: 10, value: -1 }, subRows: [] }];
const errors = [];
const seen = new Set();
collectInvalidErrors(rows, rule, errors, seen);
collectInvalidErrors(rows, rule, errors, seen);
assert.equal(errors.length, 1);
});
test('collectInvalidErrors fills rowLabel from data.header.name, falling back to data.name then the row number', () => {
const rule = {
id: 'label-rule',
columnKeys: ['data.value'],
message: 'msg',
isInvalid: (value) => value === 'bad',
};
const rows = [
{ data: { line_id: 1, value: 'bad', header: { name: 'Статья 1' } }, subRows: [] },
{ data: { line_id: 2, value: 'bad', name: 'ВСП 2' }, subRows: [] },
{ data: { line_id: 3, value: 'bad' }, sort_order: 7, subRows: [] },
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.deepEqual(
errors.map((error) => error.rowLabel),
['Статья 1', 'ВСП 2', '7'],
);
});
// AURORA-1893: меню «Контроль ошибок» группирует по правилу + колонке со счётчиком.
test('groupValidationErrors groups cell errors by rule and column with a count', () => {
const errors = [
{ id: 'r1:c1:1', ruleId: 'r1', columnId: 'c1', lineId: 1, rowLabel: '1', message: 'm1' },
{ id: 'r1:c1:2', ruleId: 'r1', columnId: 'c1', lineId: 2, rowLabel: '2', message: 'm1' },
{ id: 'r1:c2:1', ruleId: 'r1', columnId: 'c2', lineId: 1, rowLabel: '1', message: 'm1' },
{ id: 'r2:c1:1', ruleId: 'r2', columnId: 'c1', lineId: 1, rowLabel: '1', message: 'm2' },
];
const groups = groupValidationErrors(errors);
assert.equal(groups.length, 3);
const byKey = new Map(groups.map((group) => [`${group.ruleId}:${group.columnId}`, group]));
assert.equal(byKey.get('r1:c1').count, 2);
assert.deepEqual(
byKey.get('r1:c1').cells.map((cell) => cell.lineId),
[1, 2],
);
assert.equal(byKey.get('r1:c2').count, 1);
assert.equal(byKey.get('r2:c1').count, 1);
});
test('groupValidationErrors returns an empty list for an empty errors list', () => {
assert.deepEqual(groupValidationErrors([]), []);
});
// AURORA-1893: appliesToRow ограничивает правило «корректировки = 0» строками ROOT/depth 0.
test('current-adjustments-zero only reports rows that appliesToRow allows (ROOT / depth 0)', () => {
const rule = validationRules.find((r) => r.id === 'current-adjustments-zero');
const rows = [
{
data: { line_id: 1, q1: { adj_current: 5 } },
row_type: 'ROOT',
depth: 0,
subRows: [{ data: { line_id: 2, q1: { adj_current: 7 } }, row_type: 'ITEM', depth: 1, subRows: [] }],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.equal(errors.length, 1);
assert.equal(errors[0].lineId, 1);
assert.equal(errors[0].columnId, 'data.q1.adj_current');
});
// AURORA-1893: errorColumnKeys ограничивает booking-less-balance колонками брони,
// а не парными колонками остатка, даже когда isInvalid читает обе.
test('booking-less-balance only reports its errorColumnKeys (booking columns), not the paired balance columns', () => {
const rule = validationRules.find((r) => r.id === 'booking-less-balance');
const rows = [
{
data: {
line_id: 5,
q1: { booking: 100, residual_after_booking: 50 },
q2: { booking: 10, residual_after_booking: 50 },
},
subRows: [],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.equal(errors.length, 1);
assert.equal(errors[0].columnId, 'data.q1.booking');
assert.equal(errors[0].lineId, 5);
});