diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
index b2e84e1..472c89c 100644
--- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
+++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx
@@ -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
diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx
index c26ea96..2711f3f 100644
--- a/web/src/components/RealtimeTable/RealtimeTable.jsx
+++ b/web/src/components/RealtimeTable/RealtimeTable.jsx
@@ -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}
/>
diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
index 8ac307c..6064f9b 100644
--- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
+++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx
@@ -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 = ({
/>
+
>
);
diff --git a/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx
new file mode 100644
index 0000000..c4b340d
--- /dev/null
+++ b/web/src/components/RealtimeTable/SettingPanel/ValidationErrorsAlert.jsx
@@ -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 (
+ <>
+
+
+
+ >
+ );
+};
+
+export default ValidationErrorsAlert;
diff --git a/web/src/components/RealtimeTable/constants/validation.js b/web/src/components/RealtimeTable/constants/validation.js
new file mode 100644
index 0000000..df7ab76
--- /dev/null
+++ b/web/src/components/RealtimeTable/constants/validation.js
@@ -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,
+ },
+];
\ No newline at end of file
diff --git a/web/src/components/RealtimeTable/hooks/useValidationRules.js b/web/src/components/RealtimeTable/hooks/useValidationRules.js
new file mode 100644
index 0000000..e156ebb
--- /dev/null
+++ b/web/src/components/RealtimeTable/hooks/useValidationRules.js
@@ -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 };
+};
diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx
index 0e3c51b..43fb911 100644
--- a/web/src/components/RealtimeTable/tableColumns.jsx
+++ b/web/src/components/RealtimeTable/tableColumns.jsx
@@ -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;
diff --git a/web/src/components/RealtimeTable/utils/validationUtils.js b/web/src/components/RealtimeTable/utils/validationUtils.js
new file mode 100644
index 0000000..0f7203b
--- /dev/null
+++ b/web/src/components/RealtimeTable/utils/validationUtils.js
@@ -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);
+ }
+};