diff --git a/src/components/DualSpreadsheet/DualSpreadsheet.tsx b/src/components/DualSpreadsheet/DualSpreadsheet.tsx index ca087da..1ce1681 100644 --- a/src/components/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/components/DualSpreadsheet/DualSpreadsheet.tsx @@ -1,851 +1,21 @@ import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave' import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine' -import { MergedCell } from '@/types/template' import { produce } from 'immer' +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +// Импорты из новых модулей +import { FormulaBar, Spreadsheet } from './components' +import { useDraggableDivider } from './hooks/useDraggableDivider' import { - CSSProperties, - FC, - memo, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react' -import { VariableSizeGrid } from 'react-window' - -const ROW_COUNT = 90 -const COL_COUNT = 20 -const ROW_HEIGHT = 28 - -const SHEET_NAMES = { - report: 'L', - calculations: 'R', -} as const - -type SheetType = keyof typeof SHEET_NAMES - -// Добавляем массив типов листов для итерации -const SHEET_TYPES: SheetType[] = ['report', 'calculations'] - -// --- Helper Functions --- -const getColumnLabel = (index: number) => String.fromCharCode(65 + index) - -// Функция для преобразования адреса ячейки в координаты -const cellAddressToCoords = (address: string): { row: number; col: number } => { - const match = address.match(/^([A-Z]+)(\d+)$/) - if (!match) return { row: 0, col: 0 } - - const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы - const row = parseInt(match[2]) - 1 - - return { row, col } -} - -// Функция для проверки, является ли ячейка частью объединенной группы -const findMergedCellInfo = ( - row: number, - col: number, - mergedCells: MergedCell[] -): { - isMerged: boolean - isMainCell: boolean - mergedCell?: MergedCell - spans?: { rowSpan: number; colSpan: number } -} => { - for (const merged of mergedCells) { - const fromCoords = cellAddressToCoords(merged.from) - const toCoords = cellAddressToCoords(merged.to) - - // Проверяем, попадает ли текущая ячейка в диапазон объединения - if ( - row >= fromCoords.row && - row <= toCoords.row && - col >= fromCoords.col && - col <= toCoords.col - ) { - const isMainCell = row === fromCoords.row && col === fromCoords.col - const rowSpan = toCoords.row - fromCoords.row + 1 - const colSpan = toCoords.col - fromCoords.col + 1 - - return { - isMerged: true, - isMainCell, - mergedCell: merged, - spans: { rowSpan, colSpan }, - } - } - } - - return { isMerged: false, isMainCell: false } -} - -// --- Хук для перетаскивания разделителя --- -const useDraggableDivider = (onDrag: (delta: number) => void) => { - const ref = useRef(null) - - useEffect(() => { - const el = ref.current - if (!el) return - - const handleMouseDown = (e: MouseEvent) => { - e.preventDefault() - document.body.style.cursor = 'col-resize' - - const container = el.parentElement - if (!container) return - - const bounds = container.getBoundingClientRect() - - const handleMouseMove = (me: MouseEvent) => { - // Получаем актуальные границы контейнера - const currentBounds = container.getBoundingClientRect() - const newLeftWidth = me.clientX - currentBounds.left - const totalWidth = currentBounds.width - - // Рассчитываем процент напрямую от позиции мыши - let newLeftPercentage = newLeftWidth / totalWidth - newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage)) - - // Вызываем callback с новыми процентами - onDrag(newLeftPercentage) - } - - const handleMouseUp = () => { - document.body.style.cursor = 'auto' - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - } - - el.addEventListener('mousedown', handleMouseDown) - - return () => { - el.removeEventListener('mousedown', handleMouseDown) - } - }, [onDrag]) - - return ref -} - -// --- Interfaces --- -interface DualSpreadsheetProps { - templateData?: Record> - mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек - templateId?: string // Добавляем ID шаблона для сохранения -} - -// Кэшированные данные ячеек для оптимизации -interface CachedCellData { - displayValue: string - rawValue: string - isModified: boolean - hasRightContent: boolean -} - -// --- Child Components --- -interface CellProps { - sheetType: SheetType - rowIndex: number - colIndex: number - columnWidth: number - columnWidths: number[] // Добавляем массив всех ширин колонок - isSelected: boolean - isEditing: boolean - displayValue: string // Отображаемое значение (вычисленное) - rawValue: string // "Сырое" значение (формула) - isModified: boolean - hasRightContent: boolean // Есть ли содержимое в ячейках справа - onCellClick: (row: number, col: number) => void - onCellDoubleClick: (row: number, col: number) => void - onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования - stopEditing: (save: boolean) => void - setActiveSheet: (type: SheetType) => void - activeCellRef: React.RefObject - style: CSSProperties // Добавляем style для react-window - mergedCellInfo?: { - // Информация об объединенных ячейках - isMerged: boolean - isMainCell: boolean - rowSpan?: number - colSpan?: number - } -} - -const Cell = memo( - ({ - sheetType, - rowIndex, - colIndex, - columnWidth, - columnWidths, - isSelected, - isEditing, - displayValue, - rawValue, - isModified, - hasRightContent, - onCellClick, - onCellDoubleClick, - onCellValueChange, - stopEditing, - setActiveSheet, - activeCellRef, - style, // Добавляем style для react-window - mergedCellInfo, - }: CellProps) => { - // Если ячейка объединена, но не является основной, не рендерим её - if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) { - return null - } - - const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${ - isModified ? 'bg-yellow-400 !important' : '' - }` - - // Отладка стилей для измененных ячеек - if (isModified) { - console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, { - className, - isModified, - isSelected, - displayValue, - }) - } - - // Для объединенных ячеек нужно рассчитать правильные размеры - let cellStyle: CSSProperties = { - ...style, - overflow: 'visible', - padding: 0, - } - - // Если это основная ячейка объединенной группы, расширяем её размеры - if ( - mergedCellInfo?.isMainCell && - mergedCellInfo.rowSpan && - mergedCellInfo.colSpan - ) { - const { rowSpan, colSpan } = mergedCellInfo - - // Рассчитываем общую ширину для объединенной ячейки - let totalWidth = 0 - for (let i = 0; i < colSpan; i++) { - const currentColIndex = colIndex + i - if (currentColIndex < columnWidths.length) { - totalWidth += columnWidths[currentColIndex] - } - } - - // Рассчитываем общую высоту для объединенной ячейки - const totalHeight = ROW_HEIGHT * rowSpan - - cellStyle = { - ...cellStyle, - width: totalWidth, - height: totalHeight, - zIndex: 10, // Поднимаем выше обычных ячеек - backgroundColor: isSelected - ? 'hsl(var(--accent))' - : isModified - ? '#fef3c7' - : 'white', - } - } - - // Используем обычное значение displayValue для всех ячеек, включая объединенные - const cellDisplayValue = displayValue - - const handleClick = useCallback(() => { - setActiveSheet(sheetType) - onCellClick(rowIndex, colIndex) - }, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex]) - - const handleDoubleClick = useCallback( - () => onCellDoubleClick(rowIndex, colIndex), - [onCellDoubleClick, rowIndex, colIndex] - ) - - return ( -
- {isEditing && isSelected ? ( - onCellValueChange(e.target.value)} - onBlur={e => { - // Проверяем, не переходит ли фокус в formula bar - const relatedTarget = e.relatedTarget as HTMLElement - if (relatedTarget && relatedTarget.closest('.formula-bar')) { - return // Не завершаем редактирование - } - stopEditing(true) - }} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault() - stopEditing(true) - } else if (e.key === 'Escape') { - e.preventDefault() - stopEditing(false) - } - }} - className="absolute left-0 top-0 z-20 box-border h-full w-full border-2 border-primary px-2 py-1" - /> - ) : ( -
-
- {cellDisplayValue} -
-
- )} -
- ) - } -) - -interface FormulaBarProps { - sheetType: SheetType - activeSheet: SheetType - selectedCell: { row: number; col: number } | null - formulaBarValue: string - isCalculating: boolean - isEditing: boolean - onValueChange: (value: string) => void - onFocus: () => void - onBlur: (e: React.FocusEvent) => void - onKeyDown: (e: React.KeyboardEvent) => void - formulaBarRef: (el: HTMLInputElement | null) => void - // Обновляем props для сохранения - isSaving?: boolean - hasUnsavedChanges?: boolean - lastSaveError?: string | null - onManualSave?: () => void - onClearError?: () => void -} - -const FormulaBar = memo( - ({ - selectedCell, - formulaBarValue, - isCalculating, - isEditing, - onValueChange, - onFocus, - onBlur, - onKeyDown, - formulaBarRef, - isSaving = false, - hasUnsavedChanges = false, - lastSaveError = null, - onManualSave, - onClearError, - }: Omit) => { - return ( -
-
-
- - {selectedCell - ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` - : 'A1'} - -
-
-
- - - -
- onValueChange(e.target.value)} - onFocus={onFocus} - onBlur={e => { - // Проверяем, не переходит ли фокус в ячейку редактирования - const relatedTarget = e.relatedTarget as HTMLElement - if ( - relatedTarget && - relatedTarget.closest('input[type="text"]') - ) { - return // Не завершаем редактирование - } - onBlur(e) - }} - onKeyDown={onKeyDown} - /> - {(isCalculating || isSaving) && ( -
- - - - - - {isSaving ? 'Сохранение...' : 'Расчет...'} - -
- )} - {/* Ошибка сохранения */} - {lastSaveError && ( -
- - - - - Ошибка сохранения - - {onClearError && ( - - )} -
- )} - {/* Кнопка ручного сохранения */} - {onManualSave && ( - - )} -
-
-
- ) - } -) - -// Рендер-проп для ячеек грида -const CellRenderer = ({ - columnIndex, - rowIndex, - style, - data, -}: { - columnIndex: number - rowIndex: number - style: CSSProperties - data: any -}) => { - const { - sheetType, - widths, - selectedCell, - activeSheet, - isEditing, - editingValue, - cachedData, - mergedCells, - handleCellClick, - handleCellDoubleClick, - handleCellChange, - stopEditing, - setActiveSheet, - activeCellInputRef, - } = data - - const isCellSelected = - selectedCell?.row === rowIndex && - selectedCell?.col === columnIndex && - activeSheet === sheetType - const isCellEditing = isCellSelected && isEditing - - const cellKey = `${rowIndex}-${columnIndex}` - const cellData = cachedData[cellKey] - - const rawValue = isCellEditing ? editingValue : cellData?.rawValue || '' - const displayValue = isCellEditing - ? '' // Не показывать вычисленное значение во время редактирования - : cellData?.displayValue || '' - - // Отладка для измененных ячеек - if (cellData?.isModified) { - console.log( - `🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`, - { - cellKey, - isModified: cellData.isModified, - displayValue, - rawValue, - } - ) - } - - // Получаем информацию об объединенных ячейках только для листа отчета - const mergedCellInfo = - sheetType === 'report' && mergedCells - ? findMergedCellInfo(rowIndex, columnIndex, mergedCells) - : { isMerged: false, isMainCell: false } - - const mergedCellProps = mergedCellInfo.isMerged - ? { - isMerged: true, - isMainCell: mergedCellInfo.isMainCell, - rowSpan: mergedCellInfo.spans?.rowSpan, - colSpan: mergedCellInfo.spans?.colSpan, - // Убираем mergedValue - будем использовать актуальные данные из движка - } - : undefined - - return ( - - ) -} - -interface SpreadsheetProps { - sheetType: SheetType - columnWidths: number[] - spreadsheetWidths: { L: number; R: number } - getCachedCellData: (sheetType: SheetType) => Record - selectedCell: { row: number; col: number } | null - activeSheet: SheetType - isEditing: boolean - editingValue: string - handleCellClick: (row: number, col: number) => void - handleCellDoubleClick: (row: number, col: number) => void - handleCellChange: (newValue: string) => void - stopEditing: (save: boolean) => void - setActiveSheet: (type: SheetType) => void - activeCellInputRef: React.RefObject - containerRefs: React.MutableRefObject< - Record - > - handleKeyDown: (e: React.KeyboardEvent) => void - headerRefs: React.MutableRefObject> - rowHeaderRefs: React.MutableRefObject< - Record - > - gridRefs: React.MutableRefObject> - onGridScroll: ( - sheetType: SheetType, - params: { scrollLeft: number; scrollTop: number } - ) => void - handleColumnResize: ( - sheetType: SheetType, - colIndex: number, - newWidth: number - ) => void - mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек -} - -// --- Spreadsheet Component --- -const Spreadsheet = ({ - sheetType, - columnWidths, - spreadsheetWidths, - getCachedCellData, - selectedCell, - activeSheet, - isEditing, - editingValue, - handleCellClick, - handleCellDoubleClick, - handleCellChange, - stopEditing, - setActiveSheet, - activeCellInputRef, - containerRefs, - handleKeyDown, - headerRefs, - rowHeaderRefs, - gridRefs, - onGridScroll, - handleColumnResize, - mergedCells, -}: SpreadsheetProps) => { - const widths = columnWidths - const cachedData = getCachedCellData(sheetType) - - const itemData = { - sheetType, - widths, - selectedCell, - activeSheet, - isEditing, - editingValue, - cachedData, - mergedCells, - handleCellClick, - handleCellDoubleClick, - handleCellChange, - stopEditing, - setActiveSheet, - activeCellInputRef, - } - - const parentRef = useRef(null) - const [gridSize, setGridSize] = useState({ width: 0, height: 0 }) - - useEffect(() => { - const parent = parentRef.current - if (!parent) return - - const resizeObserver = new ResizeObserver(() => { - const { width, height } = parent.getBoundingClientRect() - const headerHeight = - parent.querySelector('.column-headers')?.clientHeight || 0 - setGridSize({ width, height: height - headerHeight }) - }) - - resizeObserver.observe(parent) - return () => resizeObserver.disconnect() - }, []) - - return ( -
setActiveSheet(sheetType)} - > -
(containerRefs.current[sheetType] = el)} - className="flex h-full flex-col overflow-hidden border bg-card shadow-sm" - onKeyDown={handleKeyDown} - tabIndex={0} - > - {/* Spreadsheet Body */} -
- {/* Row Headers */} -
-
- {/* Компактное обозначение листа */} -
- - {SHEET_NAMES[sheetType]} - -
-
-
(rowHeaderRefs.current[sheetType] = el)} - className="overflow-y-hidden" - style={{ height: gridSize.height }} - > -
- {Array.from({ length: ROW_COUNT }).map((_, rowIndex) => ( -
- {rowIndex + 1} -
- ))} -
-
-
- -
- {/* Column Headers */} -
(headerRefs.current[sheetType] = el)} - className="column-headers overflow-x-hidden" - > -
- {Array.from({ length: COL_COUNT }).map((_, i) => { - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault() - const startX = e.clientX - const startWidth = widths[i] - - const handleMouseMove = (me: MouseEvent) => { - const newWidth = startWidth + (me.clientX - startX) - handleColumnResize(sheetType, i, newWidth) - } - - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - } - return ( -
- {getColumnLabel(i)} -
-
- ) - })} -
-
- - {/* Grid */} -
- (gridRefs.current[sheetType] = el)} - columnCount={COL_COUNT} - rowCount={ROW_COUNT} - columnWidth={(index: number) => widths[index]} - rowHeight={() => ROW_HEIGHT} - height={gridSize.height} - width={gridSize.width} - itemData={itemData} - onScroll={e => onGridScroll(sheetType, e)} - > - {CellRenderer} - -
-
-
-
-
- ) -} + CachedCellData, + COL_COUNT, + DualSpreadsheetProps, + ROW_COUNT, + SHEET_NAMES, + SHEET_TYPES, + SheetType, +} from './types' +import { getColumnLabel } from './utils' const DualSpreadsheet: FC = ({ templateData = {}, @@ -1413,8 +583,6 @@ const DualSpreadsheet: FC = ({ isInitialized, ]) - // --- Spreadsheet Component --- - return (
{ + // Если ячейка объединена, но не является основной, не рендерим её + if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) { + return null + } + + const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${ + isModified ? 'bg-yellow-400 !important' : '' + }` + + // Отладка стилей для измененных ячеек + if (isModified) { + console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, { + className, + isModified, + isSelected, + displayValue, + }) + } + + // Для объединенных ячеек нужно рассчитать правильные размеры + let cellStyle: CSSProperties = { + ...style, + overflow: 'visible', + padding: 0, + } + + // Если это основная ячейка объединенной группы, расширяем её размеры + if ( + mergedCellInfo?.isMainCell && + mergedCellInfo.rowSpan && + mergedCellInfo.colSpan + ) { + const { rowSpan, colSpan } = mergedCellInfo + + // Рассчитываем общую ширину для объединенной ячейки + let totalWidth = 0 + for (let i = 0; i < colSpan; i++) { + const currentColIndex = colIndex + i + if (currentColIndex < columnWidths.length) { + totalWidth += columnWidths[currentColIndex] + } + } + + // Рассчитываем общую высоту для объединенной ячейки + const totalHeight = ROW_HEIGHT * rowSpan + + cellStyle = { + ...cellStyle, + width: totalWidth, + height: totalHeight, + zIndex: 10, // Поднимаем выше обычных ячеек + backgroundColor: isSelected + ? 'hsl(var(--accent))' + : isModified + ? '#fef3c7' + : 'white', + } + } + + // Используем обычное значение displayValue для всех ячеек, включая объединенные + const cellDisplayValue = displayValue + + const handleClick = useCallback(() => { + setActiveSheet(sheetType) + onCellClick(rowIndex, colIndex) + }, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex]) + + const handleDoubleClick = useCallback( + () => onCellDoubleClick(rowIndex, colIndex), + [onCellDoubleClick, rowIndex, colIndex] + ) + + return ( +
+ {isEditing && isSelected ? ( + onCellValueChange(e.target.value)} + onBlur={e => { + // Проверяем, не переходит ли фокус в formula bar + const relatedTarget = e.relatedTarget as HTMLElement + if (relatedTarget && relatedTarget.closest('.formula-bar')) { + return // Не завершаем редактирование + } + stopEditing(true) + }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + stopEditing(true) + } else if (e.key === 'Escape') { + e.preventDefault() + stopEditing(false) + } + }} + className="absolute left-0 top-0 z-20 box-border h-full w-full border-2 border-primary px-2 py-1" + /> + ) : ( +
+
+ {cellDisplayValue} +
+
+ )} +
+ ) + } +) diff --git a/src/components/DualSpreadsheet/components/CellRenderer.tsx b/src/components/DualSpreadsheet/components/CellRenderer.tsx new file mode 100644 index 0000000..694e36c --- /dev/null +++ b/src/components/DualSpreadsheet/components/CellRenderer.tsx @@ -0,0 +1,95 @@ +import { CellRendererProps } from '../types' +import { findMergedCellInfo } from '../utils' +import { Cell } from './Cell' + +// Рендер-проп для ячеек грида +export const CellRenderer = ({ + columnIndex, + rowIndex, + style, + data, +}: CellRendererProps) => { + const { + sheetType, + widths, + selectedCell, + activeSheet, + isEditing, + editingValue, + cachedData, + mergedCells, + handleCellClick, + handleCellDoubleClick, + handleCellChange, + stopEditing, + setActiveSheet, + activeCellInputRef, + } = data + + const isCellSelected = + selectedCell?.row === rowIndex && + selectedCell?.col === columnIndex && + activeSheet === sheetType + const isCellEditing = isCellSelected && isEditing + + const cellKey = `${rowIndex}-${columnIndex}` + const cellData = cachedData[cellKey] + + const rawValue = isCellEditing ? editingValue : cellData?.rawValue || '' + const displayValue = isCellEditing + ? '' // Не показывать вычисленное значение во время редактирования + : cellData?.displayValue || '' + + // Отладка для измененных ячеек + if (cellData?.isModified) { + console.log( + `🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`, + { + cellKey, + isModified: cellData.isModified, + displayValue, + rawValue, + } + ) + } + + // Получаем информацию об объединенных ячейках только для листа отчета + const mergedCellInfo = + sheetType === 'report' && mergedCells + ? findMergedCellInfo(rowIndex, columnIndex, mergedCells) + : { isMerged: false, isMainCell: false } + + const mergedCellProps = mergedCellInfo.isMerged + ? { + isMerged: true, + isMainCell: mergedCellInfo.isMainCell, + rowSpan: mergedCellInfo.spans?.rowSpan, + colSpan: mergedCellInfo.spans?.colSpan, + // Убираем mergedValue - будем использовать актуальные данные из движка + } + : undefined + + return ( + + ) +} diff --git a/src/components/DualSpreadsheet/components/FormulaBar.tsx b/src/components/DualSpreadsheet/components/FormulaBar.tsx new file mode 100644 index 0000000..86e8488 --- /dev/null +++ b/src/components/DualSpreadsheet/components/FormulaBar.tsx @@ -0,0 +1,186 @@ +import { memo } from 'react' +import { FormulaBarProps } from '../types' +import { getColumnLabel } from '../utils' + +export const FormulaBar = memo( + ({ + selectedCell, + formulaBarValue, + isCalculating, + isEditing, + onValueChange, + onFocus, + onBlur, + onKeyDown, + formulaBarRef, + isSaving = false, + hasUnsavedChanges = false, + lastSaveError = null, + onManualSave, + onClearError, + }: Omit) => { + return ( +
+
+
+ + {selectedCell + ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` + : 'A1'} + +
+
+
+ + + +
+ onValueChange(e.target.value)} + onFocus={onFocus} + onBlur={e => { + // Проверяем, не переходит ли фокус в ячейку редактирования + const relatedTarget = e.relatedTarget as HTMLElement + if ( + relatedTarget && + relatedTarget.closest('input[type="text"]') + ) { + return // Не завершаем редактирование + } + onBlur(e) + }} + onKeyDown={onKeyDown} + /> + {(isCalculating || isSaving) && ( +
+ + + + + + {isSaving ? 'Сохранение...' : 'Расчет...'} + +
+ )} + {/* Ошибка сохранения */} + {lastSaveError && ( +
+ + + + + Ошибка сохранения + + {onClearError && ( + + )} +
+ )} + {/* Кнопка ручного сохранения */} + {onManualSave && ( + + )} +
+
+
+ ) + } +) diff --git a/src/components/DualSpreadsheet/components/Spreadsheet.tsx b/src/components/DualSpreadsheet/components/Spreadsheet.tsx new file mode 100644 index 0000000..c511ea6 --- /dev/null +++ b/src/components/DualSpreadsheet/components/Spreadsheet.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, useState } from 'react' +import { VariableSizeGrid } from 'react-window' +import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types' +import { getColumnLabel } from '../utils' +import { CellRenderer } from './CellRenderer' + +// --- Spreadsheet Component --- +export const Spreadsheet = ({ + sheetType, + columnWidths, + spreadsheetWidths, + getCachedCellData, + selectedCell, + activeSheet, + isEditing, + editingValue, + handleCellClick, + handleCellDoubleClick, + handleCellChange, + stopEditing, + setActiveSheet, + activeCellInputRef, + containerRefs, + handleKeyDown, + headerRefs, + rowHeaderRefs, + gridRefs, + onGridScroll, + handleColumnResize, + mergedCells, +}: SpreadsheetProps) => { + const widths = columnWidths + const cachedData = getCachedCellData(sheetType) + + const itemData = { + sheetType, + widths, + selectedCell, + activeSheet, + isEditing, + editingValue, + cachedData, + mergedCells, + handleCellClick, + handleCellDoubleClick, + handleCellChange, + stopEditing, + setActiveSheet, + activeCellInputRef, + } + + const parentRef = useRef(null) + const [gridSize, setGridSize] = useState({ width: 0, height: 0 }) + + useEffect(() => { + const parent = parentRef.current + if (!parent) return + + const resizeObserver = new ResizeObserver(() => { + const { width, height } = parent.getBoundingClientRect() + const headerHeight = + parent.querySelector('.column-headers')?.clientHeight || 0 + setGridSize({ width, height: height - headerHeight }) + }) + + resizeObserver.observe(parent) + return () => resizeObserver.disconnect() + }, []) + + return ( +
setActiveSheet(sheetType)} + > +
(containerRefs.current[sheetType] = el)} + className="flex h-full flex-col overflow-hidden border bg-card shadow-sm" + onKeyDown={handleKeyDown} + tabIndex={0} + > + {/* Spreadsheet Body */} +
+ {/* Row Headers */} +
+
+ {/* Компактное обозначение листа */} +
+ + {SHEET_NAMES[sheetType]} + +
+
+
(rowHeaderRefs.current[sheetType] = el)} + className="overflow-y-hidden" + style={{ height: gridSize.height }} + > +
+ {Array.from({ length: ROW_COUNT }).map((_, rowIndex) => ( +
+ {rowIndex + 1} +
+ ))} +
+
+
+ +
+ {/* Column Headers */} +
(headerRefs.current[sheetType] = el)} + className="column-headers overflow-x-hidden" + > +
+ {Array.from({ length: COL_COUNT }).map((_, i) => { + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + const startX = e.clientX + const startWidth = widths[i] + + const handleMouseMove = (me: MouseEvent) => { + const newWidth = startWidth + (me.clientX - startX) + handleColumnResize(sheetType, i, newWidth) + } + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + return ( +
+ {getColumnLabel(i)} +
+
+ ) + })} +
+
+ + {/* Grid */} +
+ + (gridRefs.current[sheetType] = el) + } + columnCount={COL_COUNT} + rowCount={ROW_COUNT} + columnWidth={(index: number) => widths[index]} + rowHeight={() => 28} + height={gridSize.height} + width={gridSize.width} + itemData={itemData} + onScroll={e => onGridScroll(sheetType, e)} + > + {CellRenderer} + +
+
+
+
+
+ ) +} diff --git a/src/components/DualSpreadsheet/components/index.ts b/src/components/DualSpreadsheet/components/index.ts new file mode 100644 index 0000000..f2325fa --- /dev/null +++ b/src/components/DualSpreadsheet/components/index.ts @@ -0,0 +1,4 @@ +export { Cell } from './Cell' +export { CellRenderer } from './CellRenderer' +export { FormulaBar } from './FormulaBar' +export { Spreadsheet } from './Spreadsheet' diff --git a/src/components/DualSpreadsheet/hooks/useDraggableDivider.ts b/src/components/DualSpreadsheet/hooks/useDraggableDivider.ts new file mode 100644 index 0000000..6319fd1 --- /dev/null +++ b/src/components/DualSpreadsheet/hooks/useDraggableDivider.ts @@ -0,0 +1,52 @@ +import { useEffect, useRef } from 'react' + +// --- Хук для перетаскивания разделителя --- +export const useDraggableDivider = (onDrag: (delta: number) => void) => { + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault() + document.body.style.cursor = 'col-resize' + + const container = el.parentElement + if (!container) return + + const bounds = container.getBoundingClientRect() + + const handleMouseMove = (me: MouseEvent) => { + // Получаем актуальные границы контейнера + const currentBounds = container.getBoundingClientRect() + const newLeftWidth = me.clientX - currentBounds.left + const totalWidth = currentBounds.width + + // Рассчитываем процент напрямую от позиции мыши + let newLeftPercentage = newLeftWidth / totalWidth + newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage)) + + // Вызываем callback с новыми процентами + onDrag(newLeftPercentage) + } + + const handleMouseUp = () => { + document.body.style.cursor = 'auto' + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + el.addEventListener('mousedown', handleMouseDown) + + return () => { + el.removeEventListener('mousedown', handleMouseDown) + } + }, [onDrag]) + + return ref +} diff --git a/src/components/DualSpreadsheet/types.ts b/src/components/DualSpreadsheet/types.ts new file mode 100644 index 0000000..434328d --- /dev/null +++ b/src/components/DualSpreadsheet/types.ts @@ -0,0 +1,146 @@ +import { MergedCell } from '@/types/template' +import { CSSProperties } from 'react' +import { VariableSizeGrid } from 'react-window' + +export const ROW_COUNT = 90 +export const COL_COUNT = 20 +export const ROW_HEIGHT = 28 + +export const SHEET_NAMES = { + report: 'L', + calculations: 'R', +} as const + +export type SheetType = keyof typeof SHEET_NAMES + +// Добавляем массив типов листов для итерации +export const SHEET_TYPES: SheetType[] = ['report', 'calculations'] + +// Кэшированные данные ячеек для оптимизации +export interface CachedCellData { + displayValue: string + rawValue: string + isModified: boolean + hasRightContent: boolean +} + +export interface DualSpreadsheetProps { + templateData?: Record> + mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек + templateId?: string // Добавляем ID шаблона для сохранения +} + +export interface CellProps { + sheetType: SheetType + rowIndex: number + colIndex: number + columnWidth: number + columnWidths: number[] // Добавляем массив всех ширин колонок + isSelected: boolean + isEditing: boolean + displayValue: string // Отображаемое значение (вычисленное) + rawValue: string // "Сырое" значение (формула) + isModified: boolean + hasRightContent: boolean // Есть ли содержимое в ячейках справа + onCellClick: (row: number, col: number) => void + onCellDoubleClick: (row: number, col: number) => void + onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования + stopEditing: (save: boolean) => void + setActiveSheet: (type: SheetType) => void + activeCellRef: React.RefObject + style: CSSProperties // Добавляем style для react-window + mergedCellInfo?: { + // Информация об объединенных ячейках + isMerged: boolean + isMainCell: boolean + rowSpan?: number + colSpan?: number + } +} + +export interface FormulaBarProps { + sheetType: SheetType + activeSheet: SheetType + selectedCell: { row: number; col: number } | null + formulaBarValue: string + isCalculating: boolean + isEditing: boolean + onValueChange: (value: string) => void + onFocus: () => void + onBlur: (e: React.FocusEvent) => void + onKeyDown: (e: React.KeyboardEvent) => void + formulaBarRef: (el: HTMLInputElement | null) => void + // Обновляем props для сохранения + isSaving?: boolean + hasUnsavedChanges?: boolean + lastSaveError?: string | null + onManualSave?: () => void + onClearError?: () => void +} + +export interface CellRendererData { + sheetType: SheetType + widths: number[] + selectedCell: { row: number; col: number } | null + activeSheet: SheetType + isEditing: boolean + editingValue: string + cachedData: Record + mergedCells?: MergedCell[] + handleCellClick: (row: number, col: number) => void + handleCellDoubleClick: (row: number, col: number) => void + handleCellChange: (newValue: string) => void + stopEditing: (save: boolean) => void + setActiveSheet: (type: SheetType) => void + activeCellInputRef: React.RefObject +} + +export interface CellRendererProps { + columnIndex: number + rowIndex: number + style: CSSProperties + data: CellRendererData +} + +export interface SpreadsheetProps { + sheetType: SheetType + columnWidths: number[] + spreadsheetWidths: { L: number; R: number } + getCachedCellData: (sheetType: SheetType) => Record + selectedCell: { row: number; col: number } | null + activeSheet: SheetType + isEditing: boolean + editingValue: string + handleCellClick: (row: number, col: number) => void + handleCellDoubleClick: (row: number, col: number) => void + handleCellChange: (newValue: string) => void + stopEditing: (save: boolean) => void + setActiveSheet: (type: SheetType) => void + activeCellInputRef: React.RefObject + containerRefs: React.MutableRefObject< + Record + > + handleKeyDown: (e: React.KeyboardEvent) => void + headerRefs: React.MutableRefObject> + rowHeaderRefs: React.MutableRefObject< + Record + > + gridRefs: React.MutableRefObject> + onGridScroll: ( + sheetType: SheetType, + params: { scrollLeft: number; scrollTop: number } + ) => void + handleColumnResize: ( + sheetType: SheetType, + colIndex: number, + newWidth: number + ) => void + mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек +} + +export interface MergedCellInfo { + isMerged: boolean + isMainCell: boolean + mergedCell?: MergedCell + spans?: { rowSpan: number; colSpan: number } +} diff --git a/src/components/DualSpreadsheet/utils.ts b/src/components/DualSpreadsheet/utils.ts new file mode 100644 index 0000000..0d43ea3 --- /dev/null +++ b/src/components/DualSpreadsheet/utils.ts @@ -0,0 +1,51 @@ +import { MergedCell } from '@/types/template' +import { MergedCellInfo } from './types' + +// --- Helper Functions --- +export const getColumnLabel = (index: number) => String.fromCharCode(65 + index) + +// Функция для преобразования адреса ячейки в координаты +export const cellAddressToCoords = ( + address: string +): { row: number; col: number } => { + const match = address.match(/^([A-Z]+)(\d+)$/) + if (!match) return { row: 0, col: 0 } + + const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы + const row = parseInt(match[2]) - 1 + + return { row, col } +} + +// Функция для проверки, является ли ячейка частью объединенной группы +export const findMergedCellInfo = ( + row: number, + col: number, + mergedCells: MergedCell[] +): MergedCellInfo => { + for (const merged of mergedCells) { + const fromCoords = cellAddressToCoords(merged.from) + const toCoords = cellAddressToCoords(merged.to) + + // Проверяем, попадает ли текущая ячейка в диапазон объединения + if ( + row >= fromCoords.row && + row <= toCoords.row && + col >= fromCoords.col && + col <= toCoords.col + ) { + const isMainCell = row === fromCoords.row && col === fromCoords.col + const rowSpan = toCoords.row - fromCoords.row + 1 + const colSpan = toCoords.col - fromCoords.col + 1 + + return { + isMerged: true, + isMainCell, + mergedCell: merged, + spans: { rowSpan, colSpan }, + } + } + } + + return { isMerged: false, isMainCell: false } +}