PotapovaA d9511ae7c5 fix
2026-08-19 18:38:54 +03:00

711 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<>
<SettingsPanel
selectedColumnId={selectedColumnId}
columnPinning={columnPinning}
columns={columns}
onPinColumn={handlePinColumn}
onUnpinColumn={handleUnpinColumn}
onToggleColumnVisibility={handleToggleColumnVisibility}
onSetColumnsVisibility={handleSetColumnsVisibility}
columnVisibility={columnVisibility}
columnsCurStage={columnsCurStage}
onChangeSizeMult={setSizeMult}
onGlobalFilterChange={handleGlobalFilterChange}
sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters}
showColumnFilters={showColumnFilters}
onAddRow={addRow}
onAddProgram={addProgram}
onAddProject={addProject}
onDeleteRow={handleDeleteRow}
formId={formId}
sheetName={sheetName}
direction={direction}
year={year}
isFormNoCanAddRow={isFormNoCanAddRow}
formType={formType}
validationErrors={validationErrors}
onNavigateToColumn={handleNavigateToColumn}
showOnlyDepth2={showOnlyDepth2}
onToggleDepthVisibility={handleToggleDepthVisibility}
/>
<div style={tableWrapperStyle}>
<div style={tableScaleStyle}>
<div style={tableContentStyle}>
<MaterialReactTable table={table} />
<ColumnSelectionOverlay
selectedColumnId={selectedColumnId}
containerRef={containerRef}
scale={sizeMult}
columnSizing={columnSizing}
columnPinning={columnPinning}
showColumnFilters={showColumnFilters}
/>
{isTableLoading && (
<div style={loadingOverlayStyle}>
<CircularProgress />
</div>
)}
{headerPortalRef.current &&
createPortal(
<TableHead
table={table}
columns={columns}
columnPinning={columnPinning}
columnSizing={columnSizing}
columnVisibility={columnVisibility}
columnFilters={table.getState().columnFilters}
showColumnFilters={showColumnFilters}
selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
/>,
headerPortalRef.current,
)}
</div>
</div>
</div>
<SelectVspModal
isOpen={isOpenModalSelectVsp}
onClose={closeSelectVspModal}
formId={formId}
onSelect={handleAddVspRow}
title='Выбор ВСП'
/>
<SelectExpenseItemModal
isOpen={isOpenModalSelectExpenseItem}
onClose={closeSelectExpenseItemModal}
formId={formId}
onSelect={handleAddExpenseItemRow}
title='Добавление строки'
sheet={sheetName}
/>
<CreateProgramModal
isOpen={createModalType !== null}
onClose={closeCreateModal}
title={createModalType === 'program' ? 'Создание программы' : 'Создание проекта'}
onCreate={createModalType === 'program' ? handleAddProgramRow : handleAddProjectRow}
/>
</>
);
};
export default React.memo(RealtimeTable);