diff --git a/src/app/store.ts b/src/app/store.ts index c5db1dc..62c32b0 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -1,7 +1,18 @@ -import { configureStore } from "@reduxjs/toolkit"; +import { configureStore, createSlice } from "@reduxjs/toolkit"; + +// Создаем базовый slice для приложения +const appSlice = createSlice({ + name: 'app', + initialState: { + initialized: true + }, + reducers: {} +}); export const store = configureStore({ - reducer: {}, + reducer: { + app: appSlice.reducer + }, }); export type RootState = ReturnType; diff --git a/src/components/DualSpreadsheet/DualSpreadsheet.tsx b/src/components/DualSpreadsheet/DualSpreadsheet.tsx new file mode 100644 index 0000000..140f819 --- /dev/null +++ b/src/components/DualSpreadsheet/DualSpreadsheet.tsx @@ -0,0 +1,882 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'; + +interface CellData { + value: string; + isSelected: boolean; + isModified: boolean; // Флаг для отслеживания изменений + templateValue?: string; // Исходное значение из шаблона +} + +interface DualSpreadsheetProps { + templateData?: Record>; // Данные для обоих листов +} + +const DualSpreadsheet: FC = ({ templateData = {} }) => { + // Используем мультилистовый движок + const multiSheetEngine = useMultiSheetEngine({ + sheets: [ + { name: 'Report', rows: 20, cols: 10 }, + { name: 'Calculations', rows: 20, cols: 10 } + ], + initialData: templateData || {} + }); + + const [activeSheet, setActiveSheet] = useState<'report' | 'calculations'>('report'); + const [reportCells, setReportCells] = useState(() => { + return Array(20).fill(null).map(() => + Array(10).fill(null).map(() => ({ + value: '', + isSelected: false, + isModified: false, + templateValue: '' + })) + ); + }); + + const [calculationsCells, setCalculationsCells] = useState(() => { + return Array(20).fill(null).map(() => + Array(10).fill(null).map(() => ({ + value: '', + isSelected: false, + isModified: false + })) + ); + }); + + const [selectedCell, setSelectedCell] = useState<{row: number, col: number} | null>(null); + const [isEditing, setIsEditing] = useState(false); + const [editingValue, setEditingValue] = useState(''); + const [formulaBarValue, setFormulaBarValue] = useState(''); + const [reportColumnWidths, setReportColumnWidths] = useState(Array(10).fill(96)); + const [calculationsColumnWidths, setCalculationsColumnWidths] = useState(Array(10).fill(96)); + const [rowHeights, setRowHeights] = useState(Array(20).fill(32)); + + const reportContainerRef = useRef(null); + const calculationsContainerRef = useRef(null); + const reportInputRefs = useRef<(HTMLInputElement | null)[][]>([]); + const calculationsInputRefs = useRef<(HTMLInputElement | null)[][]>([]); + const reportFormulaBarRef = useRef(null); + const calculationsFormulaBarRef = useRef(null); + + // Инициализируем refs для всех input + useEffect(() => { + reportInputRefs.current = Array(20).fill(null).map(() => Array(10).fill(null)); + calculationsInputRefs.current = Array(20).fill(null).map(() => Array(10).fill(null)); + }, []); + + // Загружаем шаблонные данные в отчет + useEffect(() => { + if (templateData && templateData.Report && Object.keys(templateData.Report).length > 0) { + setReportCells(prev => { + const newCells = [...prev]; + for (let row = 0; row < 20; row++) { + for (let col = 0; col < 10; col++) { + const cellName = getColumnLabel(col) + (row + 1); + const templateValue = templateData.Report[cellName]; + if (templateValue !== undefined) { + newCells[row][col] = { + ...newCells[row][col], + value: String(templateValue), + templateValue: String(templateValue) + }; + } + } + } + return newCells; + }); + } + }, [templateData]); + + // Синхронизация с движком + useEffect(() => { + const reportSheetCells = multiSheetEngine.sheetCells['Report']; + const calculationsSheetCells = multiSheetEngine.sheetCells['Calculations']; + + if (reportSheetCells) { + setReportCells(prev => { + const newCells = [...prev]; + for (let row = 0; row < 20; row++) { + for (let col = 0; col < 10; col++) { + const rawValue = multiSheetEngine.getCellRawValue('Report', row, col); + if (rawValue !== newCells[row][col].value) { + newCells[row][col] = { ...newCells[row][col], value: rawValue }; + } + } + } + return newCells; + }); + } + + if (calculationsSheetCells) { + setCalculationsCells(prev => { + const newCells = [...prev]; + for (let row = 0; row < 20; row++) { + for (let col = 0; col < 10; col++) { + const rawValue = multiSheetEngine.getCellRawValue('Calculations', row, col); + if (rawValue !== newCells[row][col].value) { + newCells[row][col] = { ...newCells[row][col], value: rawValue }; + } + } + } + return newCells; + }); + } + }, [multiSheetEngine.sheetCells, multiSheetEngine.getCellRawValue]); + + // Синхронизация formula bar + useEffect(() => { + if (selectedCell) { + const sheetName = activeSheet === 'report' ? 'Report' : 'Calculations'; + const rawValue = multiSheetEngine.getCellRawValue(sheetName, selectedCell.row, selectedCell.col); + setFormulaBarValue(rawValue); + } else { + setFormulaBarValue(''); + } + }, [selectedCell, activeSheet, multiSheetEngine.sheetCells, multiSheetEngine.getCellRawValue]); + + const getColumnLabel = (index: number) => { + return String.fromCharCode(65 + index); + }; + + // Получение измененных ячеек для бэкенда + const getChangedCells = () => { + // Возвращаем только измененные ячейки листа "Отчет" + const reportChanges: Record = {}; + + // Проверяем каждую ячейку в отчете на изменения + for (let row = 0; row < 20; row++) { + for (let col = 0; col < 10; col++) { + const cell = reportCells[row][col]; + if (cell.isModified) { + const cellName = getColumnLabel(col) + (row + 1); + const rawValue = multiSheetEngine.getCellRawValue('Report', row, col); + const calculatedValue = multiSheetEngine.getCellValue('Report', row, col); + + reportChanges[cellName] = { + rawValue, + calculatedValue + }; + } + } + } + + return reportChanges; + }; + + // Экспорт функции для родительского компонента + useEffect(() => { + if (typeof window !== 'undefined') { + (window as any).getChangedCells = getChangedCells; + } + }, [multiSheetEngine]); + + // Обработчик экспорта изменений + const handleExportChanges = () => { + const changes = getChangedCells(); + console.log('Измененные ячейки отчета для отправки на бэкенд:', changes); + + // Здесь можно добавить реальную отправку на бэкенд + const changedCount = Object.keys(changes).length; + if (changedCount > 0) { + alert(`Изменения экспортированы!\nИзмененных ячеек в отчете: ${changedCount}\n\nПроверьте консоль для подробностей.`); + } else { + alert('Нет изменений для экспорта.'); + } + }; + + const getCurrentSheetName = () => { + return activeSheet === 'report' ? 'Report' : 'Calculations'; + }; + + const getCurrentCells = () => { + return activeSheet === 'report' ? reportCells : calculationsCells; + }; + + const setCurrentCells = (updater: (prev: CellData[][]) => CellData[][]) => { + if (activeSheet === 'report') { + setReportCells(updater); + } else { + setCalculationsCells(updater); + } + }; + + const handleCellClick = (row: number, col: number) => { + setSelectedCell({ row, col }); + setIsEditing(false); + const containerRef = activeSheet === 'report' ? reportContainerRef : calculationsContainerRef; + containerRef.current?.focus(); + }; + + const handleCellDoubleClick = (row: number, col: number) => { + setSelectedCell({ row, col }); + const cells = getCurrentCells(); + setEditingValue(cells[row][col].value); + setIsEditing(true); + setTimeout(() => { + const inputRefs = activeSheet === 'report' ? reportInputRefs : calculationsInputRefs; + const input = inputRefs.current[row]?.[col]; + if (input) { + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + }, 10); + }; + + const handleCellChange = (row: number, col: number, value: string) => { + const sheetName = getCurrentSheetName(); + + // Для отчета отслеживаем изменения относительно шаблона + if (activeSheet === 'report') { + setReportCells(prev => { + const newCells = [...prev]; + const cell = newCells[row][col]; + + // Определяем, изменилось ли значение относительно шаблона + // Пользователь может добавить формулу поверх простого значения или очистить ячейку + const isModified = value !== (cell.templateValue || ''); + + newCells[row][col] = { + ...cell, + value, + isModified + }; + return newCells; + }); + } else { + setCalculationsCells(prev => { + const newCells = [...prev]; + newCells[row][col] = { ...newCells[row][col], value }; + return newCells; + }); + } + + // Обновляем formula bar + if (selectedCell && selectedCell.row === row && selectedCell.col === col) { + setFormulaBarValue(value); + } + + // Обновляем движок + multiSheetEngine.setCellValue(sheetName, row, col, value); + }; + + // Функция для восстановления шаблонного значения + const restoreTemplateValue = (row: number, col: number) => { + if (activeSheet !== 'report') return; + + setReportCells(prev => { + const newCells = [...prev]; + const cell = newCells[row][col]; + + if (cell.templateValue) { + const restoredValue = cell.templateValue; + newCells[row][col] = { + ...cell, + value: restoredValue, + isModified: false + }; + + // Обновляем движок с восстановленным значением + multiSheetEngine.setCellValue('Report', row, col, restoredValue); + + // Обновляем formula bar + if (selectedCell && selectedCell.row === row && selectedCell.col === col) { + setFormulaBarValue(restoredValue); + } + } + + return newCells; + }); + }; + + const moveSelection = (deltaRow: number, deltaCol: number) => { + if (!selectedCell) return; + + const newRow = Math.max(0, Math.min(19, selectedCell.row + deltaRow)); + const newCol = Math.max(0, Math.min(9, selectedCell.col + deltaCol)); + + setSelectedCell({ row: newRow, col: newCol }); + setIsEditing(false); + }; + + const startEditing = (focusTarget?: 'cell' | 'formula') => { + if (!selectedCell) return; + const cells = getCurrentCells(); + setEditingValue(cells[selectedCell.row][selectedCell.col].value); + setIsEditing(true); + + if (focusTarget === 'cell') { + setTimeout(() => { + const inputRefs = activeSheet === 'report' ? reportInputRefs : calculationsInputRefs; + const input = inputRefs.current[selectedCell.row]?.[selectedCell.col]; + if (input) { + input.focus(); + input.select(); + } + }, 0); + } else if (focusTarget === 'formula') { + setTimeout(() => { + const formulaBarRef = activeSheet === 'report' ? reportFormulaBarRef : calculationsFormulaBarRef; + if (formulaBarRef.current) { + formulaBarRef.current.focus(); + formulaBarRef.current.select(); + } + }, 0); + } + }; + + const stopEditing = (save: boolean = true) => { + if (!save && selectedCell) { + const cells = getCurrentCells(); + const oldValue = editingValue; + + setCurrentCells(prev => { + const newCells = [...prev]; + newCells[selectedCell.row][selectedCell.col] = { + ...newCells[selectedCell.row][selectedCell.col], + value: oldValue + }; + return newCells; + }); + + setFormulaBarValue(oldValue); + multiSheetEngine.setCellValue(getCurrentSheetName(), selectedCell.row, selectedCell.col, oldValue); + } + setIsEditing(false); + const containerRef = activeSheet === 'report' ? reportContainerRef : calculationsContainerRef; + containerRef.current?.focus(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!selectedCell) return; + + if (isEditing) { + if (e.key === 'Enter') { + e.preventDefault(); + stopEditing(true); + moveSelection(1, 0); + } else if (e.key === 'Escape') { + e.preventDefault(); + stopEditing(false); + } + return; + } + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + moveSelection(-1, 0); + break; + case 'ArrowDown': + e.preventDefault(); + moveSelection(1, 0); + break; + case 'ArrowLeft': + e.preventDefault(); + moveSelection(0, -1); + break; + case 'ArrowRight': + e.preventDefault(); + moveSelection(0, 1); + break; + case 'Enter': + e.preventDefault(); + startEditing('cell'); + break; + case 'F2': + e.preventDefault(); + startEditing('cell'); + break; + case 'Delete': + case 'Backspace': + e.preventDefault(); + handleCellChange(selectedCell.row, selectedCell.col, ''); + break; + default: + if (e.key === 'z' && (e.ctrlKey || e.metaKey) && activeSheet === 'report') { + e.preventDefault(); + restoreTemplateValue(selectedCell.row, selectedCell.col); + } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) { + e.preventDefault(); + handleCellChange(selectedCell.row, selectedCell.col, e.key); + startEditing('cell'); + } + break; + } + }; + + const handleReportResize = (colIndex: number, newWidth: number) => { + setReportColumnWidths(prev => { + const newWidths = [...prev]; + newWidths[colIndex] = Math.max(60, newWidth); + return newWidths; + }); + }; + + const handleCalculationsResize = (colIndex: number, newWidth: number) => { + setCalculationsColumnWidths(prev => { + const newWidths = [...prev]; + newWidths[colIndex] = Math.max(60, newWidth); + return newWidths; + }); + }; + + const renderReportSpreadsheet = () => { + const cells = reportCells; + const sheetName = 'Report'; + + return ( +
+ a + b, 0)}px` }}> + + + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + + + + {cells.map((row, rowIndex) => ( + + + {row.map((cell, colIndex) => ( + + ))} + + ))} + +
+ {getColumnLabel(i)} +
{ + e.preventDefault(); + const startX = e.clientX; + const startWidth = reportColumnWidths[i]; + + const handleMouseMove = (e: MouseEvent) => { + const newWidth = startWidth + (e.clientX - startX); + handleReportResize(i, newWidth); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> +
+ {rowIndex + 1} + { + if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'report') { + return; + } + if (activeSheet !== 'report') { + setActiveSheet('report'); + } + handleCellClick(rowIndex, colIndex); + }} + onDoubleClick={() => { + if (activeSheet !== 'report') { + setActiveSheet('report'); + } + handleCellDoubleClick(rowIndex, colIndex); + }} + > + { + handleCellChange(rowIndex, colIndex, e.target.value); + setFormulaBarValue(e.target.value); + }} + onFocus={(e) => { + if (activeSheet !== 'report') { + setActiveSheet('report'); + } + setSelectedCell({ row: rowIndex, col: colIndex }); + if (!isEditing || activeSheet !== 'report') { + (e.target as HTMLInputElement).blur(); + } + }} + onBlur={() => stopEditing(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + stopEditing(true); + moveSelection(1, 0); + } else if (e.key === 'Escape') { + e.preventDefault(); + stopEditing(false); + } + }} + onMouseUp={(e) => { + if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'report') { + e.stopPropagation(); + } + }} + readOnly={!isEditing || selectedCell?.row !== rowIndex || selectedCell?.col !== colIndex || activeSheet !== 'report'} + ref={(el) => { + if (reportInputRefs.current[rowIndex]) { + reportInputRefs.current[rowIndex][colIndex] = el; + } + }} + /> +
+
+ ); + }; + + const renderCalculationsSpreadsheet = () => { + const cells = calculationsCells; + const sheetName = 'Calculations'; + + return ( +
+ a + b, 0)}px` }}> + + + + {Array.from({ length: 10 }, (_, i) => ( + + ))} + + + + {cells.map((row, rowIndex) => ( + + + {row.map((cell, colIndex) => ( + + ))} + + ))} + +
+ {getColumnLabel(i)} +
{ + e.preventDefault(); + const startX = e.clientX; + const startWidth = calculationsColumnWidths[i]; + + const handleMouseMove = (e: MouseEvent) => { + const newWidth = startWidth + (e.clientX - startX); + handleCalculationsResize(i, newWidth); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }} + /> +
+ {rowIndex + 1} + { + if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'calculations') { + return; + } + if (activeSheet !== 'calculations') { + setActiveSheet('calculations'); + } + handleCellClick(rowIndex, colIndex); + }} + onDoubleClick={() => { + if (activeSheet !== 'calculations') { + setActiveSheet('calculations'); + } + handleCellDoubleClick(rowIndex, colIndex); + }} + > + { + handleCellChange(rowIndex, colIndex, e.target.value); + setFormulaBarValue(e.target.value); + }} + onFocus={(e) => { + if (activeSheet !== 'calculations') { + setActiveSheet('calculations'); + } + setSelectedCell({ row: rowIndex, col: colIndex }); + if (!isEditing || activeSheet !== 'calculations') { + (e.target as HTMLInputElement).blur(); + } + }} + onBlur={() => stopEditing(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + stopEditing(true); + moveSelection(1, 0); + } else if (e.key === 'Escape') { + e.preventDefault(); + stopEditing(false); + } + }} + onMouseUp={(e) => { + if (isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'calculations') { + e.stopPropagation(); + } + }} + readOnly={!isEditing || selectedCell?.row !== rowIndex || selectedCell?.col !== colIndex || activeSheet !== 'calculations'} + ref={(el) => { + if (calculationsInputRefs.current[rowIndex]) { + calculationsInputRefs.current[rowIndex][colIndex] = el; + } + }} + /> +
+
+ ); + }; + + return ( +
+ {/* Заголовок */} +
+
+
+

Система отчетов

+
+
+
+ Измененные ячейки +
+
+
+ Шаблонные данные +
+
+ Ctrl+Z - восстановить шаблон +
+
+
+
+ +
+
+
+ + {/* Двойная панель */} +
+ {/* Левая панель - Отчет */} +
+
+

Отчет

+
+ + {/* Formula bar для отчета */} +
+
+
+ {selectedCell && activeSheet === 'report' ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : ''} +
+ {multiSheetEngine.isCalculating && ( +
+ Вычисляется... +
+ )} +
+ { + if (activeSheet === 'report') { + setFormulaBarValue(e.target.value); + if (selectedCell) { + handleCellChange(selectedCell.row, selectedCell.col, e.target.value); + } + } + }} + onFocus={() => { + if (selectedCell && activeSheet === 'report') { + startEditing('formula'); + } + }} + onBlur={() => stopEditing(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + stopEditing(true); + reportContainerRef.current?.focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + stopEditing(false); + } + }} + /> +
+
+
+ +
setActiveSheet('report')} + > + {renderReportSpreadsheet()} +
+
+ + {/* Правая панель - Вычисления */} +
+
+

Вычисления

+
+ + {/* Formula bar для вычислений */} +
+
+
+ {selectedCell && activeSheet === 'calculations' ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : ''} +
+ {multiSheetEngine.isCalculating && ( +
+ Вычисляется... +
+ )} +
+ { + if (activeSheet === 'calculations') { + setFormulaBarValue(e.target.value); + if (selectedCell) { + handleCellChange(selectedCell.row, selectedCell.col, e.target.value); + } + } + }} + onFocus={() => { + if (selectedCell && activeSheet === 'calculations') { + startEditing('formula'); + } + }} + onBlur={() => stopEditing(true)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + stopEditing(true); + calculationsContainerRef.current?.focus(); + } else if (e.key === 'Escape') { + e.preventDefault(); + stopEditing(false); + } + }} + /> +
+
+
+ +
setActiveSheet('calculations')} + > + {renderCalculationsSpreadsheet()} +
+
+
+
+ ); +}; + +export default DualSpreadsheet; \ No newline at end of file diff --git a/src/components/DualSpreadsheet/index.ts b/src/components/DualSpreadsheet/index.ts new file mode 100644 index 0000000..f3b6b51 --- /dev/null +++ b/src/components/DualSpreadsheet/index.ts @@ -0,0 +1 @@ +export { default as DualSpreadsheet } from './DualSpreadsheet'; diff --git a/src/components/index.ts b/src/components/index.ts index 3aea011..7bd9907 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,2 +1,3 @@ +export { DualSpreadsheet } from "./DualSpreadsheet"; export { Spreadsheet } from "./Spreadsheet"; diff --git a/src/hooks/useMultiSheetEngine.ts b/src/hooks/useMultiSheetEngine.ts new file mode 100644 index 0000000..0ce6e40 --- /dev/null +++ b/src/hooks/useMultiSheetEngine.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CellValue, Workbook } from '../lib/spreadsheet-engine'; + +interface CellData { + value: string; + isSelected: boolean; +} + +interface SheetConfig { + name: string; + rows: number; + cols: number; +} + +interface UseMultiSheetEngineProps { + sheets: SheetConfig[]; + initialData?: Record>; +} + +export function useMultiSheetEngine({ + sheets, + initialData = {} +}: UseMultiSheetEngineProps) { + const workbookRef = useRef(null); + const [isCalculating, setIsCalculating] = useState(false); + const [sheetCells, setSheetCells] = useState>({}); + + // Инициализация workbook и листов + useEffect(() => { + if (!workbookRef.current) { + workbookRef.current = new Workbook(); + + // Создаем листы + sheets.forEach(sheetConfig => { + workbookRef.current!.addSheet(sheetConfig.name); + }); + + // Инициализируем состояние ячеек для каждого листа + const initialSheetCells: Record = {}; + sheets.forEach(sheetConfig => { + initialSheetCells[sheetConfig.name] = Array(sheetConfig.rows).fill(null).map(() => + Array(sheetConfig.cols).fill(null).map(() => ({ value: '', isSelected: false })) + ); + }); + setSheetCells(initialSheetCells); + + // Загружаем начальные данные + if (Object.keys(initialData).length > 0) { + workbookRef.current.fromJSON(initialData); + syncAllSheetsFromEngine(); + } + } + }, [sheets, initialData]); + + // Синхронизация всех листов из движка + const syncAllSheetsFromEngine = useCallback(() => { + if (!workbookRef.current) return; + + const newSheetCells: Record = {}; + + sheets.forEach(sheetConfig => { + const sheet = workbookRef.current!.getSheet(sheetConfig.name); + if (!sheet) return; + + const cells = Array(sheetConfig.rows).fill(null).map(() => + Array(sheetConfig.cols).fill(null).map(() => ({ value: '', isSelected: false })) + ); + + // Получаем все ячейки из движка + for (let row = 0; row < sheetConfig.rows; row++) { + for (let col = 0; col < sheetConfig.cols; col++) { + const cellName = columnToLabel(col) + (row + 1); + const cell = sheet.getCell(cellName); + + if (cell.raw !== null && cell.raw !== undefined) { + cells[row][col].value = String(cell.raw); + } + } + } + + newSheetCells[sheetConfig.name] = cells; + }); + + setSheetCells(newSheetCells); + }, [sheets]); + + // Установка значения ячейки + const setCellValue = useCallback((sheetName: string, row: number, col: number, value: string) => { + if (!workbookRef.current) return; + + const cellName = columnToLabel(col) + (row + 1); + const qualifiedName = `${sheetName}!${cellName}`; + + setIsCalculating(true); + + // Устанавливаем значение в движке + workbookRef.current.setCell(qualifiedName, value); + + // Запускаем пересчет + workbookRef.current.recalc(); + + setIsCalculating(false); + + // Синхронизируем состояние + syncAllSheetsFromEngine(); + }, [syncAllSheetsFromEngine]); + + // Получение значения ячейки (вычисленное) + const getCellValue = useCallback((sheetName: string, row: number, col: number): CellValue => { + if (!workbookRef.current) return ''; + + const cellName = columnToLabel(col) + (row + 1); + const qualifiedName = `${sheetName}!${cellName}`; + + return workbookRef.current.getValue(qualifiedName); + }, []); + + // Получение сырого значения ячейки (формула) + const getCellRawValue = useCallback((sheetName: string, row: number, col: number): string => { + if (!workbookRef.current) return ''; + + const sheet = workbookRef.current.getSheet(sheetName); + if (!sheet) return ''; + + const cellName = columnToLabel(col) + (row + 1); + const cell = sheet.getCell(cellName); + + return String(cell.raw || ''); + }, []); + + // Получение всех измененных ячеек для листа + const getModifiedCells = useCallback((sheetName: string): Record => { + if (!workbookRef.current) return {}; + + const sheet = workbookRef.current.getSheet(sheetName); + if (!sheet) return {}; + + return sheet.toJSON(); + }, []); + + // Получение всех измененных ячеек для всех листов + const getAllModifiedCells = useCallback((): Record> => { + if (!workbookRef.current) return {}; + + return workbookRef.current.toJSON(); + }, []); + + // Очистка всех данных + const clearAllData = useCallback(() => { + if (!workbookRef.current) return; + + workbookRef.current.clear(); + syncAllSheetsFromEngine(); + }, [syncAllSheetsFromEngine]); + + // Загрузка данных из JSON + const loadData = useCallback((data: Record>) => { + if (!workbookRef.current) return; + + workbookRef.current.fromJSON(data); + syncAllSheetsFromEngine(); + }, [syncAllSheetsFromEngine]); + + // Пересчет всех формул + const recalculate = useCallback(() => { + if (!workbookRef.current) return; + + setIsCalculating(true); + workbookRef.current.recalc(); + setIsCalculating(false); + syncAllSheetsFromEngine(); + }, [syncAllSheetsFromEngine]); + + return { + sheetCells, + setCellValue, + getCellValue, + getCellRawValue, + getModifiedCells, + getAllModifiedCells, + clearAllData, + loadData, + recalculate, + isCalculating, + workbook: workbookRef.current + }; +} + +// Вспомогательная функция для конвертации индекса колонки в букву +function columnToLabel(index: number): string { + return String.fromCharCode(65 + index); +} \ No newline at end of file diff --git a/src/lib/spreadsheet-engine.ts b/src/lib/spreadsheet-engine.ts index d7424df..f302fe4 100644 --- a/src/lib/spreadsheet-engine.ts +++ b/src/lib/spreadsheet-engine.ts @@ -1,6 +1,8 @@ // Регулярные выражения для ссылок const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+\b/g; // SheetName!A1 const LOCAL_REF_RE = /\b[A-Z]+[0-9]+\b/g; // A1, B2, etc. +const RANGE_RE = /\b[A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g; // A1:B2, D5:D7, etc. +const QUALIFIED_RANGE_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+:[A-Z]+[0-9]+\b/g; // SheetName!A1:B2 // Типы для значений ячеек export type CellValue = string | number | boolean | null | undefined; @@ -18,14 +20,29 @@ const DEFAULT_FUNCTIONS: Record = { RAND: () => Math.random(), RANDBETWEEN: (bottom: number, top: number) => Math.floor(Math.random() * (top - bottom + 1)) + bottom, - SUM: (...args: number[]) => args.reduce((sum, val) => sum + (val || 0), 0), - AVERAGE: (...args: number[]) => { - const validArgs = args.filter(val => val !== null && val !== undefined); + SUM: (...args: any[]) => { + const flatArgs = args.flat(); + return flatArgs.reduce((sum, val) => sum + (typeof val === 'number' ? val : 0), 0); + }, + AVERAGE: (...args: any[]) => { + const flatArgs = args.flat(); + const validArgs = flatArgs.filter(val => typeof val === 'number'); return validArgs.length > 0 ? validArgs.reduce((sum, val) => sum + val, 0) / validArgs.length : 0; }, - COUNT: (...args: any[]) => args.filter(val => typeof val === 'number').length, - MAX: (...args: number[]) => Math.max(...args.filter(val => val !== null && val !== undefined)), - MIN: (...args: number[]) => Math.min(...args.filter(val => val !== null && val !== undefined)), + COUNT: (...args: any[]) => { + const flatArgs = args.flat(); + return flatArgs.filter(val => typeof val === 'number').length; + }, + MAX: (...args: any[]) => { + const flatArgs = args.flat(); + const validArgs = flatArgs.filter(val => typeof val === 'number'); + return validArgs.length > 0 ? Math.max(...validArgs) : 0; + }, + MIN: (...args: any[]) => { + const flatArgs = args.flat(); + const validArgs = flatArgs.filter(val => typeof val === 'number'); + return validArgs.length > 0 ? Math.min(...validArgs) : 0; + }, IF: (condition: boolean, trueValue: CellValue, falseValue: CellValue) => condition ? trueValue : falseValue, AND: (...args: boolean[]) => args.every(Boolean), @@ -83,6 +100,37 @@ export class Cell { this.name = name; } + // Функция для развертывания диапазона в массив значений + private expandRange(range: string, sheetName?: string): number[] { + const parts = range.split(':'); + if (parts.length !== 2) return []; + + const startCell = parts[0]; + const endCell = parts[1]; + + const startCol = startCell.match(/[A-Z]+/)?.[0] || ''; + const startRow = parseInt(startCell.match(/[0-9]+/)?.[0] || '0'); + const endCol = endCell.match(/[A-Z]+/)?.[0] || ''; + const endRow = parseInt(endCell.match(/[0-9]+/)?.[0] || '0'); + + const startColIndex = startCol.charCodeAt(0) - 65; + const endColIndex = endCol.charCodeAt(0) - 65; + + const values: number[] = []; + + for (let row = startRow; row <= endRow; row++) { + for (let col = startColIndex; col <= endColIndex; col++) { + const cellName = String.fromCharCode(65 + col) + row; + const qualifiedName = sheetName ? `${sheetName}!${cellName}` : `${this.sheet.name}!${cellName}`; + const cellValue = this.sheet.workbook.getCell(qualifiedName).evaluate(); + const numValue = typeof cellValue === 'number' ? cellValue : parseFloat(String(cellValue)) || 0; + values.push(numValue); + } + } + + return values; + } + get qualifiedName(): string { return `${this.sheet.name}!${this.name}`; } @@ -147,6 +195,19 @@ export class Cell { // Заменяем ссылки на вычисленные значения let code = expr; + // ВАЖНО: Сначала заменяем диапазоны ячеек на массивы значений, + // затем отдельные ячейки, чтобы избежать конфликтов + code = code.replace(QUALIFIED_RANGE_RE, (match) => { + const [sheetName, range] = match.split('!'); + const values = this.expandRange(range, sheetName); + return `[${values.join(',')}]`; + }); + + code = code.replace(RANGE_RE, (match) => { + const values = this.expandRange(match); + return `[${values.join(',')}]`; + }); + // Заменяем квалифицированные ссылки на ячейки code = code.replace(QUALIFIED_REF_RE, (match) => { const cellValue = this.sheet.workbook.getCell(match).evaluate(); diff --git a/src/lib/template-data.ts b/src/lib/template-data.ts new file mode 100644 index 0000000..4fb96a9 --- /dev/null +++ b/src/lib/template-data.ts @@ -0,0 +1,63 @@ +// Пример шаблонных данных для отчета (только простые значения из Excel) +export const exampleTemplateData = { + // Заголовки + A1: 'Отчет о продажах', + A2: 'Период: Январь 2024', + A3: '', + + // Таблица данных + A4: 'Товар', + B4: 'Количество', + C4: 'Цена', + D4: 'Сумма', + + A5: 'Товар A', + B5: 100, + C5: 1500, + D5: 150000, // Простое значение вместо формулы + + A6: 'Товар B', + B6: 50, + C6: 2000, + D6: 100000, // Простое значение вместо формулы + + A7: 'Товар C', + B7: 75, + C7: 1200, + D7: 90000, // Простое значение вместо формулы + + A8: '', + A9: 'Итого:', + D9: 340000, // Простое значение вместо формулы + + // Дополнительные поля для расчетов + A11: 'НДС (20%):', + D11: 68000, // Простое значение вместо формулы + + A12: 'Всего к оплате:', + D12: 408000, // Простое значение вместо формулы + + // Поля для ссылок на правый лист (пока пустые, формулы можно добавить поверх) + A14: 'Скидка из расчетов:', + D14: '', // Пустое значение, формулу можно добавить поверх + + A15: 'Итого со скидкой:', + D15: '' // Пустое значение, формулу можно добавить поверх +}; + +// Пример данных для листа вычислений +export const exampleCalculationsData = { + A1: 'Расчеты', + A2: 'Скидка:', + B2: '=Report!D9*0.05', // 5% скидка от общей суммы + + A4: 'Дополнительные расчеты:', + A5: 'Средняя цена:', + B5: '=AVERAGE(Report!C5:C7)', + + A6: 'Максимальная сумма:', + B6: '=MAX(Report!D5:D7)', + + A7: 'Минимальная сумма:', + B7: '=MIN(Report!D5:D7)', +}; \ No newline at end of file diff --git a/src/pages/Home/ui/Page/Page.tsx b/src/pages/Home/ui/Page/Page.tsx index da88b3a..47ba248 100644 --- a/src/pages/Home/ui/Page/Page.tsx +++ b/src/pages/Home/ui/Page/Page.tsx @@ -1,10 +1,16 @@ import { FC } from "react"; -import { Spreadsheet } from "../../../../components"; +import { DualSpreadsheet } from "../../../../components"; +import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data"; const Home: FC = () => { + const initialData = { + Report: exampleTemplateData, + Calculations: exampleCalculationsData + }; + return (
- +
); };