872 lines
27 KiB
JavaScript
872 lines
27 KiB
JavaScript
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 (
|
||
<>
|
||
<SettingsPanel
|
||
selectedColumnId={selectedColumnId}
|
||
columnPinning={columnPinning}
|
||
columns={columns}
|
||
onPinColumn={handlePinColumn}
|
||
onUnpinColumn={handleUnpinColumn}
|
||
onToggleColumnVisibility={handleToggleColumnVisibility}
|
||
onSetColumnsVisibility={handleSetColumnsVisibility}
|
||
columnVisibility={columnVisibility}
|
||
columnsCurStage={columnsCurStage}
|
||
onChangeSizeMult={setSizeMult}
|
||
onGlobalFilterChange={handleGlobalFilterChange}
|
||
globalFilter={globalFilter}
|
||
onSearchKeyDown={handleSearchKeyDown}
|
||
onSearchNext={() => 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}
|
||
/>
|
||
|
||
<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);
|