From f26691805a8c407cfd1e3c8c0e5e5f262ef96997 Mon Sep 17 00:00:00 2001 From: Veronika Tsygankova Date: Mon, 29 Jun 2026 11:10:14 +0300 Subject: [PATCH] =?UTF-8?q?[AURORA-1148]=20-=20=D1=81=D1=82=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RealtimeTable/Cell/Cell/Cell.jsx | 21 ++- .../ColumnSelectionOverlay.jsx | 148 ++++++++++++++++++ .../RealtimeTable/RealtimeTable.jsx | 48 +++++- .../SettingPanel/SettingPanel.jsx | 38 +++-- .../RealtimeTable/TableHead/TableHead.jsx | 60 ++++--- .../RealtimeTable/constants/tableConfig.js | 12 ++ .../RealtimeTable/hooks/useColumnSettings.js | 1 + 7 files changed, 288 insertions(+), 40 deletions(-) create mode 100644 web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 5f05400..2164513 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -59,13 +59,29 @@ const highlightText = (text, searchQuery) => { ); }; +const INTEGER_COLUMN_IDS = new Set([ + 'data.header.num_group', + 'data.nomenclature_group_id', + 'data.header.item_id', + 'data.article_id', + 'data.code_razdela', +]); + // Функция для форматирования числа в финансовый формат -const formatNumber = (value) => { +const formatNumber = (value, asInteger = false) => { if (value === null || value === undefined || value === '') return String(value || ''); const num = Number(value); if (isNaN(num)) return String(value); + if (asInteger) { + return num.toLocaleString('ru-RU', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + useGrouping: true, + }); + } + return num.toLocaleString('ru-RU', { minimumFractionDigits: 1, maximumFractionDigits: 1, @@ -84,7 +100,8 @@ const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundC const isNumeric = !isNaN(Number(cellValue)) && cellValue !== null && cellValue !== undefined && cellValue !== ''; if (isNumeric) { - textValue = formatNumber(cellValue); + const asInteger = INTEGER_COLUMN_IDS.has(column.id); + textValue = formatNumber(cellValue, asInteger); } const highlightedContent = useMemo(() => { diff --git a/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx b/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx new file mode 100644 index 0000000..5bed9f8 --- /dev/null +++ b/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +const toLocalCoord = (value, scale) => value / scale; + +const getPinnedClipLeft = (container, overlayParent, columnPinning, scale) => { + const pinnedIds = (columnPinning?.left || []).filter(Boolean); + if (!pinnedIds.length) return 0; + + const parentRect = overlayParent.getBoundingClientRect(); + let maxRight = 0; + + for (const id of pinnedIds) { + const el = container.querySelector(`[data-col-select-id="${id}"]`); + if (!el) continue; + const rect = el.getBoundingClientRect(); + maxRight = Math.max(maxRight, rect.right - parentRect.left); + } + + return toLocalCoord(maxRight, scale); +}; + +const getOverlayRect = (container, overlayParent, selectedColumnId, columnPinning, scale) => { + const columnEl = container.querySelector( + `[data-col-select-id="${selectedColumnId}"]`, + ); + if (!columnEl || !overlayParent) return null; + + const bodyCell = container.querySelector( + `td[data-col-id="${selectedColumnId}"]`, + ); + + const parentRect = overlayParent.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const columnRect = columnEl.getBoundingClientRect(); + const alignRect = bodyCell?.getBoundingClientRect() ?? columnRect; + + let top = toLocalCoord(columnRect.top - parentRect.top, scale); + let left = toLocalCoord(alignRect.left - parentRect.left, scale); + let width = toLocalCoord(alignRect.width, scale); + const height = toLocalCoord(containerRect.bottom - columnRect.top, scale); + + const pinnedIds = (columnPinning?.left || []).filter(Boolean); + const isSelectedPinned = pinnedIds.includes(selectedColumnId); + + if (!isSelectedPinned && pinnedIds.length) { + const clipLeft = getPinnedClipLeft(container, overlayParent, columnPinning, scale); + const overlayRight = left + width; + if (overlayRight <= clipLeft) return null; + if (left < clipLeft) { + width = overlayRight - clipLeft; + left = clipLeft; + } + } + + if (width <= 0 || height <= 0) return null; + + return { top, left, width, height }; +}; + +export const ColumnSelectionOverlay = ({ + selectedColumnId, + containerRef, + scale = 1, + columnSizing, + columnPinning, + showColumnFilters, +}) => { + const [rect, setRect] = useState(null); + const [portalTarget, setPortalTarget] = useState(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const overlayParent = container.parentElement; + setPortalTarget(overlayParent); + }, [containerRef, selectedColumnId]); + + useEffect(() => { + const container = containerRef.current; + const overlayParent = container?.parentElement; + if (!selectedColumnId || !container || !overlayParent) { + setRect(null); + return; + } + + const updateRect = () => { + setRect(getOverlayRect(container, overlayParent, selectedColumnId, columnPinning, scale)); + }; + + updateRect(); + + const scrollTargets = new Set([container]); + let scrollParent = container.parentElement; + for (let i = 0; i < 3 && scrollParent; i += 1) { + scrollTargets.add(scrollParent); + scrollParent = scrollParent.parentElement; + } + + scrollTargets.forEach((target) => { + target.addEventListener('scroll', updateRect, { passive: true }); + }); + window.addEventListener('resize', updateRect); + + const resizeObserver = new ResizeObserver(updateRect); + resizeObserver.observe(container); + resizeObserver.observe(overlayParent); + + const header = container.querySelector('.custom-table-header'); + if (header) { + resizeObserver.observe(header); + } + + const tableBody = container.querySelector('.MuiTableBody-root'); + if (tableBody) { + resizeObserver.observe(tableBody); + } + + return () => { + scrollTargets.forEach((target) => { + target.removeEventListener('scroll', updateRect); + }); + window.removeEventListener('resize', updateRect); + resizeObserver.disconnect(); + }; + }, [selectedColumnId, containerRef, scale, columnSizing, columnPinning, showColumnFilters]); + + if (!rect || !portalTarget) return null; + + return createPortal( +
, + portalTarget, + ); +}; diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 6b37f2a..da518a6 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -11,13 +11,14 @@ import { getTableColumns } from './tableColumns'; import { Cell, EditCell } from './index'; import { TableHead } from './TableHead/TableHead'; import SettingsPanel from './SettingPanel/SettingPanel'; +import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelectionOverlay'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useTableScale } from './hooks/useTableScale'; import { useHeaderPortal } from './hooks/useHeaderPortal'; import { BASE_TABLE_CONFIG, - getTableHeadCellStyles, + getTableBodyCellProps, getTablePaperStyles, TABLE_ROW_HEIGHT, } from './constants/tableConfig'; @@ -29,7 +30,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction); const [globalFilter, setGlobalFilter] = useState(''); const [showColumnFilters, setShowColumnFilters] = useState(false); - const [selectedColumn, setSelectedColumn] = useState(); + const [selectedColumnId, setSelectedColumnId] = useState(); const [rowSelection, setRowSelection] = useState({}); const [isLoadingData, setIsLoadingData] = useState(false); const [isPending, startTransition] = useTransition(); @@ -102,6 +103,26 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { })); }, []) + const handleColumnSelect = useCallback((columnId) => { + setSelectedColumnId((prev) => { + const next = prev === columnId ? undefined : columnId; + return next; + }); + }, []); + + useEffect(() => { + if (!selectedColumnId) return; + + const handleDocumentClick = (e) => { + if (e.target.closest('[data-col-select-id]')) return; + if (e.target.closest('[data-pin-panel]')) return; + + setSelectedColumnId(undefined); + }; + + document.addEventListener('mousedown', handleDocumentClick); + return () => document.removeEventListener('mousedown', handleDocumentClick); + }, [selectedColumnId]); const columns = useMemo(() => { if (!columnsConfig || !columnsConfig.columns) return []; @@ -121,11 +142,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, handleUpdateCell]); + }, [columnsConfig, handleUpdateCell, handleClickRowCell]); const tableMeta = useMemo(() => ({ updateCell: handleUpdateCell, - }), [handleUpdateCell]); + selectedColumnId, + }), [handleUpdateCell, selectedColumnId]); // Конфигурация таблицы const tableConfig = useMemo( @@ -188,7 +210,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { muiTableHeadCellProps: { sx: { boxSizing: 'border-box' }, }, - muiTableBodyCellProps: getTableHeadCellStyles, + muiTableBodyCellProps: getTableBodyCellProps, muiTableHeadProps: { sx: { display: 'table-header-group', @@ -301,7 +323,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction }) => { return ( <> { > + + {isTableLoading && (
{ theme.zIndex.modal - 1 }} - onHeaderSelect={setSelectedColumn} - selectHeader={selectedColumn} + selectedColumnId={selectedColumnId} + onColumnSelect={handleColumnSelect} onChangeWidth={handleSetColumnWidth} isLoadingData={isLoadingData} />, diff --git a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx index 6644329..5725c84 100644 --- a/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx +++ b/web/src/components/RealtimeTable/SettingPanel/SettingPanel.jsx @@ -21,7 +21,8 @@ import { ExportDefaultButton } from '../../common/Buttons/ButtonsActions'; import { exportSheet } from '../../../utils/exportForm'; const SettingPanel = ({ - selectedColumn, + selectedColumnId, + columnPinning, table, columns, onPinColumn, @@ -44,12 +45,16 @@ const SettingPanel = ({ const [selectedColumnIds, setSelectedColumnIds] = useState(); const [showColumnFilters, setShowColumnFilters] = useState(false); + // TODO: console.log(!!!) везде посомтреть useEffect(() => { - if (!selectedColumn) return; - const pinningColumn = table.getState().columnPinning.left; - const isPinned = pinningColumn.includes(selectedColumn.id); - setIsPinned(isPinned); - }, [selectedColumn, table]); + if (!selectedColumnId) { + setIsPinned(false); + return; + } + const pinningColumn = columnPinning?.left || table.getState().columnPinning.left || []; + const pinned = pinningColumn.includes(selectedColumnId); + setIsPinned(pinned); + }, [selectedColumnId, columnPinning, table]); useEffect(() => { setSelectedColumnIds( @@ -79,7 +84,7 @@ const SettingPanel = ({ return ( <> - + {/* */} @@ -88,11 +93,22 @@ const SettingPanel = ({ + disabled={!selectedColumnId} + onMouseDown={(e) => e.stopPropagation()} + onClick={() => { + if (!selectedColumnId) { + console.warn('[ColumnPin] клик Pin: selectedColumnId пустой'); + return; + } + console.log('[ColumnPin] клик Pin:', { + selectedColumnId, + isPinned, + action: isPinned ? 'unpin' : 'pin', + }); isPinned - ? onUnpinColumn?.(selectedColumn.id) - : onPinColumn?.(selectedColumn.id) - } + ? onUnpinColumn?.(selectedColumnId) + : onPinColumn?.(selectedColumnId); + }} > diff --git a/web/src/components/RealtimeTable/TableHead/TableHead.jsx b/web/src/components/RealtimeTable/TableHead/TableHead.jsx index 7bee2df..9f819d9 100644 --- a/web/src/components/RealtimeTable/TableHead/TableHead.jsx +++ b/web/src/components/RealtimeTable/TableHead/TableHead.jsx @@ -2,9 +2,12 @@ import React from 'react'; import ColumnResizer from './ColumnResizer'; import { columnPinningDefault } from '../constants/columnConfig'; +const getPinnedColumnIds = (table) => + (table.getState().columnPinning.left || []).filter(Boolean); + const getSortedColumns = (table) => { const allLeafColumns = table.getAllLeafColumns(); - const pinnedColumns = table.getState().columnPinning.left || []; + const pinnedColumns = getPinnedColumnIds(table); return [...allLeafColumns].sort((a, b) => { const aIsPinned = pinnedColumns.includes(a.id); @@ -46,7 +49,7 @@ const getPinnedColumnsWidth = (header, pinningColumn) => { const calculateLeftOffset = (table, columnOrHeader) => { const columnId = getColumnId(columnOrHeader); - const pinnedColumns = table.getState().columnPinning.left || []; + const pinnedColumns = getPinnedColumnIds(table); const currentPinnedIndex = pinnedColumns.indexOf(columnId); let leftOffset = 0; @@ -64,13 +67,13 @@ const calculateLeftOffset = (table, columnOrHeader) => { }; const isColumnPin = (table, header) => { - const pinningColumn = table.getState().columnPinning.left; + const pinningColumn = getPinnedColumnIds(table); const columnId = getColumnId(header); const isPinned = pinningColumn.includes(columnId); return isPinned; }; -const ColumnNumbersRow = ({ table }) => { +const ColumnNumbersRow = ({ table, selectedColumnId, onColumnSelect }) => { const sortedColumns = getSortedColumns(table); const tableState = table.getState(); const filteredColumns = sortedColumns.filter( @@ -95,27 +98,38 @@ const ColumnNumbersRow = ({ table }) => { key={`col-number-${column.id}`} column={column} table={table} + selectedColumnId={selectedColumnId} + onColumnSelect={onColumnSelect} /> ))}
); }; -const ColumnNumberCell = ({ column, table }) => { +const ColumnNumberCell = ({ column, table, selectedColumnId, onColumnSelect }) => { const isPinned = isColumnPin(table, column); const allLeafColumns = table.getAllLeafColumns(); const originalIndex = allLeafColumns.findIndex((col) => col.id === column.id); const leftOffset = calculateLeftOffset(table, column); + const selected = selectedColumnId === column.id; + return (
{ + e.stopPropagation(); + onColumnSelect?.(column.id); + }} + onMouseDown={(e) => e.stopPropagation()} style={{ width: column.getSize(), minWidth: column.getSize(), maxWidth: column.getSize(), padding: '2px 6px', textAlign: 'center', - backgroundColor: '#e5e5e5', + backgroundColor: selected ? '#589A2F' : '#FBFBFB', + color: selected ? '#ffffff' : '#5E5E5E', boxSizing: 'border-box', display: 'flex', alignItems: 'center', @@ -125,7 +139,9 @@ const ColumnNumberCell = ({ column, table }) => { borderBottom: 'none', position: isPinned ? 'sticky' : 'relative', left: isPinned ? leftOffset : 'auto', - zIndex: isPinned ? 20 : 'auto', + zIndex: selected ? 16 : isPinned ? 20 : 'auto', + cursor: 'pointer', + userSelect: 'none', }} > {originalIndex} @@ -133,9 +149,9 @@ const ColumnNumberCell = ({ column, table }) => { ); }; -const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => { +const HeaderCell = ({ header, table, onClick, onChangeWidth }) => { const column = header.column; - const pinningColumn = table.getState().columnPinning.left || []; + const pinningColumn = getPinnedColumnIds(table); const isGroup = header.subHeaders?.length > 0; const isPinned = isColumnPin(table, header); const leftOffset = calculateLeftOffset(table, header); @@ -148,7 +164,7 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => { const backgroundColor = customBgColor ? customBgColor - : (hightlight ? '#cbccce' : 'rgba(243, 244, 246, 1)'); + : 'rgba(243, 244, 246, 1)'; const handleChangeWidth = (deltaWidth) => { const size = header.getSize(); @@ -164,7 +180,7 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => { } // Для незакрепленных колонок текст должен начинаться после всех закрепленных // Получаем общую ширину всех закрепленных колонок - const pinnedColumns = table.getState().columnPinning.left || []; + const pinnedColumns = getPinnedColumnIds(table); let totalPinnedWidth = 0; for (const pinnedId of pinnedColumns) { const pinnedCol = table.getColumn(pinnedId); @@ -188,6 +204,7 @@ const HeaderCell = ({ header, table, onClick, hightlight, onChangeWidth }) => { textAlign: 'center', fontWeight: 600, fontSize: '12px', + lineHeight: 1, backgroundColor: backgroundColor, color: '#424242', boxSizing: 'border-box', @@ -379,13 +396,15 @@ const FilterCell = ({ column, table }) => { export const TableHead = ({ table, - onHeaderSelect, - selectHeader, + selectedColumnId, + onColumnSelect, onChangeWidth, isLoadingData, }) => { const handleHeaderClick = (header) => { - onHeaderSelect(header); + if (!header.subHeaders?.length && header.column?.id) { + onColumnSelect?.(header.column.id); + } }; if (!isLoadingData) { @@ -402,9 +421,15 @@ export const TableHead = ({ }} > {/* Строка с номерами колонок */} - + - {table.getState().showColumnFilters && } + {table.getState().showColumnFilters && ( + + )} {/* Существующие строки заголовков */} {table.getHeaderGroups().map((headerGroup) => ( @@ -413,9 +438,6 @@ export const TableHead = ({ ({ }, }); +export const getTableBodyCellProps = ({ column, table }) => { + const isPinned = (table.getState().columnPinning?.left || []).includes(column.id); + + return { + 'data-col-id': column.id, + sx: { + ...getTableHeadCellStyles().sx, + ...(isPinned && { zIndex: 5 }), + }, + }; +}; + export const getTablePaperStyles = () => ({ sx: { boxShadow: "none", diff --git a/web/src/components/RealtimeTable/hooks/useColumnSettings.js b/web/src/components/RealtimeTable/hooks/useColumnSettings.js index 5f6c869..dafbc9c 100644 --- a/web/src/components/RealtimeTable/hooks/useColumnSettings.js +++ b/web/src/components/RealtimeTable/hooks/useColumnSettings.js @@ -41,6 +41,7 @@ export const useColumnSettings = (tableKey) => { }, []); const handleUnpinColumn = useCallback((colId) => { + if (!colId) return; if (columnPinningDefault.left.includes(colId)) return; setColumnPinning((prev) => ({ ...prev,