From 3c7aafc8bb942d5d68ec8a837cae7a9ea5f6fa63 Mon Sep 17 00:00:00 2001 From: PotapovaA Date: Fri, 24 Jul 2026 12:58:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RealtimeTable/Cell/Cell/Cell.jsx | 308 +++++++++--------- .../RealtimeTable/Cell/EditCell/EditCell.jsx | 291 +++++++++-------- .../RealtimeTable/RealtimeTable.jsx | 79 ++--- .../components/RealtimeTable/tableColumns.jsx | 234 ++++++++----- 4 files changed, 498 insertions(+), 414 deletions(-) diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 3392699..b2e84e1 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,41 +1,67 @@ -import React, { useEffect, useState } from 'react'; -import { useMemo } from 'react'; -import styled from '@emotion/styled'; +import React, { memo, useMemo, useCallback } from 'react'; import { useRealtime } from '../../contexts/RealtimeContext'; -import { toast } from 'react-toastify'; -const ContainerForText = styled.span` - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - white-space: pre-wrap; -`; +// Константы вне компонента +const BASE_CELL_STYLES = { + width: '100%', + 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', +}; -// Стилизованный компонент для плашки блокировки -const LockBadge = styled.div` - position: absolute; - top: 2px; - right: 2px; - background: rgba(0, 0, 0, 0.7); - color: white; - font-size: 9px; - padding: 1px 4px; - border-radius: 3px; - pointer-events: none; - z-index: 10; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - letter-spacing: 0.3px; - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.1); -`; +const LOCKED_STYLES = { + border: '2px solid #e0e0e0', + backgroundColor: '#f5f5f5', + cursor: 'not-allowed', + opacity: 0.85, +}; + +const LOCK_BADGE_STYLES = { + position: 'absolute', + top: '2px', + right: '2px', + background: 'rgba(0, 0, 0, 0.7)', + color: 'white', + fontSize: '9px', + padding: '1px 4px', + 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) => { if (!hexColor) return 255; - const color = hexColor.replace('#', ''); - let r, g, b; if (color.length === 3) { r = parseInt(color[0] + color[0], 16); @@ -48,174 +74,132 @@ const getColorBrightness = (hexColor) => { } else { return 255; } - return (0.299 * r + 0.587 * g + 0.114 * b); }; -// Функция для подсветки текста -const highlightText = (text, searchQueries) => { - if (!text) return text; - const cleanSearchQueries = searchQueries.filter(q => (q !== undefined && q !== '')); - if (cleanSearchQueries.length == 0) return text; - - const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); - const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); - const parts = text.split(regex); - - return parts.map((part, index) => - regex.test(part) ? ( - - {part} - - ) : ( - part - ), - ); +const formatNumber = (value, asInteger = false) => { + if (value === null || value === undefined || value === '') return String(value || ''); + const num = Number(value); + if (isNaN(num)) return String(value); + return num.toLocaleString('ru-RU', { + minimumFractionDigits: asInteger ? 0 : 1, + maximumFractionDigits: asInteger ? 0 : 1, + useGrouping: true, + }); }; -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 !== '')); - if (cleanSearchQueries.length == 0) return formattedValue; - - const originalString = String(originalValue); - const escapedQueries = cleanSearchQueries.map(query => query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const searchStr = String(originalValue ?? displayValue); + const escapedQueries = cleanQueries.map(q => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi'); - if (!regex.test(originalString)) { - return formattedValue; - } + if (!regex.test(searchStr)) return displayValue; - return ( - { + if (!regex.test(part)) return part; + return React.createElement('mark', { + key: index, + style: { backgroundColor: '#ffeb3b', color: '#000', fontWeight: 'bold', padding: '0 2px', borderRadius: '2px', - }} - > - {formattedValue} - - ); -}; - -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, + }, + }, part); }); }; -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 cellKey = `${row.id}_${column.id}`; - const isLocked = lockedCells.includes(cellKey); - const cellValue = cell.getValue(); - let originalValue = cellValue; - let displayValue = String(cellValue || ''); + // Мемоизация значения + const { rawValue, displayValue, isNumeric } = useMemo(() => { + 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 (isNumeric) { - const asInteger = INTEGER_COLUMN_IDS.has(column.id); - displayValue = formatNumber(cellValue, asInteger); - } + if (isNum) { + const asInteger = INTEGER_COLUMN_IDS.has(column.id); + display = formatNumber(value, asInteger); + } + + return { rawValue: value, displayValue: display, isNumeric: isNum }; + }, [cell, column.id]); const highlightedContent = useMemo(() => { - if (!isNumeric) { - return highlightText(displayValue, [globalFilter, columnFilter]); - } - return highlightWithFormatting(originalValue, displayValue, [globalFilter, columnFilter]); - }, [originalValue, displayValue, globalFilter, columnFilter, isNumeric]); + const searchQueries = [globalFilter, columnFilter]; + return createHighlightedContent(rawValue, displayValue, searchQueries); + }, [rawValue, displayValue, globalFilter, columnFilter]); - // Определяем цвет текста на основе яркости фона - const getTextColor = () => { + const textColor = useMemo(() => { + if (isLocked) return '#999999'; const brightness = getColorBrightness(backgroundColor); - - // Если яркость меньше 128 (темный фон) - используем белый текст, иначе черный return brightness < 128 ? '#ffffff' : '#000000'; - }; + }, [backgroundColor, isLocked]); - // Базовые стили для заблокированной ячейки - const lockedStyles = isLocked ? { - border: '2px solid #e0e0e0', - backgroundColor: '#f5f5f5', - cursor: 'not-allowed', - opacity: 0.85, - } : {}; + const cellStyles = useMemo(() => ({ + ...BASE_CELL_STYLES, + backgroundColor: isLocked ? '#f5f5f5' : backgroundColor, + color: textColor, + ...(isLocked ? LOCKED_STYLES : {}), + }), [backgroundColor, isLocked, textColor]); + const lockBadge = useMemo(() => { + if (!isLocked) return null; + return
🔒
; + }, [isLocked]); - - // Определяем финальный цвет текста - const finalTextColor = isLocked ? '#999999' : getTextColor(); + const handleClick = useCallback(() => { + if (!isLocked && onClick) { + onClick(); + } + }, [isLocked, onClick]); return (
- {highlightedContent} - {isLocked && ( - - 🔒 - - )} + {highlightedContent} + {lockBadge}
); +}; + +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; \ No newline at end of file diff --git a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx index 0f87650..b9b4554 100644 --- a/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx +++ b/web/src/components/RealtimeTable/Cell/EditCell/EditCell.jsx @@ -1,195 +1,228 @@ -// EditCell.js - обновленная версия -import { useEffect, useRef, useLayoutEffect, useState } from 'react'; +import React, { memo, useEffect, useRef, useLayoutEffect, useState, useCallback, useMemo } from 'react'; import { isFormula, extractFormula, calculateFormula, formatNumber } from '../../utils/formulaUtils'; import { saveToStorage, loadFromStorage } from '../../utils/localStorageUtils'; 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 [curValue, setCurValue] = useState(value); - const [displayValue, setDisplayValue] = useState(value); - const [isFormulaMode, setIsFormulaMode] = useState(false); - - // Ключ для хранения формул в localStorage const storageKey = `formula_${tableId}_${cellId}`; - const trFrag = refCell.current.offsetParent.offsetParent; - const tdFrag = refCell.current.offsetParent; - const left = tdFrag.offsetLeft; - const width = refCell.current.offsetParent.offsetWidth; - const translateY = trFrag.style.transform; + // Объединенное состояние + const [state, setState] = useState(() => ({ + currentValue: value, + displayValue: value, + isFormulaMode: false, + })); - // Загружаем сохраненную формулу при монтировании + // Позиция ячейки + const [position, setPosition] = useState({ left: 0, width: 0, translateY: '' }); + const textareaRef = useRef(null); + + const refFinished = useRef(false); + + // Загрузка сохраненной формулы useEffect(() => { const savedFormula = loadFromStorage(storageKey); if (savedFormula && isFormula(savedFormula)) { - setIsFormulaMode(true); - setCurValue(savedFormula); - // Вычисляем и отображаем результат try { const formula = extractFormula(savedFormula); const result = calculateFormula(formula); - setDisplayValue(formatNumber(result)); - } catch (error) { - setDisplayValue('#ОШИБКА'); + setState({ + currentValue: savedFormula, + 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 el = ref.current; - if (!el) return; + const td = refCell.current.offsetParent; + const tr = td?.offsetParent; + 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.minHeight = '3rem'; el.style.height = el.scrollHeight + 'px'; 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(() => { - if (ref.current && !disabled) { - ref.current.focus(); - ref.current.setSelectionRange( - ref.current.value.length, - ref.current.value.length, - ); + const el = textareaRef.current; + if (el && !disabled) { + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); } - }, [disabled, ref]); + }, [disabled]); - const handleSave = () => { - // Проверяем, является ли введенное значение формулой - if (isFormula(curValue)) { - // Сохраняем формулу в localStorage - saveToStorage(storageKey, curValue); - // Вычисляем и сохраняем результат + // Сохранение + const handleSave = useCallback(() => { + const { currentValue } = state; + + if (isFormula(currentValue)) { + saveToStorage(storageKey, currentValue); try { - const formula = extractFormula(curValue); + const formula = extractFormula(currentValue); const result = calculateFormula(formula); const formattedResult = formatNumber(result); - setDisplayValue(formattedResult); + setState(prev => ({ + ...prev, + displayValue: formattedResult, + isFormulaMode: true, + })); onChange?.(formattedResult); - setIsFormulaMode(true); - return; - } catch (error) { - setDisplayValue('#ОШИБКА'); - onChange?.(curValue); // Сохраняем как текст, если ошибка - return; + } catch { + setState(prev => ({ + ...prev, + displayValue: '#ОШИБКА', + isFormulaMode: true, + })); + onChange?.(currentValue); } } else { - // Если это не формула, удаляем сохраненную формулу localStorage.removeItem(storageKey); - setIsFormulaMode(false); - setDisplayValue(curValue); - onChange?.(curValue); + setState(prev => ({ + ...prev, + displayValue: currentValue, + isFormulaMode: false, + })); + onChange?.(currentValue); } - }; + }, [state, storageKey, onChange]); - const handleChangeValue = (event) => { - const input = event.target.value; - setCurValue(input); - // При редактировании показываем введенный текст - setDisplayValue(input); - }; - - // Функция для отображения значения в ячейке - const getDisplayValue = () => { - if (isFormulaMode && isFormula(curValue)) { - return displayValue; - } - return displayValue; - }; - - const finishEditing = () => { + // Завершение редактирования + const finishEditing = useCallback(() => { + if(refFinished.current) return; + refFinished.current = true; if (!disabled) { handleSave(); } - const editingCell = table.getState().editingCell || null; + const editingCell = table.getState().editingCell; if (editingCell) { contextEndEditing?.(editingCell.row, editingCell.column); } - }; + }, [disabled, handleSave, table, contextEndEditing]); useEffect(() => { const handleClickOutside = (event) => { - // Проверяем, был ли клик внутри элемента редактирования - const editElement = ref.current; - if (editElement && !editElement.contains(event.target)) { + const el = textareaRef.current; + if (el && !el.contains(event.target)) { finishEditing(); } }; - document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [finishEditing]); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [ref, finishEditing]); + // Обработчики событий + const handleChange = useCallback((e) => { + const value = e.target.value; + 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 ( -
- {isFormulaMode && ( -
- fx -
+
+ {state.isFormulaMode && ( +
fx
)}