2 menus
This commit is contained in:
@@ -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({
|
export const store = configureStore({
|
||||||
reducer: {},
|
reducer: {
|
||||||
|
app: appSlice.reducer
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>;
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
|||||||
882
src/components/DualSpreadsheet/DualSpreadsheet.tsx
Normal file
882
src/components/DualSpreadsheet/DualSpreadsheet.tsx
Normal 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\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;
|
||||||
1
src/components/DualSpreadsheet/index.ts
Normal file
1
src/components/DualSpreadsheet/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as DualSpreadsheet } from './DualSpreadsheet';
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
|
export { DualSpreadsheet } from "./DualSpreadsheet";
|
||||||
export { Spreadsheet } from "./Spreadsheet";
|
export { Spreadsheet } from "./Spreadsheet";
|
||||||
|
|
||||||
|
|||||||
192
src/hooks/useMultiSheetEngine.ts
Normal file
192
src/hooks/useMultiSheetEngine.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Регулярные выражения для ссылок
|
// Регулярные выражения для ссылок
|
||||||
const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+\b/g; // SheetName!A1
|
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 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;
|
export type CellValue = string | number | boolean | null | undefined;
|
||||||
@@ -18,14 +20,29 @@ const DEFAULT_FUNCTIONS: Record<string, ExcelFunction> = {
|
|||||||
RAND: () => Math.random(),
|
RAND: () => Math.random(),
|
||||||
RANDBETWEEN: (bottom: number, top: number) =>
|
RANDBETWEEN: (bottom: number, top: number) =>
|
||||||
Math.floor(Math.random() * (top - bottom + 1)) + bottom,
|
Math.floor(Math.random() * (top - bottom + 1)) + bottom,
|
||||||
SUM: (...args: number[]) => args.reduce((sum, val) => sum + (val || 0), 0),
|
SUM: (...args: any[]) => {
|
||||||
AVERAGE: (...args: number[]) => {
|
const flatArgs = args.flat();
|
||||||
const validArgs = args.filter(val => val !== null && val !== undefined);
|
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;
|
return validArgs.length > 0 ? validArgs.reduce((sum, val) => sum + val, 0) / validArgs.length : 0;
|
||||||
},
|
},
|
||||||
COUNT: (...args: any[]) => args.filter(val => typeof val === 'number').length,
|
COUNT: (...args: any[]) => {
|
||||||
MAX: (...args: number[]) => Math.max(...args.filter(val => val !== null && val !== undefined)),
|
const flatArgs = args.flat();
|
||||||
MIN: (...args: number[]) => Math.min(...args.filter(val => val !== null && val !== undefined)),
|
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) =>
|
IF: (condition: boolean, trueValue: CellValue, falseValue: CellValue) =>
|
||||||
condition ? trueValue : falseValue,
|
condition ? trueValue : falseValue,
|
||||||
AND: (...args: boolean[]) => args.every(Boolean),
|
AND: (...args: boolean[]) => args.every(Boolean),
|
||||||
@@ -83,6 +100,37 @@ export class Cell {
|
|||||||
this.name = name;
|
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 {
|
get qualifiedName(): string {
|
||||||
return `${this.sheet.name}!${this.name}`;
|
return `${this.sheet.name}!${this.name}`;
|
||||||
}
|
}
|
||||||
@@ -147,6 +195,19 @@ export class Cell {
|
|||||||
// Заменяем ссылки на вычисленные значения
|
// Заменяем ссылки на вычисленные значения
|
||||||
let code = expr;
|
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) => {
|
code = code.replace(QUALIFIED_REF_RE, (match) => {
|
||||||
const cellValue = this.sheet.workbook.getCell(match).evaluate();
|
const cellValue = this.sheet.workbook.getCell(match).evaluate();
|
||||||
|
|||||||
63
src/lib/template-data.ts
Normal file
63
src/lib/template-data.ts
Normal 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)',
|
||||||
|
};
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { Spreadsheet } from "../../../../components";
|
import { DualSpreadsheet } from "../../../../components";
|
||||||
|
import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data";
|
||||||
|
|
||||||
const Home: FC = () => {
|
const Home: FC = () => {
|
||||||
|
const initialData = {
|
||||||
|
Report: exampleTemplateData,
|
||||||
|
Calculations: exampleCalculationsData
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-gray-50">
|
<div className="h-screen bg-gray-50">
|
||||||
<Spreadsheet />
|
<DualSpreadsheet templateData={initialData} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user