import { useMultiSheetEngine } from '@/hook/useMultiSheetEngine' import { useSheetAutoSave } from '@/hook/useSheetAutoSave' import { getFileData, getLatestFileForTemplate } from '@/service/fileApiSevice' import { produce } from 'immer' import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' // Импорты из новых модулей import { FormulaBar, Spreadsheet } from './components' import { useDraggableDivider } from './hooks/useDraggableDivider' import { CachedCellData, COL_COUNT, DualSpreadsheetProps, ROW_COUNT, SHEET_NAMES, SHEET_TYPES, SheetType, } from './types' import { getColumnLabel } from './utils' const DualSpreadsheet: FC = ({ templateData = {}, mergedCells = [], templateId, onEngineReady, }) => { const { revision, updateRevision, setTemplateData, ...multiSheetEngine } = useMultiSheetEngine({ sheets: [ { name: SHEET_NAMES.report, rows: ROW_COUNT, cols: COL_COUNT }, { name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT }, ], initialData: templateData || {}, }) // Передаем методы движка родительскому компоненту useEffect(() => { if (onEngineReady) { onEngineReady({ ...multiSheetEngine, updateRevision, }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [onEngineReady]) const [activeSheet, setActiveSheet] = useState('report') const [selectedCell, setSelectedCell] = useState<{ row: number col: number } | null>(null) const [isEditing, setIsEditing] = useState(false) const [editingValue, setEditingValue] = useState('') // Локальное значение во время редактирования const [isInitialized, setIsInitialized] = useState(false) const [baseFileData, setBaseFileData] = useState< Record> >({}) // Базовые данные файла const [columnWidths, setColumnWidths] = useState>( () => ({ report: Array(COL_COUNT).fill(96), calculations: Array(COL_COUNT).fill(96), }) ) const gridRefs = useRef>({ report: null, calculations: null, }) const containerRefs = useRef>({ report: null, calculations: null, }) const activeCellInputRef = useRef(null) const formulaBarRef = useRef(null) const headerRefs = useRef>({ report: null, calculations: null, }) const rowHeaderRefs = useRef>({ report: null, calculations: null, }) const initialTemplateValues = useRef>({}) const cellDataCache = useRef>>( {} ) const lastRevision = useRef(-1) useEffect(() => { // Используем baseFileData как источник базовых значений для сравнения const reportBaseData = baseFileData?.[SHEET_NAMES.report] if (reportBaseData && Object.keys(reportBaseData).length > 0) { // Очищаем старые значения initialTemplateValues.current = {} Object.entries(reportBaseData).forEach(([cellName, value]) => { initialTemplateValues.current[cellName] = String(value) }) console.log( '✅ Загружено базовых значений файла для сравнения:', Object.keys(initialTemplateValues.current).length ) } else { // Fallback: используем templateData, если baseFileData еще не загружен const reportTemplate = templateData?.[SHEET_NAMES.report] if (reportTemplate && Object.keys(reportTemplate).length > 0) { // Очищаем старые значения initialTemplateValues.current = {} Object.entries(reportTemplate).forEach(([cellName, value]) => { initialTemplateValues.current[cellName] = String(value) }) console.log( '✅ Загружено значений templateData для сравнения (fallback):', Object.keys(initialTemplateValues.current).length ) } else { console.log('⚠️ DualSpreadsheet: Нет базовых данных для сравнения') } } }, [baseFileData, templateData]) useEffect(() => { if (selectedCell && activeSheet) { const grid = gridRefs.current[activeSheet] grid?.scrollToItem({ rowIndex: selectedCell.row, columnIndex: selectedCell.col, align: 'auto', }) } }, [selectedCell, activeSheet]) const getCachedCellData = useCallback( (sheetType: SheetType): Record => { const cacheKey = `${sheetType}-${revision}-${JSON.stringify(columnWidths[sheetType])}` 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] ?? '' // Базовое значение из файла // Отладка только для измененных ячеек if ( sheetType === 'report' && baseFileValue && rawValue !== baseFileValue ) { console.log(`🔧 Измененная ячейка ${cellName}:`, { baseFileValue, rawValue, isModified: true, }) } // Нормализуем значения для сравнения 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 // Отладка только для измененных ячеек if (sheetType === 'report' && isModified) { console.log(`🔍 Измененная ячейка ${cellName}:`, { baseFileValue, rawValue, isModified, }) } 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 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]) const startEditing = useCallback(() => { if (!selectedCell) return 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] const cellName = getColumnLabel(selectedCell.col) + (selectedCell.row + 1) const oldValue = multiSheetEngine.getCellRawValue( sheetName, selectedCell.row, selectedCell.col ) const baseFileValue = initialTemplateValues.current[cellName] ?? '' // Базовое значение из файла console.log('💾 Сохранение ячейки:', { cellName, oldValue, newValue: editingValue, baseFileValue, isChanged: oldValue !== editingValue, isModifiedFromBaseFile: baseFileValue !== editingValue, }) multiSheetEngine.setCellValueWithoutRecalc( sheetName, selectedCell.row, selectedCell.col, editingValue ) updateRevision() multiSheetEngine.debouncedRecalc() } setIsEditing(false) setEditingValue('') containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, [activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision] ) const handleCellChange = useCallback( (newValue: string) => { if (isEditing) { setEditingValue(newValue) } }, [isEditing] ) const handleClearCell = useCallback(() => { if (!selectedCell) return const sheetName = SHEET_NAMES[activeSheet] multiSheetEngine.setCellValueWithoutRecalc( sheetName, selectedCell.row, selectedCell.col, '' ) updateRevision() multiSheetEngine.debouncedRecalc() }, [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 }) }, [selectedCell, isEditing, stopEditing] ) 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: () => startEditing(), F2: () => startEditing(), Delete: handleClearCell, Backspace: handleClearCell, } if (keyHandlers[e.key]) { e.preventDefault() keyHandlers[e.key]() } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { e.preventDefault() setEditingValue(e.key) setIsEditing(true) } }, [selectedCell, isEditing, moveSelection, startEditing, handleClearCell] ) const handleCellClick = useCallback( (row: number, col: number) => { if (isEditing) { stopEditing(true) } setSelectedCell({ row, col }) containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, [isEditing, stopEditing, activeSheet] ) const handleCellDoubleClick = useCallback( (row: number, col: number) => { setSelectedCell({ row, col }) 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) } }, [selectedCell, isEditing] ) const handleFormulaBarFocus = useCallback(() => { if (selectedCell && !isEditing) { 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 { isSaving, isLoading, hasUnsavedChanges, lastSaveError, manualSave, clearError, loadSavedSheets, loadSheets, // Добавляем асинхронную функцию загрузки } = useSheetAutoSave( multiSheetEngine.getAllModifiedCells, multiSheetEngine.isCalculating, revision, { templateId, autoSaveDelay: 2000, enableAutoSave: true, } ) // Отладка templateId useEffect(() => { console.log('🆔 Текущий templateId в DualSpreadsheet:', { templateId, type: typeof templateId, isValid: templateId ? /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( templateId ) : false, }) }, [templateId]) // Используем хук для перетаскивания разделителя const handleDividerDrag = useCallback((newLeftPercentage: number) => { // Используем requestAnimationFrame для более плавного обновления requestAnimationFrame(() => { setSpreadsheetWidths({ L: newLeftPercentage, R: 1 - newLeftPercentage, }) }) }, []) const separatorRef = useDraggableDivider(handleDividerDrag) // Загружаем сохраненные формулы при инициализации useEffect(() => { if (!templateId || isInitialized) return // Асинхронная функция для загрузки и слияния данных const initializeData = async () => { let finalBaseData: Record> = {} // 1. ЗАГРУЖАЕМ БАЗОВЫЕ ДАННЫЕ ФАЙЛА if (templateId) { try { console.log('🔍 Ищем файлы для templateId:', templateId) const latestFile = await getLatestFileForTemplate(templateId) if (latestFile) { console.log( '📄 Найден файл:', latestFile.name, 'ID:', latestFile.id ) const fileData = await getFileData(latestFile.id) // TODO: Пока getFileData возвращает пустые данные, используем templateData // Когда API будет готов, здесь будут реальные данные файла if (Object.keys(fileData.data).length > 0) { finalBaseData = { [SHEET_NAMES.report]: fileData.data } setBaseFileData(finalBaseData) // Сохраняем baseline, чтобы в L попадали только пользовательские изменения setTemplateData(finalBaseData) console.log('📄 Загружены базовые данные файла:', finalBaseData) } else { console.log( '⚠️ getFileData вернул пустые данные, используем templateData' ) } } else { console.log('⚠️ Файл не найден для templateId:', templateId) } } catch (error) { console.error('❌ Ошибка при загрузке данных файла:', error) } } // 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) console.log( '📄 Используем templateData как базовые данные:', finalBaseData ) setTemplateData(templateData) } // 3. ЗАГРУЖАЕМ ПОЛЬЗОВАТЕЛЬСКИЕ ИЗМЕНЕНИЯ ИЗ API try { console.log('🔍 Загружаем пользовательские изменения из API...') 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 } }) console.log( '📂 Загружены пользовательские изменения:', Object.keys(userChanges) ) // 4. ОБЪЕДИНЯЕМ БАЗОВЫЕ ДАННЫЕ С ПОЛЬЗОВАТЕЛЬСКИМИ ИЗМЕНЕНИЯМИ if (Object.keys(userChanges).length > 0) { console.log( '🔄 Объединяем базовые данные с пользовательскими изменениями' ) // Создаем объединенные данные: базовые данные файла + пользовательские изменения 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 }) }) console.log( '📊 Загружаем объединенные данные (базовые + пользовательские)' ) multiSheetEngine.loadData(mergedData) // Обновляем baseline, включая уже сохранённые изменения пользователя, // чтобы их не сохранять повторно } else if (Object.keys(finalBaseData).length > 0) { console.log( '📋 Используем только базовые данные (нет пользовательских изменений)' ) multiSheetEngine.loadData(finalBaseData) } else { console.log('⚠️ Нет данных для загрузки') } } catch (error) { console.error( '❌ Ошибка при загрузке пользовательских изменений:', error ) // В случае ошибки используем только базовые данные if (Object.keys(finalBaseData).length > 0) { console.log('📋 Ошибка API, используем только базовые данные') multiSheetEngine.loadData(finalBaseData) } } setIsInitialized(true) } initializeData() }, [ templateId, templateData, loadSheets, multiSheetEngine, setTemplateData, isInitialized, ]) return (
{ formulaBarRef.current = el }} isSaving={isSaving} isLoading={isLoading} hasUnsavedChanges={hasUnsavedChanges} lastSaveError={lastSaveError} onManualSave={manualSave} onClearError={clearError} />
{SHEET_TYPES.map((sheetType, index) => ( ))}
) } export default DualSpreadsheet