585 lines
18 KiB
JavaScript
585 lines
18 KiB
JavaScript
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 { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
|
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';
|
|
|
|
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 [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
|
|
const [isOpenModalCreateProgram, setIsOpenModalCreateProgram] = useState(false);
|
|
const [isOpenModalCreateProject, setIsOpenModalCreateProject] = useState(false);
|
|
const [isProgramCreate, setIsProgramCreate] = useState(false)
|
|
|
|
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 = useCallback(
|
|
debounce((value) => {
|
|
startTransition(() => {
|
|
setGlobalFilter(value);
|
|
});
|
|
}, 300),
|
|
[]
|
|
);
|
|
|
|
const {
|
|
isConnected,
|
|
isAuthenticated,
|
|
subscribeToErrors,
|
|
subscribeToAuthSuccess,
|
|
error: wsError,
|
|
updateCell: contextUpdateCell,
|
|
addRow: contextAddRow,
|
|
startEditing: contextStartEditing,
|
|
addProgram: contextAddProgram,
|
|
addProject: contextAddProject,
|
|
} = 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);
|
|
|
|
const configCache = useRef(new Map());
|
|
|
|
useEffect(() => {
|
|
if (!formType || !sheetName) return;
|
|
const cacheKey = `${formType}_${sheetName}`;
|
|
|
|
if (configCache.current.has(cacheKey)) {
|
|
setColumnsConfig(configCache.current.get(cacheKey));
|
|
return;
|
|
}
|
|
|
|
const loadConfig = async () => {
|
|
try {
|
|
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
|
|
configCache.current.set(cacheKey, config.config);
|
|
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]);
|
|
|
|
const selectedColumnIdRef = useRef(selectedColumnId);
|
|
|
|
useEffect(() => {
|
|
selectedColumnIdRef.current = selectedColumnId;
|
|
}, [selectedColumnId]);
|
|
|
|
const tableMeta = useMemo(() => ({
|
|
updateCell: handleUpdateCell,
|
|
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,
|
|
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?.line_id) {
|
|
setEditingCell(null);
|
|
return;
|
|
}
|
|
if (editingCell) return;
|
|
|
|
// Отложить поиск до следующего тика
|
|
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);
|
|
}
|
|
});
|
|
}, [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 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 });
|
|
}, []);
|
|
|
|
const handleAddExpenseItemRow = useCallback((expense_item) => {
|
|
const allRows = table.getRowModel().rows;
|
|
const selectedRows = allRows.filter(row => rowSelection[row.id]);
|
|
const row = selectedRows[0];
|
|
contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id });
|
|
}, [rowSelection]);
|
|
|
|
const handleAddProgramRow = useCallback((name) => {
|
|
contextAddProgram({ name: name });
|
|
}, []);
|
|
|
|
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 handleOpenModalSelectVsp = useCallback(() => {
|
|
setIsOpenModalSelectVsp(true);
|
|
}, [])
|
|
|
|
const handleOpenModalSelectExpenseItem = 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];
|
|
if (row.original.row_type !== 'ITEM') {
|
|
toast.error('Выделите строку проекта');
|
|
return;
|
|
}
|
|
setIsOpenModalSelectExpenseItem(true);
|
|
}, [rowSelection])
|
|
|
|
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])
|
|
|
|
const addRow = useCallback(() => {
|
|
|
|
if (isVspAdditionRow) {
|
|
return handleOpenModalSelectVsp();
|
|
}
|
|
if (isAdditionExpenseItem) {
|
|
return handleOpenModalSelectExpenseItem();
|
|
}
|
|
return handleAddRow();
|
|
}, [isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow]);
|
|
|
|
const addProgram = useCallback(() => {
|
|
setIsProgramCreate(true);
|
|
setIsOpenModalCreateProgram(true);
|
|
}, [setIsOpenModalCreateProgram])
|
|
|
|
const addProject = useCallback(() => {
|
|
setIsProgramCreate(false);
|
|
const allRows = table.getRowModel().rows;
|
|
|
|
const selectedRows = allRows.filter(row => rowSelection[row.id]);
|
|
if (selectedRows.length === 0) {
|
|
toast.error('Выделите строку для вставки');
|
|
return;
|
|
}
|
|
const row = selectedRows[0];
|
|
if (row.original.row_type !== 'GROUP') {
|
|
toast.error('Выделите строку программы');
|
|
return;
|
|
}
|
|
setIsOpenModalCreateProgram(true);
|
|
}, [setIsOpenModalCreateProgram, rowSelection])
|
|
|
|
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={addRow}
|
|
onAddProgram={addProgram}
|
|
onAddProject={addProject}
|
|
onDeleteRow={handleDeleteRow}
|
|
isLoadingData={isLoadingData}
|
|
formId={formId}
|
|
sheetName={sheetName}
|
|
direction={direction}
|
|
year={year}
|
|
isProject={formType == 'PROJECT'}
|
|
formType={formType}
|
|
/>
|
|
|
|
<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="Выбор ВСП"
|
|
/>
|
|
<SelectExpenseItemModal
|
|
isOpen={isOpenModalSelectExpenseItem}
|
|
onClose={() => setIsOpenModalSelectExpenseItem(false)}
|
|
formId={formId}
|
|
onSelect={handleAddExpenseItemRow}
|
|
title="Добавление строки"
|
|
sheet={sheetName}
|
|
/>
|
|
<CreateProgramModal
|
|
isOpen={isOpenModalCreateProgram}
|
|
onClose={() => setIsOpenModalCreateProgram(false)}
|
|
title={isProgramCreate ? "Создание программы" : "Создание проекта"}
|
|
onCreate={isProgramCreate ? handleAddProgramRow : handleAddProjectRow}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default React.memo(RealtimeTable); |