Compare commits

..

2 Commits

Author SHA1 Message Date
e6133b82fe Merge pull request 'Оптимизация' (#46) from table-optimization into test
Reviewed-on: #46
2026-07-24 14:14:46 +03:00
PotapovaA
3c7aafc8bb Оптимизация 2026-07-24 12:58:15 +03:00
4 changed files with 498 additions and 414 deletions

View File

@ -1,41 +1,67 @@
import React, { useEffect, useState } from 'react'; import React, { memo, useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import styled from '@emotion/styled';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useRealtime } from '../../contexts/RealtimeContext';
import { toast } from 'react-toastify';
const ContainerForText = styled.span` // Константы вне компонента
display: -webkit-box; const BASE_CELL_STYLES = {
-webkit-box-orient: vertical; width: '100%',
-webkit-line-clamp: 3; height: '100%',
overflow: hidden; display: 'flex',
white-space: pre-wrap; alignItems: 'center',
`; justifyContent: 'center',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '2px solid transparent',
padding: '8px',
};
// Стилизованный компонент для плашки блокировки const LOCKED_STYLES = {
const LockBadge = styled.div` border: '2px solid #e0e0e0',
position: absolute; backgroundColor: '#f5f5f5',
top: 2px; cursor: 'not-allowed',
right: 2px; opacity: 0.85,
background: rgba(0, 0, 0, 0.7); };
color: white;
font-size: 9px; const LOCK_BADGE_STYLES = {
padding: 1px 4px; position: 'absolute',
border-radius: 3px; top: '2px',
pointer-events: none; right: '2px',
z-index: 10; background: 'rgba(0, 0, 0, 0.7)',
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: 'white',
letter-spacing: 0.3px; fontSize: '9px',
backdrop-filter: blur(4px); padding: '1px 4px',
border: 1px solid rgba(255, 255, 255, 0.1); borderRadius: '3px',
`; pointerEvents: 'none',
zIndex: 10,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
letterSpacing: '0.3px',
backdropFilter: 'blur(4px)',
border: '1px solid rgba(255, 255, 255, 0.1)',
};
const CONTAINER_STYLES = {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 3,
overflow: 'hidden',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
};
const INTEGER_COLUMN_IDS = new Set([
'data.header.num_group',
'data.nomenclature_group_id',
'data.header.item_id',
'data.article_id',
'data.code_razdela',
]);
// Функция для определения яркости цвета
const getColorBrightness = (hexColor) => { const getColorBrightness = (hexColor) => {
if (!hexColor) return 255; if (!hexColor) return 255;
const color = hexColor.replace('#', ''); const color = hexColor.replace('#', '');
let r, g, b; let r, g, b;
if (color.length === 3) { if (color.length === 3) {
r = parseInt(color[0] + color[0], 16); r = parseInt(color[0] + color[0], 16);
@ -48,174 +74,132 @@ const getColorBrightness = (hexColor) => {
} else { } else {
return 255; return 255;
} }
return (0.299 * r + 0.587 * g + 0.114 * b); return (0.299 * r + 0.587 * g + 0.114 * b);
}; };
// Функция для подсветки текста const formatNumber = (value, asInteger = false) => {
const highlightText = (text, searchQueries) => { if (value === null || value === undefined || value === '') return String(value || '');
if (!text) return text; const num = Number(value);
const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== '')); if (isNaN(num)) return String(value);
if (cleanSearchQueries.length == 0) return text; return num.toLocaleString('ru-RU', {
minimumFractionDigits: asInteger ? 0 : 1,
const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); maximumFractionDigits: asInteger ? 0 : 1,
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); useGrouping: true,
const parts = text.split(regex); });
return parts.map((part, index) =>
regex.test(part) ? (
<mark
key={index}
style={{
backgroundColor: '#ffeb3b',
color: '#000',
fontWeight: 'bold',
padding: '0 2px',
borderRadius: '2px',
}}
>
{part}
</mark>
) : (
part
),
);
}; };
const highlightWithFormatting = (originalValue, formattedValue, searchQueries) => { // Оптимизированная функция подсветки
if (!originalValue && originalValue !== 0) return formattedValue; const createHighlightedContent = (originalValue, displayValue, searchQueries) => {
const cleanQueries = searchQueries.filter(Boolean);
if (cleanQueries.length === 0) return displayValue;
const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== '')); const searchStr = String(originalValue ?? displayValue);
if (cleanSearchQueries.length == 0) return formattedValue; const escapedQueries = cleanQueries.map(q => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const originalString = String(originalValue);
const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
if (!regex.test(originalString)) { if (!regex.test(searchStr)) return displayValue;
return formattedValue;
}
return ( const parts = searchStr.split(regex);
<mark return parts.map((part, index) => {
style={{ if (!regex.test(part)) return part;
return React.createElement('mark', {
key: index,
style: {
backgroundColor: '#ffeb3b', backgroundColor: '#ffeb3b',
color: '#000', color: '#000',
fontWeight: 'bold', fontWeight: 'bold',
padding: '0 2px', padding: '0 2px',
borderRadius: '2px', borderRadius: '2px',
}} },
> }, part);
{formattedValue}
</mark>
);
};
const INTEGER_COLUMN_IDS = new Set([
'data.header.num_group',
'data.nomenclature_group_id',
'data.header.item_id',
'data.article_id',
'data.code_razdela',
]);
// Функция для форматирования числа в финансовый формат
const formatNumber = (value, asInteger = false) => {
if (value === null || value === undefined || value === '') return String(value || '');
const num = Number(value);
if (isNaN(num)) return String(value);
if (asInteger) {
return num.toLocaleString('ru-RU', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
useGrouping: true,
});
}
return num.toLocaleString('ru-RU', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
useGrouping: true,
}); });
}; };
const Cell = React.memo(({ cell, row, column, onClick, globalFilter, columnFilter, backgroundColor, color, isEditable }) => { const CellComponent = ({
cell,
row,
column,
onClick,
globalFilter,
columnFilter,
backgroundColor,
color,
isEditable,
isUpdating,
onCellNumberClick,
}) => {
const { lockedCells } = useRealtime(); const { lockedCells } = useRealtime();
const cellKey = `${row.id}_${column.id}`; const cellKey = `${row.id}_${column.id}`;
const isLocked = lockedCells.includes(cellKey); const isLocked = lockedCells.includes(cellKey);
const cellValue = cell.getValue(); // Мемоизация значения
let originalValue = cellValue; const { rawValue, displayValue, isNumeric } = useMemo(() => {
let displayValue = String(cellValue || ''); const value = cell.getValue();
const isNum = !isNaN(Number(value)) && value !== null && value !== undefined && value !== '';
let display = String(value || '');
const isNumeric = !isNaN(Number(cellValue)) && cellValue !== null && cellValue !== undefined && cellValue !== ''; if (isNum) {
if (isNumeric) { const asInteger = INTEGER_COLUMN_IDS.has(column.id);
const asInteger = INTEGER_COLUMN_IDS.has(column.id); display = formatNumber(value, asInteger);
displayValue = formatNumber(cellValue, asInteger); }
}
return { rawValue: value, displayValue: display, isNumeric: isNum };
}, [cell, column.id]);
const highlightedContent = useMemo(() => { const highlightedContent = useMemo(() => {
if (!isNumeric) { const searchQueries = [globalFilter, columnFilter];
return highlightText(displayValue, [globalFilter, columnFilter]); return createHighlightedContent(rawValue, displayValue, searchQueries);
} }, [rawValue, displayValue, globalFilter, columnFilter]);
return highlightWithFormatting(originalValue, displayValue, [globalFilter, columnFilter]);
}, [originalValue, displayValue, globalFilter, columnFilter, isNumeric]);
// Определяем цвет текста на основе яркости фона const textColor = useMemo(() => {
const getTextColor = () => { if (isLocked) return '#999999';
const brightness = getColorBrightness(backgroundColor); const brightness = getColorBrightness(backgroundColor);
// Если яркость меньше 128 (темный фон) - используем белый текст, иначе черный
return brightness < 128 ? '#ffffff' : '#000000'; return brightness < 128 ? '#ffffff' : '#000000';
}; }, [backgroundColor, isLocked]);
// Базовые стили для заблокированной ячейки const cellStyles = useMemo(() => ({
const lockedStyles = isLocked ? { ...BASE_CELL_STYLES,
border: '2px solid #e0e0e0', backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
backgroundColor: '#f5f5f5', color: textColor,
cursor: 'not-allowed', ...(isLocked ? LOCKED_STYLES : {}),
opacity: 0.85, }), [backgroundColor, isLocked, textColor]);
} : {};
const lockBadge = useMemo(() => {
if (!isLocked) return null;
return <div style={LOCK_BADGE_STYLES}>🔒</div>;
}, [isLocked]);
const handleClick = useCallback(() => {
// Определяем финальный цвет текста if (!isLocked && onClick) {
const finalTextColor = isLocked ? '#999999' : getTextColor(); onClick();
}
}, [isLocked, onClick]);
return ( return (
<div <div
className="cell" className="cell"
style={{ style={cellStyles}
width: '100%', onClick={handleClick}
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
border: '2px solid transparent',
padding: '8px',
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: finalTextColor,
...lockedStyles,
}}
onClick={isLocked ? undefined : onClick}
> >
<ContainerForText>{highlightedContent}</ContainerForText> <span style={CONTAINER_STYLES}>{highlightedContent}</span>
{isLocked && ( {lockBadge}
<LockBadge>
🔒
</LockBadge>
)}
</div> </div>
); );
};
const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
// Возвращаем true если пропсы равны (не нужно перерендеривать)
return (
prevProps.cell.getValue() === nextProps.cell.getValue() &&
prevProps.row.id === nextProps.row.id &&
prevProps.column.id === nextProps.column.id &&
prevProps.globalFilter === nextProps.globalFilter &&
prevProps.columnFilter === nextProps.columnFilter &&
prevProps.backgroundColor === nextProps.backgroundColor &&
prevProps.isEditable === nextProps.isEditable &&
prevProps.isUpdating === nextProps.isUpdating &&
prevProps.onCellNumberClick === nextProps.onCellNumberClick
);
}); });
export default Cell; export default Cell;

View File

@ -1,195 +1,228 @@
// EditCell.js - обновленная версия import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react';
import { useEffect, useRef, useLayoutEffect, useState } from 'react';
import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils'; import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils';
import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils'; import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useRealtime } from '../../contexts/RealtimeContext';
const EditCell = ({ refCell, onChange, disabled, value, tableId, cellId, table }) => { const EDITOR_STYLES = {
position: 'absolute',
zIndex: 12,
top: 0,
};
const FORMULA_INDICATOR_STYLES = {
position: 'absolute',
top: '-20px',
left: '4px',
fontSize: '10px',
color: '#666',
background: '#f0f0f0',
padding: '2px 6px',
borderRadius: '3px',
pointerEvents: 'none',
};
const TEXTAREA_STYLES = {
width: '100%',
height: 'auto',
fieldSizing: 'content',
fontSize: 'inherit',
};
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
const { endEditing: contextEndEditing } = useRealtime(); const { endEditing: contextEndEditing } = useRealtime();
const [curValue, setCurValue] = useState(value);
const [displayValue, setDisplayValue] = useState(value);
const [isFormulaMode, setIsFormulaMode] = useState(false);
// Ключ для хранения формул в localStorage
const storageKey = `formula_${tableId}_${cellId}`; const storageKey = `formula_${tableId}_${cellId}`;
const trFrag = refCell.current.offsetParent.offsetParent; // Объединенное состояние
const tdFrag = refCell.current.offsetParent; const [state, setState] = useState(() => ({
const left = tdFrag.offsetLeft; currentValue: value,
const width = refCell.current.offsetParent.offsetWidth; displayValue: value,
const translateY = trFrag.style.transform; isFormulaMode: false,
}));
// Загружаем сохраненную формулу при монтировании // Позиция ячейки
const [position, setPosition] = useState({ left: 0, width: 0, translateY: '' });
const textareaRef = useRef(null);
const refFinished = useRef(false);
// Загрузка сохраненной формулы
useEffect(() => { useEffect(() => {
const savedFormula = loadFromStorage(storageKey); const savedFormula = loadFromStorage(storageKey);
if (savedFormula && isFormula(savedFormula)) { if (savedFormula && isFormula(savedFormula)) {
setIsFormulaMode(true);
setCurValue(savedFormula);
// Вычисляем и отображаем результат
try { try {
const formula = extractFormula(savedFormula); const formula = extractFormula(savedFormula);
const result = calculateFormula(formula); const result = calculateFormula(formula);
setDisplayValue(formatNumber(result)); setState({
} catch (error) { currentValue: savedFormula,
setDisplayValue('#ОШИБКА'); displayValue: formatNumber(result),
isFormulaMode: true,
});
} catch {
setState({
currentValue: savedFormula,
displayValue: '#ОШИБКА',
isFormulaMode: true,
});
} }
} else {
setDisplayValue(value);
} }
}, [storageKey, value]); }, [storageKey]);
function useAutoResize(value) { // Вычисление позиции
const ref = useRef(null); useLayoutEffect(() => {
if (!refCell?.current) return;
useLayoutEffect(() => { const td = refCell.current.offsetParent;
const el = ref.current; const tr = td?.offsetParent;
if (!el) return;
if (td && tr) {
setPosition({
left: td.offsetLeft,
width: td.offsetWidth,
translateY: tr.style.transform || '',
});
}
}, [refCell]);
// Auto-resize
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
const resize = () => {
el.style.height = 'auto'; el.style.height = 'auto';
el.style.minHeight = '3rem'; el.style.minHeight = '3rem';
el.style.height = el.scrollHeight + 'px'; el.style.height = el.scrollHeight + 'px';
el.style.maxHeight = '180px'; el.style.maxHeight = '180px';
}, [value]); };
return ref; resize();
} const observer = new ResizeObserver(resize);
observer.observe(el);
const ref = useAutoResize(value); return () => observer.disconnect();
}, [state.currentValue]);
// Фокус при монтировании
useEffect(() => { useEffect(() => {
if (ref.current && !disabled) { const el = textareaRef.current;
ref.current.focus(); if (el && !disabled) {
ref.current.setSelectionRange( el.focus();
ref.current.value.length, el.setSelectionRange(el.value.length, el.value.length);
ref.current.value.length,
);
} }
}, [disabled, ref]); }, [disabled]);
const handleSave = () => { // Сохранение
// Проверяем, является ли введенное значение формулой const handleSave = useCallback(() => {
if (isFormula(curValue)) { const { currentValue } = state;
// Сохраняем формулу в localStorage
saveToStorage(storageKey, curValue); if (isFormula(currentValue)) {
// Вычисляем и сохраняем результат saveToStorage(storageKey, currentValue);
try { try {
const formula = extractFormula(curValue); const formula = extractFormula(currentValue);
const result = calculateFormula(formula); const result = calculateFormula(formula);
const formattedResult = formatNumber(result); const formattedResult = formatNumber(result);
setDisplayValue(formattedResult); setState(prev => ({
...prev,
displayValue: formattedResult,
isFormulaMode: true,
}));
onChange?.(formattedResult); onChange?.(formattedResult);
setIsFormulaMode(true); } catch {
return; setState(prev => ({
} catch (error) { ...prev,
setDisplayValue('#ОШИБКА'); displayValue: '#ОШИБКА',
onChange?.(curValue); // Сохраняем как текст, если ошибка isFormulaMode: true,
return; }));
onChange?.(currentValue);
} }
} else { } else {
// Если это не формула, удаляем сохраненную формулу
localStorage.removeItem(storageKey); localStorage.removeItem(storageKey);
setIsFormulaMode(false); setState(prev => ({
setDisplayValue(curValue); ...prev,
onChange?.(curValue); displayValue: currentValue,
isFormulaMode: false,
}));
onChange?.(currentValue);
} }
}; }, [state, storageKey, onChange]);
const handleChangeValue = (event) => { // Завершение редактирования
const input = event.target.value; const finishEditing = useCallback(() => {
setCurValue(input); if(refFinished.current) return;
// При редактировании показываем введенный текст refFinished.current = true;
setDisplayValue(input);
};
// Функция для отображения значения в ячейке
const getDisplayValue = () => {
if (isFormulaMode && isFormula(curValue)) {
return displayValue;
}
return displayValue;
};
const finishEditing = () => {
if (!disabled) { if (!disabled) {
handleSave(); handleSave();
} }
const editingCell = table.getState().editingCell || null; const editingCell = table.getState().editingCell;
if (editingCell) { if (editingCell) {
contextEndEditing?.(editingCell.row, editingCell.column); contextEndEditing?.(editingCell.row, editingCell.column);
} }
}; }, [disabled, handleSave, table, contextEndEditing]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
// Проверяем, был ли клик внутри элемента редактирования const el = textareaRef.current;
const editElement = ref.current; if (el && !el.contains(event.target)) {
if (editElement && !editElement.contains(event.target)) {
finishEditing(); finishEditing();
} }
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [finishEditing]);
return () => { // Обработчики событий
document.removeEventListener('mousedown', handleClickOutside); const handleChange = useCallback((e) => {
}; const value = e.target.value;
}, [ref, finishEditing]); setState(prev => ({
...prev,
currentValue: value,
displayValue: value,
}));
}, []);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finishEditing();
}
}, [finishEditing]);
// Мемоизация стилей
const editorStyles = useMemo(() => ({
...EDITOR_STYLES,
left: position.left,
width: position.width,
transform: position.translateY,
}), [position]);
const textareaStyles = useMemo(() => ({
...TEXTAREA_STYLES,
backgroundColor: state.isFormulaMode ? '#f8f9fa' : 'white',
border: state.isFormulaMode ? '2px solid #4a90d9' : undefined,
}), [state.isFormulaMode]);
return ( return (
<tr> <tr>
<td> <td>
<div <div style={editorStyles}>
key="blockForEdit" {state.isFormulaMode && (
style={{ <div style={FORMULA_INDICATOR_STYLES}>fx</div>
position: 'absolute',
left: left,
zIndex: 12,
width: width,
transform: translateY,
top: 0,
}}
>
{isFormulaMode && (
<div style={{
position: 'absolute',
top: '-20px',
left: '4px',
fontSize: '10px',
color: '#666',
background: '#f0f0f0',
padding: '2px 6px',
borderRadius: '3px',
pointerEvents: 'none'
}}>
fx
</div>
)} )}
<textarea <textarea
ref={ref} ref={textareaRef}
disabled={disabled} disabled={disabled}
style={{ style={textareaStyles}
width: '100%', onChange={handleChange}
height: 'auto', value={state.currentValue}
fieldSizing: 'content', onKeyDown={handleKeyDown}
fontSize: 'inherit', placeholder={state.isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
backgroundColor: isFormulaMode ? '#f8f9fa' : 'white',
border: isFormulaMode ? '2px solid #4a90d9' : undefined,
}}
onChange={handleChangeValue}
value={curValue}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finishEditing();
}
}}
placeholder={isFormulaMode ? 'Введите формулу (начинается с =)' : ''}
/> />
</div> </div>
</td> </td>
</tr> </tr>
); );
}; });
EditCell.displayName = 'EditCell';
export default EditCell; export default EditCell;

View File

@ -28,6 +28,7 @@ import { CircularProgress } from '@mui/material';
import { additionVspRowTable } from './constants/addingRowConfig'; import { additionVspRowTable } from './constants/addingRowConfig';
import { SelectVspModal } from './Modals/SelectVspModal'; import { SelectVspModal } from './Modals/SelectVspModal';
import { getRowId } from './utils/rowUtils'; import { getRowId } from './utils/rowUtils';
import { debounce } from '@mui/material';
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => { const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year); const { data, setData, isLoading: isTableLoading, editingCells: dataEditingCells } = useRealtimeData(formId, sheetName, direction, formType, year);
@ -47,11 +48,14 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
return additionVspRowTable[formType].includes(sheetName); return additionVspRowTable[formType].includes(sheetName);
}, [formType, sheetName]); }, [formType, sheetName]);
const handleGlobalFilterChange = (value) => { const handleGlobalFilterChange = useCallback(
startTransition(() => { debounce((value) => {
setGlobalFilter(value); startTransition(() => {
}); setGlobalFilter(value);
}; });
}, 300),
[]
);
const { const {
isConnected, isConnected,
@ -89,17 +93,27 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const [columnsConfig, setColumnsConfig] = useState(null); const [columnsConfig, setColumnsConfig] = useState(null);
const configCache = useRef(new Map());
useEffect(() => { useEffect(() => {
if (!formType || !sheetName) return;
const cacheKey = `${formType}_${sheetName}`;
if (configCache.current.has(cacheKey)) {
setColumnsConfig(configCache.current.get(cacheKey));
return;
}
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`); const { config } = await import(`./constants/${formType}/${sheetName}.js`);
configCache.current.set(cacheKey, config.config);
setColumnsConfig(config.config); setColumnsConfig(config.config);
} catch (error) { } catch (error) {
console.error(`Failed to load config for ${formType}/${sheetName}:`, error); console.error(`Failed to load config for ${formType}/${sheetName}:`, error);
setColumnsConfig({ columns: [], colors: {} }); setColumnsConfig({ columns: [], colors: {} });
} }
}; };
loadConfig(); loadConfig();
}, [formType, sheetName]); }, [formType, sheetName]);
@ -154,7 +168,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
console.error('Error creating columns:', error); console.error('Error creating columns:', error);
return []; return [];
} }
}, [columnsConfig, handleUpdateCell, handleClickRowCell, handleCellUpdateError]); }, [columnsConfig]);
const selectedColumnIdRef = useRef(selectedColumnId); const selectedColumnIdRef = useRef(selectedColumnId);
@ -168,7 +182,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
}), [handleUpdateCell]); }), [handleUpdateCell]);
const ROW_VIRTUALIZER_OPTIONS = { const ROW_VIRTUALIZER_OPTIONS = {
overscan: 30, overscan: 5,
scrollPaddingStart: 0, scrollPaddingStart: 0,
scrollPaddingEnd: 0, scrollPaddingEnd: 0,
estimateSize: () => TABLE_ROW_HEIGHT, estimateSize: () => TABLE_ROW_HEIGHT,
@ -306,43 +320,34 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
useEffect(() => { useEffect(() => {
if (!dataEditingCells || !dataEditingCells.line_id) { if (!dataEditingCells?.line_id) {
setEditingCell(null); setEditingCell(null);
return; return;
} }
if (editingCell) return; if (editingCell) return;
const rowId = dataEditingCells.line_id;
const columnId = dataEditingCells.column; // Отложить поиск до следующего тика
try { requestAnimationFrame(() => {
const row = table.getRow(rowId); try {
const column = table.getColumn(columnId); const row = table.getRow(dataEditingCells.line_id);
const cell = row?.getVisibleCells().find(c => c.column.id === columnId); const cell = row?.getVisibleCells().find(
if (cell) { c => c.column.id === dataEditingCells.column
setEditingCell(cell); );
if (cell) setEditingCell(cell);
} catch (error) {
console.error(error);
} }
} catch (error) { });
console.error(error); }, [dataEditingCells, editingCell, table]);
toast.error('Ошибка редактирования ячейки');
} const [isFirstLoad, setIsFirstLoad] = useState(true);
}, [dataEditingCells, editingCell]);
useEffect(() => { useEffect(() => {
if (table && data && columns.length !== 0) { if (isFirstLoad && table && data && columns.length !== 0) {
setIsLoadingData(true) setIsFirstLoad(false);
setIsLoadingData(true);
} }
}, [data, columns, table]) }, [data, columns, table, isFirstLoad]);
useEffect(() => {
// Принудительно обновляем состояние закрепленных колонок
// если так не сделать при скролле будут пропадать закрепленные колонки
if (columnPinning && Object.keys(columnPinning).length > 0) {
const timer = setTimeout(() => {
setColumnPinning(prev => ({ ...prev }));
}, 100);
return () => clearTimeout(timer);
}
}, []);
const handleAddRow = useCallback(() => { const handleAddRow = useCallback(() => {
const allRows = table.getRowModel().rows; const allRows = table.getRowModel().rows;

View File

@ -1,11 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'; // getTableColumns.js
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { columns as mockColumns } from '../../mocks/mockTable';
import { config } from './constants/FORM_2/AHR';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { useRealtime } from './contexts/RealtimeContext'; import { useRealtime } from './contexts/RealtimeContext';
function EditCellPortal({ // Мемоизированный компонент EditCellPortal
const EditCellPortal = React.memo(({
cell, cell,
table, table,
EditCell, EditCell,
@ -13,89 +13,78 @@ function EditCellPortal({
onSaveEnd, onSaveEnd,
onError, onError,
isEditable isEditable
}) { }) => {
const { formId, formType, sheetName, direction } = useParams(); const { formId, formType, sheetName, direction } = useParams();
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`; const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
const ref = useRef(null); const ref = useRef(null);
const [refTbody, setRefTbody] = useState(false); const [refTbody, setRefTbody] = useState(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const isDisabled = false;
const { endEditing: contextEndEditing } = useRealtime(); const { endEditing: contextEndEditing } = useRealtime();
// Оптимизация: вычисляем позицию только при монтировании и изменении cell
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent; const tbodyRef = ref.current.offsetParent?.offsetParent?.offsetParent;
setRefTbody(tbodyRef); if (tbodyRef && tbodyRef !== refTbody) {
setRefTbody(tbodyRef);
}
} }
}, []); }, [refTbody]); // Добавляем refTbody в зависимости
const handleChange = async (val) => { // Мемоизация обработчика изменения
const handleChange = useCallback( (val) => {
table.setEditingCell(null); table.setEditingCell(null);
try { try {
if (table.options.meta?.updateCell) { if (table.options.meta?.updateCell) {
const success = await table.options.meta.updateCell( const success = table.options.meta.updateCell(
cell.row, cell.row,
cell.column, cell.column,
val val
); );
if (success) { if (!success) {
console.log('Cell updated successfully via WebSocket');
} else {
console.error('Failed to update cell via WebSocket'); console.error('Failed to update cell via WebSocket');
const errorMsg = 'Не удалось сохранить изменение'; onError?.('Не удалось сохранить изменение');
onError?.(errorMsg);
} }
} }
} catch (error) { } catch (error) {
console.error('Error updating cell:', error); console.error('Error updating cell:', error);
onError?.(error.message); onError?.(error.message);
} }
}; }, [table, cell, onError]);
// Мемоизация пропсов для EditCell
const editCellProps = useMemo(() => ({
refCell: ref,
cell,
value: cell.getValue(),
disabled: !isEditable,
onChange: handleChange,
tableId: tableKey,
table,
cellId: `${cell.row.id}_${cell.column.id}`,
}), [cell, isEditable, handleChange, tableKey, table]);
// Мемоизация портала
const portalContent = useMemo(() => {
if (!refTbody || isSaving) return null;
return createPortal(
<EditCell {...editCellProps} />,
refTbody
);
}, [refTbody, isSaving, editCellProps, EditCell]);
return ( return (
<> <>
<div <div ref={ref} style={{ width: '100%', height: '100%' }} />
ref={ref} {portalContent}
style={{
width: '100%',
height: '100%',
}}
/>
{refTbody && !isSaving &&
createPortal(
<EditCell
refCell={ref}
cell={cell}
value={cell.getValue()}
disabled={!isEditable}
onChange={handleChange}
tableId={tableKey}
table={table}
cellId={cell.row.id + '_' + cell.column.id}
/>,
refTbody,
)}
{isSaving && (
createPortal(
<div style={{
position: 'absolute',
background: 'rgba(0,0,0,0.5)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 1000
}}>
Saving...
</div>,
refTbody
)
)}
</> </>
); );
} });
EditCellPortal.displayName = 'EditCellPortal';
// Основная функция getTableColumns
export const getTableColumns = ({ export const getTableColumns = ({
Cell, Cell,
EditCell, EditCell,
@ -104,54 +93,127 @@ export const getTableColumns = ({
onCellNumberClick, onCellNumberClick,
}) => { }) => {
const columnColors = { ...columnsConfig.colors }; const columnColors = { ...columnsConfig.colors };
const columns = [...columnsConfig.columns]; const columns = structuredClone(columnsConfig.columns);
const processColumns = (columns, EditCell, Cell) => { // Кэши для вычисленных значений
const cellPropsCache = new WeakMap();
const editPropsCache = new WeakMap();
// Функция для получения кешированных пропсов Cell
const getCachedCellProps = (row, column, table) => {
const key = `${row.id}_${column.id}`;
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) {
return cached;
}
}
const globalFilter = table.getState().globalFilter;
const columnFilter = table.getState().columnFilters?.find(f => f.id === column.id)?.value;
const props = {
globalFilter,
columnFilter,
isEditable: row.original?.row_type === 'INPUT' || false,
backgroundColor: columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type],
isUpdating: table.options.meta?.updatingCells?.[`${row.id}_${column.id}`],
_hash: `${globalFilter}_${columnFilter}_${row.id}_${column.id}`,
};
rowCache[key] = props;
return props;
};
// Функция для получения кешированных пропсов Edit
const getCachedEditProps = (row, column, table) => {
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: row.original?.row_type === 'INPUT' || false,
};
rowCache[key] = props;
return props;
};
// Оптимизированная функция processColumns
const processColumns = (columns) => {
columns.forEach((col) => { columns.forEach((col) => {
col.Cell = ({ cell, table, column, row }) => ( // Оптимизированный Cell
<Cell col.Cell = ({ cell, table, column, row }) => {
row={row} const props = getCachedCellProps(row, column, table);
column={column}
cell={cell}
globalFilter={
table.getState().globalFilter
}
columnFilter={table.getState().columnFilters?.find(f => f.id === column.id)?.value}
isEditable={row.original?.row_type === 'INPUT' || false}
backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]}
color={columnColors[column.id]?.color_type?.['COLOR']}
isUpdating={table.options.meta?.updatingCells?.[`${row.id}_${column.id}`]}
/>
);
col.Edit = ({ cell, table, row, column }) => ( // Используем useMemo для мемоизации пропсов
<EditCellPortal const cellProps = useMemo(() => ({
cell={cell} row,
table={table} column,
EditCell={EditCell} cell,
onError={onCellUpdateError} ...props,
isEditable={row.original?.row_type === 'INPUT' || false} }), [row.id, column.id, cell.getValue(), props._hash]);
/>
); return <Cell {...cellProps} />;
};
// Оптимизированный Edit
col.Edit = ({ cell, table, row, column }) => {
const props = getCachedEditProps(row, column, table);
// Мемоизируем компонент EditCellPortal
const editComponent = useMemo(() => (
<EditCellPortal
cell={cell}
table={table}
EditCell={EditCell}
onError={onCellUpdateError}
isEditable={props.isEditable}
/>
), [cell, table, EditCell, onCellUpdateError, props.isEditable]);
return editComponent;
};
// Рекурсивно обрабатываем вложенные колонки // Рекурсивно обрабатываем вложенные колонки
if (col.columns?.length) { if (col.columns?.length) {
processColumns(col.columns, EditCell, Cell); processColumns(col.columns);
} }
}); });
}; };
processColumns(columns, EditCell, Cell); processColumns(columns);
// Оптимизированная колонка с номерами строк
const rowNumber = { const rowNumber = {
accessorKey: 'sort_order', accessorKey: 'sort_order',
header: '#', header: '#',
size: 50, size: 50,
enableEditing: false, enableEditing: false,
Cell: ({ cell, row }) => { Cell: ({ cell, row }) => {
return <button onClick={() => { onCellNumberClick(row.id) }}> const handleClick = useCallback(() => {
{cell.getValue() + ' '} onCellNumberClick(row.id);
</button> }, [row.id, onCellNumberClick]);
return <button onClick={handleClick}>{cell.getValue() + ' '}</button>;
}, },
}; };