Merge pull request 'AURORA-1891: редактирование одним кликом и с клавиатуры, без лишних записей в аудит' (#290) from feat/284-aurora-1891-cell-click-keyboard-edit into test
This commit is contained in:
commit
12dc8414d1
19
web/package-lock.json
generated
19
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
55
web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx
Normal file
55
web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx
Normal 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);
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
|
||||
@ -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: "Отрицательное значение в этой колонке недопустимо",
|
||||
}),
|
||||
];
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
107
web/src/components/RealtimeTable/utils/cellEditability.js
Normal file
107
web/src/components/RealtimeTable/utils/cellEditability.js
Normal 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));
|
||||
146
web/src/components/RealtimeTable/utils/cellEditability.test.js
Normal file
146
web/src/components/RealtimeTable/utils/cellEditability.test.js
Normal 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);
|
||||
});
|
||||
46
web/src/components/RealtimeTable/utils/cellNavigation.js
Normal file
46
web/src/components/RealtimeTable/utils/cellNavigation.js
Normal 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;
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
17
web/src/components/RealtimeTable/utils/deadlineFormat.js
Normal file
17
web/src/components/RealtimeTable/utils/deadlineFormat.js
Normal 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}`;
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
@ -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 - Число для форматирования
|
||||
|
||||
38
web/src/components/RealtimeTable/utils/formulaUtils.test.js
Normal file
38
web/src/components/RealtimeTable/utils/formulaUtils.test.js
Normal 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);
|
||||
});
|
||||
17
web/src/components/RealtimeTable/utils/numberFormat.js
Normal file
17
web/src/components/RealtimeTable/utils/numberFormat.js
Normal 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 };
|
||||
};
|
||||
37
web/src/components/RealtimeTable/utils/numberFormat.test.js
Normal file
37
web/src/components/RealtimeTable/utils/numberFormat.test.js
Normal 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);
|
||||
});
|
||||
@ -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()];
|
||||
};
|
||||
|
||||
214
web/src/components/RealtimeTable/utils/validationUtils.test.js
Normal file
214
web/src/components/RealtimeTable/utils/validationUtils.test.js
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user