[AURORA-1225] - логические ограничения для столбцов (требования №1 и требования №2)
This commit is contained in:
parent
5498bf4935
commit
65ecb164f3
@ -25,6 +25,12 @@ const LOCKED_STYLES = {
|
||||
opacity: 0.85,
|
||||
};
|
||||
|
||||
const INVALID_STYLES = {
|
||||
border: '2px solid #D32F2F',
|
||||
backgroundColor: '#FDEDED',
|
||||
color: '#C60C0C',
|
||||
};
|
||||
|
||||
const LOCK_BADGE_STYLES = {
|
||||
position: 'absolute',
|
||||
top: '2px',
|
||||
@ -127,6 +133,7 @@ const CellComponent = ({
|
||||
isEditable,
|
||||
isUpdating,
|
||||
onCellNumberClick,
|
||||
isInvalid,
|
||||
}) => {
|
||||
const { lockedCells } = useRealtime();
|
||||
const cellKey = `${row.id}_${column.id}`;
|
||||
@ -162,7 +169,8 @@ const CellComponent = ({
|
||||
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
|
||||
color: textColor,
|
||||
...(isLocked ? LOCKED_STYLES : {}),
|
||||
}), [backgroundColor, isLocked, textColor]);
|
||||
...(isInvalid ? INVALID_STYLES : {}),
|
||||
}), [backgroundColor, isInvalid, isLocked, textColor]);
|
||||
|
||||
const lockBadge = useMemo(() => {
|
||||
if (!isLocked) return null;
|
||||
@ -196,6 +204,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
||||
prevProps.globalFilter === nextProps.globalFilter &&
|
||||
prevProps.columnFilter === nextProps.columnFilter &&
|
||||
prevProps.backgroundColor === nextProps.backgroundColor &&
|
||||
prevProps.isInvalid === nextProps.isInvalid &&
|
||||
prevProps.isEditable === nextProps.isEditable &&
|
||||
prevProps.isUpdating === nextProps.isUpdating &&
|
||||
prevProps.onCellNumberClick === nextProps.onCellNumberClick
|
||||
|
||||
@ -15,6 +15,7 @@ import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelection
|
||||
import { useColumnSettings } from './hooks/useColumnSettings';
|
||||
import { useTableScale } from './hooks/useTableScale';
|
||||
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||||
import { useValidationRules } from './hooks/useValidationRules';
|
||||
import {
|
||||
BASE_TABLE_CONFIG,
|
||||
getTableBodyCellProps,
|
||||
@ -41,6 +42,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [editingCell, setEditingCell] = useState(null);
|
||||
const [columnsConfig, setColumnsConfig] = useState(null);
|
||||
const { errors: validationErrors, isCellInvalid } = useValidationRules(
|
||||
data,
|
||||
columnsConfig?.columns,
|
||||
);
|
||||
|
||||
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
|
||||
const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
|
||||
@ -105,8 +111,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
rowVirtualizerRef.current?.measure?.();
|
||||
}, [sizeMult]);
|
||||
|
||||
const [columnsConfig, setColumnsConfig] = useState(null);
|
||||
|
||||
const configCache = useRef(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
@ -155,6 +159,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const handleDocumentClick = (e) => {
|
||||
if (e.target.closest('[data-col-select-id]')) return;
|
||||
if (e.target.closest('[data-pin-panel]')) return;
|
||||
if (e.target.closest('[data-validation-errors-menu]')) return;
|
||||
|
||||
setSelectedColumnId(undefined);
|
||||
};
|
||||
@ -178,12 +183,19 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
onCellUpdate: handleUpdateCell,
|
||||
onCellNumberClick: handleClickRowCell,
|
||||
onCellUpdateError: handleCellUpdateError,
|
||||
isCellInvalid,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating columns:', error);
|
||||
return [];
|
||||
}
|
||||
}, [columnsConfig]);
|
||||
}, [
|
||||
columnsConfig,
|
||||
handleCellUpdateError,
|
||||
handleClickRowCell,
|
||||
handleUpdateCell,
|
||||
isCellInvalid,
|
||||
]);
|
||||
|
||||
const selectedColumnIdRef = useRef(selectedColumnId);
|
||||
|
||||
@ -334,6 +346,33 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
|
||||
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(() => {
|
||||
if (!dataEditingCells?.line_id) {
|
||||
setEditingCell(null);
|
||||
@ -503,6 +542,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
year={year}
|
||||
isProject={formType == 'PROJECT'}
|
||||
formType={formType}
|
||||
validationErrors={validationErrors}
|
||||
onNavigateToColumn={handleNavigateToColumn}
|
||||
/>
|
||||
|
||||
<div style={tableWrapperStyle}>
|
||||
|
||||
@ -2,12 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Panel } from './SettingPanel.style';
|
||||
import { Divider, IconButton, Stack, Tooltip } from '@mui/material';
|
||||
|
||||
import { GroupByObject } from './GroupByObject/GroupByObject';
|
||||
import { ColorPickerButton } from './ColorPicker/ColorPickerButton';
|
||||
import {
|
||||
AddLine,
|
||||
FormulaSettingPanel,
|
||||
Minus,
|
||||
Pin,
|
||||
Plus,
|
||||
@ -22,6 +19,7 @@ import SearchComponent from '../../common/SearchComponent';
|
||||
import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions';
|
||||
import { exportSheet, exportSheetProject } from '../../../utils/exportFile';
|
||||
import { FORM_TYPE_OPTIONS } from '../../../constants/constants';
|
||||
import ValidationErrorsAlert from './ValidationErrorsAlert';
|
||||
|
||||
const SettingPanel = ({
|
||||
selectedColumnId,
|
||||
@ -47,6 +45,8 @@ const SettingPanel = ({
|
||||
year,
|
||||
isProject,
|
||||
formType,
|
||||
validationErrors = [],
|
||||
onNavigateToColumn,
|
||||
}) => {
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@ -198,6 +198,10 @@ const SettingPanel = ({
|
||||
/>
|
||||
</GroupByObject>
|
||||
</Stack>
|
||||
<ValidationErrorsAlert
|
||||
errors={validationErrors}
|
||||
onNavigateToColumn={onNavigateToColumn}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
41
web/src/components/RealtimeTable/constants/validation.js
Normal file
41
web/src/components/RealtimeTable/constants/validation.js
Normal 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,
|
||||
},
|
||||
];
|
||||
37
web/src/components/RealtimeTable/hooks/useValidationRules.js
Normal file
37
web/src/components/RealtimeTable/hooks/useValidationRules.js
Normal 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 };
|
||||
};
|
||||
@ -83,6 +83,7 @@ export const getTableColumns = ({
|
||||
columnsConfig,
|
||||
onCellUpdateError,
|
||||
onCellNumberClick,
|
||||
isCellInvalid,
|
||||
}) => {
|
||||
const columnColors = { ...columnsConfig.colors };
|
||||
const columns = structuredClone(columnsConfig.columns);
|
||||
@ -92,6 +93,8 @@ 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;
|
||||
|
||||
if (!cellPropsCache.has(row)) {
|
||||
cellPropsCache.set(row, {});
|
||||
@ -104,7 +107,9 @@ export const getTableColumns = ({
|
||||
const currentColumnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
|
||||
|
||||
if (cached.globalFilter === currentGlobalFilter &&
|
||||
cached.columnFilter === currentColumnFilter) {
|
||||
cached.columnFilter === currentColumnFilter &&
|
||||
cached.value === value &&
|
||||
cached.isInvalid === isInvalid) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
@ -118,7 +123,9 @@ export const getTableColumns = ({
|
||||
isEditable: row.original?.row_type === 'INPUT' || false,
|
||||
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
|
||||
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;
|
||||
|
||||
48
web/src/components/RealtimeTable/utils/validationUtils.js
Normal file
48
web/src/components/RealtimeTable/utils/validationUtils.js
Normal 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);
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user