import { produce } from 'immer' import { FC, memo, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine' 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 // --- Helper Functions --- const getColumnLabel = (index: number) => String.fromCharCode(65 + index) // --- Interfaces --- interface DualSpreadsheetProps { templateData?: Record> } // --- Child Components --- interface CellProps { sheetType: SheetType rowIndex: number colIndex: number columnWidth: number isSelected: boolean isEditing: boolean displayValue: string // Отображаемое значение (вычисленное) rawValue: string // "Сырое" значение (формула) isModified: 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 } const Cell = memo( ({ sheetType, rowIndex, colIndex, columnWidth, isSelected, isEditing, displayValue, rawValue, isModified, onCellClick, onCellDoubleClick, onCellValueChange, stopEditing, setActiveSheet, activeCellRef, }: CellProps) => { const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${ isModified ? 'bg-yellow-50' : '' }` const style = { minWidth: `${columnWidth}px`, width: `${columnWidth}px`, height: `${ROW_HEIGHT}px`, overflow: 'hidden', padding: 0, position: 'relative' as const, } 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={() => 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" /> ) : (
{displayValue}
)} ) }, ) 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: () => void onKeyDown: (e: React.KeyboardEvent) => void formulaBarRef: (el: HTMLInputElement | null) => void } const FormulaBar = memo( ({ sheetType, activeSheet, selectedCell, formulaBarValue, isCalculating, isEditing, onValueChange, onFocus, onBlur, onKeyDown, formulaBarRef, }: FormulaBarProps) => { if (activeSheet !== sheetType) { return (
) } return (
{selectedCell ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : 'A1'}
onValueChange(e.target.value)} onFocus={onFocus} onBlur={onBlur} onKeyDown={onKeyDown} /> {isCalculating && (
)}
) }, ) // --- Main Component --- const DualSpreadsheet: FC = ({ templateData = {} }) => { const { revision, ...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 || {}, }) 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 [columnWidths, setColumnWidths] = useState>( () => ({ report: Array(COL_COUNT).fill(96), calculations: Array(COL_COUNT).fill(96), }), ) const containerRefs = useRef>({ report: null, calculations: null, }) const activeCellInputRef = useRef(null) const formulaBarRefs = useRef>({ report: null, calculations: null, }) const initialTemplateValues = useRef>({}) // Загрузка данных шаблона один раз при инициализации useEffect(() => { const reportTemplate = templateData?.[SHEET_NAMES.report] if (reportTemplate) { Object.entries(reportTemplate).forEach(([cellName, value]) => { initialTemplateValues.current[cellName] = String(value) }) } }, [templateData]) // --- Синхронизация UI --- // Синхронизация строки формул 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) { if (activeCellInputRef.current) { activeCellInputRef.current.focus() activeCellInputRef.current.select() } } }, [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] multiSheetEngine.setCellValueWithoutRecalc( sheetName, selectedCell.row, selectedCell.col, editingValue, ) multiSheetEngine.debouncedRecalc() } setIsEditing(false) setEditingValue('') // Очищаем временное значение containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, [activeSheet, selectedCell, editingValue, multiSheetEngine], ) const handleCellChange = useCallback( (newValue: string) => { if (isEditing) { setEditingValue(newValue) } }, [isEditing], ) const handleClearCell = useCallback(() => { if (!selectedCell) return const sheetName = SHEET_NAMES[activeSheet] multiSheetEngine.setCellValue( sheetName, selectedCell.row, selectedCell.col, '', ) }, [activeSheet, selectedCell, multiSheetEngine]) const moveSelection = useCallback( (deltaRow: number, deltaCol: number) => { if (!selectedCell) { // Если ничего не выбрано, выбираем A1 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) { // Логика внутри Cell компонента 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) }), ) }, [], ) // --- Formula Bar Specific Handlers --- 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(() => { 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], ) // --- Render Functions --- const renderSpreadsheet = (sheetType: SheetType) => { const widths = columnWidths[sheetType] const sheetName = SHEET_NAMES[sheetType] return (
(containerRefs.current[sheetType] = el)} className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm" onKeyDown={handleKeyDown} tabIndex={0} >

{sheetType === 'report' ? 'Отчет' : 'Расчеты'}

{Array.from({ length: COL_COUNT }, (_, 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 ( ) })} {Array.from({ length: ROW_COUNT }).map((_, rowIndex) => ( {Array.from({ length: COL_COUNT }).map((_, colIndex) => { const isCellSelected = selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === sheetType const isCellEditing = isCellSelected && isEditing const rawValue = isCellEditing ? editingValue : multiSheetEngine.getCellRawValue( sheetName, rowIndex, colIndex, ) const displayValue = isCellEditing ? '' // Не показывать вычисленное значение во время редактирования : String( multiSheetEngine.getCellValue( sheetName, rowIndex, colIndex, ) || '', ) const cellName = getColumnLabel(colIndex) + (rowIndex + 1) const templateValue = initialTemplateValues.current[cellName] ?? '' const isModified = sheetType === 'report' && templateValue !== '' && rawValue !== templateValue return ( ) })} ))}
{getColumnLabel(i)}
{rowIndex + 1}
) } // Используем revision в качестве зависимости, чтобы гарантировать перерисовку // при изменении данных в движке. const memoizedReportSheet = useMemo( () => renderSpreadsheet('report'), // eslint-disable-next-line react-hooks/exhaustive-deps [ columnWidths.report, activeSheet, selectedCell, isEditing, editingValue, revision, ], ) const memoizedCalculationsSheet = useMemo( () => renderSpreadsheet('calculations'), // eslint-disable-next-line react-hooks/exhaustive-deps [ columnWidths.calculations, activeSheet, selectedCell, isEditing, editingValue, revision, ], ) return (
(formulaBarRefs.current[activeSheet] = el)} />
{/* Двойная панель */}
{memoizedReportSheet}
{memoizedCalculationsSheet}
) } export default DualSpreadsheet