From fa586c85cc678d0fb94b7d442f693aa6b08f5069 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 1 Nov 2026 12:20:06 +0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(table):=20=D0=BE=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81?= =?UTF-8?q?=D1=83=D0=BC=D0=BC=D1=8B=20=D0=BA=D1=80=D0=B0=D1=81=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE=20?= =?UTF-8?q?=C2=AB=D0=BC=D0=B8=D0=BD=D1=83=D1=81=20=D0=B7=D0=B0=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D1=89=D1=91=D0=BD=C2=BB=20(AURORA-1892)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell.jsx: formatNumber вынесен в utils/numberFormat.js и возвращает { text, isNegative }; отрицательные числа выделяются цветом (#C62828 на светлом фоне, #FFCDD2 на тёмном) и font-weight 600, ноль остаётся «-». constants/validation.js: фабрика nonNegativeRule и общий список nonNegativeColumnKeys (план, бронь на 2026/2027, факт по месяцам и СПОД, сумма платежа, предельная стоимость договора/ЦКК, сателлиты АХР Аренда/Коммунальные/Безопасность, АХР Лимиты целиком). Корректировки и переносы (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) не включены — там минус — нормальная операция. Запрет мягкий: подсветка и запись в «Контроль ошибок» через существующий useValidationRules/isCellInvalid, сохранение не блокируется. EditCell.jsx: handleSave предупреждает toast.warning при сохранении отрицательного числа в защищённой колонке, значение всё равно сохраняется. useValidationRules и RealtimeTable прокидывают человекочитаемый заголовок колонки для текста предупреждения. Тесты (node --test): numberFormat.test.js, validationUtils.test.js — новые, 10/10 зелёных. --- .../RealtimeTable/Cell/Cell/Cell.jsx | 34 +++--- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 25 ++++- .../RealtimeTable/RealtimeTable.jsx | 6 +- .../RealtimeTable/constants/validation.js | 102 ++++++++++++++++++ .../RealtimeTable/hooks/useValidationRules.js | 2 +- .../RealtimeTable/utils/numberFormat.js | 17 +++ .../RealtimeTable/utils/numberFormat.test.js | 37 +++++++ .../utils/validationUtils.test.js | 67 ++++++++++++ 8 files changed, 267 insertions(+), 23 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/numberFormat.js create mode 100644 web/src/components/RealtimeTable/utils/numberFormat.test.js create mode 100644 web/src/components/RealtimeTable/utils/validationUtils.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 7ada266..8a7faa0 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,5 +1,6 @@ import React, { useMemo, useCallback } from 'react'; import { useCellLock } from '../../contexts/RealtimeContext'; +import { formatNumber } from '../../utils/numberFormat'; const BASE_CELL_STYLES = { width: '100%', @@ -29,6 +30,11 @@ const INVALID_STYLES = { color: '#C60C0C', }; +// AURORA-1892: отрицательные суммы должны быть явно отличимы от нуля и обычных чисел, +// в том числе на тёмных фонах палитры (например ROOT красной палитры #933634). +const NEGATIVE_COLOR_ON_LIGHT = '#C62828'; +const NEGATIVE_COLOR_ON_DARK = '#FFCDD2'; + const EDITABLE_STYLES = { cursor: 'pointer', border: '2px solid #d8e4ea94', @@ -95,17 +101,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,22 +139,25 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, const isVspDropdown = column?.columnDef?.editType === 'vsp_dropdown'; const nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8; - 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) / displayDivisor, false); + const formatted = formatNumber(Number(display) / displayDivisor, 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, displayDivisor]); const highlightedContent = useMemo(() => { @@ -170,8 +168,9 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, 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, isLocked, isNegative]); const cellStyles = useMemo( () => ({ @@ -181,11 +180,12 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, textAlign: isNumeric ? 'right' : 'left', backgroundColor: isLocked ? '#f5f5f5' : backgroundColor, color: textColor, + ...(isNegative ? { fontWeight: 600 } : {}), ...(isLocked ? LOCKED_STYLES : {}), ...(isInvalid ? INVALID_STYLES : {}), ...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES), }), - [backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent], + [backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent, isNegative], ); const lockBadge = useMemo(() => { diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index acdf758..ae33a0d 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,4 +1,6 @@ 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 { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; @@ -29,7 +31,7 @@ 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}`; @@ -114,6 +116,20 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta return () => clearTimeout(timeoutId); }, [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 +144,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta displayValue: formattedResult, isFormulaMode: true, })); + warnIfNegativeNotAllowed(formattedResult); onChange?.(formattedResult); } catch { setState((prev) => ({ @@ -144,9 +161,11 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta displayValue: currentValue, isFormulaMode: false, })); - onChange?.(normalizeNumericInput(currentValue)); + const normalizedValue = normalizeNumericInput(currentValue); + warnIfNegativeNotAllowed(normalizedValue); + onChange?.(normalizedValue); } - }, [state, storageKey, onChange]); + }, [state, storageKey, onChange, warnIfNegativeNotAllowed]); const finishEditing = useCallback(() => { if (refFinished.current) return; diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 7aded64..7d0f231 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -97,7 +97,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [, startTransition] = useTransition(); const [editingCell, setEditingCell] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null); - const { errors: validationErrors, isCellInvalid } = useValidationRules(data, columnsConfig?.columns, formType, sheetName); + const { errors: validationErrors, isCellInvalid, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName); const [expanded, setExpanded] = useState(true); const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide'); @@ -300,8 +300,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { () => ({ updateCell: handleUpdateCell, getSelectedColumnId: () => selectedColumnIdRef.current, + // AURORA-1892: человекочитаемый заголовок колонки для предупреждения о запрете минуса. + getColumnTitle: (columnId) => headerPaths[columnId]?.filter(Boolean).join(' / ') || columnId, }), - [handleUpdateCell], + [handleUpdateCell, headerPaths], ); const vspRegistrationById = useMemo( diff --git a/web/src/components/RealtimeTable/constants/validation.js b/web/src/components/RealtimeTable/constants/validation.js index 0abee24..197f6f9 100644 --- a/web/src/components/RealtimeTable/constants/validation.js +++ b/web/src/components/RealtimeTable/constants/validation.js @@ -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: "Отрицательное значение в этой колонке недопустимо", + }), ]; diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js index b234f12..1758ec6 100644 --- a/web/src/components/RealtimeTable/hooks/useValidationRules.js +++ b/web/src/components/RealtimeTable/hooks/useValidationRules.js @@ -48,5 +48,5 @@ export const useValidationRules = (data, columns, formType, sheetName) => { [activeRules], ); - return { errors, isCellInvalid }; + return { errors, isCellInvalid, headerPaths }; }; diff --git a/web/src/components/RealtimeTable/utils/numberFormat.js b/web/src/components/RealtimeTable/utils/numberFormat.js new file mode 100644 index 0000000..9e8db1b --- /dev/null +++ b/web/src/components/RealtimeTable/utils/numberFormat.js @@ -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 }; +}; diff --git a/web/src/components/RealtimeTable/utils/numberFormat.test.js b/web/src/components/RealtimeTable/utils/numberFormat.test.js new file mode 100644 index 0000000..02757e8 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/numberFormat.test.js @@ -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); +}); diff --git a/web/src/components/RealtimeTable/utils/validationUtils.test.js b/web/src/components/RealtimeTable/utils/validationUtils.test.js new file mode 100644 index 0000000..6989933 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/validationUtils.test.js @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { nonNegativeColumnKeys, nonNegativeRule } from '../constants/validation.js'; +import { 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`, + ); + } + } +}); -- 2.47.2 From df6ec795a3bf83b3839c39a73cc5cbf9c2dea3cd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 1 Nov 2026 13:00:40 +0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(table):=20=D0=B2=D0=B8=D0=B7=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D1=83=D0=B5=D0=BC=D1=8B=D0=B5=20=D1=8F=D1=87=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=20=D1=80=D0=B0=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=D1=85=20(AURORA-1890)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/cellEditability.js: isCellEditable/isComputedColumn — чистые функции, isComputedColumn повторяет часть v3._apply_form_cell/_apply_form3_cell (0023_apply_cell_empty_string.sql), чтобы админ не видел вычисляемые колонки как редактируемые. tableColumns.jsx использует isCellEditable вместо инлайновой проверки. - useRealtimeData.js: возвращает stageColumnDeadlines (closes_at по колонке текущего этапа) рядом с columnsCurStage. - Cell.jsx: редактируемая ячейка — белый фон, рамка 1px #7FA7C9 (2px при наведении), курсор text; нередактируемая ячейка строки INPUT — фон #F4F5F7, текст #5F6B7A; занятая коллегой — пунктирная оранжевая рамка 2px #F0A030 без изменения фона. Строки ITEM/GROUP/ROOT не затронуты. Сохранён порядок стилей из AURORA-1894 (INVALID_STYLES последним) и спред fontWeight для отрицательных чисел из AURORA-1892. - TableHead.jsx: метка срока ввода «✎ до DD.MM HH:mm» в заголовке колонки текущего этапа, только для исполнителя (utils/deadlineFormat.js). - SettingPanel/CellLegend.jsx: иконка "?" с легендой (можно вводить / расчёт или закрыто) рядом с плашкой контроля ошибок, без новой панели и без новых цветов в palettes columnColors.js. node --test: cellEditability.test.js, deadlineFormat.test.js — 18/18. Co-Authored-By: Claude Sonnet 5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 60 +++++-- .../RealtimeTable/RealtimeTable.jsx | 3 + .../RealtimeTable/SettingPanel/CellLegend.jsx | 55 +++++++ .../SettingPanel/SettingPanel.jsx | 6 +- .../RealtimeTable/TableHead/TableHead.jsx | 40 ++++- .../RealtimeTable/hooks/useRealtimeData.js | 9 +- .../components/RealtimeTable/tableColumns.jsx | 20 ++- .../RealtimeTable/utils/cellEditability.js | 107 +++++++++++++ .../utils/cellEditability.test.js | 146 ++++++++++++++++++ .../RealtimeTable/utils/deadlineFormat.js | 17 ++ .../utils/deadlineFormat.test.js | 25 +++ 11 files changed, 465 insertions(+), 23 deletions(-) create mode 100644 web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx create mode 100644 web/src/components/RealtimeTable/utils/cellEditability.js create mode 100644 web/src/components/RealtimeTable/utils/cellEditability.test.js create mode 100644 web/src/components/RealtimeTable/utils/deadlineFormat.js create mode 100644 web/src/components/RealtimeTable/utils/deadlineFormat.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index bada91a..9fbbdbb 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import { useCellLock } from '../../contexts/RealtimeContext'; import { formatNumber } from '../../utils/numberFormat'; @@ -17,11 +17,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 = { @@ -35,11 +36,25 @@ const INVALID_STYLES = { 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', }; @@ -135,9 +150,18 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) => const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 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 nameIndent = column.id === 'data.header.name' || column.id === 'data.name' ? 8 + row.depth * 16 : 8; + // 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, isNegative } = useMemo(() => { const value = cell.getValue(); @@ -165,12 +189,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, isNegative]); + }, [backgroundColor, isNegative]); + + // AURORA-1890: нередактируемый стиль зависит от типа строки (INPUT получает + // приглушённую заливку, ITEM/GROUP/ROOT — прежнее поведение без изменений). + const notEditableStyles = isInputRow ? NOT_EDITABLE_INPUT_STYLES : NOT_EDITABLE_STYLES; const cellStyles = useMemo( () => ({ @@ -178,14 +207,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, ...(isNegative ? { fontWeight: 600 } : {}), - ...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES), + ...(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, isNegative], + [backgroundColor, isInvalid, isLocked, textColor, isEditable, notEditableStyles, isNumeric, nameIndent, isNegative, isHovered], ); const lockBadge = useMemo(() => { @@ -201,7 +230,14 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, return ( // biome-ignore lint/a11y/useKeyWithClickEvents: -
+
{highlightedContent} {lockBadge}
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 7d0f231..a35aec1 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -82,6 +82,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { data, columnsCurStage, + stageColumnDeadlines, isLoading: isTableLoading, editingCells: dataEditingCells, } = useRealtimeData(formId, sheetName, direction, formType, year); @@ -844,6 +845,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onColumnSelect={handleColumnSelect} onChangeWidth={handleSetColumnWidth} isLoadingData={isLoadingData} + stageColumnDeadlines={stageColumnDeadlines} + userRoleId={userRoleId} />, headerPortalRef.current, )} diff --git a/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx b/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx new file mode 100644 index 0000000..8bcc26c --- /dev/null +++ b/web/src/components/RealtimeTable/SettingPanel/CellLegend.jsx @@ -0,0 +1,55 @@ +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +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 = () => ( + + + + можно вводить + + + + расчёт или закрыто на этапе + + + }> + + + + +); + +export default memo(CellLegend); diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 3acceb7..6b4dba4 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -22,6 +22,7 @@ import { VisibleExpenseItems, } from '../../common/icons/icons'; import { collectLeafColumns } from '../utils/columnUtils'; +import CellLegend from './CellLegend'; import { GroupByObject } from './GroupByObject/GroupByObject'; import { Panel } from './SettingPanel.style'; import ValidationErrorsAlert from './ValidationErrorsAlert'; @@ -321,7 +322,10 @@ const SettingPanel = ({ /> - + + + + ); diff --git a/web/src/components/RealtimeTable/TableHead/TableHead.jsx b/web/src/components/RealtimeTable/TableHead/TableHead.jsx index fa6d1d7..39dce95 100644 --- a/web/src/components/RealtimeTable/TableHead/TableHead.jsx +++ b/web/src/components/RealtimeTable/TableHead/TableHead.jsx @@ -2,6 +2,21 @@ import React, { useMemo } from 'react'; import ColumnResizer from './ColumnResizer'; import { formatHeaderYear } from '../utils/headerYearUtils'; import { getOrderedVisibleColumns } from '../utils/columnLayoutUtils'; +import { formatDeadline } from '../utils/deadlineFormat'; +import { ROLES_NAME_ID } from '../../../constants/constants'; + +// 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 }) => { +const HeaderCell = ({ header, table, pinningPosition, onClick, onChangeWidth, budgetYear, stageColumnDeadlines, userRoleId }) => { 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 : formatHeaderYear(column.columnDef.header, budgetYear)} + {deadlineLabel && {deadlineLabel}}
@@ -317,7 +342,16 @@ const FilterCell = ({ column }) => { ); }; -const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeWidth, isLoadingData, budgetYear }) => { +const TableHeadComponent = ({ + table, + selectedColumnId, + onColumnSelect, + onChangeWidth, + isLoadingData, + budgetYear, + stageColumnDeadlines, + userRoleId, +}) => { const handleHeaderClick = (header) => { if (!header.subHeaders?.length && header.column?.id) { onColumnSelect?.(header.column.id); @@ -361,6 +395,8 @@ const TableHeadComponent = ({ table, selectedColumnId, onColumnSelect, onChangeW pinningPosition={pinningPosition} onClick={handleHeaderClick} onChangeWidth={onChangeWidth} + stageColumnDeadlines={stageColumnDeadlines} + userRoleId={userRoleId} /> )), )} diff --git a/web/src/components/RealtimeTable/hooks/useRealtimeData.js b/web/src/components/RealtimeTable/hooks/useRealtimeData.js index df45906..2b4973b 100644 --- a/web/src/components/RealtimeTable/hooks/useRealtimeData.js +++ b/web/src/components/RealtimeTable/hooks/useRealtimeData.js @@ -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."), 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, }; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index cf557e0..baee1c2 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -7,6 +7,7 @@ import { getDenominationDivisor } from '../common/useDenomination'; 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'; @@ -128,13 +129,18 @@ export const getTableColumns = ({ const cellPropsCache = new WeakMap(); const editPropsCache = new WeakMap(); - const isEditable = (row, column) => { - if (row.original?.row_type !== 'INPUT') return false; - if (userRoleId === ROLES_NAME_ID.admin) return true; - const keyCol = column.id; - if (columnsCurStage.includes(keyCol)) return true; - return false; - }; + // AURORA-1890: stageColumns ожидается как Set (сигнатура isCellEditable), + // columnsCurStage из useRealtimeData — массив; строим Set один раз на + // пересборку колонок, а не на каждую ячейку. + const stageColumns = new Set(columnsCurStage); + + const isEditable = (row, column) => + isCellEditable({ + rowType: row.original?.row_type, + columnId: column.id, + isAdmin: userRoleId === ROLES_NAME_ID.admin, + stageColumns, + }); const getCachedCellProps = (row, column, table) => { const key = `${row.id}_${column.id}`; diff --git a/web/src/components/RealtimeTable/utils/cellEditability.js b/web/src/components/RealtimeTable/utils/cellEditability.js new file mode 100644 index 0000000..36efce4 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellEditability.js @@ -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. из списка вычисляемых полей квартала. + 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." (columnsCurStage из + * useRealtimeData, отражающий окно текущего этапа пользователя). + */ +export const isCellEditable = ({ rowType, columnId, isAdmin, stageColumns }) => + rowType === 'INPUT' && !isComputedColumn(columnId) && (isAdmin || stageColumns.has(columnId)); diff --git a/web/src/components/RealtimeTable/utils/cellEditability.test.js b/web/src/components/RealtimeTable/utils/cellEditability.test.js new file mode 100644 index 0000000..618a710 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/cellEditability.test.js @@ -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); +}); diff --git a/web/src/components/RealtimeTable/utils/deadlineFormat.js b/web/src/components/RealtimeTable/utils/deadlineFormat.js new file mode 100644 index 0000000..e539bef --- /dev/null +++ b/web/src/components/RealtimeTable/utils/deadlineFormat.js @@ -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}`; +}; diff --git a/web/src/components/RealtimeTable/utils/deadlineFormat.test.js b/web/src/components/RealtimeTable/utils/deadlineFormat.test.js new file mode 100644 index 0000000..6ef1e27 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/deadlineFormat.test.js @@ -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); +}); -- 2.47.2 From d7ab4d170cdb00a730d9632da63961cf3d2de990 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 1 Nov 2026 15:11:26 +0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(table):=20=D0=BF=D0=BB=D0=B0=D1=88?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20=D1=81=D0=BE=20=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=BE=D0=BC,=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=D0=BE=D0=BC=20=D0=BA=20?= =?UTF-8?q?=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B5=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=BE=D0=B9=20(AURORA-1893?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validationUtils.collectInvalidErrors теперь собирает ошибки по ЯЧЕЙКАМ (id/ruleId/columnId/lineId/rowLabel/message), а не одну запись на колонку; новая groupValidationErrors(errors) группирует их обратно по правилу+колонке со счётчиком для меню плашки. - useValidationRules: getCellErrors(columnId, value, row) отдаёт причины конкретной ячейки (для подсказки), isCellInvalid выражен через него; сравнение стабильности errors — по id (он уже кодирует правило+колонку+строку). - RealtimeTable: handleNavigateToError({lineId, columnId}) — находит строку по getRowId в table.getRowModel().flatRows (не .rows, т.к. flatRows не фильтруется по свёрнутым группам), раскрывает родителей через setExpanded, включает скрытую колонку через handleSetColumnsVisibility, выделяет ячейку как activeSearchMatch и скроллит к ней — по образцу navigateToSearchMatch. - ValidationErrorsAlert: красная плашка (#FDEDED/#D32F2F) со счётчиком «Ошибок: N», кнопками ‹› для прохода по всем ошибкам и меню, сгруппированным по правилу+колонке (до 20 строк, дальше «и ещё N»); перенесена в начало SettingPanel, перед группами настроек. - tableColumns/Cell: invalidMessage (из getCellErrors) доходит до Cell и показывается подсказкой (MUI Tooltip) при наведении на невалидную ячейку. Тесты (node --test .../validationUtils.test.js): 12/12, включая 5 существующих тестов nonNegativeRule из AURORA-1892 (не менялись, подтверждают отсутствие регрессии) и 7 новых для collectInvalidErrors/groupValidationErrors. Co-Authored-By: Claude Sonnet 5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 31 ++- .../RealtimeTable/RealtimeTable.jsx | 60 +++++- .../SettingPanel/SettingPanel.jsx | 12 +- .../SettingPanel/ValidationErrorsAlert.jsx | 198 ++++++++++++------ .../RealtimeTable/hooks/useValidationRules.js | 32 +-- .../components/RealtimeTable/tableColumns.jsx | 12 +- .../RealtimeTable/utils/validationUtils.js | 57 ++++- .../utils/validationUtils.test.js | 151 ++++++++++++- 8 files changed, 450 insertions(+), 103 deletions(-) diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 9fbbdbb..e90ad40 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,3 +1,4 @@ +import { Tooltip } from '@mui/material'; import React, { useMemo, useCallback, useState } from 'react'; import { useCellLock } from '../../contexts/RealtimeContext'; import { formatNumber } from '../../utils/numberFormat'; @@ -147,7 +148,20 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) => }); }; -const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, isEditable, isInvalid, vspOptions, displayDivisor = 1 }) => { +const CellComponent = ({ + cell, + row, + column, + onClick, + globalFilter, + columnFilter, + backgroundColor, + isEditable, + isInvalid, + invalidMessage, + vspOptions, + displayDivisor = 1, +}) => { const cellKey = `${row.id}_${column.id}`; const isLocked = useCellLock(cellKey); // AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px). @@ -228,7 +242,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter, } }, [isLocked, isEditable, onClick]); - return ( + const cellNode = ( // biome-ignore lint/a11y/useKeyWithClickEvents:
); + + // AURORA-1893: причина ошибки по наведению — только когда ячейка действительно + // невалидна и есть текст причины (invalidMessage собран useValidationRules'ом). + if (isInvalid && invalidMessage) { + return ( + + {cellNode} + + ); + } + + return cellNode; }; const Cell = React.memo(CellComponent, (prevProps, nextProps) => { @@ -255,6 +281,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.displayDivisor === nextProps.displayDivisor diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index a35aec1..a2e164f 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -98,7 +98,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [, startTransition] = useTransition(); const [editingCell, setEditingCell] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null); - const { errors: validationErrors, isCellInvalid, headerPaths } = 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'); @@ -263,14 +263,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { userRoleId, onCellNumberClick: handleClickRowCell, onCellUpdateError: handleCellUpdateError, - isCellInvalid, + getCellErrors, denomination, }); } catch (error) { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]); + }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination]); // MRT запоминает индексы pinned-колонок до завершения асинхронной // загрузки конфигурации. Новая ссылка на columnPinning заставляет @@ -518,6 +518,57 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { [columnPinning, containerRef, table], ); + // 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 []; @@ -805,7 +856,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { canModifyRows={canModifyRows} formType={formType} validationErrors={validationErrors} - onNavigateToColumn={handleNavigateToColumn} + onNavigateToError={handleNavigateToError} + headerPaths={headerPaths} expenseItemsVisibilityAction={expenseItemsVisibilityAction} onToggleDepthVisibility={handleToggleDepthVisibility} /> diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 6b4dba4..bc20e07 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -96,7 +96,8 @@ const SettingPanel = ({ canModifyRows, formType, validationErrors = [], - onNavigateToColumn, + onNavigateToError, + headerPaths, expenseItemsVisibilityAction, onToggleDepthVisibility, denomination, @@ -165,7 +166,13 @@ const SettingPanel = ({ return ( <> - + + {validationErrors.length > 0 && ( + <> + + + + )} {/* */} @@ -323,7 +330,6 @@ const SettingPanel = ({ - diff --git a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx index 18377d2..95b1a68 100644 --- a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx @@ -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/ErrorOutline'; +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 ( <> - - - Подробнее - - - + handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}> + + + { '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) => ( - handleNavigate(error.columnId)} - sx={{ - whiteSpace: 'normal', - color: warningColors.text, - cursor: 'pointer', - '&:hover': { - bgcolor: warningColors.hover, - }, - }}> - {error.message} - - ))} + {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 [ + + {group.message} · {columnTitle} ({group.count}) + , + ...visibleCells.map((cell) => ( + handleSelectCell(cell)} + sx={{ + whiteSpace: 'normal', + color: errorColors.text, + cursor: 'pointer', + pl: 3, + '&:hover': { + bgcolor: errorColors.hover, + }, + }}> + {cell.rowLabel} + + )), + ...(hiddenCount > 0 + ? [ + + и ещё {hiddenCount} + , + ] + : []), + ]; + })} ); diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js index 1758ec6..1bbe499 100644 --- a/web/src/components/RealtimeTable/hooks/useValidationRules.js +++ b/web/src/components/RealtimeTable/hooks/useValidationRules.js @@ -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, headerPaths }; + const isCellInvalid = useCallback((columnId, value, row) => getCellErrors(columnId, value, row).length > 0, [getCellErrors]); + + return { errors, isCellInvalid, getCellErrors, headerPaths }; }; diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index baee1c2..ec1d952 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -119,7 +119,7 @@ export const getTableColumns = ({ userRoleId, onCellUpdateError, onCellNumberClick, - isCellInvalid, + getCellErrors, denomination = 'rub', }) => { const displayDivisor = getDenominationDivisor(denomination); @@ -145,7 +145,11 @@ export const getTableColumns = ({ 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); if (!cellPropsCache.has(row)) { @@ -164,6 +168,7 @@ export const getTableColumns = ({ cached.value === value && cached.displayDivisor === displayDivisor && cached.isInvalid === isInvalid && + cached.invalidMessage === invalidMessage && cached.backgroundColor === backgroundColor ) { return cached; @@ -179,9 +184,10 @@ export const getTableColumns = ({ isEditable: isEditableCell, backgroundColor, isInvalid, + invalidMessage, value, displayDivisor, - _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`, + _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayDivisor}`, }; rowCache[key] = props; diff --git a/web/src/components/RealtimeTable/utils/validationUtils.js b/web/src/components/RealtimeTable/utils/validationUtils.js index c17d470..00db287 100644 --- a/web/src/components/RealtimeTable/utils/validationUtils.js +++ b/web/src/components/RealtimeTable/utils/validationUtils.js @@ -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()]; +}; diff --git a/web/src/components/RealtimeTable/utils/validationUtils.test.js b/web/src/components/RealtimeTable/utils/validationUtils.test.js index 6989933..028f79f 100644 --- a/web/src/components/RealtimeTable/utils/validationUtils.test.js +++ b/web/src/components/RealtimeTable/utils/validationUtils.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { nonNegativeColumnKeys, nonNegativeRule } from '../constants/validation.js'; -import { isValidationRuleEnabled } from './validationUtils.js'; +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({ @@ -65,3 +65,150 @@ test('the shared nonNegativeColumnKeys list excludes correction and transfer col } } }); + +// 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); +}); -- 2.47.2 From 8558670b468afdc0f1dea5a0e22a6973bc324c89 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 1 Nov 2026 16:14:02 +0400 Subject: [PATCH 4/5] =?UTF-8?q?feat(table):=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=B8=D0=BC=20=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=B8=20=D1=81=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=80=D1=8B,=20=D0=B1=D0=B5=D0=B7=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D1=85=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B2=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20(AURORA-1891?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - processColumns (tableColumns.jsx): enableEditing для каждой листовой колонки через isCellEditable (PR #281), а не только для vsp_dropdown — MRT больше не открывает редактор (даже disabled) на нередактируемой ячейке, сервер не берёт блокировку, коллеги не видят 🔒. - Одиночный клик по редактируемой, не заблокированной ячейке открывает редактор (muiTableBodyCellProps.onClick, RealtimeTable.jsx); клик по нередактируемой/занятой ячейке, как раньше, выделяет строку. Удалён мёртвый onClick/handleClick из Cell.jsx. - Клавиатура на выделенной ячейке: Enter/F2 — открыть редактор; Delete/Backspace — открыть с пустым значением; печатный символ — открыть и подставить его вместо текущего значения (хранится в state RealtimeTable, читается через table.options.meta.getEditorInitialInput). - EditCell.jsx/VspDropdownEditCell.jsx: Esc — отмена без cell_updated; Tab/ Shift+Tab и Enter — сохранить и перейти к следующей/предыдущей редактируемой ячейке через table.options.meta.openNextEditableCell → utils/cellNavigation.js (чистые findNextEditableColumnId/findNextEditableRowId по getOrderedVisibleColumns и table.getRowModel().flatRows). - utils/formulaUtils.js: isSameCellValue — handleSave не шлёт cell_updated, если значение не изменилось (число/строка, «1 500,0», пусто/null); формула всегда считается изменённой. - Фокус: setTimeout(50) → requestAnimationFrame; после закрытия редактора фокус возвращается в ячейки. Тесты: utils/cellNavigation.test.js, utils/formulaUtils.test.js (новые, 15 тестов). node --test src/components/RealtimeTable/utils/*.test.js: 85 тестов, 84 passed, 1 известный pre-existing fail (columnLayoutUtils.test.js, нет node_modules в этом worktree, см. PR #281/#283). Co-Authored-By: Claude Sonnet 5 --- .../RealtimeTable/Cell/Cell/Cell.jsx | 21 +- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 108 +++++++-- .../Cell/EditCell/VspDropdownEditCell.jsx | 39 +++- .../RealtimeTable/RealtimeTable.jsx | 213 +++++++++++++++++- .../RealtimeTable/contexts/RealtimeContext.js | 7 + .../components/RealtimeTable/tableColumns.jsx | 28 ++- .../RealtimeTable/utils/cellNavigation.js | 46 ++++ .../utils/cellNavigation.test.js | 78 +++++++ .../RealtimeTable/utils/formulaUtils.js | 42 ++++ .../RealtimeTable/utils/formulaUtils.test.js | 38 ++++ 10 files changed, 577 insertions(+), 43 deletions(-) create mode 100644 web/src/components/RealtimeTable/utils/cellNavigation.js create mode 100644 web/src/components/RealtimeTable/utils/cellNavigation.test.js create mode 100644 web/src/components/RealtimeTable/utils/formulaUtils.test.js diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index e90ad40..0e5cd69 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -152,7 +152,6 @@ const CellComponent = ({ cell, row, column, - onClick, globalFilter, columnFilter, backgroundColor, @@ -236,22 +235,12 @@ const CellComponent = ({ return
🔒
; }, [isLocked]); - const handleClick = useCallback(() => { - if (!isLocked && isEditable && onClick) { - onClick(); - } - }, [isLocked, isEditable, onClick]); - + // AURORA-1891: клик теперь обрабатывает muiTableBodyCellProps (RealtimeTable.jsx) + // — там же проверяется редактируемость/блокировка и открывается редактор одним + // кликом; onClick/handleClick здесь были мёртвым кодом (tableColumns.jsx никогда + // не передавал onClick в Cell). const cellNode = ( - // biome-ignore lint/a11y/useKeyWithClickEvents: -
+
{highlightedContent} {lockBadge}
diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index ae33a0d..619bfe2 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { toast } from 'react-toastify'; import { nonNegativeColumnKeys } from '../../constants/validation'; import { useRealtimeActions } from '../../contexts/RealtimeContext'; -import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils'; +import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, normalizeNumericInput } from '../../utils/formulaUtils'; import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils'; const EDITOR_STYLES = { @@ -35,15 +35,29 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta const { endEditing: contextEndEditing } = useRealtimeActions(); const storageKey = `formula_${tableId}_${cellId}`; - const [state, setState] = useState(() => ({ - currentValue: value, - displayValue: value, - isFormulaMode: false, - })); + // AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не + // редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и + // подставляют этот символ (или пустую строку) вместо текущего значения — + // RealtimeTable хранит его в своём state и отдаёт через + // table.options.meta.getEditorInitialInput(), тот же приём, что уже + // применяется для getSelectedColumnId/getColumnTitle в этом файле. + // Инициализатор useState вызывается один раз при монтировании — ровно + // тогда, когда этот редактор открылся именно так. + const [state, setState] = useState(() => { + const initialInput = table?.options?.meta?.getEditorInitialInput?.(); + if (initialInput !== null && initialInput !== undefined) { + return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false }; + } + return { currentValue: value, displayValue: value, isFormulaMode: false }; + }); // Позиция ячейки const [position, setPosition] = useState(); const textareaRef = useRef(null); + // AURORA-1891: этой ячейки — чтобы после закрытия редактора вернуть + // туда фокус (иначе стрелки/клавиатурная навигация отваливаются после + // первого же редактирования, см. muiTableBodyCellProps.onKeyDown). + const tdRef = useRef(null); const refFinished = useRef(false); @@ -77,6 +91,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta const tr = td?.offsetParent; if (td && tr) { + tdRef.current = td; setPosition({ left: td.offsetLeft, width: td.offsetWidth, @@ -85,6 +100,15 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta } }, [refCell]); + // AURORA-1891: при закрытии редактора (размонтирование) фокус возвращается + // в этой ячейки — без этого фокус терялся бы на удалённом textarea, + // и стрелки/Tab/Enter перестают работать сразу после первого редактирования. + useEffect(() => { + return () => { + tdRef.current?.focus?.(); + }; + }, []); + // Auto-resize useLayoutEffect(() => { const el = textareaRef.current; @@ -105,15 +129,18 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta }, [state.currentValue]); useEffect(() => { - // Небольшая задержка для гарантии, что DOM полностью готов - const timeoutId = setTimeout(() => { + // AURORA-1891: setTimeout(50) был произвольной задержкой "на всякий + // случай"; requestAnimationFrame ждёт ровно до следующего кадра + // отрисовки — DOM (textarea) к этому моменту уже готов, фокус приходит + // на кадр раньше и без угадывания длительности. + const frameId = requestAnimationFrame(() => { if (textareaRef.current && !disabled) { textareaRef.current.focus(); textareaRef.current.select(); } - }, 50); + }); - return () => clearTimeout(timeoutId); + return () => cancelAnimationFrame(frameId); }, [disabled]); // AURORA-1892: мягкий запрет минуса для заданных колонок — предупреждаем, @@ -163,9 +190,27 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta })); const normalizedValue = normalizeNumericInput(currentValue); warnIfNegativeNotAllowed(normalizedValue); + // AURORA-1891: значение не изменилось — не шлём onChange (а значит и + // cell_updated): открытие и закрытие ячейки без правки не должно + // создавать запись в журнале аудита. + if (isSameCellValue(normalizedValue, value)) return; onChange?.(normalizedValue); } - }, [state, storageKey, onChange, warnIfNegativeNotAllowed]); + }, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]); + + // Завершает редактирование текущей ячейки (cell_edit_end) — читает + // table.getState().editingCell, а не cell.row/cell.column: к этому моменту + // handleSave (через onChange -> EditCellPortal.handleChange) уже мог + // вызвать table.setEditingCell(null), но реальным состоянием владеет + // RealtimeTable.jsx (контролируемый state.editingCell), так что + // table.getState().editingCell всё ещё возвращает ячейку, которую мы + // только что редактировали. + const endCurrentEditing = useCallback(() => { + const editingCell = table.getState().editingCell; + if (editingCell) { + contextEndEditing?.(editingCell.row, editingCell.column); + } + }, [table, contextEndEditing]); const finishEditing = useCallback(() => { if (refFinished.current) return; @@ -173,11 +218,16 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta if (!disabled) { handleSave(); } - const editingCell = table.getState().editingCell; - if (editingCell) { - contextEndEditing?.(editingCell.row, editingCell.column); - } - }, [disabled, handleSave, table, contextEndEditing]); + endCurrentEditing(); + }, [disabled, handleSave, endCurrentEditing]); + + // AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого + // cell_updated), только завершение редактирования текущей ячейки. + const cancelEditing = useCallback(() => { + if (refFinished.current) return; + refFinished.current = true; + endCurrentEditing(); + }, [endCurrentEditing]); useEffect(() => { const handleClickOutside = (event) => { @@ -201,12 +251,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta const handleKeyDown = useCallback( (e) => { + if (refFinished.current) return; + + // AURORA-1891: отмена — значение не сохраняется, cell_updated не шлётся. + if (e.key === 'Escape') { + e.preventDefault(); + cancelEditing(); + return; + } + + // AURORA-1891: Tab/Shift+Tab — сохранить и перейти к следующей/предыдущей + // редактируемой ячейке в строке. Переход резолвит и прокручивает + // table.options.meta.openNextEditableCell (RealtimeTable.jsx); сама + // ячейка открывается позже, в эффекте, который ждёт cell_edit_end + // текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем. + if (e.key === 'Tab') { + e.preventDefault(); + table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right'); + finishEditing(); + return; + } + + // Shift+Enter — перенос строки в textarea (стандартное поведение, + // e.preventDefault() не вызывается). if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); + table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down'); finishEditing(); } }, - [finishEditing], + [cancelEditing, finishEditing, table, cell], ); const editorStyles = useMemo( 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 })