diff --git a/web/package-lock.json b/web/package-lock.json index c2d71df..a9e476f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 2b14b85..d034297 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 6d786b2..e97cd3c 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -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
🔒
; }, [isLocked]); - const handleClick = useCallback(() => { - if (!isLocked && isEditable && onClick) { - onClick(); - } - }, [isLocked, isEditable, onClick]); - - return ( - // biome-ignore lint/a11y/useKeyWithClickEvents: -
+ // AURORA-1891: клик теперь обрабатывает muiTableBodyCellProps (RealtimeTable.jsx) + // — там же проверяется редактируемость/блокировка и открывается редактор одним + // кликом; onClick/handleClick здесь были мёртвым кодом (tableColumns.jsx никогда + // не передавал onClick в Cell). + const cellNode = ( +
{highlightedContent} {isOverLimit && сверх лимита} {lockBadge}
); + + // AURORA-1893: причина ошибки по наведению — только когда ячейка действительно + // невалидна и есть текст причины (invalidMessage собран useValidationRules'ом). + if (isInvalid && invalidMessage) { + return ( + + {cellNode} + + ); + } + + 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 && diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index acdf758..619bfe2 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -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: этой ячейки — чтобы после закрытия редактора вернуть + // туда фокус (иначе стрелки/клавиатурная навигация отваливаются после + // первого же редактирования, см. 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: при закрытии редактора (размонтирование) фокус возвращается + // в этой ячейки — без этого фокус терялся бы на удалённом 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( diff --git a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx index 7af48c7..beafa61 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/VspDropdownEditCell.jsx @@ -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 — фокус после закрытия + // редактора возвращается в , чтобы клавиатурная навигация не отваливалась. + 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 })