[AURORA-1225] - логические ограничения для столбцов (требования №1 и требования №2) #65

Merged
Veronika merged 1 commits from AURORA-1225 into test 2026-08-03 12:39:15 +03:00
8 changed files with 324 additions and 9 deletions

View File

@ -25,6 +25,12 @@ const LOCKED_STYLES = {
opacity: 0.85, opacity: 0.85,
}; };
const INVALID_STYLES = {
border: '2px solid #D32F2F',
backgroundColor: '#FDEDED',
color: '#C60C0C',
};
const LOCK_BADGE_STYLES = { const LOCK_BADGE_STYLES = {
position: 'absolute', position: 'absolute',
top: '2px', top: '2px',
@ -127,6 +133,7 @@ const CellComponent = ({
isEditable, isEditable,
isUpdating, isUpdating,
onCellNumberClick, onCellNumberClick,
isInvalid,
}) => { }) => {
const { lockedCells } = useRealtime(); const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`; const cellKey = `${row.id}_${column.id}`;
@ -162,7 +169,8 @@ const CellComponent = ({
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor, backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: textColor, color: textColor,
...(isLocked ? LOCKED_STYLES : {}), ...(isLocked ? LOCKED_STYLES : {}),
}), [backgroundColor, isLocked, textColor]); ...(isInvalid ? INVALID_STYLES : {}),
}), [backgroundColor, isInvalid, isLocked, textColor]);
const lockBadge = useMemo(() => { const lockBadge = useMemo(() => {
if (!isLocked) return null; if (!isLocked) return null;
@ -196,6 +204,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
prevProps.globalFilter === nextProps.globalFilter && prevProps.globalFilter === nextProps.globalFilter &&
prevProps.columnFilter === nextProps.columnFilter && prevProps.columnFilter === nextProps.columnFilter &&
prevProps.backgroundColor === nextProps.backgroundColor && prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isInvalid === nextProps.isInvalid &&
prevProps.isEditable === nextProps.isEditable && prevProps.isEditable === nextProps.isEditable &&
prevProps.isUpdating === nextProps.isUpdating && prevProps.isUpdating === nextProps.isUpdating &&
prevProps.onCellNumberClick === nextProps.onCellNumberClick prevProps.onCellNumberClick === nextProps.onCellNumberClick

View File

@ -15,6 +15,7 @@ import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelection
import { useColumnSettings } from './hooks/useColumnSettings'; import { useColumnSettings } from './hooks/useColumnSettings';
import { useTableScale } from './hooks/useTableScale'; import { useTableScale } from './hooks/useTableScale';
import { useHeaderPortal } from './hooks/useHeaderPortal'; import { useHeaderPortal } from './hooks/useHeaderPortal';
import { useValidationRules } from './hooks/useValidationRules';
import { import {
BASE_TABLE_CONFIG, BASE_TABLE_CONFIG,
getTableBodyCellProps, getTableBodyCellProps,
@ -41,6 +42,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [isLoadingData, setIsLoadingData] = useState(false); const [isLoadingData, setIsLoadingData] = useState(false);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null); const [editingCell, setEditingCell] = useState(null);
const [columnsConfig, setColumnsConfig] = useState(null);
const { errors: validationErrors, isCellInvalid } = useValidationRules(
data,
columnsConfig?.columns,
);
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
@ -105,8 +111,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
rowVirtualizerRef.current?.measure?.(); rowVirtualizerRef.current?.measure?.();
}, [sizeMult]); }, [sizeMult]);
const [columnsConfig, setColumnsConfig] = useState(null);
const configCache = useRef(new Map()); const configCache = useRef(new Map());
useEffect(() => { useEffect(() => {
@ -155,6 +159,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const handleDocumentClick = (e) => { const handleDocumentClick = (e) => {
if (e.target.closest('[data-col-select-id]')) return; if (e.target.closest('[data-col-select-id]')) return;
if (e.target.closest('[data-pin-panel]')) return; if (e.target.closest('[data-pin-panel]')) return;
if (e.target.closest('[data-validation-errors-menu]')) return;
setSelectedColumnId(undefined); setSelectedColumnId(undefined);
}; };
@ -178,12 +183,19 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
onCellUpdate: handleUpdateCell, onCellUpdate: handleUpdateCell,
onCellNumberClick: handleClickRowCell, onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError, onCellUpdateError: handleCellUpdateError,
isCellInvalid,
}); });
} catch (error) { } catch (error) {
console.error('Error creating columns:', error); console.error('Error creating columns:', error);
return []; return [];
} }
}, [columnsConfig]); }, [
columnsConfig,
handleCellUpdateError,
handleClickRowCell,
handleUpdateCell,
isCellInvalid,
]);
const selectedColumnIdRef = useRef(selectedColumnId); const selectedColumnIdRef = useRef(selectedColumnId);
@ -334,6 +346,33 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
const handleNavigateToColumn = useCallback((columnId) => {
if (!columnId) return;
setSelectedColumnId(columnId);
const container = containerRef.current;
if (!container) return;
const pinnedIds = new Set(columnPinning?.left || []);
if (pinnedIds.has(columnId)) return;
const centerColumns = table
.getVisibleLeafColumns()
.filter((column) => !pinnedIds.has(column.id));
let offset = 0;
for (const column of centerColumns) {
if (column.id === columnId) break;
offset += column.getSize();
}
container.scrollTo({
left: Math.max(0, offset - 40),
behavior: 'smooth',
});
}, [columnPinning, containerRef, table]);
useEffect(() => { useEffect(() => {
if (!dataEditingCells?.line_id) { if (!dataEditingCells?.line_id) {
setEditingCell(null); setEditingCell(null);
@ -503,6 +542,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
year={year} year={year}
isProject={formType == 'PROJECT'} isProject={formType == 'PROJECT'}
formType={formType} formType={formType}
validationErrors={validationErrors}
onNavigateToColumn={handleNavigateToColumn}
/> />
<div style={tableWrapperStyle}> <div style={tableWrapperStyle}>

View File

@ -2,12 +2,9 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Panel } from './SettingPanel.style'; import { Panel } from './SettingPanel.style';
import { Divider, IconButton, Stack, Tooltip } from '@mui/material'; import { Divider, IconButton, Stack, Tooltip } from '@mui/material';
import { GroupByObject } from './GroupByObject/GroupByObject'; import { GroupByObject } from './GroupByObject/GroupByObject';
import { ColorPickerButton } from './ColorPicker/ColorPickerButton';
import { import {
AddLine, AddLine,
FormulaSettingPanel,
Minus, Minus,
Pin, Pin,
Plus, Plus,
@ -22,6 +19,7 @@ import SearchComponent from '../../common/SearchComponent';
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
import { exportSheet, exportSheetProject } from '../../../utils/exportFile'; import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
import { FORM_TYPE_OPTIONS } from '../../../constants/constants'; import { FORM_TYPE_OPTIONS } from '../../../constants/constants';
import ValidationErrorsAlert from './ValidationErrorsAlert';
const SettingPanel = ({ const SettingPanel = ({
selectedColumnId, selectedColumnId,
@ -47,6 +45,8 @@ const SettingPanel = ({
year, year,
isProject, isProject,
formType, formType,
validationErrors = [],
onNavigateToColumn,
}) => { }) => {
const [isPinned, setIsPinned] = useState(false); const [isPinned, setIsPinned] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, setIsExporting] = useState(false);
@ -198,6 +198,10 @@ const SettingPanel = ({
/> />
</GroupByObject> </GroupByObject>
</Stack> </Stack>
<ValidationErrorsAlert
errors={validationErrors}
onNavigateToColumn={onNavigateToColumn}
/>
</Panel> </Panel>
</> </>
); );

View File

@ -0,0 +1,128 @@
import { useEffect, useState } from 'react';
import { Button, Menu, MenuItem, Stack } from '@mui/material';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
const warningColors = {
bg: '#fff7e6',
text: '#b54708',
border: '#FCD99C',
hover: '#fff1d6',
};
const ValidationErrorsAlert = ({ errors = [], onNavigateToColumn }) => {
const [anchorEl, setAnchorEl] = useState(null);
const isOpen = Boolean(anchorEl);
useEffect(() => {
if (errors.length === 0) {
setAnchorEl(null);
}
}, [errors.length]);
if (errors.length === 0) return null;
const handleNavigate = (columnId) => {
onNavigateToColumn?.(columnId);
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 }} />
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textAlign: 'left',
}}
>
Контроль ошибок
</span>
</Stack>
<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>
<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: warningColors.bg,
color: warningColors.text,
border: `1px solid ${warningColors.border}`,
borderRadius: '0.5rem',
boxShadow: 'none',
minWidth: anchorEl?.offsetWidth,
maxWidth: 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>
))}
</Menu>
</>
);
};
export default ValidationErrorsAlert;

View File

@ -0,0 +1,41 @@
// Требование №1. Контроль превышения бронирования над сметой.
const remainingBookingKeys = [
"data.q1.rem_booking",
"data.q2.rem_booking",
"data.q3.rem_booking",
"data.q4.rem_booking"
];
// Требование №2. Контроль столбца «Текущие корректировки = 0».
const zeroValueRequirementKeys = [
"data.q1.adj_current",
"data.q2.adj_current",
"data.q3.adj_current",
"data.q4.adj_current"
];
export const validationRules = [
{
id: "remaining-booking",
columnKeys: remainingBookingKeys,
message: "Сумма бронирования превышает сумму, предусмотренную сметой",
isInvalid: (value) =>
value !== null &&
value !== undefined &&
value !== "" &&
Number.isFinite(Number(value)) &&
Number(value) < 0,
},
{
id: "current-adjustments-zero",
columnKeys: zeroValueRequirementKeys,
message: "Текущие корректировки должны быть равны 0",
appliesToRow: (row) => row?.depth === 0 || row?.row_type === "ROOT",
isInvalid: (value) =>
value !== null &&
value !== undefined &&
value !== "" &&
Number.isFinite(Number(value)) &&
Number(value) !== 0,
},
];

View File

@ -0,0 +1,37 @@
import { useCallback, useMemo } from "react";
import { validationRules } from "../constants/validation";
import {
buildColumnHeaderPaths,
collectInvalidErrors,
} from "../utils/validationUtils";
export const useValidationRules = (data = [], columns = []) => {
const headerPaths = useMemo(
() => buildColumnHeaderPaths(columns),
[columns],
);
const errors = useMemo(() => {
const result = [];
const seen = new Set();
for (const rule of validationRules) {
collectInvalidErrors(data, rule, headerPaths, result, seen);
}
return result;
}, [data, headerPaths]);
const isCellInvalid = useCallback(
(columnId, value, row) =>
validationRules.some(
(rule) =>
rule.columnKeys.includes(columnId) &&
(!rule.appliesToRow || rule.appliesToRow(row)) &&
rule.isInvalid(value),
),
[],
);
return { errors, isCellInvalid };
};

View File

@ -83,6 +83,7 @@ export const getTableColumns = ({
columnsConfig, columnsConfig,
onCellUpdateError, onCellUpdateError,
onCellNumberClick, onCellNumberClick,
isCellInvalid,
}) => { }) => {
const columnColors = { ...columnsConfig.colors }; const columnColors = { ...columnsConfig.colors };
const columns = structuredClone(columnsConfig.columns); const columns = structuredClone(columnsConfig.columns);
@ -92,6 +93,8 @@ 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 isInvalid = isCellInvalid?.(column.id, value, row.original) || false;
if (!cellPropsCache.has(row)) { if (!cellPropsCache.has(row)) {
cellPropsCache.set(row, {}); cellPropsCache.set(row, {});
@ -104,7 +107,9 @@ export const getTableColumns = ({
const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value; const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
if (cached.globalFilter === currentGlobalFilter && if (cached.globalFilter === currentGlobalFilter &&
cached.columnFilter === currentColumnFilter) { cached.columnFilter === currentColumnFilter &&
cached.value === value &&
cached.isInvalid === isInvalid) {
return cached; return cached;
} }
} }
@ -118,7 +123,9 @@ export const getTableColumns = ({
isEditable: row.original?.row_type === 'INPUT' || false, isEditable: row.original?.row_type === 'INPUT' || false,
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type], backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`], isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}`, isInvalid,
value,
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}_${value}_${isInvalid}`,
}; };
rowCache[key] = props; rowCache[key] = props;

View File

@ -0,0 +1,48 @@
// Достаёт вложенное поле из объекта строки таблицы
export const getValueByPath = (row, path) =>
path.split(".").reduce((value, key) => value?.[key], row);
// Строит пути заголовков колонок
export const buildColumnHeaderPaths = (columns = [], path = [], map = {}) => {
for (const column of columns) {
const nextPath = column.header ? [...path, column.header] : path;
if (column.columns?.length) {
buildColumnHeaderPaths(column.columns, nextPath, map);
} else if (column.accessorKey) {
map[column.accessorKey] = nextPath;
}
}
return map;
};
// Обходит строки и собирает ошибки по правилу валидации
export const collectInvalidErrors = (rows, rule, headerPaths, errors, seen) => {
for (const row of rows) {
if (!rule.appliesToRow || rule.appliesToRow(row)) {
for (const columnKey of rule.columnKeys) {
if (!rule.isInvalid(getValueByPath(row, columnKey))) continue;
const errorId = `${rule.id}:${columnKey}`;
if (seen.has(errorId)) continue;
seen.add(errorId);
const columnPath = headerPaths[columnKey];
const columnMessage = columnPath?.length
? `Ошибка в колонке: ${columnPath.join(" => ")}`
: null;
errors.push({
id: errorId,
columnId: columnKey,
message: columnMessage
? `${rule.message}. ${columnMessage}`
: rule.message,
});
}
}
collectInvalidErrors(row.subRows || [], rule, headerPaths, errors, seen);
}
};