import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'; import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react'; import { createPortal } from 'react-dom'; import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelectionOverlay'; import SettingsPanel from './SettingPanel/SettingPanel'; import { TableHead } from './TableHead/TableHead'; import useRealtimeData from './hooks/useRealtimeData'; import { Cell, EditCell } from './index'; import { getTableColumns } from './tableColumns'; import { CircularProgress } from '@mui/material'; import { debounce } from '@mui/material'; import { toast } from 'react-toastify'; import { CreateProgramModal } from './Modals/CreateProgramProjectModal'; import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal'; import { SelectVspModal } from './Modals/SelectVspModal'; import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig'; import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig'; import { useRealtimeActions } from './contexts/RealtimeContext'; import { useColumnSettings } from './hooks/useColumnSettings'; import { useHeaderPortal } from './hooks/useHeaderPortal'; import { useTableScale } from './hooks/useTableScale'; import { useValidationRules } from './hooks/useValidationRules'; import { getRowId } from './utils/rowUtils'; import { DictVspApi } from '../../api/dict-vsp'; import { useAuth } from '../../app/context/AuthProvider'; import { FormsNoCanAddRow } from './constants/formConfig'; 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 userRoleId = user?.role_id; 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 rowSelectionRef = useRef(rowSelection); rowSelectionRef.current = rowSelection; const [, startTransition] = useTransition(); const [editingCell, setEditingCell] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null); const { errors: validationErrors, isCellInvalid } = useValidationRules(data, columnsConfig?.columns, formType, sheetName); const [expanded, setExpanded] = useState(true); const [showOnlyDepth2, setShowOnlyDepth2] = useState(false); const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false); const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false); const [createModalType, setCreateModalType] = useState(null); const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]); const isVspAdditionRow = useMemo(() => { if (!formType || !sheetName) return false; if (!additionVspRowTable[formType]) return false; return additionVspRowTable[formType].includes(sheetName); }, [formType, sheetName]); const isAdditionExpenseItem = useMemo(() => { if (!formType || !sheetName) return false; if (!additionExpenseRowTable[formType]) return false; return additionExpenseRowTable[formType].includes(sheetName); }, [formType, sheetName]); const handleGlobalFilterChange = useMemo( () => debounce((value) => { startTransition(() => { setGlobalFilter(value); }); }, 300), [startTransition], ); useEffect(() => () => handleGlobalFilterChange.clear(), [handleGlobalFilterChange]); const { updateCell: contextUpdateCell, addRow: contextAddRow, deleteRow: contextDeleteRow, startEditing: contextStartEditing, addProgram: contextAddProgram, addProject: contextAddProject, setVspOptions, } = useRealtimeActions(); const { columnSizing, columnPinning, columnVisibility, setColumnSizing, setColumnPinning, handlePinColumn, handleUnpinColumn, handleSetColumnWidth, handleToggleColumnVisibility, handleSetColumnsVisibility, } = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`); const { headerPortalRef, containerRef } = useHeaderPortal(); const rowVirtualizerRef = useRef(null); const columnVirtualizerRef = useRef(null); const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(); useEffect(() => { rowVirtualizerRef.current?.measure?.(); columnVirtualizerRef.current?.measure?.(); }, [sizeMult]); useEffect(() => { if (!formType || !sheetName) return; let isCancelled = false; const cacheKey = `${formType}_${sheetName}`; if (CONFIG_CACHE.has(cacheKey)) { setColumnsConfig(CONFIG_CACHE.get(cacheKey)); return; } const loadConfig = async () => { try { const { config } = await import(`./constants/${formType}/${sheetName}.js`); CONFIG_CACHE.set(cacheKey, config.config); if (!isCancelled) setColumnsConfig(config.config); } catch (error) { console.error(`Failed to load config for ${formType}/${sheetName}:`, error); if (!isCancelled) setColumnsConfig({ columns: [], colors: {} }); } }; loadConfig(); return () => { isCancelled = true; }; }, [formType, sheetName]); useEffect(() => { if (!formId || !columnsConfig?.columns) return; if (!hasVspDropdown(columnsConfig.columns)) return; let isCancelled = false; DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => { if (!isCancelled && data.success) { setVspOptions(data.result); } }); return () => { isCancelled = true; }; }, [formId, columnsConfig, setVspOptions]); const handleUpdateCell = useCallback( (row, column, value) => { return contextUpdateCell(row, column, value); }, [contextUpdateCell], ); const handleClickRowCell = useCallback((rowId) => { setRowSelection((prev) => ({ [rowId]: !prev[rowId], })); }, []); 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; if (e.target.closest('[data-validation-errors-menu]')) return; setSelectedColumnId(undefined); }; document.addEventListener('mousedown', handleDocumentClick); return () => document.removeEventListener('mousedown', handleDocumentClick); }, [selectedColumnId]); const handleCellUpdateError = useCallback((rowId, columnId, error) => { console.error(`Error updating cell ${rowId}_${columnId}:`, error); toast.error('Ошибка обновления ячейки'); }, []); const columns = useMemo(() => { if (!columnsConfig || !columnsConfig.columns) return []; try { return getTableColumns({ Cell, EditCell, columnsConfig, columnsCurStage, userRoleId, onCellNumberClick: handleClickRowCell, onCellUpdateError: handleCellUpdateError, isCellInvalid, }); } catch (error) { console.error('Error creating columns:', error); return []; } }, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, 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); selectedColumnIdRef.current = selectedColumnId; const tableMeta = useMemo( () => ({ updateCell: handleUpdateCell, getSelectedColumnId: () => selectedColumnIdRef.current, }), [handleUpdateCell], ); const createTableConfig = ({ columns, data, columnSizing, columnPinning, columnVisibility, expanded, globalFilter, showColumnFilters, rowSelection, containerRef, tableMeta, setColumnSizing, setColumnPinning, setExpanded, editingCell, contextStartEditing, }) => ({ ...BASE_TABLE_CONFIG, columns, data, enableRowVirtualization: true, enableColumnVirtualization: true, rowVirtualizerInstanceRef: rowVirtualizerRef, columnVirtualizerInstanceRef: columnVirtualizerRef, rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS, onEditingCellChange: (cell) => { if (cell && !editingCell) { contextStartEditing?.(cell.row, cell.column); } }, columnVirtualizerOptions: getColumnVirtualizerOptions, enableRowSelection: true, onRowSelectionChange: setRowSelection, onColumnSizingChange: setColumnSizing, onColumnPinningChange: setColumnPinning, onExpandedChange: setExpanded, onGlobalFilterChange: handleGlobalFilterChange, state: { columnSizing, columnPinning, columnVisibility, globalFilter, showColumnFilters, rowSelection, editingCell, expanded, }, initialState: { expanded: true, }, meta: tableMeta, muiTableHeadCellProps: { sx: { boxSizing: 'border-box' }, }, muiTableBodyCellProps: getTableBodyCellProps, muiTableHeadProps: { sx: { display: 'table-header-group', height: '1px', minHeight: '1px', maxHeight: '1px', visibility: 'hidden', '& svg': { height: '1px', minHeight: '1px', maxHeight: '1px', visibility: 'hidden', }, }, }, muiTablePaperProps: getTablePaperStyles(), muiTableContainerProps: { ref: containerRef, sx: { position: 'relative', contain: 'layout', minHeight: '100%', '& .MuiTable-root': { position: 'relative' }, }, }, muiTableHeadCellFilterTextFieldProps: { placeholder: 'Поиск...', size: 'small', }, }); const tableConfig = useMemo( () => createTableConfig({ columns, data, columnSizing, columnPinning, columnVisibility, expanded, globalFilter, showColumnFilters, rowSelection, containerRef, tableMeta, setColumnSizing, setColumnPinning, setExpanded, editingCell, contextStartEditing, }), [ columns, data, columnSizing, columnPinning, columnVisibility, globalFilter, showColumnFilters, rowSelection, containerRef, tableMeta, setColumnSizing, setColumnPinning, editingCell, expanded, ], ); const table = useMaterialReactTable(tableConfig); const isLoadingData = columns.length > 0; 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); return; } if (editingCell) return; const animationFrameId = requestAnimationFrame(() => { try { const row = table.getRow(dataEditingCells.line_id); const cell = row?.getVisibleCells().find((c) => c.column.id === dataEditingCells.column); if (cell) setEditingCell(cell); } catch (error) { console.error(error); } }); return () => cancelAnimationFrame(animationFrameId); }, [dataEditingCells, editingCell, table]); const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]); const handleAddRow = useCallback(() => { const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для вставки'); return; } contextAddRow({ expense_item_id: row.original.data.header.expense_item_id }); }, [contextAddRow, getSelectedRow]); const handleAddVspRow = useCallback( (vsp_id) => { contextAddRow({ vsp_id }); }, [contextAddRow], ); const handleAddExpenseItemRow = useCallback( (expense_item) => { const row = getSelectedRow(); if (!row) return; contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id }); }, [contextAddRow, getSelectedRow], ); const handleAddProgramRow = useCallback( (name) => { contextAddProgram({ name }); }, [contextAddProgram], ); const handleAddProjectRow = useCallback( (name) => { 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 row = getSelectedRow(); if (!row) { toast.error('Выделите строку для вставки'); return; } if (row.original.row_type !== 'ITEM') { toast.error('Выделите строку проекта'); return; } setIsOpenModalSelectExpenseItem(true); }, [getSelectedRow]); const handleDeleteRow = useCallback(() => { const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для удаления'); return; } contextDeleteRow(getRowId(row)); setRowSelection({}); }, [contextDeleteRow, getSelectedRow]); const addRow = useCallback(() => { if (isVspAdditionRow) { return handleOpenModalSelectVsp(); } if (isAdditionExpenseItem) { return handleOpenModalSelectExpenseItem(); } return handleAddRow(); }, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); const addProgram = useCallback(() => { setCreateModalType('program'); }, []); const addProject = useCallback(() => { const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для вставки'); return; } if (row.original.row_type !== 'GROUP') { toast.error('Выделите строку программы'); return; } setCreateModalType('project'); }, [getSelectedRow]); const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []); const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []); const closeCreateModal = useCallback(() => setCreateModalType(null), []); useEffect(() => { return () => { rowVirtualizerRef.current = null; columnVirtualizerRef.current = null; }; }, []); const handleToggleDepthVisibility = useCallback(() => { setShowOnlyDepth2((prev) => { const newState = !prev; if (newState) { const allRows = table.getRowModel().flatRows; const newExpanded = {}; for (const row of allRows) { const depth = row.original.depth || 0; if (depth < 2 && row.getCanExpand()) { newExpanded[row.id] = true; } } setExpanded(newExpanded); } else { setExpanded(true); } return newState; }); }, [table]); return ( <>
{isTableLoading && (
)} {headerPortalRef.current && createPortal( , headerPortalRef.current, )}
); }; export default React.memo(RealtimeTable);