Andrey Epifancev 455393cc76 Merge origin/test в feat/264-aurora-1895-formula-localstorage
Конфликты (EditCell.jsx, RealtimeTable.jsx, formulaUtils.test.js):
- EditCell: сохранены правки 1891 (editorInitialInput, Esc/Tab/Enter,
  возврат фокуса) и 1892 (warnIfNegativeNotAllowed); поверх них
  повторена логика 1895: формула из localStorage подставляется только
  если совпадает с текущим значением, иначе stale и удаляется.
- Сохранённая формула не подставляется, если редактор открыт набором
  символа (getEditorInitialInput) и не затирает набранный текст при
  изменении value другим пользователем (разрешение один раз при открытии).
- Tab/Enter помечают следующую ячейку (openNextEditableCell) только после
  фактического закрытия редактора: при невычисляемой формуле он остаётся
  открытым и не оставляет висящую цель перехода.
- formulaUtils.js: единственный isSameCellValue из test (с нормализацией
  разделителей); дубль из ветки удалён.
- formulaUtils.test.js: тесты обеих сторон.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 10:24:55 +04:00

327 lines
12 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 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 { 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(<VspDropdownEditCell {...editCellProps} />, refTbody);
}
return createPortal(<EditCell {...editCellProps} />, refTbody);
}, [refTbody, editCellProps, EditCell, editType]);
return (
<>
<div ref={ref} style={{ width: '100%', height: '100%' }} />
{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 <CellComponent {...cellProps} vspOptions={vspOptions} />;
});
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 && 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 ? <CellWithVspOptions CellComponent={Cell} cellProps={cellProps} /> : <Cell {...cellProps} />;
};
col.Edit = ({ cell, table, row, column }) => {
const props = getCachedEditProps(row, column);
const editComponent = useMemo(
() => <EditCellPortal cell={cell} table={table} EditCell={EditCell} onError={onCellUpdateError} isEditable={props.isEditable} />,
[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 <button type='button' onClick={handleClick}>{`${cell.getValue()} `}</button>;
},
};
return [rowNumber, ...columns];
};