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, useVspOptions } 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 { ROLES_NAME_ID } from '../../constants/constants'; 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.getVisibleLeafColumns(); const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150; const scrollPaddingStart = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); const scrollPaddingEnd = table.getRightVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); return { overscan: 10, scrollPaddingStart, scrollPaddingEnd, 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 normalizeFilterValue = (value) => String(value ?? '') .trim() .toLocaleLowerCase('ru-RU'); const doesCellMatchFilter = (rawValue, columnDef, query, vspRegistrationById) => { if (normalizeFilterValue(rawValue).includes(query)) return true; if (columnDef?.editType !== 'vsp_dropdown') return false; return vspRegistrationById.get(Number(rawValue))?.includes(query) ?? false; }; 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 vspOptions = useVspOptions(); const userRoleId = user?.role_id; const { data, columnsCurStage, isLoading: isTableLoading, editingCells: dataEditingCells, } = useRealtimeData(formId, sheetName, direction, formType, year); const [globalFilter, setGlobalFilter] = useState(''); const [searchMatchIndex, setSearchMatchIndex] = useState(-1); const [activeSearchMatch, setActiveSearchMatch] = useState(null); const [pendingSearchDirection, setPendingSearchDirection] = useState(0); 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 [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide'); 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 canModifyRows = userRoleId === ROLES_NAME_ID.admin || columnsCurStage.length > 0; 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((valueOrUpdater) => { startTransition(() => { setGlobalFilter((currentValue) => (typeof valueOrUpdater === 'function' ? valueOrUpdater(currentValue) : valueOrUpdater)); }); }, 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 vspRegistrationById = useMemo( () => new Map(vspOptions.map((vsp) => [Number(vsp.id), normalizeFilterValue(vsp.registration_number)])), [vspOptions], ); const globalFilterFn = useCallback( (row, columnId, filterValue) => { const query = normalizeFilterValue(filterValue); if (!query) return true; const rawValue = row.getValue(columnId); const column = row.getAllCells().find((cell) => cell.column.id === columnId)?.column; return doesCellMatchFilter(rawValue, column?.columnDef, query, vspRegistrationById); }, [vspRegistrationById], ); const createTableConfig = ({ columns, data, columnSizing, columnPinning, columnVisibility, expanded, globalFilter, showColumnFilters, rowSelection, containerRef, tableMeta, setColumnSizing, setColumnPinning, setExpanded, editingCell, contextStartEditing, globalFilterFn, activeSearchMatch, }) => ({ ...BASE_TABLE_CONFIG, globalFilterFn, 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: (props) => { const cellProps = getTableBodyCellProps(props); const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id; return { ...cellProps, sx: { ...cellProps.sx, ...(isActiveMatch && { zIndex: 7, boxShadow: 'inset 0 0 0 3px #1976d2', }), }, }; }, 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, globalFilterFn, activeSearchMatch, }), [ columns, data, columnSizing, columnPinning, columnVisibility, globalFilter, showColumnFilters, rowSelection, containerRef, tableMeta, setColumnSizing, setColumnPinning, editingCell, expanded, globalFilterFn, activeSearchMatch, ], ); const table = useMaterialReactTable(tableConfig); const isLoadingData = columns.length > 0; const handleNavigateToColumn = useCallback( (columnId) => { if (!columnId) return; setSelectedColumnId(columnId); const pinnedIds = new Set([...(columnPinning?.left || []), ...(columnPinning?.right || [])]); if (pinnedIds.has(columnId)) return; const visibleColumns = table.getVisibleLeafColumns(); const columnIndex = visibleColumns.findIndex((column) => column.id === columnId); if (columnIndex < 0) return; const columnVirtualizer = columnVirtualizerRef.current; const container = containerRef.current; if (!container) return; const leftPinnedSize = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0); const calculatedStart = visibleColumns.slice(0, columnIndex).reduce((offset, column) => offset + column.getSize(), 0); const measurement = columnVirtualizer?.measurementsCache?.[columnIndex]; const targetStart = measurement?.start ?? calculatedStart; const targetOffset = Math.max(0, targetStart - leftPinnedSize); container.scrollTo({ left: targetOffset, }); }, [columnPinning, containerRef, table], ); const searchMatches = useMemo(() => { const query = normalizeFilterValue(globalFilter); if (!query) return []; const matches = []; for (const [rowIndex, row] of table.getRowModel().rows.entries()) { for (const cell of row.getVisibleCells()) { if (doesCellMatchFilter(cell.getValue(), cell.column.columnDef, query, vspRegistrationById)) { matches.push({ rowId: row.id, rowIndex, columnId: cell.column.id }); } } } return matches; }, [data, globalFilter, table, vspRegistrationById, columnVisibility]); const navigateToSearchMatch = useCallback( (direction) => { if (!searchMatches.length) return; setSearchMatchIndex((currentIndex) => { const startIndex = currentIndex < 0 ? (direction > 0 ? -1 : 0) : currentIndex; const nextIndex = (startIndex + direction + searchMatches.length) % searchMatches.length; const match = searchMatches[nextIndex]; setActiveSearchMatch(match); setRowSelection({ [match.rowId]: true }); console.log(match.columnId); handleNavigateToColumn(match.columnId); requestAnimationFrame(() => { rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' }); }); return nextIndex; }); }, [handleNavigateToColumn, searchMatches], ); const handleSearchKeyDown = useCallback( (event) => { if (event.key !== 'Enter') return; event.preventDefault(); const direction = event.shiftKey ? -1 : 1; const inputValue = event.target.value; if (normalizeFilterValue(inputValue) !== normalizeFilterValue(globalFilter)) { handleGlobalFilterChange.clear(); setGlobalFilter(inputValue); setPendingSearchDirection(direction); return; } navigateToSearchMatch(direction); }, [globalFilter, handleGlobalFilterChange, navigateToSearchMatch], ); useEffect(() => { setSearchMatchIndex(-1); setActiveSearchMatch(null); }, [globalFilter]); useEffect(() => { if (!pendingSearchDirection) return; navigateToSearchMatch(pendingSearchDirection); setPendingSearchDirection(0); }, [navigateToSearchMatch, pendingSearchDirection]); 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(() => { if (!canModifyRows) { toast.error('Удаление строк недоступно: нет доступных для редактирования столбцов'); return; } const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для удаления'); return; } contextDeleteRow(getRowId(row)); setRowSelection({}); }, [canModifyRows, contextDeleteRow, getSelectedRow]); const addRow = useCallback(() => { if (!canModifyRows) { toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); return; } if (isVspAdditionRow) { return handleOpenModalSelectVsp(); } if (isAdditionExpenseItem) { return handleOpenModalSelectExpenseItem(); } return handleAddRow(); }, [canModifyRows, isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]); const addProgram = useCallback(() => { if (!canModifyRows) { toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); return; } setCreateModalType('program'); }, [canModifyRows]); const addProject = useCallback(() => { if (!canModifyRows) { toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов'); return; } const row = getSelectedRow(); if (!row) { toast.error('Выделите строку для вставки'); return; } if (row.original.row_type !== 'GROUP') { toast.error('Выделите строку программы'); return; } setCreateModalType('project'); }, [canModifyRows, 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(() => { const expandableRows = table.getCoreRowModel().flatRows.filter((row) => row.getCanExpand()); if (!expandableRows.length) return; const expandedByRowId = Object.fromEntries(expandableRows.map((row) => [row.id, expanded === true || expanded[row.id] === true])); const rowsForCurrentAction = expandableRows.filter((row) => expenseItemsVisibilityAction === 'hide' ? expandedByRowId[row.id] : !expandedByRowId[row.id], ); // Состояние могло измениться через стандартные контролы таблицы. // В таком случае сразу продолжаем в доступном направлении. let action = expenseItemsVisibilityAction; if (!rowsForCurrentAction.length) action = expenseItemsVisibilityAction === 'hide' ? 'show' : 'hide'; const rowsForAction = rowsForCurrentAction.length ? rowsForCurrentAction : expandableRows.filter((row) => (action === 'hide' ? expandedByRowId[row.id] : !expandedByRowId[row.id])); if (!rowsForAction.length) return; const targetDepth = action === 'hide' ? Math.max(...rowsForAction.map((row) => row.depth)) : Math.min(...rowsForAction.map((row) => row.depth)); for (const row of expandableRows) { if (row.depth === targetDepth) expandedByRowId[row.id] = action === 'show'; } const hasExpandedRows = expandableRows.some((row) => expandedByRowId[row.id]); const hasCollapsedRows = expandableRows.some((row) => !expandedByRowId[row.id]); let nextAction = action; if (hasExpandedRows && !hasCollapsedRows) nextAction = 'hide'; if (hasCollapsedRows && !hasExpandedRows) nextAction = 'show'; setExpenseItemsVisibilityAction(nextAction); setExpanded(hasCollapsedRows ? Object.fromEntries(Object.entries(expandedByRowId).filter(([, isExpanded]) => isExpanded)) : true); }, [expanded, expenseItemsVisibilityAction, table]); return ( <> navigateToSearchMatch(1)} onSearchPrevious={() => navigateToSearchMatch(-1)} searchMatchCount={searchMatches.length} searchMatchIndex={searchMatchIndex} sizeMult={sizeMult} onChangeShowColumnFilters={setShowColumnFilters} showColumnFilters={showColumnFilters} onAddRow={addRow} onAddProgram={addProgram} onAddProject={addProject} onDeleteRow={handleDeleteRow} formId={formId} sheetName={sheetName} direction={direction} year={year} isFormNoCanAddRow={isFormNoCanAddRow} canModifyRows={canModifyRows} formType={formType} validationErrors={validationErrors} onNavigateToColumn={handleNavigateToColumn} expenseItemsVisibilityAction={expenseItemsVisibilityAction} onToggleDepthVisibility={handleToggleDepthVisibility} />
{isTableLoading && (
)} {headerPortalRef.current && createPortal( , headerPortalRef.current, )}
); }; export default React.memo(RealtimeTable);