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 (
<>