import { useFileData, useLatestFileForTemplate } from '@/hook/useFileQueries' import { useMultiSheetEngine } from '@/hook/useMultiSheetEngine' import { useSheetAutoSave } from '@/hook/useSheetAutoSave' import { produce } from 'immer' import React, { FC, useCallback, useEffect, useMemo, useRef, useState, } from 'react' // Импорты из новых модулей import { FormulaBar, Spreadsheet } from './components' import { useDraggableDivider } from './hooks/useDraggableDivider' import { CachedCellData, CellSelection, COL_COUNT, DualSpreadsheetProps, HighlightedCell, ROW_COUNT, SHEET_NAMES, SHEET_TYPES, SheetType, } from './types' import { convertReferencesToHighlights, createCellAddress, createQualifiedCellReference, createSelectionFromRange, extractCellReferences, getAllCellsInSelections, getColumnLabel, } from './utils' const DualSpreadsheet: FC = ({ templateData = {}, mergedCells = [], templateId, onEngineReady, enableAutoSave = true, }) => { // Мемоизируем конфигурацию листов const sheetsConfig = useMemo( () => [ { name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT }, { name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT }, ], [] ) const { revision, updateRevision, setTemplateData, ...multiSheetEngine } = useMultiSheetEngine({ sheets: sheetsConfig, initialData: templateData || {}, }) // Мемоизируем объект для передачи родительскому компоненту const engineAPI = useMemo( () => ({ ...multiSheetEngine, updateRevision, }), [multiSheetEngine, updateRevision] ) // Передаем методы движка родительскому компоненту useEffect(() => { if (onEngineReady) { onEngineReady(engineAPI) } }, [onEngineReady, engineAPI]) const [activeSheet, setActiveSheet] = useState('report') const [selectedCell, setSelectedCell] = useState<{ row: number col: number } | null>(null) // Флаг, что пользователь изменил хотя бы одну ячейку const [userEdited, setUserEdited] = useState(false) // Состояние для множественного выделения const [multiSelection, setMultiSelection] = useState([]) const [isDragging, setIsDragging] = useState(false) const [dragStart, setDragStart] = useState<{ row: number col: number } | null>(null) const [isEditing, setIsEditing] = useState(false) const [editingValue, setEditingValue] = useState('') // Локальное значение во время редактирования // Источник ввода: 'cell' (редактирование внутри ячейки) или 'formula' const [editingSource, setEditingSource] = useState<'cell' | 'formula'>('cell') const [isInitialized, setIsInitialized] = useState(false) const [baseFileData, setBaseFileData] = useState< Record> >({}) // Базовые данные файла const [highlightedCells, setHighlightedCells] = useState( [] ) // React Query хуки для оптимизации API запросов const { data: latestFile, isLoading: isLoadingLatestFile } = useLatestFileForTemplate(templateId) const { data: fileData, isLoading: isLoadingFileData } = useFileData( latestFile?.id ) // Мемоизируем начальное состояние ширин колонок const initialColumnWidths = useMemo( () => ({ report: Array(COL_COUNT).fill(96), calculations: Array(COL_COUNT).fill(96), }), [] ) const [columnWidths, setColumnWidths] = useState>(initialColumnWidths) const gridRefs = useRef>({ report: null, calculations: null, }) const containerRefs = useRef>({ report: null, calculations: null, }) const headerRefs = useRef>({ report: null, calculations: null, }) const rowHeaderRefs = useRef>({ report: null, calculations: null, }) const activeCellInputRef = useRef(null) const formulaBarRef = useRef(null) const initialTemplateValues = useRef>({}) const cellDataCache = useRef>>( {} ) const lastRevision = useRef(-1) // Мемоизируем обработчик обновления начальных значений const updateInitialValues = useCallback(() => { // Обновляем начальные значения только если есть данные const hasBaseFileData = Object.keys(baseFileData).length > 0 const hasTemplateData = Object.keys(templateData).length > 0 if (hasBaseFileData) { Object.entries(baseFileData).forEach(([sheetName, sheetData]) => { Object.entries(sheetData).forEach(([cellAddress, value]) => { initialTemplateValues.current[cellAddress] = String(value || '') }) }) } else if (hasTemplateData) { Object.entries(templateData).forEach(([sheetName, sheetData]) => { Object.entries(sheetData).forEach(([cellAddress, value]) => { initialTemplateValues.current[cellAddress] = String(value || '') }) }) } }, [baseFileData, templateData]) useEffect(() => { updateInitialValues() }, [updateInitialValues]) // Мемоизируем обработчик скролла к выбранной ячейке const scrollToSelectedCell = useCallback(() => { if (selectedCell && activeSheet) { const grid = gridRefs.current[activeSheet] grid?.scrollToItem({ rowIndex: selectedCell.row, columnIndex: selectedCell.col, align: 'auto', }) } }, [selectedCell, activeSheet]) useEffect(() => { scrollToSelectedCell() }, [scrollToSelectedCell]) const getCachedCellData = useCallback( (sheetType: SheetType): Record => { const cacheKey = `${sheetType}-${revision}-${columnWidths[sheetType].join('-')}` if (cellDataCache.current[cacheKey]) { return cellDataCache.current[cacheKey] } const sheetName = SHEET_NAMES[sheetType] const cachedData: Record = {} const allCellValues: Record = {} for (let row = 0; row < ROW_COUNT; row++) { for (let col = 0; col < COL_COUNT; col++) { const cellKey = `${row}-${col}` const displayValue = String( multiSheetEngine.getCellValue(sheetName, row, col) || '' ) allCellValues[cellKey] = displayValue.trim() } } // 1. Находим ближайшую занятую колонку справа для каждой ячейки строки const nextContentInRow: Record = {} for (let row = 0; row < ROW_COUNT; row++) { nextContentInRow[row] = Array(COL_COUNT).fill(COL_COUNT) let next = COL_COUNT for (let col = COL_COUNT - 1; col >= 0; col--) { const cellKey = `${row}-${col}` nextContentInRow[row][col] = next // сохранить для использования ниже if (allCellValues[cellKey]) { next = col // текущая колонка занята → "новый" предел } } } // 2. Заполняем cache for (let row = 0; row < ROW_COUNT; row++) { for (let col = 0; col < COL_COUNT; col++) { const cellKey = `${row}-${col}` const rawValue = multiSheetEngine.getCellRawValue(sheetName, row, col) const displayValue = allCellValues[cellKey] const nextCol = nextContentInRow[row][col] // ширина от текущей колонки до nextCol-1 включительно const availablePx = columnWidths[sheetType] .slice(col, nextCol) .reduce((sum, w) => sum + w, 0) const cellName = getColumnLabel(col) + (row + 1) const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла // Нормализуем значения для сравнения const normalizeValue = (value: any): string => { if (value === null || value === undefined) return '' const str = String(value).trim() // Считаем "0" и пустую строку эквивалентными if (str === '0' || str === '') return '' return str } const normalizedBaseFile = normalizeValue(baseFileValue) const normalizedRaw = normalizeValue(rawValue) const isModified = sheetType === 'report' && normalizedRaw !== normalizedBaseFile cachedData[cellKey] = { displayValue, rawValue, isModified, nextContentCol: nextCol, availablePx, } } } if (lastRevision.current !== revision) { Object.keys(cellDataCache.current).forEach(key => { if (!key.endsWith(`-${revision}`)) { delete cellDataCache.current[key] } }) lastRevision.current = revision } cellDataCache.current[cacheKey] = cachedData return cachedData }, [revision, multiSheetEngine, initialTemplateValues, columnWidths] ) // Функция для обновления подсветки ячеек при вводе формулы const updateCellHighlights = useCallback( (formula: string) => { if (!formula.startsWith('=')) { setHighlightedCells([]) return } try { // Извлекаем ссылки на ячейки из формулы const references = extractCellReferences(formula) // Конвертируем ссылки в подсвеченные ячейки const highlights = convertReferencesToHighlights( references, activeSheet ) setHighlightedCells(highlights) } catch (error) { console.error('Ошибка при обновлении подсветки ячеек:', error) setHighlightedCells([]) } }, [activeSheet] ) const formulaBarValue = useMemo(() => { if (isEditing) { return editingValue } if (selectedCell) { const sheetName = SHEET_NAMES[activeSheet] return multiSheetEngine.getCellRawValue( sheetName, selectedCell.row, selectedCell.col ) } return '' }, [ isEditing, editingValue, selectedCell, activeSheet, multiSheetEngine, revision, ]) useEffect(() => { if (isEditing && activeCellInputRef.current) { const input = activeCellInputRef.current input.focus() const length = input.value.length input.setSelectionRange(length, length) } }, [isEditing]) // Синхронизация между formula bar и ячейкой при редактировании useEffect(() => { if (isEditing && formulaBarRef.current) { formulaBarRef.current.value = editingValue } }, [editingValue, isEditing]) // Обновление подсветки ячеек при вводе формулы useEffect(() => { if (isEditing) { updateCellHighlights(editingValue) } else { // Очищаем подсветку когда не редактируем setHighlightedCells([]) } }, [isEditing, editingValue, updateCellHighlights]) const startEditing = useCallback(() => { if (!selectedCell) return // Сбрасываем множественное выделение при начале редактирования setMultiSelection([]) const sheetName = SHEET_NAMES[activeSheet] const rawValue = multiSheetEngine.getCellRawValue( sheetName, selectedCell.row, selectedCell.col ) setIsEditing(true) setEditingValue(rawValue) }, [activeSheet, selectedCell, multiSheetEngine]) const stopEditing = useCallback( (save: boolean) => { if (save && selectedCell) { const sheetName = SHEET_NAMES[activeSheet] multiSheetEngine.setCellValueWithoutRecalc( sheetName, selectedCell.row, selectedCell.col, editingValue ) updateRevision() multiSheetEngine.debouncedRecalc() } setIsEditing(false) setEditingValue('') setEditingSource('cell') containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, [activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision] ) const handleCellChange = useCallback( (newValue: string) => { if (isEditing) { setEditingValue(newValue) if (!userEdited) setUserEdited(true) } }, [isEditing, userEdited] ) const handleClearCell = useCallback(() => { if (!selectedCell) return const sheetName = SHEET_NAMES[activeSheet] multiSheetEngine.setCellValueWithoutRecalc( sheetName, selectedCell.row, selectedCell.col, '' ) setUserEdited(true) updateRevision() multiSheetEngine.debouncedRecalc() setMultiSelection([]) }, [activeSheet, selectedCell, multiSheetEngine, updateRevision]) const moveSelection = useCallback( (deltaRow: number, deltaCol: number) => { if (!selectedCell) { setSelectedCell({ row: 0, col: 0 }) return } if (isEditing) { stopEditing(true) } const newRow = Math.max( 0, Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow) ) const newCol = Math.max( 0, Math.min(COL_COUNT - 1, selectedCell.col + deltaCol) ) setSelectedCell({ row: newRow, col: newCol }) // Сбрасываем множественное выделение при навигации setMultiSelection([]) }, [selectedCell, isEditing, stopEditing] ) // Функция для очистки множества выделенных ячеек const handleClearMultipleCells = useCallback(() => { if (multiSelection.length === 0) return const sheetName = SHEET_NAMES[activeSheet] const cellsToDelete = getAllCellsInSelections(multiSelection) cellsToDelete.forEach(({ row, col }) => { multiSheetEngine.setCellValueWithoutRecalc(sheetName, row, col, '') }) setUserEdited(true) updateRevision() multiSheetEngine.debouncedRecalc() setMultiSelection([]) }, [multiSelection, activeSheet, multiSheetEngine, updateRevision]) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (isEditing) return if (!selectedCell && e.key.match(/^(Arrow|Enter|F2|Delete|Backspace)$/)) { moveSelection(0, 0) e.preventDefault() return } if (!selectedCell) return const keyHandlers: Record void> = { ArrowUp: () => moveSelection(-1, 0), ArrowDown: () => moveSelection(1, 0), ArrowLeft: () => moveSelection(0, -1), ArrowRight: () => moveSelection(0, 1), Enter: () => { setEditingSource('cell') startEditing() }, F2: () => { setEditingSource('cell') startEditing() }, Delete: () => { // Если есть множественное выделение, удаляем все выделенные ячейки if (multiSelection.length > 0) { handleClearMultipleCells() } else { handleClearCell() } }, Backspace: () => { // Если есть множественное выделение, удаляем все выделенные ячейки if (multiSelection.length > 0) { handleClearMultipleCells() } else { handleClearCell() } }, Escape: () => { // Сбрасываем множественное выделение setMultiSelection([]) }, } // Ctrl+A - выделить все ячейки if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { e.preventDefault() const allSelection = createSelectionFromRange( 0, 0, ROW_COUNT - 1, COL_COUNT - 1 ) setMultiSelection([allSelection]) return } if (keyHandlers[e.key]) { e.preventDefault() keyHandlers[e.key]() } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { e.preventDefault() setEditingSource('cell') setEditingValue(e.key) setIsEditing(true) if (!userEdited) setUserEdited(true) } }, [ selectedCell, isEditing, moveSelection, startEditing, handleClearCell, multiSelection, handleClearMultipleCells, ] ) const handleCellClick = useCallback( (row: number, col: number, event?: React.MouseEvent) => { if (isEditing) { stopEditing(true) } // Если удерживается Ctrl (или Cmd на Mac), добавляем к множественному выделению if (event && (event.ctrlKey || event.metaKey)) { const newSelection = createSelectionFromRange(row, col, row, col) setMultiSelection(prev => [...prev, newSelection]) setSelectedCell({ row, col }) return } // Если удерживается Shift, расширяем выделение if (event && event.shiftKey && selectedCell) { const newSelection = createSelectionFromRange( selectedCell.row, selectedCell.col, row, col ) setMultiSelection([newSelection]) return } // Обычный клик - очищаем множественное выделение setMultiSelection([]) setSelectedCell({ row, col }) containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, [isEditing, stopEditing, activeSheet, selectedCell] ) // Новый обработчик для начала drag selection const handleCellMouseDown = useCallback( (row: number, col: number, event: React.MouseEvent) => { // Игнорируем правый клик if (event.button !== 0) return // Если удерживаются модификаторы, не начинаем drag if (event.ctrlKey || event.metaKey || event.shiftKey) return setIsDragging(true) setDragStart({ row, col }) setMultiSelection([]) setSelectedCell({ row, col }) }, [] ) // Обработчик для drag selection const handleCellMouseEnter = useCallback( (row: number, col: number) => { if (!isDragging || !dragStart) return const newSelection = createSelectionFromRange( dragStart.row, dragStart.col, row, col ) setMultiSelection([newSelection]) }, [isDragging, dragStart] ) // Обработчик завершения drag selection const handleCellMouseUp = useCallback(() => { if (isDragging) { setIsDragging(false) setDragStart(null) } }, [isDragging]) // Глобальный обработчик для завершения drag selection useEffect(() => { const handleGlobalMouseUp = () => { if (isDragging) { setIsDragging(false) setDragStart(null) } } if (isDragging) { document.addEventListener('mouseup', handleGlobalMouseUp) return () => { document.removeEventListener('mouseup', handleGlobalMouseUp) } } }, [isDragging]) // Новый обработчик для кликов в режиме редактирования - вставляет ссылку const handleCellReferenceClick = useCallback( (targetSheetType: SheetType, row: number, col: number) => { if (!isEditing || !selectedCell || !editingValue.startsWith('=')) { stopEditing(true) setSelectedCell({ row, col }) return } let cellReference: string if (targetSheetType !== activeSheet) { cellReference = createQualifiedCellReference(targetSheetType, row, col) } else { cellReference = createCellAddress(row, col) } const newValue = editingValue + cellReference setEditingValue(newValue) if (!userEdited) setUserEdited(true) if (editingSource === 'formula') { if (formulaBarRef.current) { formulaBarRef.current.value = newValue formulaBarRef.current.focus() const length = newValue.length formulaBarRef.current.setSelectionRange(length, length) } } else { if (activeCellInputRef.current) { activeCellInputRef.current.value = newValue activeCellInputRef.current.focus({ preventScroll: true }) const length = newValue.length activeCellInputRef.current.setSelectionRange(length, length) } } }, [isEditing, selectedCell, editingValue, activeSheet, editingSource] ) const handleCellDoubleClick = useCallback( (row: number, col: number) => { setSelectedCell({ row, col }) setEditingSource('cell') startEditing() }, [startEditing] ) const handleColumnResize = useCallback( (sheetType: SheetType, colIndex: number, newWidth: number) => { setColumnWidths( produce(draft => { draft[sheetType][colIndex] = Math.max(60, newWidth) const grid = gridRefs.current[sheetType] if (grid) { grid.resetAfterColumnIndex(colIndex) } }) ) }, [] ) const handleFormulaBarChange = useCallback( (newValue: string) => { if (selectedCell) { if (!isEditing) setIsEditing(true) setEditingValue(newValue) if (!userEdited) setUserEdited(true) } }, [selectedCell, isEditing, userEdited] ) const handleFormulaBarFocus = useCallback(() => { if (selectedCell && !isEditing) { setEditingSource('formula') startEditing() } }, [selectedCell, isEditing, startEditing]) const handleFormulaBarBlur = useCallback( (e: React.FocusEvent) => { // Проверяем, не переходит ли фокус в ячейку редактирования const relatedTarget = e.relatedTarget as HTMLElement if (relatedTarget && relatedTarget.closest('input[type="text"]')) { return // Не завершаем редактирование } // Завершаем редактирование только если фокус ушел полностью if (isEditing) { stopEditing(true) } }, [isEditing, stopEditing] ) const handleFormulaBarKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() stopEditing(true) moveSelection(1, 0) setTimeout(() => { containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, 0) } else if (e.key === 'Escape') { e.preventDefault() stopEditing(false) containerRefs.current[activeSheet]?.focus({ preventScroll: true }) } }, [stopEditing, moveSelection, activeSheet] ) const onGridScroll = useCallback( ( sheetType: SheetType, { scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number } ) => { if (headerRefs.current[sheetType]) { headerRefs.current[sheetType]!.scrollLeft = scrollLeft } if (rowHeaderRefs.current[sheetType]) { rowHeaderRefs.current[sheetType]!.scrollTop = scrollTop } }, [] ) // Мемоизируем состояние ширин таблиц const [spreadsheetWidths, setSpreadsheetWidths] = useState({ L: 0.5, R: 0.5, }) // Мемоизируем опции автосохранения const autoSaveOptions = useMemo( () => ({ templateId, autoSaveDelay: 4000, enableAutoSave: enableAutoSave && userEdited, }), [templateId, enableAutoSave, userEdited] ) // Мемоизируем getAllModifiedCells для предотвращения частых ререндеров хука автосохранения const memoizedGetAllModifiedCells = useCallback( multiSheetEngine.getAllModifiedCells, [revision] // Используем revision вместо функции для более стабильной мемоизации ) // Используем новый хук автосохранения const { isSaving, isLoading, hasUnsavedChanges, lastSaveError, manualSave, clearError, loadSheets, } = useSheetAutoSave( memoizedGetAllModifiedCells, multiSheetEngine.isCalculating, revision, autoSaveOptions ) // Используем хук для перетаскивания разделителя const handleDividerDrag = useCallback((newLeftPercentage: number) => { // Используем requestAnimationFrame для более плавного обновления requestAnimationFrame(() => { setSpreadsheetWidths({ L: newLeftPercentage, R: 1 - newLeftPercentage, }) }) }, []) const separatorRef = useDraggableDivider(handleDividerDrag) // Мемоизируем функцию инициализации данных const initializeData = useCallback(async () => { if (!templateId || isInitialized) return let finalBaseData: Record> = {} // 1. ИСПОЛЬЗУЕМ ДАННЫЕ ИЗ REACT QUERY КЕША if (fileData && Object.keys(fileData.data).length > 0) { finalBaseData = { [SHEET_NAMES.report]: fileData.data } setBaseFileData(finalBaseData) setTemplateData(finalBaseData) } // 2. ИСПОЛЬЗУЕМ templateData КАК FALLBACK или основные данные const hasTemplateData = Object.keys(templateData).length > 0 && Object.values(templateData).some(sheet => Object.keys(sheet).length > 0) if (hasTemplateData && Object.keys(finalBaseData).length === 0) { finalBaseData = templateData setBaseFileData(finalBaseData) setTemplateData(templateData) } // 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API try { const apiSheets = await loadSheets() // Преобразуем листы API в формат данных const userChanges: Record> = {} Object.entries(apiSheets).forEach(([sheetName, sheet]) => { // Нормализуем имя листа: "Report" -> "L" const normalizedName = sheetName === 'Report' ? SHEET_NAMES.report : sheetName === 'Calculations' ? SHEET_NAMES.calculations : sheetName // Интересуют только листы L и R if ( (normalizedName === SHEET_NAMES.report || normalizedName === SHEET_NAMES.calculations) && sheet.cells && Object.keys(sheet.cells).length > 0 ) { userChanges[normalizedName] = sheet.cells } }) // 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ if (Object.keys(userChanges).length > 0) { // Создаем объединенные данные: базовые данные файла + пользовательские изменения const mergedData: Record> = {} // Копируем базовые данные файла как основу Object.entries(finalBaseData).forEach(([sheetName, sheetData]) => { mergedData[sheetName] = { ...sheetData } }) // Применяем пользовательские изменения поверх базовых данных Object.entries(userChanges).forEach(([sheetName, sheetData]) => { if (!mergedData[sheetName]) { mergedData[sheetName] = {} } Object.entries(sheetData).forEach(([cellAddress, value]) => { mergedData[sheetName][cellAddress] = value }) }) multiSheetEngine.loadData(mergedData) } else if (Object.keys(finalBaseData).length > 0) { multiSheetEngine.loadData(finalBaseData) } } catch (error) { console.error('❌ Ошибка при загрузке пользовательских изменений:', error) // В случае ошибки используем только базовые данные if (Object.keys(finalBaseData).length > 0) { multiSheetEngine.loadData(finalBaseData) } } setIsInitialized(true) }, [ templateId, templateData, fileData, loadSheets, multiSheetEngine, setTemplateData, isInitialized, ]) // Загружаем сохраненные формулы при инициализации // Ждем загрузки данных из React Query перед инициализацией useEffect(() => { // Инициализируем только когда все данные загружены или их нет вообще const canInitialize = !isLoadingLatestFile && !isLoadingFileData && templateId && !isInitialized if (canInitialize) { initializeData() } }, [ initializeData, isLoadingLatestFile, isLoadingFileData, templateId, isInitialized, ]) return (
{ formulaBarRef.current = el }} isSaving={isSaving} isLoading={isLoading} hasUnsavedChanges={hasUnsavedChanges} lastSaveError={lastSaveError} onManualSave={manualSave} onClearError={clearError} />
{SHEET_TYPES.map((sheetType, index) => ( {index === 0 && (
)} ))}
) } export default DualSpreadsheet