переименования папок

This commit is contained in:
2025-07-21 15:02:10 +03:00
parent d0c79bd334
commit 84f0c9c6aa
72 changed files with 245 additions and 249 deletions

View File

@@ -0,0 +1,797 @@
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<DualSpreadsheetProps> = ({
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<SheetType>('report')
const [selectedCell, setSelectedCell] = useState<{
row: number
col: number
} | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [editingValue, setEditingValue] = useState<string>('') // Локальное значение во время редактирования
const [isInitialized, setIsInitialized] = useState(false)
const [baseFileData, setBaseFileData] = useState<
Record<string, Record<string, any>>
>({}) // Базовые данные файла
const [columnWidths, setColumnWidths] = useState<Record<SheetType, number[]>>(
() => ({
report: Array(COL_COUNT).fill(96),
calculations: Array(COL_COUNT).fill(96),
})
)
const gridRefs = useRef<Record<SheetType, any>>({
report: null,
calculations: null,
})
const containerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const activeCellInputRef = useRef<HTMLInputElement>(null)
const formulaBarRef = useRef<HTMLInputElement | null>(null)
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const rowHeaderRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
})
const initialTemplateValues = useRef<Record<string, string>>({})
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
{}
)
const lastRevision = useRef<number>(-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<string, CachedCellData> => {
const cacheKey = `${sheetType}-${revision}-${JSON.stringify(columnWidths[sheetType])}`
if (cellDataCache.current[cacheKey]) {
return cellDataCache.current[cacheKey]
}
const sheetName = SHEET_NAMES[sheetType]
const cachedData: Record<string, CachedCellData> = {}
const allCellValues: Record<string, string> = {}
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<number, number[]> = {}
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<string, () => 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<HTMLInputElement>) => {
// Проверяем, не переходит ли фокус в ячейку редактирования
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<HTMLInputElement>) => {
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<string, Record<string, any>> = {}
// 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<string, Record<string, any>> = {}
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<string, Record<string, any>> = {}
// Копируем базовые данные файла как основу
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 (
<div className="flex h-full flex-col bg-background">
<FormulaBar
selectedCell={selectedCell}
formulaBarValue={formulaBarValue}
isCalculating={multiSheetEngine.isCalculating}
isEditing={isEditing}
onValueChange={handleFormulaBarChange}
onFocus={handleFormulaBarFocus}
onBlur={handleFormulaBarBlur}
onKeyDown={handleFormulaBarKeyDown}
formulaBarRef={el => {
formulaBarRef.current = el
}}
isSaving={isSaving}
isLoading={isLoading}
hasUnsavedChanges={hasUnsavedChanges}
lastSaveError={lastSaveError}
onManualSave={manualSave}
onClearError={clearError}
/>
<div
className="flex min-w-0 flex-1 gap-1 overflow-hidden"
style={{ transition: 'none' }}
>
{SHEET_TYPES.map((sheetType, index) => (
<Spreadsheet
key={sheetType}
sheetType={sheetType}
columnWidths={columnWidths[sheetType]}
spreadsheetWidths={spreadsheetWidths}
getCachedCellData={getCachedCellData}
selectedCell={selectedCell}
activeSheet={activeSheet}
isEditing={isEditing}
editingValue={editingValue}
handleCellClick={handleCellClick}
handleCellDoubleClick={handleCellDoubleClick}
handleCellChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellInputRef={activeCellInputRef}
containerRefs={containerRefs}
handleKeyDown={handleKeyDown}
headerRefs={headerRefs}
rowHeaderRefs={rowHeaderRefs}
gridRefs={gridRefs}
onGridScroll={onGridScroll}
handleColumnResize={handleColumnResize}
mergedCells={mergedCells}
/>
))}
<div
ref={separatorRef}
className="flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20"
style={{ userSelect: 'none' }}
>
<div className="h-4 w-0.5 rounded-full bg-muted-foreground/50" />
</div>
</div>
</div>
)
}
export default DualSpreadsheet