Andrey Epifancev fa586c85cc feat(table): отрицательные суммы красным и правило «минус запрещён» (AURORA-1892)
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 зелёных.
2026-11-01 12:20:06 +04:00

53 lines
1.7 KiB
JavaScript

import { useCallback, useMemo, useRef } from 'react';
import { validationRules } from '../constants/validation';
import { buildColumnHeaderPaths, collectInvalidErrors, isValidationRuleEnabled } from '../utils/validationUtils';
const EMPTY_ARRAY = [];
export const useValidationRules = (data, columns, formType, sheetName) => {
const safeData = data || EMPTY_ARRAY;
const safeColumns = columns || EMPTY_ARRAY;
const stableErrorsRef = useRef([]);
const headerPaths = useMemo(() => buildColumnHeaderPaths(safeColumns), [safeColumns]);
const activeRules = useMemo(
() => validationRules.filter((rule) => isValidationRuleEnabled(rule, { formType, sheetName })),
[formType, sheetName],
);
const calculatedErrors = useMemo(() => {
const result = [];
const seen = new Set();
for (const rule of activeRules) {
collectInvalidErrors(safeData, rule, headerPaths, result, seen);
}
return result;
}, [activeRules, headerPaths, safeData]);
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,
);
if (!errorsAreEqual) {
stableErrorsRef.current = calculatedErrors;
}
const errors = stableErrorsRef.current;
const isCellInvalid = useCallback(
(columnId, value, row) =>
activeRules.some(
(rule) =>
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
),
[activeRules],
);
return { errors, isCellInvalid, headerPaths };
};