2026-07-20 11:30:51 +03:00

482 lines
14 KiB
JavaScript

// components/RealtimeTable/index.js
import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react';
import { createPortal } from 'react-dom';
import {
useMaterialReactTable,
MaterialReactTable,
} from 'material-react-table';
import useRealtimeData from './hooks/useRealtimeData';
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,
getTableBodyCellProps,
getTablePaperStyles,
TABLE_ROW_HEIGHT,
} from './constants/tableConfig';
import { useRealtime } from './contexts/RealtimeContext';
import { toast } from 'react-toastify';
import { CircularProgress } from '@mui/material';
import { additionVspRowTable } from './constants/addingRowConfig';
import { SelectVspModal } from './Modals/SelectVspModal';
import { getRowId } from './utils/rowUtils';
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { data, setData, 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 [editingCell, setEditingCell] = useState(null);
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
const isVspAdditionRow = useMemo(() => {
if (!formType || !sheetName) return false;
if (!additionVspRowTable[formType]) return false;
return additionVspRowTable[formType].includes(sheetName);
}, [formType, sheetName]);
const handleGlobalFilterChange = (value) => {
startTransition(() => {
setGlobalFilter(value);
});
};
const {
isConnected,
isAuthenticated,
subscribeToErrors,
subscribeToAuthSuccess,
error: wsError,
updateCell: contextUpdateCell,
addRow: contextAddRow,
deleteRow: contextDeleteRow,
startEditing: contextStartEditing,
} = useRealtime();
const {
columnSizing,
columnPinning,
columnVisibility,
setColumnSizing,
setColumnPinning,
handlePinColumn,
handleUnpinColumn,
handleSetColumnWidth,
handleToggleColumnVisibility,
} = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`);
const { headerPortalRef, containerRef } = useHeaderPortal();
const rowVirtualizerRef = useRef(null);
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } =
useTableScale();
useEffect(() => {
rowVirtualizerRef.current?.measure?.();
}, [sizeMult]);
const [columnsConfig, setColumnsConfig] = useState(null);
useEffect(() => {
const loadConfig = async () => {
try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
setColumnsConfig(config.config);
} catch (error) {
console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
setColumnsConfig({ columns: [], colors: {} });
}
};
loadConfig();
}, [formType, sheetName]);
const handleUpdateCell = useCallback(async (row, column, value) => {
return await 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;
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,
onCellUpdate: handleUpdateCell,
onCellNumberClick: handleClickRowCell,
onCellUpdateError: handleCellUpdateError,
});
} catch (error) {
console.error('Error creating columns:', error);
return [];
}
}, [columnsConfig, handleUpdateCell, handleClickRowCell, handleCellUpdateError]);
const selectedColumnIdRef = useRef(selectedColumnId);
useEffect(() => {
selectedColumnIdRef.current = selectedColumnId;
}, [selectedColumnId]);
const tableMeta = useMemo(() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
}), [handleUpdateCell]);
const ROW_VIRTUALIZER_OPTIONS = {
overscan: 30,
scrollPaddingStart: 0,
scrollPaddingEnd: 0,
estimateSize: () => TABLE_ROW_HEIGHT,
measureElement: (el) => el?.offsetHeight || TABLE_ROW_HEIGHT,
};
const createTableConfig = ({
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
contextStartEditing,
}) => ({
...BASE_TABLE_CONFIG,
columns,
data,
enableRowVirtualization: true,
enableColumnVirtualization: true,
rowVirtualizerInstanceRef: rowVirtualizerRef,
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;
},
}),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onColumnSizingChange: setColumnSizing,
onColumnPinningChange: setColumnPinning,
onGlobalFilterChange: handleGlobalFilterChange,
state: {
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
editingCell,
},
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,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
contextStartEditing,
}), [
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
]);
const table = useMaterialReactTable(tableConfig);
useEffect(() => {
if (!dataEditingCells || !dataEditingCells.line_id) {
setEditingCell(null);
return;
}
if (editingCell) return;
const rowId = dataEditingCells.line_id;
const columnId = dataEditingCells.column;
try {
const row = table.getRow(rowId);
const column = table.getColumn(columnId);
const cell = row?.getVisibleCells().find(c => c.column.id === columnId);
if (cell) {
setEditingCell(cell);
}
} catch (error) {
console.error(error);
toast.error('Ошибка редактирования ячейки');
}
}, [dataEditingCells, editingCell]);
useEffect(() => {
if (table && data && columns.length !== 0) {
setIsLoadingData(true)
}
}, [data, columns, table])
useEffect(() => {
// Принудительно обновляем состояние закрепленных колонок
// если так не сделать при скролле будут пропадать закрепленные колонки
if (columnPinning && Object.keys(columnPinning).length > 0) {
const timer = setTimeout(() => {
setColumnPinning(prev => ({ ...prev }));
}, 100);
return () => clearTimeout(timer);
}
}, []);
const handleAddRow = useCallback(() => {
const allRows = table.getRowModel().rows;
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
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])
const handleAddVspRow = useCallback((vsp_id) => {
contextAddRow({ vsp_id: vsp_id });
}, [rowSelection, table]);
const handleOpenModalSelectVsp = useCallback(() => {
setIsOpenModalSelectVsp(true);
}, [rowSelection, table])
const handleDeleteRow = useCallback(() => {
const allRows = table.getRowModel().rows;
const selectedRows = allRows.filter(row => rowSelection[row.id]);
if (selectedRows.length === 0) {
toast.error('Выделите строку для удаления');
return;
}
const row = selectedRows[0];
const rowId = getRowId(row);
contextDeleteRow(rowId);
setRowSelection({});
}, [rowSelection, table])
useEffect(() => {
return () => {
setData([]);
setRowSelection({});
setEditingCell(null);
if (rowVirtualizerRef.current) {
rowVirtualizerRef.current = null;
}
};
}, []);
return (
<>
<SettingsPanel
selectedColumnId={selectedColumnId}
columnPinning={columnPinning}
table={table}
columns={columns}
onPinColumn={handlePinColumn}
onUnpinColumn={handleUnpinColumn}
onToggleColumnVisibility={handleToggleColumnVisibility}
columnVisibility={columnVisibility}
onChangeSizeMult={setSizeMult}
onGlobalFilterChange={handleGlobalFilterChange}
sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters}
onAddRow={isVspAdditionRow ? handleOpenModalSelectVsp : handleAddRow}
onDeleteRow={handleDeleteRow}
isLoadingData={isLoadingData}
formId={formId}
sheetName={sheetName}
direction={direction}
year={year}
isProject={formType == 'PROJECT'}
/>
<div style={tableWrapperStyle}>
<div style={tableScaleStyle}>
<div
style={{
position: 'relative',
width: '100%',
flex: 1,
minHeight: 0,
height: '100%',
}}
>
<MaterialReactTable table={table} />
<ColumnSelectionOverlay
selectedColumnId={selectedColumnId}
containerRef={containerRef}
scale={sizeMult}
columnSizing={columnSizing}
columnPinning={columnPinning}
showColumnFilters={showColumnFilters}
/>
{isTableLoading && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(255, 255, 255, 0.6)',
zIndex: 2,
}}
>
<CircularProgress />
</div>
)}
{headerPortalRef.current &&
createPortal(
<TableHead
table={table}
style={{ zIndex: (theme) => theme.zIndex.modal - 1 }}
selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
/>,
headerPortalRef.current,
)}
</div>
</div>
</div>
<SelectVspModal
isOpen={isOpenModalSelectVsp}
onClose={() => setIsOpenModalSelectVsp(false)}
formId={formId}
onSelect={handleAddVspRow}
title="Выбор ВСП"
/>
</>
);
};
export default React.memo(RealtimeTable);