1163 lines
44 KiB
JavaScript
1163 lines
44 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 { ConfirmDeleteRowModal } from './Modals/ConfirmDeleteRowModal';
|
||
import { SheetModals } from './Modals/SheetModals';
|
||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
||
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
|
||
import { getActiveCellSx } from './utils/activeCellCss';
|
||
import { useLockedCellsStore, useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
||
import { useBudgetYear } from './hooks/useBudgetYear';
|
||
import { useColumnSettings } from './hooks/useColumnSettings';
|
||
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||
import { useWheelScrollStep } from './hooks/useWheelScrollStep';
|
||
import { useTableScale } from './hooks/useTableScale';
|
||
import { useValidationRules } from './hooks/useValidationRules';
|
||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||
import { isCellEditable } from './utils/cellEditability';
|
||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||
import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget } from './utils/rowUtils';
|
||
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
||
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
||
|
||
import { DictVspApi } from '../../api/dict-vsp';
|
||
import { useAuth } from '../../app/context/AuthProvider';
|
||
import { ROLES_NAME_ID } from '../../constants/constants';
|
||
import useDenomination from '../common/useDenomination';
|
||
import { FormsNoCanAddRow } from './constants/formConfig';
|
||
|
||
const CONFIG_CACHE = new Map();
|
||
|
||
const ROW_VIRTUALIZER_OPTIONS = createFixedRowVirtualizerOptions({
|
||
rowHeight: getTableRowHeightPx,
|
||
overscan: 2,
|
||
extra: { scrollPaddingStart: 0, scrollPaddingEnd: 0 },
|
||
});
|
||
|
||
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,
|
||
};
|
||
|
||
// AURORA-1893 (возврат 06.11): после scrollToIndex строка появляется в DOM не сразу
|
||
// (виртуализатор перерисовывает диапазон по событию scroll) — ждём до нескольких кадров
|
||
// и прокручиваем к самой ячейке: scrollIntoView учитывает реальную раскладку, включая шапку.
|
||
const SCROLL_CELL_MAX_FRAMES = 10;
|
||
const scrollCellIntoViewWhenRendered = (container, rowId, columnId, framesLeft = SCROLL_CELL_MAX_FRAMES) => {
|
||
if (!container || framesLeft <= 0) return;
|
||
const escapeIdent = globalThis.CSS?.escape ?? String;
|
||
const cell = container.querySelector(`tbody tr[data-row-id="${escapeIdent(rowId)}"] > td[data-col-id="${escapeIdent(columnId)}"]`);
|
||
if (cell) {
|
||
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||
return;
|
||
}
|
||
requestAnimationFrame(() => scrollCellIntoViewWhenRendered(container, rowId, columnId, framesLeft - 1));
|
||
};
|
||
|
||
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||
const budgetYear = useBudgetYear(formId, formType, year);
|
||
const { user } = useAuth();
|
||
const vspOptions = useVspOptions();
|
||
const userRoleId = user?.role_id;
|
||
const {
|
||
data,
|
||
columnsCurStage,
|
||
stageColumnDeadlines,
|
||
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);
|
||
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной ячейке,
|
||
// открывают редактор и подставляют этот символ (или '') вместо текущего
|
||
// значения — EditCell читает его через table.options.meta.getEditorInitialInput
|
||
// (ref-обёртка ниже — тот же приём, что у selectedColumnIdRef, чтобь не
|
||
// пересоздавать tableMeta на каждое нажатие клавиши).
|
||
const [editorInitialInput, setEditorInitialInput] = useState(null);
|
||
const editorInitialInputRef = useRef(editorInitialInput);
|
||
editorInitialInputRef.current = editorInitialInput;
|
||
// AURORA-1891: «отложенная цель» для Tab/Enter — следующая ячейка
|
||
// открывается не сразу (сервер ещё не закончил cell_edit_end текущей), а
|
||
// в эффекте ниже, когда dataEditingCells (и вместе с ним editingCell)
|
||
// станет null. См. ruling в PR про порядок cell_updated -> cell_edit_end
|
||
// -> cell_edit_start следующей.
|
||
const pendingEditTargetRef = useRef(null);
|
||
const [columnsConfig, setColumnsConfig] = useState(null);
|
||
const { errors: validationErrors, getCellErrors, headerPaths: rawHeaderPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
|
||
|
||
const [expanded, setExpanded] = useState(true);
|
||
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
||
|
||
// AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget.
|
||
const [pendingDelete, setPendingDelete] = useState(null);
|
||
const modalsRef = useRef(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,
|
||
deleteProgram: contextDeleteProgram,
|
||
deleteProject: contextDeleteProject,
|
||
startEditing: contextStartEditing,
|
||
addProgram: contextAddProgram,
|
||
addProject: contextAddProject,
|
||
setVspOptions,
|
||
} = useRealtimeActions();
|
||
// AURORA-1891: нужен сам стор (не подписка на конкретную ячейку, как
|
||
// useCellLock) — для имперактивной проверки «не заблокирована ли эта
|
||
// ячейка» внутри onClick/onKeyDown на td (muiTableBodyCellProps ниже).
|
||
const lockedCellsStore = useLockedCellsStore();
|
||
|
||
const isAdminUser = userRoleId === ROLES_NAME_ID.admin;
|
||
const stageColumnsSet = useMemo(() => new Set(columnsCurStage), [columnsCurStage]);
|
||
|
||
// AURORA-1891: та же проверка, что isCellEditable/isEditable в
|
||
// tableColumns.jsx, нужна здесь отдельно — у клика/клавиатуры на ячейке
|
||
// (и у Tab/Enter перехода) нет доступа к замыканию processColumns.
|
||
const cellIsEditable = useCallback(
|
||
(row, columnId) =>
|
||
isCellEditable({
|
||
rowType: row.original?.row_type,
|
||
columnId,
|
||
isAdmin: isAdminUser,
|
||
stageColumns: stageColumnsSet,
|
||
}),
|
||
[isAdminUser, stageColumnsSet],
|
||
);
|
||
|
||
// AURORA-1891: cellIsEditable(row, columnId) не знает про сам column.def —
|
||
// для номера строки (accessorKey 'sort_order') enableEditing жёстко false
|
||
// (tableColumns.jsx), а cellIsEditable для INPUT-строки у админа сказала
|
||
// бы true. Без этой сверки клик/Tab у админа мог бы попытаться открыть
|
||
// (несуществующий) редактор для номера строки вместо выделения строки
|
||
// или перехода дальше. Используется и в muiTableBodyCellProps, и в
|
||
// openNextEditableCell (Tab/Enter не должны переходить на такую колонку).
|
||
const isColumnActuallyEditable = useCallback(
|
||
(row, column) => {
|
||
if (!cellIsEditable(row, column.id)) return false;
|
||
const enableEditing = column.columnDef?.enableEditing;
|
||
if (typeof enableEditing === 'function') return enableEditing(row);
|
||
if (typeof enableEditing === 'boolean') return enableEditing;
|
||
return true;
|
||
},
|
||
[cellIsEditable],
|
||
);
|
||
|
||
const {
|
||
columnSizing,
|
||
columnPinning,
|
||
columnVisibility,
|
||
setColumnSizing,
|
||
setColumnPinning,
|
||
handlePinColumn,
|
||
handleUnpinColumn,
|
||
handleSetColumnWidth,
|
||
handleToggleColumnVisibility,
|
||
handleSetColumnsVisibility,
|
||
} = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`);
|
||
|
||
const { headerPortalRef, containerRef } = useHeaderPortal();
|
||
useWheelScrollStep(containerRef);
|
||
const rowVirtualizerRef = useRef(null);
|
||
const columnVirtualizerRef = useRef(null);
|
||
|
||
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
|
||
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`;
|
||
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
|
||
// AURORA-1893 (возврат 06.11): названия колонок для «Контроля ошибок» и тоста EditCell —
|
||
// с подставленными годом и единицами, как в шапке таблицы.
|
||
const headerPaths = useMemo(
|
||
() => formatHeaderPaths(rawHeaderPaths, budgetYear, denomination),
|
||
[rawHeaderPaths, budgetYear, denomination],
|
||
);
|
||
|
||
useEffect(() => {
|
||
rowVirtualizerRef.current?.measure?.();
|
||
columnVirtualizerRef.current?.measure?.();
|
||
}, [sizeMult]);
|
||
|
||
// Разовая очистка устаревших ключей формул, привязанных к позиции строки
|
||
// (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*.
|
||
useEffect(() => {
|
||
clearLegacyFormulaKeys();
|
||
}, []);
|
||
|
||
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) => (prev[rowId] ? {} : { [rowId]: true }));
|
||
}, []);
|
||
|
||
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,
|
||
getCellErrors,
|
||
denomination,
|
||
sheetName,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error creating columns:', error);
|
||
return [];
|
||
}
|
||
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, getCellErrors, denomination, sheetName]);
|
||
|
||
// 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;
|
||
|
||
// AURORA-1891: сама функция объявляется ниже (после создания table — ей
|
||
// нужны getOrderedVisibleColumns(table)/table.getRowModel().flatRows), а
|
||
// tableMeta собирается до table. Тот же приём, что у selectedColumnIdRef:
|
||
// стабильная обёртка в tableMeta, реальная реализация — в ref, который
|
||
// обновляется на каждом рендере ниже.
|
||
const openNextEditableCellRef = useRef(null);
|
||
|
||
const tableMeta = useMemo(
|
||
() => ({
|
||
updateCell: handleUpdateCell,
|
||
getSelectedColumnId: () => selectedColumnIdRef.current,
|
||
// AURORA-1892: человекочитаемый заголовок колонки для предупреждения о запрете минуса.
|
||
getColumnTitle: (columnId) => headerPaths[columnId]?.filter(Boolean).join(' / ') || columnId,
|
||
// AURORA-1891: «начальный ввод» для EditCell при открытии редактора цифрой/Backspace/Delete.
|
||
getEditorInitialInput: () => editorInitialInputRef.current,
|
||
// AURORA-1891: Tab/Shift+Tab/Enter из EditCell.jsx/VspDropdownEditCell.jsx.
|
||
openNextEditableCell: (row, column, direction) => openNextEditableCellRef.current?.(row, column, direction),
|
||
}),
|
||
[handleUpdateCell, headerPaths],
|
||
);
|
||
|
||
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,
|
||
isColumnActuallyEditable,
|
||
lockedCellsStore,
|
||
setEditorInitialInput,
|
||
}) => ({
|
||
...BASE_TABLE_CONFIG,
|
||
globalFilterFn,
|
||
columns,
|
||
data,
|
||
enableRowVirtualization: true,
|
||
enableColumnVirtualization: true,
|
||
rowVirtualizerInstanceRef: rowVirtualizerRef,
|
||
columnVirtualizerInstanceRef: columnVirtualizerRef,
|
||
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
|
||
onEditingCellChange: (cell) => {
|
||
// AURORA-1891: защита на случай, если что-то кроме нашего собственного
|
||
// onClick/onKeyDown откроет редактор для нередактируемой ячейки —
|
||
// cell_edit_start на сервер не уйдёт, блокировка (🔒 у коллег) не возьмётся.
|
||
if (cell && !editingCell && isColumnActuallyEditable(cell.row, cell.column)) {
|
||
contextStartEditing?.(cell.row, cell.column);
|
||
}
|
||
},
|
||
columnVirtualizerOptions: getColumnVirtualizerOptions,
|
||
enableRowSelection: true,
|
||
onRowSelectionChange: setRowSelection,
|
||
muiTableBodyRowProps: ({ row }) => ({
|
||
onClick: () => handleClickRowCell(row.id),
|
||
hover: false,
|
||
style: { height: TABLE_ROW_HEIGHT },
|
||
'data-row-id': row.id,
|
||
}),
|
||
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: ({ cell, column, row, table }) => {
|
||
const cellKey = `${row.id}_${column.id}`;
|
||
|
||
// AURORA-1891: открыть редактор можно только если ячейка редактируема
|
||
// (isColumnActuallyEditable — см. определение выше про колонку
|
||
// номера строки), не занята коллегой (lockedCellsStore) и сейчас
|
||
// вообще ничего не редактируется. Иначе клик/клавиша не трогают td —
|
||
// клик всплывает на строку (её onClick выделяет строку — п.7
|
||
// тикета), клавиша просто игнорируется.
|
||
const canOpenEditor = () => !editingCell && isColumnActuallyEditable(row, column) && !lockedCellsStore.hasCell(cellKey);
|
||
|
||
return {
|
||
'data-col-id': column.id,
|
||
onClick: (event) => {
|
||
if (!canOpenEditor()) return;
|
||
// Не даём клику дойти до muiTableBodyRowProps.onClick — иначе
|
||
// открытие редактора попутно выделило/сняло бы выделение строки.
|
||
event.stopPropagation();
|
||
table.setEditingCell(cell);
|
||
},
|
||
onKeyDown: (event) => {
|
||
if (!canOpenEditor()) return;
|
||
|
||
// Enter/F2 — открыть редактор с текущим значением.
|
||
if (event.key === 'Enter' || event.key === 'F2') {
|
||
event.preventDefault();
|
||
table.setEditingCell(cell);
|
||
return;
|
||
}
|
||
|
||
// Delete/Backspace — открыть редактор с пустым значением.
|
||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||
event.preventDefault();
|
||
setEditorInitialInput('');
|
||
table.setEditingCell(cell);
|
||
return;
|
||
}
|
||
|
||
// Печатный символ без модификаторов — «начальный ввод»: открыть
|
||
// редактор и подставить его вместо текущего значения.
|
||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||
event.preventDefault();
|
||
setEditorInitialInput(event.key);
|
||
table.setEditingCell(cell);
|
||
}
|
||
},
|
||
};
|
||
},
|
||
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: { ...TABLE_CONTAINER_SX, ...getActiveCellSx(activeSearchMatch) },
|
||
},
|
||
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,
|
||
isColumnActuallyEditable,
|
||
lockedCellsStore,
|
||
setEditorInitialInput,
|
||
}),
|
||
[
|
||
columns,
|
||
data,
|
||
columnSizing,
|
||
columnPinning,
|
||
columnVisibility,
|
||
globalFilter,
|
||
showColumnFilters,
|
||
rowSelection,
|
||
containerRef,
|
||
tableMeta,
|
||
setColumnSizing,
|
||
setColumnPinning,
|
||
editingCell,
|
||
expanded,
|
||
globalFilterFn,
|
||
activeSearchMatch,
|
||
isColumnActuallyEditable,
|
||
lockedCellsStore,
|
||
setEditorInitialInput,
|
||
],
|
||
);
|
||
|
||
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 = getOrderedVisibleColumns(table);
|
||
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],
|
||
);
|
||
|
||
// AURORA-1891: Tab/Shift+Tab (direction 'right'/'left', в строке) и Enter
|
||
// ('down', по столбцу) — вызывается из EditCell.jsx/VspDropdownEditCell.jsx
|
||
// через table.options.meta.openNextEditableCell. Сам обход уже готового
|
||
// списка — в чистых findNextEditableColumnId/findNextEditableRowId
|
||
// (utils/cellNavigation.js); здесь — только резолв реальных MRT
|
||
// row/column (flatRows, не .rows — см. рулинг AURORA-1893 выше в
|
||
// handleNavigateToError) и прокрутка к цели, по образцу navigateToSearchMatch.
|
||
//
|
||
// Открытие целевой ячейки происходит не здесь: сервер должен сначала
|
||
// закончить cell_edit_end текущей, поэтому мы только помечаем цель в
|
||
// pendingEditTargetRef — реальный table.setEditingCell для неё вызывается
|
||
// в эффекте ниже, когда dataEditingCells (а с ним editingCell) станет null.
|
||
const openNextEditableCell = useCallback(
|
||
(row, column, direction) => {
|
||
let targetRowId = row.id;
|
||
let targetColumnId = column.id;
|
||
|
||
if (direction === 'right' || direction === 'left') {
|
||
const columns = getOrderedVisibleColumns(table);
|
||
const nextColumnId = findNextEditableColumnId({
|
||
columns,
|
||
currentColumnId: column.id,
|
||
step: direction === 'right' ? 1 : -1,
|
||
isEditable: (candidateColumn) => isColumnActuallyEditable(row, candidateColumn),
|
||
});
|
||
if (!nextColumnId) return;
|
||
targetColumnId = nextColumnId;
|
||
} else if (direction === 'down' || direction === 'up') {
|
||
const rows = table.getRowModel().flatRows;
|
||
const nextRowId = findNextEditableRowId({
|
||
rows,
|
||
currentRowId: row.id,
|
||
step: direction === 'down' ? 1 : -1,
|
||
isEditable: (candidateRow) => isColumnActuallyEditable(candidateRow, column),
|
||
});
|
||
if (!nextRowId) return;
|
||
targetRowId = nextRowId;
|
||
} else {
|
||
return;
|
||
}
|
||
|
||
pendingEditTargetRef.current = { rowId: targetRowId, columnId: targetColumnId };
|
||
|
||
handleNavigateToColumn(targetColumnId);
|
||
requestAnimationFrame(() => {
|
||
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === targetRowId);
|
||
if (rowIndex < 0) return;
|
||
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
|
||
});
|
||
},
|
||
[table, isColumnActuallyEditable, handleNavigateToColumn],
|
||
);
|
||
openNextEditableCellRef.current = openNextEditableCell;
|
||
|
||
// AURORA-1893: переход от плашки «Контроль ошибок» к конкретной ячейке (строка + колонка),
|
||
// а не только к колонке, как раньше. Строим по образцу navigateToSearchMatch ниже.
|
||
//
|
||
// Рулинг: ищем строку в table.getRowModel().flatRows, а не в table.getRowModel().rows —
|
||
// flatRows содержит все строки независимо от текущего состояния свёрнутости групп
|
||
// (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows
|
||
// целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть.
|
||
const handleNavigateToError = useCallback(
|
||
({ rowKey, columnId } = {}) => {
|
||
if (rowKey === undefined || rowKey === null || !columnId) return;
|
||
|
||
// AURORA-1893 (возврат 06.11): строку ищем по тому же ключу, что и ошибку
|
||
// (getValidationRowKey): у агрегатных строк нет line id, раньше переход к ним
|
||
// молча прерывался и выделение оставалось на предыдущей ячейке.
|
||
const row = table.getRowModel().flatRows.find((candidate) => getValidationRowKey(candidate.original) === rowKey);
|
||
if (!row) return;
|
||
|
||
const parentRows = row.getParentRows();
|
||
if (parentRows.length) {
|
||
setExpanded((current) => {
|
||
if (current === true) return true;
|
||
|
||
let changed = false;
|
||
const next = { ...current };
|
||
for (const parentRow of parentRows) {
|
||
if (next[parentRow.id] !== true) {
|
||
next[parentRow.id] = true;
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed ? next : current;
|
||
});
|
||
}
|
||
|
||
// Рулинг: handleToggleColumnVisibility (которую называет тикет) принимает узел
|
||
// дерева колонок, а не columnId — здесь нужнее handleSetColumnsVisibility,
|
||
// у которой сигнатура {columnId: isVisible} и которая уже доступна в этом файле.
|
||
if (columnVisibility[columnId] === false) {
|
||
handleSetColumnsVisibility({ [columnId]: true });
|
||
}
|
||
|
||
setActiveSearchMatch({ rowId: row.id, columnId });
|
||
setRowSelection({ [row.id]: true });
|
||
handleNavigateToColumn(columnId);
|
||
|
||
requestAnimationFrame(() => {
|
||
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === row.id);
|
||
if (rowIndex < 0) return;
|
||
// AURORA-1893 (возврат 06.11): раньше было scrollToIndex(rowIndex + 5, align 'auto') —
|
||
// у последних строк листа запас +5 обрезался, а шапка внутри контейнера прокрутки
|
||
// виртуализатором не учитывается, и строка оставалась за нижним краем. Центрируем
|
||
// строку, а после отрисовки доводим до видимой области саму ячейку.
|
||
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex, { align: 'center' });
|
||
scrollCellIntoViewWhenRendered(containerRef.current, row.id, columnId);
|
||
});
|
||
},
|
||
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn, containerRef],
|
||
);
|
||
|
||
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 handleSearchNext = useCallback(() => navigateToSearchMatch(1), [navigateToSearchMatch]);
|
||
const handleSearchPrevious = useCallback(() => navigateToSearchMatch(-1), [navigateToSearchMatch]);
|
||
|
||
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);
|
||
|
||
// AURORA-1891: Tab/Enter из EditCell.jsx пометили следующую ячейку в
|
||
// pendingEditTargetRef (openNextEditableCell выше) — сервер только
|
||
// что закончил cell_edit_end текущей (editingCell/dataEditingCells
|
||
// стали null), теперь можно открыть следующую, тем же путём, что и
|
||
// обычное открытие по клику/double-click (table.setEditingCell
|
||
// триггерит onEditingCellChange -> contextStartEditing -> cell_edit_start).
|
||
const pendingTarget = pendingEditTargetRef.current;
|
||
if (pendingTarget) {
|
||
pendingEditTargetRef.current = null;
|
||
const animationFrameId = requestAnimationFrame(() => {
|
||
try {
|
||
const row = table.getRow(pendingTarget.rowId);
|
||
const cell = row?.getVisibleCells().find((c) => c.column.id === pendingTarget.columnId);
|
||
if (cell) table.setEditingCell(cell);
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
});
|
||
return () => cancelAnimationFrame(animationFrameId);
|
||
}
|
||
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]);
|
||
|
||
// AURORA-1891: «начальный ввод» (цифра/Backspace/Delete) нужен только для
|
||
// самого первого монтирования EditCell этой ячейки (useState-инициализатор
|
||
// там читает его один раз) — как только редактор открылся, очищаем, чтобы
|
||
// следующее открытие той же ячейки другим способом не подхватило старое
|
||
// значение.
|
||
useEffect(() => {
|
||
if (editingCell && editorInitialInputRef.current !== null) {
|
||
setEditorInitialInput(null);
|
||
}
|
||
}, [editingCell]);
|
||
|
||
const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]);
|
||
|
||
const handleAddRow = useCallback(() => {
|
||
const row = getSelectedRow();
|
||
if (!row) {
|
||
toast.error('Выделите строку для вставки');
|
||
return;
|
||
}
|
||
if (!canAddRowForHeader(row.original.data.header)) {
|
||
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) => {
|
||
contextAddProject({ name: name.trim(), ...getProjectAddTarget(getSelectedRow()?.original) });
|
||
},
|
||
[contextAddProject, getSelectedRow],
|
||
);
|
||
|
||
const handleOpenModalSelectVsp = useCallback(() => {
|
||
modalsRef.current?.open('vsp');
|
||
}, []);
|
||
|
||
const handleOpenModalSelectExpenseItem = useCallback(() => {
|
||
const row = getSelectedRow();
|
||
if (!row) {
|
||
toast.error('Выделите строку для вставки');
|
||
return;
|
||
}
|
||
if (row.original.row_type !== 'ITEM') {
|
||
toast.error('Выделите строку проекта');
|
||
return;
|
||
}
|
||
modalsRef.current?.open('expense');
|
||
}, [getSelectedRow]);
|
||
|
||
const handleDeleteRow = useCallback(() => {
|
||
if (!canModifyRows) {
|
||
toast.error('Удаление строк недоступно: нет доступных для редактирования столбцов');
|
||
return;
|
||
}
|
||
const row = getSelectedRow();
|
||
if (!row) {
|
||
toast.error('Выделите строку для удаления');
|
||
return;
|
||
}
|
||
const target = getDeleteTarget(row.original, { isForm4: formType === 'FORM_4' });
|
||
if (!target) {
|
||
toast.error('Эту строку нельзя удалить');
|
||
return;
|
||
}
|
||
if (target.blockedReason) {
|
||
toast.error(target.blockedReason);
|
||
return;
|
||
}
|
||
setPendingDelete(target);
|
||
}, [canModifyRows, formType, getSelectedRow]);
|
||
|
||
const closeConfirmDeleteRow = useCallback(() => setPendingDelete(null), []);
|
||
|
||
const confirmDeleteRow = useCallback(() => {
|
||
if (!pendingDelete) return;
|
||
if (pendingDelete.kind === 'program') contextDeleteProgram(pendingDelete.id);
|
||
else if (pendingDelete.kind === 'project') contextDeleteProject(pendingDelete.id);
|
||
else contextDeleteRow(pendingDelete.id);
|
||
setPendingDelete(null);
|
||
setRowSelection({});
|
||
}, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]);
|
||
|
||
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;
|
||
}
|
||
modalsRef.current?.open('program');
|
||
}, [canModifyRows]);
|
||
|
||
const addProject = useCallback(() => {
|
||
if (!canModifyRows) {
|
||
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
|
||
return;
|
||
}
|
||
modalsRef.current?.open('project');
|
||
}, [canModifyRows]);
|
||
|
||
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
|
||
budgetYear={budgetYear}
|
||
selectedColumnId={selectedColumnId}
|
||
columnPinning={columnPinning}
|
||
columns={columns}
|
||
onPinColumn={handlePinColumn}
|
||
onUnpinColumn={handleUnpinColumn}
|
||
onToggleColumnVisibility={handleToggleColumnVisibility}
|
||
onSetColumnsVisibility={handleSetColumnsVisibility}
|
||
columnVisibility={columnVisibility}
|
||
columnsCurStage={columnsCurStage}
|
||
onChangeSizeMult={setSizeMult}
|
||
denomination={denomination}
|
||
onChangeDenomination={setTableDenomination}
|
||
onGlobalFilterChange={handleGlobalFilterChange}
|
||
globalFilter={globalFilter}
|
||
onSearchKeyDown={handleSearchKeyDown}
|
||
onSearchNext={handleSearchNext}
|
||
onSearchPrevious={handleSearchPrevious}
|
||
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}
|
||
onNavigateToError={handleNavigateToError}
|
||
headerPaths={headerPaths}
|
||
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
|
||
budgetYear={budgetYear}
|
||
denomination={denomination}
|
||
table={table}
|
||
columns={columns}
|
||
columnPinning={columnPinning}
|
||
columnSizing={columnSizing}
|
||
columnVisibility={columnVisibility}
|
||
columnFilters={table.getState().columnFilters}
|
||
showColumnFilters={showColumnFilters}
|
||
selectedColumnId={selectedColumnId}
|
||
onColumnSelect={handleColumnSelect}
|
||
onChangeWidth={handleSetColumnWidth}
|
||
isLoadingData={isLoadingData}
|
||
stageColumnDeadlines={stageColumnDeadlines}
|
||
userRoleId={userRoleId}
|
||
/>,
|
||
headerPortalRef.current,
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ConfirmDeleteRowModal target={pendingDelete} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
|
||
<SheetModals
|
||
ref={modalsRef}
|
||
formId={formId}
|
||
sheetName={sheetName}
|
||
onAddVsp={handleAddVspRow}
|
||
onAddExpenseItem={handleAddExpenseItemRow}
|
||
onAddProgram={handleAddProgramRow}
|
||
onAddProject={handleAddProjectRow}
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default React.memo(RealtimeTable);
|