This commit is contained in:
Artyom Tsyrulnikov
2025-07-13 16:51:51 +03:00
parent 36bdb4b2be
commit e1fe966bcd
8 changed files with 1227 additions and 10 deletions

View File

@@ -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<typeof store.getState>;

View File

@@ -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<string, Record<string, any>>; // Данные для обоих листов
}
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ 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<CellData[][]>(() => {
return Array(20).fill(null).map(() =>
Array(10).fill(null).map(() => ({
value: '',
isSelected: false,
isModified: false,
templateValue: ''
}))
);
});
const [calculationsCells, setCalculationsCells] = useState<CellData[][]>(() => {
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<string>('');
const [formulaBarValue, setFormulaBarValue] = useState<string>('');
const [reportColumnWidths, setReportColumnWidths] = useState<number[]>(Array(10).fill(96));
const [calculationsColumnWidths, setCalculationsColumnWidths] = useState<number[]>(Array(10).fill(96));
const [rowHeights, setRowHeights] = useState<number[]>(Array(20).fill(32));
const reportContainerRef = useRef<HTMLDivElement>(null);
const calculationsContainerRef = useRef<HTMLDivElement>(null);
const reportInputRefs = useRef<(HTMLInputElement | null)[][]>([]);
const calculationsInputRefs = useRef<(HTMLInputElement | null)[][]>([]);
const reportFormulaBarRef = useRef<HTMLInputElement>(null);
const calculationsFormulaBarRef = useRef<HTMLInputElement>(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<string, { rawValue: string; calculatedValue: any }> = {};
// Проверяем каждую ячейку в отчете на изменения
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\роверьте консоль для подробностей.`);
} 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 (
<div className="overflow-auto h-full">
<table className="border-collapse" style={{ minWidth: `${48 + reportColumnWidths.reduce((a, b) => a + b, 0)}px` }}>
<thead>
<tr>
<th className="w-12 h-8 bg-gray-100 border border-gray-300 text-xs text-gray-600 font-medium"></th>
{Array.from({ length: 10 }, (_, i) => (
<th
key={i}
className="h-8 bg-gray-100 border border-gray-300 text-xs text-gray-600 font-medium text-center relative"
style={{ width: reportColumnWidths[i] }}
>
{getColumnLabel(i)}
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-blue-500 hover:bg-opacity-50"
onMouseDown={(e) => {
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);
}}
/>
</th>
))}
</tr>
</thead>
<tbody>
{cells.map((row, rowIndex) => (
<tr key={rowIndex}>
<td className="w-12 h-8 bg-gray-100 border border-gray-300 text-xs text-gray-600 font-medium text-center">
{rowIndex + 1}
</td>
{row.map((cell, colIndex) => (
<td
key={colIndex}
className={`border border-gray-300 relative ${
selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'report'
? ''
: 'hover:bg-gray-50'
} ${
cell.isModified
? 'bg-yellow-50'
: ''
}`}
style={{
width: reportColumnWidths[colIndex],
height: rowHeights[rowIndex],
...(selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'report'
? { boxShadow: isEditing
? 'inset 0 0 0 2px #3b82f6, inset 0 0 0 4px rgba(59, 130, 246, 0.3)'
: 'inset 0 0 0 2px #3b82f6'
}
: {})
}}
onClick={() => {
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);
}}
>
<input
type="text"
className={`w-full h-full px-1 text-sm bg-transparent border-none outline-none resize-none ${
isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'report'
? 'cursor-text'
: 'cursor-cell'
} ${
cell.isModified
? 'text-orange-700 font-medium'
: ''
}`}
value={isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'report'
? cell.value
: String(multiSheetEngine.getCellValue(sheetName, rowIndex, colIndex) || '')}
onChange={(e) => {
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;
}
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
const renderCalculationsSpreadsheet = () => {
const cells = calculationsCells;
const sheetName = 'Calculations';
return (
<div className="overflow-auto h-full">
<table className="border-collapse" style={{ minWidth: `${48 + calculationsColumnWidths.reduce((a, b) => a + b, 0)}px` }}>
<thead>
<tr>
<th className="w-12 h-8 bg-gray-100 border border-gray-300 text-xs text-gray-600 font-medium"></th>
{Array.from({ length: 10 }, (_, i) => (
<th
key={i}
className="h-8 bg-gray-100 border border-gray-300 text-xs text-gray-600 font-medium text-center relative"
style={{ width: calculationsColumnWidths[i] }}
>
{getColumnLabel(i)}
<div
className="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-blue-500 hover:bg-opacity-50"
onMouseDown={(e) => {
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);
}}
/>
</th>
))}
</tr>
</thead>
<tbody>
{cells.map((row, rowIndex) => (
<tr key={rowIndex}>
<td className="w-12 h-8 bg-gray-100 border border-gray-300 text-xs text-gray-600 font-medium text-center">
{rowIndex + 1}
</td>
{row.map((cell, colIndex) => (
<td
key={colIndex}
className={`border border-gray-300 relative ${
selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'calculations'
? ''
: 'hover:bg-gray-50'
}`}
style={{
width: calculationsColumnWidths[colIndex],
height: rowHeights[rowIndex],
...(selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'calculations'
? { boxShadow: isEditing
? 'inset 0 0 0 2px #3b82f6, inset 0 0 0 4px rgba(59, 130, 246, 0.3)'
: 'inset 0 0 0 2px #3b82f6'
}
: {})
}}
onClick={() => {
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);
}}
>
<input
type="text"
className={`w-full h-full px-1 text-sm bg-transparent border-none outline-none resize-none ${
isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'calculations'
? 'cursor-text'
: 'cursor-cell'
}`}
value={isEditing && selectedCell?.row === rowIndex && selectedCell?.col === colIndex && activeSheet === 'calculations'
? cell.value
: String(multiSheetEngine.getCellValue(sheetName, rowIndex, colIndex) || '')}
onChange={(e) => {
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;
}
}}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
return (
<div className="h-screen flex flex-col bg-gray-50">
{/* Заголовок */}
<div className="bg-white border-b border-gray-200 p-4 flex-shrink-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-gray-800">Система отчетов</h1>
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-600">
<div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-yellow-50 border border-yellow-200 rounded"></div>
<span>Измененные ячейки</span>
</div>
<div className="flex items-center space-x-1">
<div className="w-3 h-3 bg-white border border-gray-200 rounded"></div>
<span>Шаблонные данные</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-xs text-gray-500">Ctrl+Z - восстановить шаблон</span>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={handleExportChanges}
className="px-4 py-2 rounded-md text-sm font-medium bg-green-500 text-white hover:bg-green-600 transition-colors"
>
Экспорт изменений
</button>
</div>
</div>
</div>
{/* Двойная панель */}
<div className="flex-1 flex min-h-0">
{/* Левая панель - Отчет */}
<div className="w-1/2 border-r border-gray-200 flex flex-col">
<div className="bg-gray-100 px-4 py-2 border-b border-gray-200 flex-shrink-0">
<h2 className="text-sm font-medium text-gray-700">Отчет</h2>
</div>
{/* Formula bar для отчета */}
<div className="border-b border-gray-200 p-2 bg-white flex-shrink-0">
<div className="flex items-center space-x-2">
<div className="text-sm text-gray-600 min-w-[60px]">
{selectedCell && activeSheet === 'report' ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : ''}
</div>
{multiSheetEngine.isCalculating && (
<div className="text-xs text-blue-600 animate-pulse">
Вычисляется...
</div>
)}
<div className="flex-1">
<input
ref={reportFormulaBarRef}
type="text"
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none ${
isEditing && activeSheet === 'report'
? 'border-blue-400 focus:ring-2 focus:ring-blue-300'
: 'border-gray-300 focus:ring-2 focus:ring-blue-500'
}`}
placeholder="Введите формулу..."
value={activeSheet === 'report' ? formulaBarValue : ''}
onChange={(e) => {
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);
}
}}
/>
</div>
</div>
</div>
<div
ref={reportContainerRef}
className="flex-1 bg-white outline-none overflow-hidden"
tabIndex={activeSheet === 'report' ? 0 : -1}
onKeyDown={activeSheet === 'report' ? handleKeyDown : undefined}
onClick={() => setActiveSheet('report')}
>
{renderReportSpreadsheet()}
</div>
</div>
{/* Правая панель - Вычисления */}
<div className="w-1/2 flex flex-col">
<div className="bg-gray-100 px-4 py-2 border-b border-gray-200 flex-shrink-0">
<h2 className="text-sm font-medium text-gray-700">Вычисления</h2>
</div>
{/* Formula bar для вычислений */}
<div className="border-b border-gray-200 p-2 bg-white flex-shrink-0">
<div className="flex items-center space-x-2">
<div className="text-sm text-gray-600 min-w-[60px]">
{selectedCell && activeSheet === 'calculations' ? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}` : ''}
</div>
{multiSheetEngine.isCalculating && (
<div className="text-xs text-blue-600 animate-pulse">
Вычисляется...
</div>
)}
<div className="flex-1">
<input
ref={calculationsFormulaBarRef}
type="text"
className={`w-full px-2 py-1 text-sm border rounded focus:outline-none ${
isEditing && activeSheet === 'calculations'
? 'border-blue-400 focus:ring-2 focus:ring-blue-300'
: 'border-gray-300 focus:ring-2 focus:ring-blue-500'
}`}
placeholder="Введите формулу..."
value={activeSheet === 'calculations' ? formulaBarValue : ''}
onChange={(e) => {
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);
}
}}
/>
</div>
</div>
</div>
<div
ref={calculationsContainerRef}
className="flex-1 bg-white outline-none overflow-hidden"
tabIndex={activeSheet === 'calculations' ? 0 : -1}
onKeyDown={activeSheet === 'calculations' ? handleKeyDown : undefined}
onClick={() => setActiveSheet('calculations')}
>
{renderCalculationsSpreadsheet()}
</div>
</div>
</div>
</div>
);
};
export default DualSpreadsheet;

View File

@@ -0,0 +1 @@
export { default as DualSpreadsheet } from './DualSpreadsheet';

View File

@@ -1,2 +1,3 @@
export { DualSpreadsheet } from "./DualSpreadsheet";
export { Spreadsheet } from "./Spreadsheet";

View File

@@ -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<string, Record<string, any>>;
}
export function useMultiSheetEngine({
sheets,
initialData = {}
}: UseMultiSheetEngineProps) {
const workbookRef = useRef<Workbook | null>(null);
const [isCalculating, setIsCalculating] = useState(false);
const [sheetCells, setSheetCells] = useState<Record<string, CellData[][]>>({});
// Инициализация workbook и листов
useEffect(() => {
if (!workbookRef.current) {
workbookRef.current = new Workbook();
// Создаем листы
sheets.forEach(sheetConfig => {
workbookRef.current!.addSheet(sheetConfig.name);
});
// Инициализируем состояние ячеек для каждого листа
const initialSheetCells: Record<string, CellData[][]> = {};
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<string, CellData[][]> = {};
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<string, CellValue> => {
if (!workbookRef.current) return {};
const sheet = workbookRef.current.getSheet(sheetName);
if (!sheet) return {};
return sheet.toJSON();
}, []);
// Получение всех измененных ячеек для всех листов
const getAllModifiedCells = useCallback((): Record<string, Record<string, CellValue>> => {
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<string, Record<string, any>>) => {
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);
}

View File

@@ -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<string, ExcelFunction> = {
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();

63
src/lib/template-data.ts Normal file
View File

@@ -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)',
};

View File

@@ -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 (
<div className="h-screen bg-gray-50">
<Spreadsheet />
<DualSpreadsheet templateData={initialData} />
</div>
);
};