import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useParams } from 'react-router'; import { useVspOptions } from './contexts/RealtimeContext'; import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils'; import { ROLES_NAME_ID } from '../../constants/constants'; import { blueColumn } from './constants/columnColors'; import { sectionCodeColor } from './constants/columnConfig'; import { isCellEditable } from './utils/cellEditability'; import { getColumnValue } from './utils/columnUtils'; import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell'; import { getForm4TypeLabel, getRowId } from './utils/rowUtils'; const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => { const { formId, formType, sheetName, direction } = useParams(); const tableKey = `${formId}_${formType}_${sheetName}_${direction}`; const ref = useRef(null); const [refTbody, setRefTbody] = useState(null); useEffect(() => { if (ref.current) { const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent; if (tbodyRef && tbodyRef !== refTbody) { setRefTbody(tbodyRef); } } }, []); const handleChange = useCallback( (val) => { table.setEditingCell(null); try { if (table.options.meta?.updateCell) { const success = table.options.meta.updateCell(cell.row, cell.column, val); if (!success) { console.error('Failed to update cell via WebSocket'); onError?.('Не удалось сохранить изменение'); } } } catch (error) { console.error('Error updating cell:', error); onError?.(error.message); } }, [table, cell, onError], ); const editCellProps = useMemo( () => ({ refCell: ref, cell, value: cell.getValue(), disabled: !isEditable, onChange: handleChange, tableId: tableKey, table, // Привязан к line_id из данных строки, а не к её позиции в дереве // (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк). cellId: `${getRowId(cell.row)}_${cell.column.id}`, }), [cell, isEditable, handleChange, tableKey, table], ); const editType = cell.column.columnDef?.editType; const portalContent = useMemo(() => { if (!refTbody) return null; if (editType === 'vsp_dropdown') { return createPortal(, refTbody); } return createPortal(, refTbody); }, [refTbody, editCellProps, EditCell, editType]); return ( <>
{portalContent} ); }); function getColorCell(colors, column, row) { const rowType = row?.original?.row_type || row?.row_type; const sectionCodeValue = row?.original?.data?.section_code ?? row?.original?.data?.header?.section_code; if (colors[column.id]?.color_type?.[rowType]) { return colors[column.id]?.color_type?.[rowType]; } if (sectionCodeValue !== null && sectionCodeValue !== undefined && sectionCodeValue !== '') { const sectionCode = String(sectionCodeValue)[0]; return (sectionCodeColor[sectionCode] || blueColumn)[rowType]; } return 'white'; } const UPPERCASE_MONTH_PATTERN = /ЯНВАРЬ|ФЕВРАЛЬ|МАРТ|АПРЕЛЬ|МАЙ|ИЮНЬ|ИЮЛЬ|АВГУСТ|СЕНТЯБРЬ|ОКТЯБРЬ|НОЯБРЬ|ДЕКАБРЬ/g; const normalizeMonthHeader = (header) => { if (typeof header !== 'string') return header; return header.replace(UPPERCASE_MONTH_PATTERN, (month, offset) => { const normalizedMonth = month.toLowerCase(); if (offset !== 0) return normalizedMonth; return normalizedMonth[0].toUpperCase() + normalizedMonth.slice(1); }); }; const CellWithVspOptions = React.memo(({ CellComponent, cellProps }) => { const vspOptions = useVspOptions(); return ; }); export const getTableColumns = ({ Cell, EditCell, columnsConfig, columnsCurStage, userRoleId, onCellUpdateError, onCellNumberClick, getCellErrors, denomination = 'thousand', sheetName, }) => { const columnColors = { ...columnsConfig.colors }; const columns = structuredClone(columnsConfig.columns); const cellPropsCache = new WeakMap(); const editPropsCache = new WeakMap(); // AURORA-1890: stageColumns ожидается как Set (сигнатура isCellEditable), // columnsCurStage из useRealtimeData — массив; строим Set один раз на // пересборку колонок, а не на каждую ячейку. const stageColumns = new Set(columnsCurStage); // factor === 1 означает «как в базе»: либо колонка не денежная, либо разрядность уже совпадает с базовой. // На листе AHR_LIMIT нет селектора — значения всегда показываются как есть, независимо от сохранённой // разрядности (там она по умолчанию 'thousand', а база листа — 'rub'). const getCellDisplayFactor = (columnDef, moneyUnit = getMoneyUnit(columnDef, sheetName)) => moneyUnit && sheetName !== AHR_LIMIT_SHEET ? getDisplayFactor(moneyUnit, denomination) : 1; // AURORA-1890/1891: принимает либо настоящий MRT column (column.id + // column.columnDef), либо голый конфиг колонки из processColumns (col, // у которого своего column.id ещё нет — только col.accessorKey, а сам col // это и есть будущий columnDef). columnId уходит в isCellEditable // (вычисляемые поля/этап/админ), columnDef — в getCellDisplayFactor // (AURORA-1899: нельзя редактировать ячейку, когда выбранная разрядность // искажает число относительно базовой единицы). const isEditable = (row, column) => { const columnId = column.id ?? column.accessorKey; if ( !isCellEditable({ rowType: row.original?.row_type, columnId, isAdmin: userRoleId === ROLES_NAME_ID.admin, stageColumns, }) ) { return false; } const columnDef = column.columnDef ?? column; const moneyUnit = getMoneyUnit(columnDef, sheetName); // Разрядность, отличная от базовой «тыс. руб.», искажает значение — редактирование запрещено, // чтобы случайно не записать домноженное/разделённое число. if (moneyUnit !== null && getCellDisplayFactor(columnDef, moneyUnit) !== 1) return false; return true; }; const getCachedCellProps = (row, column, table) => { const key = `${row.id}_${column.id}`; const value = row.getValue(column.id); // AURORA-1893: причины ошибки для подсказки на ячейке; isInvalid — просто // «есть хотя бы одна причина», чтобы не держать две параллельные проверки. const cellErrors = getCellErrors?.(column.id, value, row.original) || []; const isInvalid = cellErrors.length > 0; const invalidMessage = cellErrors.join('. '); const isEditableCell = isEditable(row, column); const backgroundColor = getColorCell(columnColors, column, row); const displayFactor = getCellDisplayFactor(column); if (!cellPropsCache.has(row)) { cellPropsCache.set(row, {}); } const rowCache = cellPropsCache.get(row); if (rowCache[key]) { const cached = rowCache[key]; const currentGlobalFilter = table.getState().globalFilter; const currentColumnFilter = table.getState().columnFilters?.find((f) => f.id === column.id)?.value; if ( cached.globalFilter === currentGlobalFilter && cached.columnFilter === currentColumnFilter && cached.value === value && cached.displayFactor === displayFactor && cached.isInvalid === isInvalid && cached.invalidMessage === invalidMessage && cached.backgroundColor === backgroundColor ) { return cached; } } const globalFilter = table.getState().globalFilter; const columnFilter = table.getState().columnFilters?.find((f) => f.id === column.id)?.value; const props = { globalFilter, columnFilter, isEditable: isEditableCell, backgroundColor, isInvalid, invalidMessage, value, displayFactor, _hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`, }; rowCache[key] = props; return props; }; const getCachedEditProps = (row, column) => { const key = `${row.id}_${column.id}`; if (!editPropsCache.has(row)) { editPropsCache.set(row, {}); } const rowCache = editPropsCache.get(row); if (rowCache[key]) { return rowCache[key]; } const props = { isEditable: isEditable(row, column), }; rowCache[key] = props; return props; }; // Оптимизированная функция processColumns const processColumns = (columns) => { for (const col of columns) { col.header = normalizeMonthHeader(col.header); const usesVspOptions = col.editType === 'vsp_dropdown'; // Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit // по строкам сами — enableEditing им не нужен, в отличие от листовых. const isLeafColumn = !col.columns?.length; if (isLeafColumn && !col.accessorFn && col.valueLabel === 'form4NodeType') { // AURORA-1979: подпись «программа»/«проект»; функция назначается здесь, // а не в конфиге — structuredClone конфига не переносит функции. col.id = col.id ?? col.accessorKey; col.accessorFn = (row) => getForm4TypeLabel(row) ?? getColumnValue(row, col.accessorKey); } else if (isLeafColumn && !col.accessorFn && typeof col.accessorKey === 'string' && col.accessorKey.includes('.')) { // MRT предупреждает об undefined для вложенных путей, отсутствующих // у отдельных типов строк. Сохраняем исходный id для настроек и прав. col.id = col.id ?? col.accessorKey; col.accessorFn = (row) => getColumnValue(row, col.accessorKey); } col.Cell = ({ cell, table, column, row }) => { const props = getCachedCellProps(row, column, table); const cellProps = useMemo( () => ({ row, column, cell, ...props, }), [row, column, cell, props], ); return usesVspOptions ? : ; }; col.Edit = ({ cell, table, row, column }) => { const props = getCachedEditProps(row, column); const editComponent = useMemo( () => , [cell, table, EditCell, onCellUpdateError, props.isEditable], ); return editComponent; }; // AURORA-1891: раньше enableEditing выставлялся только для editType // 'vsp_dropdown' (частный случай) — для остальных листовых колонок MRT // считал строку INPUT редактируемой всегда, даже вычисляемые поля и // колонки вне текущего этапа; от этого двойной клик открывал редактор // (пусть и disabled) и сервер всё равно брал блокировку (🔒 у коллег). // Теперь единая проверка isCellEditable для каждой листовой колонки — // MRT не откроет редактор ни по двойному клику, ни программно. if (isLeafColumn) { col.enableEditing = (row) => isEditable(row, col); } if (col.columns?.length) { processColumns(col.columns); } } }; processColumns(columns); const rowNumber = { accessorKey: 'sort_order', header: '№', size: 50, enableEditing: false, Cell: ({ cell, row }) => { const handleClick = useCallback( (event) => { event.stopPropagation(); onCellNumberClick(row.id); }, [row.id, onCellNumberClick], ); return ; }, }; return [rowNumber, ...columns]; };