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({
|
||||
reducer: {},
|
||||
reducer: {
|
||||
app: appSlice.reducer
|
||||
},
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
|
||||
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 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
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 { 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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user