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 (
<>