Andrey Epifancev 733d7a9036 fix(denomination): база разрядности — тыс. руб., масштабировать только денежные колонки
Селектор разрядности (AURORA-1650) считал базой рубли, хотя в базе суммы
хранятся в тыс. руб. (формы 1/4, сателлиты формы 2, лимитные строки АХР
после AURORA-1897). По умолчанию стояло «в руб.», и тысячи показывались
под видом рублей; при выборе «в тыс. руб.» значения делились ещё раз.
Делитель также применялся к количествам, площади, ставке НДС, курсу,
кодам и ID — не только к деньгам. Редактор ячейки не учитывал разрядность.

- web/src/components/RealtimeTable/utils/moneyUtils.js — новый модуль:
  UNIT_IN_RUB, getDisplayFactor(baseUnit, denomination), getMoneyUnit
  (денежная единица колонки по accessorKey/sheetName, с явным
  columnDef.moneyUnit как переопределением), TEXT_COLUMN_IDS (перенесён
  из Cell.jsx), resolveDenomination/DEFAULT_DENOMINATION, AHR_LIMIT_SHEET.
  Модуль не импортирует конфиги форм — работает под node --test.
- useDenomination.js — значение по умолчанию 'thousand', ключ хранения
  со суффиксом _denomination_v2 (старые 'rub'/'thousand' больше не
  читаются со старым смыслом); getDenominationDivisor убран.
- tableColumns.jsx/Cell.jsx — displayDivisor заменён на displayFactor
  (умножение, не деление), множитель считается на колонку через
  getMoneyUnit/getDisplayFactor с учётом sheetName; isEditable запрещает
  редактирование денежной ячейки, если разрядность не базовая; на листе
  AHR_LIMIT множитель всегда 1 (значения в рублях, как есть).
- SettingPanel.jsx — селектор скрыт на листе AHR_LIMIT, рядом с ним
  подсказка «Ввод сумм — в тыс. руб.».
- SvodPage/Svod2Page/ProjectSummariesPage — масштабирование по
  meta.money вместо regex по id/блок-листа текстовых id; построители
  колонок проставляют meta.money на денежных метриках.
- DenominationSelect.jsx — «в млн руб.», «в млрд руб.» без точки.

Тесты: web/src/components/RealtimeTable/utils/moneyUtils.test.js
(node --test), полный набор web/src/components/RealtimeTable/utils —
52/52 зелёных. npm run build проходит.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-01 16:08:20 +04:00

851 lines
27 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 { CreateProgramModal } from './Modals/CreateProgramProjectModal';
import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
import { SelectVspModal } from './Modals/SelectVspModal';
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, TABLE_CONTAINER_SX, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
import { getActiveCellSx } from './utils/activeCellCss';
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
import { useColumnSettings } from './hooks/useColumnSettings';
import { useHeaderPortal } from './hooks/useHeaderPortal';
import { useTableScale } from './hooks/useTableScale';
import { useBudgetYear } from './hooks/useBudgetYear';
import { useValidationRules } from './hooks/useValidationRules';
import { getRowId } from './utils/rowUtils';
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
import { DictVspApi } from '../../api/dict-vsp';
import { useAuth } from '../../app/context/AuthProvider';
import { ROLES_NAME_ID } from '../../constants/constants';
import { FormsNoCanAddRow } from './constants/formConfig';
import useDenomination from '../common/useDenomination';
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,
};
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,
isLoading: isTableLoading,
editingCells: dataEditingCells,
} = useRealtimeData(formId, sheetName, direction, formType, year);
const [globalFilter, setGlobalFilter] = useState('');
const [searchMatchIndex, setSearchMatchIndex] = useState(-1);
const [activeSearchMatch, setActiveSearchMatch] = useState(null);
const [pendingSearchDirection, setPendingSearchDirection] = useState(0);
const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumnId, setSelectedColumnId] = useState();
const [rowSelection, setRowSelection] = useState({});
const rowSelectionRef = useRef(rowSelection);
rowSelectionRef.current = rowSelection;
const [, startTransition] = useTransition();
const [editingCell, setEditingCell] = useState(null);
const [columnsConfig, setColumnsConfig] = useState(null);
const { errors: validationErrors, isCellInvalid } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
const [isOpenModalSelectVsp, setIsOpenModalSelectVsp] = useState(false);
const [isOpenModalSelectExpenseItem, setIsOpenModalSelectExpenseItem] = useState(false);
const [createModalType, setCreateModalType] = useState(null);
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
const canModifyRows = userRoleId === ROLES_NAME_ID.admin || columnsCurStage.length > 0;
const isVspAdditionRow = useMemo(() => {
if (!formType || !sheetName) return false;
if (!additionVspRowTable[formType]) return false;
return additionVspRowTable[formType].includes(sheetName);
}, [formType, sheetName]);
const isAdditionExpenseItem = useMemo(() => {
if (!formType || !sheetName) return false;
if (!additionExpenseRowTable[formType]) return false;
return additionExpenseRowTable[formType].includes(sheetName);
}, [formType, sheetName]);
const handleGlobalFilterChange = useMemo(
() =>
debounce((valueOrUpdater) => {
startTransition(() => {
setGlobalFilter((currentValue) => (typeof valueOrUpdater === 'function' ? valueOrUpdater(currentValue) : valueOrUpdater));
});
}, 300),
[startTransition],
);
useEffect(() => () => handleGlobalFilterChange.clear(), [handleGlobalFilterChange]);
const {
updateCell: contextUpdateCell,
addRow: contextAddRow,
deleteRow: contextDeleteRow,
startEditing: contextStartEditing,
addProgram: contextAddProgram,
addProject: contextAddProject,
setVspOptions,
} = useRealtimeActions();
const {
columnSizing,
columnPinning,
columnVisibility,
setColumnSizing,
setColumnPinning,
handlePinColumn,
handleUnpinColumn,
handleSetColumnWidth,
handleToggleColumnVisibility,
handleSetColumnsVisibility,
} = useColumnSettings(`${formId}_${formType}_${sheetName}_${direction}`);
const { headerPortalRef, containerRef } = useHeaderPortal();
const rowVirtualizerRef = useRef(null);
const columnVirtualizerRef = useRef(null);
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`;
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
useEffect(() => {
rowVirtualizerRef.current?.measure?.();
columnVirtualizerRef.current?.measure?.();
}, [sizeMult]);
useEffect(() => {
if (!formType || !sheetName) return;
let isCancelled = false;
const cacheKey = `${formType}_${sheetName}`;
if (CONFIG_CACHE.has(cacheKey)) {
setColumnsConfig(CONFIG_CACHE.get(cacheKey));
return;
}
const loadConfig = async () => {
try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`);
CONFIG_CACHE.set(cacheKey, config.config);
if (!isCancelled) setColumnsConfig(config.config);
} catch (error) {
console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
if (!isCancelled) setColumnsConfig({ columns: [], colors: {} });
}
};
loadConfig();
return () => {
isCancelled = true;
};
}, [formType, sheetName]);
useEffect(() => {
if (!formId || !columnsConfig?.columns) return;
if (!hasVspDropdown(columnsConfig.columns)) return;
let isCancelled = false;
DictVspApi.getDropdownVsp({ form_id: formId }).then((data) => {
if (!isCancelled && data.success) {
setVspOptions(data.result);
}
});
return () => {
isCancelled = true;
};
}, [formId, columnsConfig, setVspOptions]);
const handleUpdateCell = useCallback(
(row, column, value) => {
return contextUpdateCell(row, column, value);
},
[contextUpdateCell],
);
const handleClickRowCell = useCallback((rowId) => {
setRowSelection((prev) => (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,
isCellInvalid,
denomination,
sheetName,
});
} catch (error) {
console.error('Error creating columns:', error);
return [];
}
}, [columnsConfig, columnsCurStage, userRoleId, handleCellUpdateError, handleClickRowCell, handleUpdateCell, isCellInvalid, 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;
const tableMeta = useMemo(
() => ({
updateCell: handleUpdateCell,
getSelectedColumnId: () => selectedColumnIdRef.current,
}),
[handleUpdateCell],
);
const vspRegistrationById = useMemo(
() => new Map(vspOptions.map((vsp) => [Number(vsp.id), normalizeFilterValue(vsp.registration_number)])),
[vspOptions],
);
const globalFilterFn = useCallback(
(row, columnId, filterValue) => {
const query = normalizeFilterValue(filterValue);
if (!query) return true;
const rawValue = row.getValue(columnId);
const column = row.getAllCells().find((cell) => cell.column.id === columnId)?.column;
return doesCellMatchFilter(rawValue, column?.columnDef, query, vspRegistrationById);
},
[vspRegistrationById],
);
const createTableConfig = ({
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
expanded,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
setExpanded,
editingCell,
contextStartEditing,
globalFilterFn,
activeSearchMatch,
}) => ({
...BASE_TABLE_CONFIG,
globalFilterFn,
columns,
data,
enableRowVirtualization: true,
enableColumnVirtualization: true,
rowVirtualizerInstanceRef: rowVirtualizerRef,
columnVirtualizerInstanceRef: columnVirtualizerRef,
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
onEditingCellChange: (cell) => {
if (cell && !editingCell) {
contextStartEditing?.(cell.row, cell.column);
}
},
columnVirtualizerOptions: getColumnVirtualizerOptions,
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
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: ({ column }) => ({ 'data-col-id': column.id }),
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,
}),
[
columns,
data,
columnSizing,
columnPinning,
columnVisibility,
globalFilter,
showColumnFilters,
rowSelection,
containerRef,
tableMeta,
setColumnSizing,
setColumnPinning,
editingCell,
expanded,
globalFilterFn,
activeSearchMatch,
],
);
const table = useMaterialReactTable(tableConfig);
const isLoadingData = columns.length > 0;
const handleNavigateToColumn = useCallback(
(columnId) => {
if (!columnId) return;
setSelectedColumnId(columnId);
const pinnedIds = new Set([...(columnPinning?.left || []), ...(columnPinning?.right || [])]);
if (pinnedIds.has(columnId)) return;
const visibleColumns = 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],
);
const searchMatches = useMemo(() => {
const query = normalizeFilterValue(globalFilter);
if (!query) return [];
const matches = [];
for (const [rowIndex, row] of table.getRowModel().rows.entries()) {
for (const cell of row.getVisibleCells()) {
if (doesCellMatchFilter(cell.getValue(), cell.column.columnDef, query, vspRegistrationById)) {
matches.push({ rowId: row.id, rowIndex, columnId: cell.column.id });
}
}
}
return matches;
}, [data, globalFilter, table, vspRegistrationById, columnVisibility]);
const navigateToSearchMatch = useCallback(
(direction) => {
if (!searchMatches.length) return;
setSearchMatchIndex((currentIndex) => {
const startIndex = currentIndex < 0 ? (direction > 0 ? -1 : 0) : currentIndex;
const nextIndex = (startIndex + direction + searchMatches.length) % searchMatches.length;
const match = searchMatches[nextIndex];
setActiveSearchMatch(match);
setRowSelection({ [match.rowId]: true });
console.log(match.columnId);
handleNavigateToColumn(match.columnId);
requestAnimationFrame(() => {
rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' });
});
return nextIndex;
});
},
[handleNavigateToColumn, searchMatches],
);
const handleSearchKeyDown = useCallback(
(event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
const direction = event.shiftKey ? -1 : 1;
const inputValue = event.target.value;
if (normalizeFilterValue(inputValue) !== normalizeFilterValue(globalFilter)) {
handleGlobalFilterChange.clear();
setGlobalFilter(inputValue);
setPendingSearchDirection(direction);
return;
}
navigateToSearchMatch(direction);
},
[globalFilter, handleGlobalFilterChange, navigateToSearchMatch],
);
useEffect(() => {
setSearchMatchIndex(-1);
setActiveSearchMatch(null);
}, [globalFilter]);
useEffect(() => {
if (!pendingSearchDirection) return;
navigateToSearchMatch(pendingSearchDirection);
setPendingSearchDirection(0);
}, [navigateToSearchMatch, pendingSearchDirection]);
useEffect(() => {
if (!dataEditingCells?.line_id) {
setEditingCell(null);
return;
}
if (editingCell) return;
const animationFrameId = requestAnimationFrame(() => {
try {
const row = table.getRow(dataEditingCells.line_id);
const cell = row?.getVisibleCells().find((c) => c.column.id === dataEditingCells.column);
if (cell) setEditingCell(cell);
} catch (error) {
console.error(error);
}
});
return () => cancelAnimationFrame(animationFrameId);
}, [dataEditingCells, editingCell, table]);
const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]);
const handleAddRow = useCallback(() => {
const row = getSelectedRow();
if (!row) {
toast.error('Выделите строку для вставки');
return;
}
contextAddRow({ expense_item_id: row.original.data.header.expense_item_id });
}, [contextAddRow, getSelectedRow]);
const handleAddVspRow = useCallback(
(vsp_id) => {
contextAddRow({ vsp_id });
},
[contextAddRow],
);
const handleAddExpenseItemRow = useCallback(
(expense_item) => {
const row = getSelectedRow();
if (!row) return;
contextAddRow({ expense_item_id: expense_item.id, project_id: row.original.data.header.project_id });
},
[contextAddRow, getSelectedRow],
);
const handleAddProgramRow = useCallback(
(name) => {
contextAddProgram({ name });
},
[contextAddProgram],
);
const handleAddProjectRow = useCallback(
(name) => {
const row = getSelectedRow();
if (!row) return;
contextAddProject({ name, program_id: row.original.data.header.program_id });
},
[contextAddProject, getSelectedRow],
);
const handleOpenModalSelectVsp = useCallback(() => {
setIsOpenModalSelectVsp(true);
}, []);
const handleOpenModalSelectExpenseItem = useCallback(() => {
const row = getSelectedRow();
if (!row) {
toast.error('Выделите строку для вставки');
return;
}
if (row.original.row_type !== 'ITEM') {
toast.error('Выделите строку проекта');
return;
}
setIsOpenModalSelectExpenseItem(true);
}, [getSelectedRow]);
const handleDeleteRow = useCallback(() => {
if (!canModifyRows) {
toast.error('Удаление строк недоступно: нет доступных для редактирования столбцов');
return;
}
const row = getSelectedRow();
if (!row) {
toast.error('Выделите строку для удаления');
return;
}
contextDeleteRow(getRowId(row));
setRowSelection({});
}, [canModifyRows, contextDeleteRow, getSelectedRow]);
const addRow = useCallback(() => {
if (!canModifyRows) {
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
return;
}
if (isVspAdditionRow) {
return handleOpenModalSelectVsp();
}
if (isAdditionExpenseItem) {
return handleOpenModalSelectExpenseItem();
}
return handleAddRow();
}, [canModifyRows, isVspAdditionRow, isAdditionExpenseItem, handleOpenModalSelectVsp, handleAddRow, handleOpenModalSelectExpenseItem]);
const addProgram = useCallback(() => {
if (!canModifyRows) {
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
return;
}
setCreateModalType('program');
}, [canModifyRows]);
const addProject = useCallback(() => {
if (!canModifyRows) {
toast.error('Добавление строк недоступно: нет доступных для редактирования столбцов');
return;
}
const row = getSelectedRow();
if (!row) {
toast.error('Выделите строку для вставки');
return;
}
if (row.original.row_type !== 'GROUP') {
toast.error('Выделите строку программы');
return;
}
setCreateModalType('project');
}, [canModifyRows, getSelectedRow]);
const closeSelectVspModal = useCallback(() => setIsOpenModalSelectVsp(false), []);
const closeSelectExpenseItemModal = useCallback(() => setIsOpenModalSelectExpenseItem(false), []);
const closeCreateModal = useCallback(() => setCreateModalType(null), []);
useEffect(() => {
return () => {
rowVirtualizerRef.current = null;
columnVirtualizerRef.current = null;
};
}, []);
const handleToggleDepthVisibility = useCallback(() => {
const expandableRows = table.getCoreRowModel().flatRows.filter((row) => row.getCanExpand());
if (!expandableRows.length) return;
const expandedByRowId = Object.fromEntries(expandableRows.map((row) => [row.id, expanded === true || expanded[row.id] === true]));
const rowsForCurrentAction = expandableRows.filter((row) =>
expenseItemsVisibilityAction === 'hide' ? expandedByRowId[row.id] : !expandedByRowId[row.id],
);
// Состояние могло измениться через стандартные контролы таблицы.
// В таком случае сразу продолжаем в доступном направлении.
let action = expenseItemsVisibilityAction;
if (!rowsForCurrentAction.length) action = expenseItemsVisibilityAction === 'hide' ? 'show' : 'hide';
const rowsForAction = rowsForCurrentAction.length
? rowsForCurrentAction
: expandableRows.filter((row) => (action === 'hide' ? expandedByRowId[row.id] : !expandedByRowId[row.id]));
if (!rowsForAction.length) return;
const targetDepth =
action === 'hide' ? Math.max(...rowsForAction.map((row) => row.depth)) : Math.min(...rowsForAction.map((row) => row.depth));
for (const row of expandableRows) {
if (row.depth === targetDepth) expandedByRowId[row.id] = action === 'show';
}
const hasExpandedRows = expandableRows.some((row) => expandedByRowId[row.id]);
const hasCollapsedRows = expandableRows.some((row) => !expandedByRowId[row.id]);
let nextAction = action;
if (hasExpandedRows && !hasCollapsedRows) nextAction = 'hide';
if (hasCollapsedRows && !hasExpandedRows) nextAction = 'show';
setExpenseItemsVisibilityAction(nextAction);
setExpanded(hasCollapsedRows ? Object.fromEntries(Object.entries(expandedByRowId).filter(([, isExpanded]) => isExpanded)) : true);
}, [expanded, expenseItemsVisibilityAction, table]);
return (
<>
<SettingsPanel
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={() => navigateToSearchMatch(1)}
onSearchPrevious={() => navigateToSearchMatch(-1)}
searchMatchCount={searchMatches.length}
searchMatchIndex={searchMatchIndex}
sizeMult={sizeMult}
onChangeShowColumnFilters={setShowColumnFilters}
showColumnFilters={showColumnFilters}
onAddRow={addRow}
onAddProgram={addProgram}
onAddProject={addProject}
onDeleteRow={handleDeleteRow}
formId={formId}
sheetName={sheetName}
direction={direction}
year={year}
isFormNoCanAddRow={isFormNoCanAddRow}
canModifyRows={canModifyRows}
formType={formType}
validationErrors={validationErrors}
onNavigateToColumn={handleNavigateToColumn}
expenseItemsVisibilityAction={expenseItemsVisibilityAction}
onToggleDepthVisibility={handleToggleDepthVisibility}
/>
<div style={tableWrapperStyle}>
<div style={tableScaleStyle}>
<div style={tableContentStyle}>
<MaterialReactTable table={table} />
<ColumnSelectionOverlay
selectedColumnId={selectedColumnId}
containerRef={containerRef}
scale={sizeMult}
columnSizing={columnSizing}
columnPinning={columnPinning}
showColumnFilters={showColumnFilters}
/>
{isTableLoading && (
<div style={loadingOverlayStyle}>
<CircularProgress />
</div>
)}
{headerPortalRef.current &&
createPortal(
<TableHead
budgetYear={budgetYear}
table={table}
columns={columns}
columnPinning={columnPinning}
columnSizing={columnSizing}
columnVisibility={columnVisibility}
columnFilters={table.getState().columnFilters}
showColumnFilters={showColumnFilters}
selectedColumnId={selectedColumnId}
onColumnSelect={handleColumnSelect}
onChangeWidth={handleSetColumnWidth}
isLoadingData={isLoadingData}
/>,
headerPortalRef.current,
)}
</div>
</div>
</div>
<SelectVspModal
isOpen={isOpenModalSelectVsp}
onClose={closeSelectVspModal}
formId={formId}
onSelect={handleAddVspRow}
title='Выбор ВСП'
/>
<SelectExpenseItemModal
isOpen={isOpenModalSelectExpenseItem}
onClose={closeSelectExpenseItemModal}
formId={formId}
onSelect={handleAddExpenseItemRow}
title='Добавление строки'
sheet={sheetName}
/>
<CreateProgramModal
isOpen={createModalType !== null}
onClose={closeCreateModal}
title={createModalType === 'program' ? 'Создание программы' : 'Создание проекта'}
onCreate={createModalType === 'program' ? handleAddProgramRow : handleAddProjectRow}
/>
</>
);
};
export default React.memo(RealtimeTable);