feat(table): плашка контроля ошибок со счётчиком, переходом к ячейке и подсказкой (AURORA-1893)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
df6ec795a3
commit
d7ab4d170c
@ -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: <explanation>
|
||||
<div
|
||||
type='button'
|
||||
@ -242,6 +256,18 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
||||
{lockBadge}
|
||||
</div>
|
||||
);
|
||||
|
||||
// AURORA-1893: причина ошибки по наведению — только когда ячейка действительно
|
||||
// невалидна и есть текст причины (invalidMessage собран useValidationRules'ом).
|
||||
if (isInvalid && invalidMessage) {
|
||||
return (
|
||||
<Tooltip title={invalidMessage} arrow placement='top'>
|
||||
{cellNode}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return cellNode;
|
||||
};
|
||||
|
||||
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -96,7 +96,8 @@ const SettingPanel = ({
|
||||
canModifyRows,
|
||||
formType,
|
||||
validationErrors = [],
|
||||
onNavigateToColumn,
|
||||
onNavigateToError,
|
||||
headerPaths,
|
||||
expenseItemsVisibilityAction,
|
||||
onToggleDepthVisibility,
|
||||
denomination,
|
||||
@ -165,7 +166,13 @@ const SettingPanel = ({
|
||||
return (
|
||||
<>
|
||||
<Panel data-pin-panel>
|
||||
<Stack direction='row' sx={{ gap: '1.25rem' }}>
|
||||
<Stack direction='row' sx={{ gap: '1.25rem', alignItems: 'center' }}>
|
||||
{validationErrors.length > 0 && (
|
||||
<>
|
||||
<ValidationErrorsAlert errors={validationErrors} onNavigateToError={onNavigateToError} headerPaths={headerPaths} />
|
||||
<Divider orientation='vertical' flexItem />
|
||||
</>
|
||||
)}
|
||||
<GroupByObject title='Отображение'>
|
||||
{/* <ColorPickerButton onColorApply={addColorForCells} /> */}
|
||||
|
||||
@ -323,7 +330,6 @@ const SettingPanel = ({
|
||||
</GroupByObject>
|
||||
</Stack>
|
||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
|
||||
<CellLegend />
|
||||
</Stack>
|
||||
</Panel>
|
||||
|
||||
@ -1,56 +1,99 @@
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
||||
import { Button, Menu, MenuItem, Stack } from '@mui/material';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/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 (
|
||||
<>
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
sx={{
|
||||
ml: 2,
|
||||
maxWidth: 360,
|
||||
height: '2.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
bgcolor: warningColors.bg,
|
||||
color: warningColors.text,
|
||||
borderColor: warningColors.border,
|
||||
textTransform: 'none',
|
||||
justifyContent: 'space-between',
|
||||
px: 1.5,
|
||||
gap: 1,
|
||||
'&:hover': {
|
||||
bgcolor: warningColors.hover,
|
||||
borderColor: warningColors.border,
|
||||
},
|
||||
}}>
|
||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
|
||||
<WarningAmberRoundedIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
|
||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||
<IconButton size='small' onClick={() => handleStep(-1)} aria-label='Предыдущая ошибка' sx={stepButtonSx}>
|
||||
<ChevronLeftIcon fontSize='small' />
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
variant='outlined'
|
||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||
sx={{
|
||||
maxWidth: 360,
|
||||
height: '2.5rem',
|
||||
borderRadius: '0.5rem',
|
||||
bgcolor: errorColors.bg,
|
||||
color: errorColors.text,
|
||||
borderColor: errorColors.border,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
justifyContent: 'flex-start',
|
||||
px: 1.5,
|
||||
gap: 1,
|
||||
'&:hover': {
|
||||
bgcolor: errorColors.hover,
|
||||
borderColor: errorColors.border,
|
||||
},
|
||||
}}>
|
||||
<ErrorOutlineIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
@ -58,21 +101,14 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
|
||||
whiteSpace: 'nowrap',
|
||||
textAlign: 'left',
|
||||
}}>
|
||||
Контроль ошибок
|
||||
Ошибок: {errors.length}
|
||||
</span>
|
||||
</Stack>
|
||||
</Button>
|
||||
|
||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
|
||||
<span>Подробнее</span>
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{
|
||||
fontSize: '1.25rem',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Button>
|
||||
<IconButton size='small' onClick={() => handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}>
|
||||
<ChevronRightIcon fontSize='small' />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
@ -85,31 +121,61 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
|
||||
'data-validation-errors-menu': true,
|
||||
sx: {
|
||||
mt: 0.5,
|
||||
bgcolor: warningColors.bg,
|
||||
color: warningColors.text,
|
||||
border: `1px solid ${warningColors.border}`,
|
||||
bgcolor: errorColors.bg,
|
||||
color: errorColors.text,
|
||||
border: `1px solid ${errorColors.border}`,
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: 'none',
|
||||
minWidth: anchorEl?.offsetWidth,
|
||||
maxWidth: 420,
|
||||
maxHeight: 420,
|
||||
},
|
||||
},
|
||||
}}>
|
||||
{errors.map((error) => (
|
||||
<MenuItem
|
||||
key={error.id}
|
||||
onClick={() => handleNavigate(error.columnId)}
|
||||
sx={{
|
||||
whiteSpace: 'normal',
|
||||
color: warningColors.text,
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
bgcolor: warningColors.hover,
|
||||
},
|
||||
}}>
|
||||
{error.message}
|
||||
</MenuItem>
|
||||
))}
|
||||
{groups.flatMap((group) => {
|
||||
const columnTitle = getColumnTitle(headerPaths, group.columnId);
|
||||
const visibleCells = group.cells.slice(0, MAX_VISIBLE_CELLS_PER_GROUP);
|
||||
const hiddenCount = group.count - visibleCells.length;
|
||||
|
||||
return [
|
||||
<ListSubheader
|
||||
key={`${group.id}-header`}
|
||||
sx={{
|
||||
bgcolor: 'inherit',
|
||||
color: errorColors.text,
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
whiteSpace: 'normal',
|
||||
py: 0.5,
|
||||
}}>
|
||||
{group.message} · {columnTitle} ({group.count})
|
||||
</ListSubheader>,
|
||||
...visibleCells.map((cell) => (
|
||||
<MenuItem
|
||||
key={cell.id}
|
||||
selected={errors[activeIndex]?.id === cell.id}
|
||||
onClick={() => handleSelectCell(cell)}
|
||||
sx={{
|
||||
whiteSpace: 'normal',
|
||||
color: errorColors.text,
|
||||
cursor: 'pointer',
|
||||
pl: 3,
|
||||
'&:hover': {
|
||||
bgcolor: errorColors.hover,
|
||||
},
|
||||
}}>
|
||||
{cell.rowLabel}
|
||||
</MenuItem>
|
||||
)),
|
||||
...(hiddenCount > 0
|
||||
? [
|
||||
<MenuItem key={`${group.id}-more`} disabled sx={{ pl: 3, color: errorColors.text, opacity: 0.7 }}>
|
||||
и ещё {hiddenCount}
|
||||
</MenuItem>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
})}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()];
|
||||
};
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user