Andrey Epifancev a61c09be18 fix(table): контроль ошибок — агрегатные строки, переход и прокрутка к ячейке, подстановка {year}/{unit} (AURORA-1893)
Доработка по возврату тестирования 06.11:
- у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) нет data.id, ключ ошибки был
  «правило:колонка:null» — все агрегаты одной колонки схлопывались в одну
  запись. Ключ строки теперь getValidationRowKey: line id или sort:<sort_order>;
- переход к ошибке ищет строку по тому же ключу (раньше для агрегата
  прерывался, выделение оставалось на предыдущей ячейке);
- прокрутка: вместо scrollToIndex(rowIndex + 5, 'auto') — центрирование и
  scrollIntoView самой ячейки после отрисовки (у последних строк запас +5
  обрезался, шапка внутри контейнера не учитывалась — ячейка оставалась ниже
  видимой области);
- названия колонок в плашке и в тосте EditCell — с подставленными годом и
  единицами (formatHeaderPaths, как в TableHead);
- рамка активной ячейки дублируется псевдоэлементом ::after: inset-тень td
  перекрывалась красной рамкой ошибочной ячейки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:38:49 +04:00

185 lines
5.5 KiB
JavaScript

import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlineOutlined';
import { Button, IconButton, ListSubheader, Menu, MenuItem, Stack } from '@mui/material';
import { memo, useEffect, useMemo, useState } from 'react';
import { groupValidationErrors } from '../utils/validationUtils';
// AURORA-1893: «более выделенный формат контроля за ошибками» — красная плашка
// вместо прежней бледно-оранжевой.
const errorColors = {
bg: '#FDEDED',
text: '#D32F2F',
border: '#D32F2F',
hover: '#FBD5D5',
};
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;
}
if (activeIndex >= errors.length) {
setActiveIndex(0);
}
}, [errors, activeIndex]);
if (errors.length === 0) return null;
const navigateToIndex = (index) => {
const error = errors[index];
if (!error) return;
setActiveIndex(index);
onNavigateToError?.({ rowKey: error.rowKey, 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 (
<>
<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',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'left',
}}>
Ошибок: {errors.length}
</span>
</Button>
<IconButton size='small' onClick={() => handleStep(1)} aria-label='Следующая ошибка' sx={stepButtonSx}>
<ChevronRightIcon fontSize='small' />
</IconButton>
</Stack>
<Menu
anchorEl={anchorEl}
open={isOpen}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
'data-validation-errors-menu': true,
sx: {
mt: 0.5,
bgcolor: errorColors.bg,
color: errorColors.text,
border: `1px solid ${errorColors.border}`,
borderRadius: '0.5rem',
boxShadow: 'none',
minWidth: anchorEl?.offsetWidth,
maxWidth: 420,
maxHeight: 420,
},
},
}}>
{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>
</>
);
};
export default memo(ValidationErrorsAlert);