diff --git a/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx b/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx index 2470c03..1b27196 100644 --- a/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx +++ b/web/src/components/RealtimeTable/ColumnSelectionOverlay/ColumnSelectionOverlay.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; const toLocalCoord = (value, scale) => value / scale; @@ -54,7 +54,7 @@ const getOverlayRect = (container, overlayParent, selectedColumnId, columnPinnin return { top, left, width, height }; }; -export const ColumnSelectionOverlay = ({ selectedColumnId, containerRef, scale = 1, columnSizing, columnPinning, showColumnFilters }) => { +const ColumnSelectionOverlayComponent = ({ selectedColumnId, containerRef, scale = 1, columnSizing, columnPinning, showColumnFilters }) => { const [rect, setRect] = useState(null); const [portalTarget, setPortalTarget] = useState(null); @@ -135,3 +135,5 @@ export const ColumnSelectionOverlay = ({ selectedColumnId, containerRef, scale = portalTarget, ); }; + +export const ColumnSelectionOverlay = memo(ColumnSelectionOverlayComponent); diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 73b574e..5e3ed64 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -29,7 +29,6 @@ import { additionExpenseRowTable, additionVspRowTable } from './constants/adding import { SelectVspModal } from './Modals/SelectVspModal'; import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { getRowId } from './utils/rowUtils'; -import { FORM_TYPE_OPTIONS } from '../../constants/constants'; import { debounce } from '@mui/material'; import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; @@ -37,15 +36,69 @@ import { FormsNoCanAddRow } from './constants/formConfig'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; +const CONFIG_CACHE = new Map(); + +const ROW_VIRTUALIZER_OPTIONS = { + overscan: 5, + scrollPaddingStart: 0, + scrollPaddingEnd: 0, + estimateSize: () => TABLE_ROW_HEIGHT, + measureElement: (element) => element?.offsetHeight || TABLE_ROW_HEIGHT, +}; + +const getColumnVirtualizerOptions = ({ table }) => { + const orderedVisibleColumns = [ + ...table.getLeftVisibleLeafColumns(), + ...table.getCenterVisibleLeafColumns(), + ...table.getRightVisibleLeafColumns(), + ]; + const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150; + + return { + overscan: 10, + estimateSize: getColumnSize, + measureElement: (element) => { + if (!element) return 150; + return getColumnSize(Number(element.getAttribute('data-index'))); + }, + }; +}; + +const hasVspDropdown = (columns) => + columns.some( + (column) => + column.editType === 'vsp_dropdown' || + (column.columns?.length && hasVspDropdown(column.columns)), + ); + +const tableContentStyle = { + position: 'relative', + width: '100%', + flex: 1, + minHeight: 0, + height: '100%', +}; + +const loadingOverlayStyle = { + position: 'absolute', + inset: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(255, 255, 255, 0.6)', + zIndex: 2, +}; + const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { user } = useAuth(); - const { data, setData, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); + const { data, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const [globalFilter, setGlobalFilter] = useState(''); const [showColumnFilters, setShowColumnFilters] = useState(false); const [selectedColumnId, setSelectedColumnId] = useState(); const [rowSelection, setRowSelection] = useState({}); - const [isLoadingData, setIsLoadingData] = useState(false); - const [isPending, startTransition] = useTransition(); + const rowSelectionRef = useRef(rowSelection); + rowSelectionRef.current = rowSelection; + const [, startTransition] = useTransition(); const [editingCell, setEditingCell] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null); const { errors: validationErrors, isCellInvalid } = useValidationRules( @@ -60,13 +113,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); - const [isOpenModalCreateProgram, setIsOpenModalCreateProgram] = useState(false); - const [isOpenModalCreateProject, setIsOpenModalCreateProject] = useState(false); - const [isProgramCreate, setIsProgramCreate] = useState(false) + const [createModalType, setCreateModalType] = useState(null); - const [isFormNoCanAddRow, setIsFormNoCanAddRow] = useState(() => { - return (FormsNoCanAddRow[formType].includes(sheetName)); - }) + const isFormNoCanAddRow = useMemo( + () => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, + [formType, sheetName], + ); const isVspAdditionRow = useMemo(() => { if (!formType || !sheetName) return false; @@ -80,21 +132,18 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { return additionExpenseRowTable[formType].includes(sheetName); }, [formType, sheetName]); - const handleGlobalFilterChange = useCallback( - debounce((value) => { + const handleGlobalFilterChange = useMemo( + () => debounce((value) => { startTransition(() => { setGlobalFilter(value); }); }, 300), - [] + [startTransition], ); + useEffect(() => () => handleGlobalFilterChange.clear(), [handleGlobalFilterChange]); + const { - isConnected, - isAuthenticated, - subscribeToErrors, - subscribeToAuthSuccess, - error: wsError, updateCell: contextUpdateCell, addRow: contextAddRow, deleteRow: contextDeleteRow, @@ -118,6 +167,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const { headerPortalRef, containerRef } = useHeaderPortal(); const rowVirtualizerRef = useRef(null); + const columnVirtualizerRef = useRef(null); const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(); @@ -126,21 +176,19 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { rowVirtualizerRef.current?.measure?.(); }, [sizeMult]); - const configCache = useRef(new Map()); - useEffect(() => { if (!formType || !sheetName) return; const cacheKey = `${formType}_${sheetName}`; - if (configCache.current.has(cacheKey)) { - setColumnsConfig(configCache.current.get(cacheKey)); + if (CONFIG_CACHE.has(cacheKey)) { + setColumnsConfig(CONFIG_CACHE.get(cacheKey)); return; } const loadConfig = async () => { try { const { config } = await import(`./constants/${formType}/${sheetName}.js`); - configCache.current.set(cacheKey, config.config); + CONFIG_CACHE.set(cacheKey, config.config); setColumnsConfig(config.config); } catch (error) { console.error(`Failed to load config for ${formType}/${sheetName}:`, error); @@ -154,14 +202,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { useEffect(() => { if (!formId || !columnsConfig?.columns) return; - const hasVspDropdown = (cols) => { - for (const col of cols) { - if (col.editType === 'vsp_dropdown') return true; - if (col.columns && hasVspDropdown(col.columns)) return true; - } - return false; - }; - if (!hasVspDropdown(columnsConfig.columns)) return; DictVspApi.getDropdownVsp({ form_id: formId }).then(data => { @@ -237,6 +277,28 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { isCellInvalid, ]); + // MRT запоминает индексы pinned-колонок до завершения асинхронной + // загрузки конфигурации. Новая ссылка на columnPinning заставляет + // виртуализатор пересчитать эти индексы уже с полным набором колонок. + useEffect(() => { + if (!columnsConfig?.columns?.length) return; + + setColumnPinning((currentPinning) => ({ + left: [...(currentPinning.left || [])], + right: [...(currentPinning.right || [])], + })); + }, [columnsConfig, setColumnPinning]); + + useEffect(() => { + if (!columns.length) return; + + const animationFrameId = requestAnimationFrame(() => { + columnVirtualizerRef.current?.measure?.(); + }); + + return () => cancelAnimationFrame(animationFrameId); + }, [columns, columnPinning, columnSizing, columnVisibility]); + const selectedColumnIdRef = useRef(selectedColumnId); useEffect(() => { @@ -248,14 +310,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { getSelectedColumnId: () => selectedColumnIdRef.current, }), [handleUpdateCell]); - const ROW_VIRTUALIZER_OPTIONS = { - overscan: 5, - scrollPaddingStart: 0, - scrollPaddingEnd: 0, - estimateSize: () => TABLE_ROW_HEIGHT, - measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT, - }; - const createTableConfig = ({ columns, data, @@ -280,27 +334,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { enableRowVirtualization: true, enableColumnVirtualization: true, rowVirtualizerInstanceRef: rowVirtualizerRef, + columnVirtualizerInstanceRef: columnVirtualizerRef, rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, onEditingCellChange: (cell) => { if (cell && !editingCell) { contextStartEditing?.(cell.row, cell.column); } }, - columnVirtualizerOptions: ({ table }) => ({ - overscan: 10, - measureElement: (el) => { - if (!el) return 150; - const index = Number(el?.getAttribute?.('data-index')); - const isPinned = Boolean(el?.getAttribute?.('data-pinned')); - if (isPinned) { - const colId = table.getState().columnPinning.left[index]; - const column = table.getColumn(colId); - return column?.getSize() ?? 150; - } - const allCols = [...table.getLeftVisibleLeafColumns(), ...table.getCenterVisibleLeafColumns()] - return allCols[index]?.getSize() ?? 150; - }, - }), + columnVirtualizerOptions: getColumnVirtualizerOptions, enableRowSelection: true, onRowSelectionChange: setRowSelection, onColumnSizingChange: setColumnSizing, @@ -391,6 +432,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { ]); const table = useMaterialReactTable(tableConfig); + const isLoadingData = columns.length > 0; const handleNavigateToColumn = useCallback((columnId) => { if (!columnId) return; @@ -426,7 +468,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { } if (editingCell) return; - requestAnimationFrame(() => { + const animationFrameId = requestAnimationFrame(() => { try { const row = table.getRow(dataEditingCells.line_id); const cell = row?.getVisibleCells().find( @@ -437,88 +479,72 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { console.error(error); } }); + + return () => cancelAnimationFrame(animationFrameId); }, [dataEditingCells, editingCell, table]); - const [isFirstLoad, setIsFirstLoad] = useState(true); - - useEffect(() => { - if (isFirstLoad && table && data && columns.length !== 0) { - setIsFirstLoad(false); - setIsLoadingData(true); - } - }, [data, columns, table, isFirstLoad]); + const getSelectedRow = useCallback( + () => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), + [table], + ); const handleAddRow = useCallback(() => { - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для вставки'); return; } - const row = selectedRows[0]; - const expense_item_id = row.original.data.header.expense_item_id; - contextAddRow({ expense_item_id: expense_item_id }) - }, [rowSelection, table]) + contextAddRow({ expense_item_id: row.original.data.header.expense_item_id }); + }, [contextAddRow, getSelectedRow]); const handleAddVspRow = useCallback((vsp_id) => { - contextAddRow({ vsp_id: vsp_id }); - }, []); + contextAddRow({ vsp_id }); + }, [contextAddRow]); const handleAddExpenseItemRow = useCallback((expense_item) => { - const allRows = table.getRowModel().rows; - const selectedRows = allRows.filter(row => rowSelection[row.id]); - const row = selectedRows[0]; + const row = getSelectedRow(); + if (!row) return; contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id }); - }, [rowSelection]); + }, [contextAddRow, getSelectedRow]); const handleAddProgramRow = useCallback((name) => { - contextAddProgram({ name: name }); - }, []); + contextAddProgram({ name }); + }, [contextAddProgram]); const handleAddProjectRow = useCallback((name) => { - const allRows = table.getRowModel().rows; - const selectedRows = allRows.filter(row => rowSelection[row.id]); - const row = selectedRows[0]; - contextAddProject({ name: name, program_id: row.original.data.header.program_id }); - }, [rowSelection]); + const row = getSelectedRow(); + if (!row) return; + contextAddProject({ name, program_id: row.original.data.header.program_id }); + }, [contextAddProject, getSelectedRow]); const handleOpenModalSelectVsp = useCallback(() => { setIsOpenModalSelectVsp(true); - }, []) + }, []); const handleOpenModalSelectExpenseItem = useCallback(() => { - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для вставки'); return; } - const row = selectedRows[0]; if (row.original.row_type !== 'ITEM') { toast.error('Выделите строку проекта'); return; } setIsOpenModalSelectExpenseItem(true); - }, [rowSelection]) + }, [getSelectedRow]); const handleDeleteRow = useCallback(() => { - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для удаления'); return; } - const row = selectedRows[0]; - const rowId = getRowId(row); - contextDeleteRow(rowId); + contextDeleteRow(getRowId(row)); setRowSelection({}); - }, [rowSelection, table]) + }, [contextDeleteRow, getSelectedRow]); const addRow = useCallback(() => { - if (isVspAdditionRow) { return handleOpenModalSelectVsp(); } @@ -529,35 +555,30 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { }, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); const addProgram = useCallback(() => { - setIsProgramCreate(true); - setIsOpenModalCreateProgram(true); - }, []) + setCreateModalType('program'); + }, []); const addProject = useCallback(() => { - setIsProgramCreate(false); - const allRows = table.getRowModel().rows; - - const selectedRows = allRows.filter(row => rowSelection[row.id]); - if (selectedRows.length === 0) { + const row = getSelectedRow(); + if (!row) { toast.error('Выделите строку для вставки'); return; } - const row = selectedRows[0]; if (row.original.row_type !== 'GROUP') { toast.error('Выделите строку программы'); return; } - setIsOpenModalCreateProgram(true); - }, [setIsOpenModalCreateProgram, rowSelection]) + setCreateModalType('project'); + }, [getSelectedRow]); + + const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []); + const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []); + const closeCreateModal = useCallback(() => setCreateModalType(null), []); useEffect(() => { return () => { - setData([]); - setRowSelection({}); - setEditingCell(null); - if (rowVirtualizerRef.current) { - rowVirtualizerRef.current = null; - } + rowVirtualizerRef.current = null; + columnVirtualizerRef.current = null; }; }, []); @@ -567,7 +588,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { if (newState) { const allRows = table.getRowModel().flatRows; - console.log(allRows); const newExpanded = {}; for (const row of allRows) { @@ -603,6 +623,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { onGlobalFilterChange={handleGlobalFilterChange} sizeMult={sizeMult} onChangeShowColumnFilters={setShowColumnFilters} + showColumnFilters={showColumnFilters} onAddRow={addRow} onAddProgram={addProgram} onAddProject={addProject} @@ -622,15 +643,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {