Andrey Epifancev 374ac4bd9e AURORA-1984: добавление проекта в разделе на фронте
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:29:42 +04:00

1163 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { MaterialReactTable, useMaterialReactTable } from 'material-react-table';
import React, { useState, useMemo, useRef, useEffect, useCallback, useTransition } from 'react';
import { createPortal } from 'react-dom';
import { ColumnSelectionOverlay } from './ColumnSelectionOverlay/ColumnSelectionOverlay';
import SettingsPanel from './SettingPanel/SettingPanel';
import { TableHead } from './TableHead/TableHead';
import useRealtimeData from './hooks/useRealtimeData';
import { Cell, EditCell } from './index';
import { getTableColumns } from './tableColumns';
import { CircularProgress } from '@mui/material';
import { debounce } from '@mui/material';
import { toast } from 'react-toastify';
import { 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);