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 React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useCellLock } from '../../contexts/RealtimeContext';
|
import { useCellLock } from '../../contexts/RealtimeContext';
|
||||||
import { formatNumber } from '../../utils/numberFormat';
|
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 cellKey = `${row.id}_${column.id}`;
|
||||||
const isLocked = useCellLock(cellKey);
|
const isLocked = useCellLock(cellKey);
|
||||||
// AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px).
|
// AURORA-1890: рамка редактируемой ячейки толще при наведении (2px вместо 1px).
|
||||||
@ -228,7 +242,7 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
}
|
}
|
||||||
}, [isLocked, isEditable, onClick]);
|
}, [isLocked, isEditable, onClick]);
|
||||||
|
|
||||||
return (
|
const cellNode = (
|
||||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||||
<div
|
<div
|
||||||
type='button'
|
type='button'
|
||||||
@ -242,6 +256,18 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
|
|||||||
{lockBadge}
|
{lockBadge}
|
||||||
</div>
|
</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) => {
|
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
||||||
@ -255,6 +281,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
|||||||
prevProps.columnFilter === nextProps.columnFilter &&
|
prevProps.columnFilter === nextProps.columnFilter &&
|
||||||
prevProps.backgroundColor === nextProps.backgroundColor &&
|
prevProps.backgroundColor === nextProps.backgroundColor &&
|
||||||
prevProps.isInvalid === nextProps.isInvalid &&
|
prevProps.isInvalid === nextProps.isInvalid &&
|
||||||
|
prevProps.invalidMessage === nextProps.invalidMessage &&
|
||||||
prevProps.isEditable === nextProps.isEditable &&
|
prevProps.isEditable === nextProps.isEditable &&
|
||||||
prevProps.vspOptions === nextProps.vspOptions &&
|
prevProps.vspOptions === nextProps.vspOptions &&
|
||||||
prevProps.displayDivisor === nextProps.displayDivisor
|
prevProps.displayDivisor === nextProps.displayDivisor
|
||||||
|
|||||||
@ -98,7 +98,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [editingCell, setEditingCell] = useState(null);
|
const [editingCell, setEditingCell] = useState(null);
|
||||||
const [columnsConfig, setColumnsConfig] = 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 [expanded, setExpanded] = useState(true);
|
||||||
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
||||||
@ -263,14 +263,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
userRoleId,
|
userRoleId,
|
||||||
onCellNumberClick: handleClickRowCell,
|
onCellNumberClick: handleClickRowCell,
|
||||||
onCellUpdateError: handleCellUpdateError,
|
onCellUpdateError: handleCellUpdateError,
|
||||||
isCellInvalid,
|
getCellErrors,
|
||||||
denomination,
|
denomination,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating columns:', error);
|
console.error('Error creating columns:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, denomination]);
|
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination]);
|
||||||
|
|
||||||
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
// MRT запоминает индексы pinned-колонок до завершения асинхронной
|
||||||
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
// загрузки конфигурации. Новая ссылка на columnPinning заставляет
|
||||||
@ -518,6 +518,57 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
[columnPinning, containerRef, table],
|
[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 searchMatches = useMemo(() => {
|
||||||
const query = normalizeFilterValue(globalFilter);
|
const query = normalizeFilterValue(globalFilter);
|
||||||
if (!query) return [];
|
if (!query) return [];
|
||||||
@ -805,7 +856,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
canModifyRows={canModifyRows}
|
canModifyRows={canModifyRows}
|
||||||
formType={formType}
|
formType={formType}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
onNavigateToColumn={handleNavigateToColumn}
|
onNavigateToError={handleNavigateToError}
|
||||||
|
headerPaths={headerPaths}
|
||||||
expenseItemsVisibilityAction={expenseItemsVisibilityAction}
|
expenseItemsVisibilityAction={expenseItemsVisibilityAction}
|
||||||
onToggleDepthVisibility={handleToggleDepthVisibility}
|
onToggleDepthVisibility={handleToggleDepthVisibility}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -96,7 +96,8 @@ const SettingPanel = ({
|
|||||||
canModifyRows,
|
canModifyRows,
|
||||||
formType,
|
formType,
|
||||||
validationErrors = [],
|
validationErrors = [],
|
||||||
onNavigateToColumn,
|
onNavigateToError,
|
||||||
|
headerPaths,
|
||||||
expenseItemsVisibilityAction,
|
expenseItemsVisibilityAction,
|
||||||
onToggleDepthVisibility,
|
onToggleDepthVisibility,
|
||||||
denomination,
|
denomination,
|
||||||
@ -165,7 +166,13 @@ const SettingPanel = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Panel data-pin-panel>
|
<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='Отображение'>
|
<GroupByObject title='Отображение'>
|
||||||
{/* <ColorPickerButton onColorApply={addColorForCells} /> */}
|
{/* <ColorPickerButton onColorApply={addColorForCells} /> */}
|
||||||
|
|
||||||
@ -323,7 +330,6 @@ const SettingPanel = ({
|
|||||||
</GroupByObject>
|
</GroupByObject>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
|
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||||
<ValidationErrorsAlert errors={validationErrors} onNavigateToColumn={onNavigateToColumn} />
|
|
||||||
<CellLegend />
|
<CellLegend />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@ -1,56 +1,99 @@
|
|||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import { Button, Menu, MenuItem, Stack } from '@mui/material';
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { Button, IconButton, ListSubheader, Menu, MenuItem, Stack } from '@mui/material';
|
||||||
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { groupValidationErrors } from '../utils/validationUtils';
|
||||||
|
|
||||||
const warningColors = {
|
// AURORA-1893: «более выделенный формат контроля за ошибками» — красная плашка
|
||||||
bg: '#fff7e6',
|
// вместо прежней бледно-оранжевой.
|
||||||
text: '#b54708',
|
const errorColors = {
|
||||||
border: '#FCD99C',
|
bg: '#FDEDED',
|
||||||
hover: '#fff1d6',
|
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 [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const isOpen = Boolean(anchorEl);
|
const isOpen = Boolean(anchorEl);
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupValidationErrors(errors), [errors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
setAnchorEl(null);
|
setAnchorEl(null);
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [errors.length]);
|
if (activeIndex >= errors.length) {
|
||||||
|
setActiveIndex(0);
|
||||||
|
}
|
||||||
|
}, [errors, activeIndex]);
|
||||||
|
|
||||||
if (errors.length === 0) return null;
|
if (errors.length === 0) return null;
|
||||||
|
|
||||||
const handleNavigate = (columnId) => {
|
const navigateToIndex = (index) => {
|
||||||
onNavigateToColumn?.(columnId);
|
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);
|
setAnchorEl(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.5 }}>
|
||||||
variant='outlined'
|
<IconButton size='small' onClick={() => handleStep(-1)} aria-label='Предыдущая ошибка' sx={stepButtonSx}>
|
||||||
onClick={(e) => setAnchorEl(e.currentTarget)}
|
<ChevronLeftIcon fontSize='small' />
|
||||||
sx={{
|
</IconButton>
|
||||||
ml: 2,
|
|
||||||
maxWidth: 360,
|
<Button
|
||||||
height: '2.5rem',
|
variant='outlined'
|
||||||
borderRadius: '0.5rem',
|
onClick={(e) => setAnchorEl(e.currentTarget)}
|
||||||
bgcolor: warningColors.bg,
|
sx={{
|
||||||
color: warningColors.text,
|
maxWidth: 360,
|
||||||
borderColor: warningColors.border,
|
height: '2.5rem',
|
||||||
textTransform: 'none',
|
borderRadius: '0.5rem',
|
||||||
justifyContent: 'space-between',
|
bgcolor: errorColors.bg,
|
||||||
px: 1.5,
|
color: errorColors.text,
|
||||||
gap: 1,
|
borderColor: errorColors.border,
|
||||||
'&:hover': {
|
textTransform: 'none',
|
||||||
bgcolor: warningColors.hover,
|
fontWeight: 600,
|
||||||
borderColor: warningColors.border,
|
justifyContent: 'flex-start',
|
||||||
},
|
px: 1.5,
|
||||||
}}>
|
gap: 1,
|
||||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 1, minWidth: 0, flex: 1 }}>
|
'&:hover': {
|
||||||
<WarningAmberRoundedIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
|
bgcolor: errorColors.hover,
|
||||||
|
borderColor: errorColors.border,
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<ErrorOutlineIcon sx={{ fontSize: '1.1rem', flexShrink: 0 }} />
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -58,21 +101,14 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}>
|
}}>
|
||||||
Контроль ошибок
|
Ошибок: {errors.length}
|
||||||
</span>
|
</span>
|
||||||
</Stack>
|
</Button>
|
||||||
|
|
||||||
<Stack direction='row' sx={{ alignItems: 'center', gap: 0.25, flexShrink: 0 }}>
|
<IconButton size='small' onClick={() => handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}>
|
||||||
<span>Подробнее</span>
|
<ChevronRightIcon fontSize='small' />
|
||||||
<KeyboardArrowDownIcon
|
</IconButton>
|
||||||
sx={{
|
</Stack>
|
||||||
fontSize: '1.25rem',
|
|
||||||
transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 0.2s ease',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
@ -85,31 +121,61 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
|
|||||||
'data-validation-errors-menu': true,
|
'data-validation-errors-menu': true,
|
||||||
sx: {
|
sx: {
|
||||||
mt: 0.5,
|
mt: 0.5,
|
||||||
bgcolor: warningColors.bg,
|
bgcolor: errorColors.bg,
|
||||||
color: warningColors.text,
|
color: errorColors.text,
|
||||||
border: `1px solid ${warningColors.border}`,
|
border: `1px solid ${errorColors.border}`,
|
||||||
borderRadius: '0.5rem',
|
borderRadius: '0.5rem',
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
minWidth: anchorEl?.offsetWidth,
|
minWidth: anchorEl?.offsetWidth,
|
||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
|
maxHeight: 420,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}>
|
}}>
|
||||||
{errors.map((error) => (
|
{groups.flatMap((group) => {
|
||||||
<MenuItem
|
const columnTitle = getColumnTitle(headerPaths, group.columnId);
|
||||||
key={error.id}
|
const visibleCells = group.cells.slice(0, MAX_VISIBLE_CELLS_PER_GROUP);
|
||||||
onClick={() => handleNavigate(error.columnId)}
|
const hiddenCount = group.count - visibleCells.length;
|
||||||
sx={{
|
|
||||||
whiteSpace: 'normal',
|
return [
|
||||||
color: warningColors.text,
|
<ListSubheader
|
||||||
cursor: 'pointer',
|
key={`${group.id}-header`}
|
||||||
'&:hover': {
|
sx={{
|
||||||
bgcolor: warningColors.hover,
|
bgcolor: 'inherit',
|
||||||
},
|
color: errorColors.text,
|
||||||
}}>
|
fontWeight: 600,
|
||||||
{error.message}
|
lineHeight: 1.3,
|
||||||
</MenuItem>
|
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>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -19,34 +19,36 @@ export const useValidationRules = (data, columns, formType, sheetName) => {
|
|||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
for (const rule of activeRules) {
|
for (const rule of activeRules) {
|
||||||
collectInvalidErrors(safeData, rule, headerPaths, result, seen);
|
collectInvalidErrors(safeData, rule, result, seen);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [activeRules, headerPaths, safeData]);
|
}, [activeRules, safeData]);
|
||||||
|
|
||||||
|
// AURORA-1893: id теперь кодирует правило+колонку+строку и уникален для каждой
|
||||||
|
// ошибочной ЯЧЕЙКИ — сравнения по нему достаточно для стабильности ссылки.
|
||||||
const previousErrors = stableErrorsRef.current;
|
const previousErrors = stableErrorsRef.current;
|
||||||
const errorsAreEqual =
|
const errorsAreEqual =
|
||||||
previousErrors.length === calculatedErrors.length &&
|
previousErrors.length === calculatedErrors.length && previousErrors.every((error, index) => error.id === calculatedErrors[index].id);
|
||||||
previousErrors.every(
|
|
||||||
(error, index) =>
|
|
||||||
error.id === calculatedErrors[index].id &&
|
|
||||||
error.columnId === calculatedErrors[index].columnId &&
|
|
||||||
error.message === calculatedErrors[index].message,
|
|
||||||
);
|
|
||||||
if (!errorsAreEqual) {
|
if (!errorsAreEqual) {
|
||||||
stableErrorsRef.current = calculatedErrors;
|
stableErrorsRef.current = calculatedErrors;
|
||||||
}
|
}
|
||||||
const errors = stableErrorsRef.current;
|
const errors = stableErrorsRef.current;
|
||||||
|
|
||||||
const isCellInvalid = useCallback(
|
// AURORA-1893: причины ошибок на ячейке (для подсказки Cell) — список сообщений
|
||||||
|
// правил, которым эта ячейка не удовлетворяет.
|
||||||
|
const getCellErrors = useCallback(
|
||||||
(columnId, value, row) =>
|
(columnId, value, row) =>
|
||||||
activeRules.some(
|
activeRules
|
||||||
(rule) =>
|
.filter(
|
||||||
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
|
(rule) =>
|
||||||
),
|
rule.columnKeys.includes(columnId) && (!rule.appliesToRow || rule.appliesToRow(row)) && rule.isInvalid(value, row, columnId),
|
||||||
|
)
|
||||||
|
.map((rule) => rule.message),
|
||||||
[activeRules],
|
[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,
|
userRoleId,
|
||||||
onCellUpdateError,
|
onCellUpdateError,
|
||||||
onCellNumberClick,
|
onCellNumberClick,
|
||||||
isCellInvalid,
|
getCellErrors,
|
||||||
denomination = 'rub',
|
denomination = 'rub',
|
||||||
}) => {
|
}) => {
|
||||||
const displayDivisor = getDenominationDivisor(denomination);
|
const displayDivisor = getDenominationDivisor(denomination);
|
||||||
@ -145,7 +145,11 @@ export const getTableColumns = ({
|
|||||||
const getCachedCellProps = (row, column, table) => {
|
const getCachedCellProps = (row, column, table) => {
|
||||||
const key = `${row.id}_${column.id}`;
|
const key = `${row.id}_${column.id}`;
|
||||||
const value = row.getValue(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 isEditableCell = isEditable(row, column);
|
||||||
const backgroundColor = getColorCell(columnColors, column, row);
|
const backgroundColor = getColorCell(columnColors, column, row);
|
||||||
if (!cellPropsCache.has(row)) {
|
if (!cellPropsCache.has(row)) {
|
||||||
@ -164,6 +168,7 @@ export const getTableColumns = ({
|
|||||||
cached.value === value &&
|
cached.value === value &&
|
||||||
cached.displayDivisor === displayDivisor &&
|
cached.displayDivisor === displayDivisor &&
|
||||||
cached.isInvalid === isInvalid &&
|
cached.isInvalid === isInvalid &&
|
||||||
|
cached.invalidMessage === invalidMessage &&
|
||||||
cached.backgroundColor === backgroundColor
|
cached.backgroundColor === backgroundColor
|
||||||
) {
|
) {
|
||||||
return cached;
|
return cached;
|
||||||
@ -179,9 +184,10 @@ export const getTableColumns = ({
|
|||||||
isEditable: isEditableCell,
|
isEditable: isEditableCell,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
isInvalid,
|
isInvalid,
|
||||||
|
invalidMessage,
|
||||||
value,
|
value,
|
||||||
displayDivisor,
|
displayDivisor,
|
||||||
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${backgroundColor}_${displayDivisor}`,
|
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayDivisor}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
rowCache[key] = props;
|
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);
|
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;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Обходит строки и собирает ошибки по правилу валидации
|
// AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
|
||||||
export const collectInvalidErrors = (rows, rule, headerPaths, errors, seen) => {
|
// название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
|
||||||
|
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) {
|
for (const row of rows) {
|
||||||
if (!rule.appliesToRow || rule.appliesToRow(row)) {
|
if (!rule.appliesToRow || rule.appliesToRow(row)) {
|
||||||
for (const columnKey of rule.errorColumnKeys || rule.columnKeys) {
|
for (const columnKey of rule.errorColumnKeys || rule.columnKeys) {
|
||||||
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue;
|
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;
|
if (seen.has(errorId)) continue;
|
||||||
seen.add(errorId);
|
seen.add(errorId);
|
||||||
|
|
||||||
const columnPath = headerPaths[columnKey];
|
|
||||||
const columnMessage = columnPath?.length ? `Ошибка в колонке: ${columnPath.join(' => ')}` : null;
|
|
||||||
|
|
||||||
errors.push({
|
errors.push({
|
||||||
id: errorId,
|
id: errorId,
|
||||||
|
ruleId: rule.id,
|
||||||
columnId: columnKey,
|
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 assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { nonNegativeColumnKeys, nonNegativeRule } from '../constants/validation.js';
|
import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
|
||||||
import { isValidationRuleEnabled } from './validationUtils.js';
|
import { collectInvalidErrors, groupValidationErrors, isValidationRuleEnabled } from './validationUtils.js';
|
||||||
|
|
||||||
test('nonNegativeRule flags negative numbers as invalid', () => {
|
test('nonNegativeRule flags negative numbers as invalid', () => {
|
||||||
const rule = nonNegativeRule({
|
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