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 ( <>
{isTableLoading && (
)} {headerPortalRef.current && createPortal( , headerPortalRef.current, )}
); }; export default React.memo(RealtimeTable);