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 ( + <> + + + 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) => ( + handleNavigate(error.columnId)} + sx={{ + whiteSpace: 'normal', + color: warningColors.text, + cursor: 'pointer', + '&:hover': { + bgcolor: warningColors.hover, + }, + }} + > + {error.message} + + ))} + + + ); +}; + +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); + } +};