From da7024cd085879f122057e0f7a317bccad7a6271 Mon Sep 17 00:00:00 2001 From: tlartem Date: Sun, 20 Jul 2025 09:11:46 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D1=83=D1=87=D0=BD=D0=BE=D0=B9=20=D1=80?= =?UTF-8?q?=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DualSpreadsheet/DualSpreadsheet.tsx | 315 +++++++++-------- .../TemplateManager/ExcelUploadPanel.tsx | 64 ++++ .../TemplateManager/FormattingToolbar.tsx | 61 ++++ src/components/TemplateManager/HeaderBar.tsx | 52 +++ .../TemplateManager/SpreadsheetViewer.tsx | 26 ++ .../TemplateManager/TemplateEditor.tsx | 327 +++--------------- src/services/excelSevice.ts | 58 ++++ tailwind.config.js | 48 +-- 8 files changed, 489 insertions(+), 462 deletions(-) create mode 100644 src/components/TemplateManager/ExcelUploadPanel.tsx create mode 100644 src/components/TemplateManager/FormattingToolbar.tsx create mode 100644 src/components/TemplateManager/HeaderBar.tsx create mode 100644 src/components/TemplateManager/SpreadsheetViewer.tsx create mode 100644 src/services/excelSevice.ts diff --git a/src/components/DualSpreadsheet/DualSpreadsheet.tsx b/src/components/DualSpreadsheet/DualSpreadsheet.tsx index 02f3471..ca087da 100644 --- a/src/components/DualSpreadsheet/DualSpreadsheet.tsx +++ b/src/components/DualSpreadsheet/DualSpreadsheet.tsx @@ -1,3 +1,6 @@ +import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave' +import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine' +import { MergedCell } from '@/types/template' import { produce } from 'immer' import { CSSProperties, @@ -10,9 +13,6 @@ import { useState, } from 'react' import { VariableSizeGrid } from 'react-window' -import { useFormulaAutoSave } from '../../hooks/useFormulaAutoSave' -import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine' -import { MergedCell } from '../../types/template' const ROW_COUNT = 90 const COL_COUNT = 20 @@ -25,6 +25,9 @@ const SHEET_NAMES = { type SheetType = keyof typeof SHEET_NAMES +// Добавляем массив типов листов для итерации +const SHEET_TYPES: SheetType[] = ['report', 'calculations'] + // --- Helper Functions --- const getColumnLabel = (index: number) => String.fromCharCode(65 + index) @@ -43,7 +46,7 @@ const cellAddressToCoords = (address: string): { row: number; col: number } => { const findMergedCellInfo = ( row: number, col: number, - mergedCells: MergedCell[], + mergedCells: MergedCell[] ): { isMerged: boolean isMainCell: boolean @@ -77,6 +80,57 @@ const findMergedCellInfo = ( return { isMerged: false, isMainCell: false } } +// --- Хук для перетаскивания разделителя --- +const useDraggableDivider = (onDrag: (delta: number) => void) => { + const ref = useRef(null) + + useEffect(() => { + const el = ref.current + if (!el) return + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault() + document.body.style.cursor = 'col-resize' + + const container = el.parentElement + if (!container) return + + const bounds = container.getBoundingClientRect() + + const handleMouseMove = (me: MouseEvent) => { + // Получаем актуальные границы контейнера + const currentBounds = container.getBoundingClientRect() + const newLeftWidth = me.clientX - currentBounds.left + const totalWidth = currentBounds.width + + // Рассчитываем процент напрямую от позиции мыши + let newLeftPercentage = newLeftWidth / totalWidth + newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage)) + + // Вызываем callback с новыми процентами + onDrag(newLeftPercentage) + } + + const handleMouseUp = () => { + document.body.style.cursor = 'auto' + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + el.addEventListener('mousedown', handleMouseDown) + + return () => { + el.removeEventListener('mousedown', handleMouseDown) + } + }, [onDrag]) + + return ref +} + // --- Interfaces --- interface DualSpreadsheetProps { templateData?: Record> @@ -197,8 +251,8 @@ const Cell = memo( backgroundColor: isSelected ? 'hsl(var(--accent))' : isModified - ? '#fef3c7' - : 'white', + ? '#fef3c7' + : 'white', } } @@ -212,7 +266,7 @@ const Cell = memo( const handleDoubleClick = useCallback( () => onCellDoubleClick(rowIndex, colIndex), - [onCellDoubleClick, rowIndex, colIndex], + [onCellDoubleClick, rowIndex, colIndex] ) return ( @@ -227,8 +281,8 @@ const Cell = memo( ref={activeCellRef} type="text" value={rawValue} // Во время редактирования показываем текущее редактируемое значение - onChange={(e) => onCellValueChange(e.target.value)} - onBlur={(e) => { + onChange={e => onCellValueChange(e.target.value)} + onBlur={e => { // Проверяем, не переходит ли фокус в formula bar const relatedTarget = e.relatedTarget as HTMLElement if (relatedTarget && relatedTarget.closest('.formula-bar')) { @@ -236,7 +290,7 @@ const Cell = memo( } stopEditing(true) }} - onKeyDown={(e) => { + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault() stopEditing(true) @@ -273,7 +327,7 @@ const Cell = memo( )} ) - }, + } ) interface FormulaBarProps { @@ -347,9 +401,9 @@ const FormulaBar = memo( } ${isCalculating ? 'animate-pulse' : ''}`} placeholder="Введите формулу или значение..." value={formulaBarValue} - onChange={(e) => onValueChange(e.target.value)} + onChange={e => onValueChange(e.target.value)} onFocus={onFocus} - onBlur={(e) => { + onBlur={e => { // Проверяем, не переходит ли фокус в ячейку редактирования const relatedTarget = e.relatedTarget as HTMLElement if ( @@ -441,15 +495,15 @@ const FormulaBar = memo( hasUnsavedChanges ? 'bg-primary text-primary-foreground hover:bg-primary/90' : lastSaveError - ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' - : 'bg-muted text-muted-foreground' + ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' + : 'bg-muted text-muted-foreground' } disabled:opacity-50`} title={ lastSaveError ? 'Повторить сохранение' : hasUnsavedChanges - ? 'Есть несохраненные изменения' - : 'Все изменения сохранены' + ? 'Есть несохраненные изменения' + : 'Все изменения сохранены' } > )} ) - }, + } ) // Рендер-проп для ячеек грида @@ -531,7 +585,7 @@ const CellRenderer = ({ isModified: cellData.isModified, displayValue, rawValue, - }, + } ) } @@ -599,15 +653,15 @@ interface SpreadsheetProps { rowHeaderRefs: React.MutableRefObject< Record > - gridRefs: React.MutableRefObject> + gridRefs: React.MutableRefObject> onGridScroll: ( sheetType: SheetType, - params: { scrollLeft: number; scrollTop: number }, + params: { scrollLeft: number; scrollTop: number } ) => void handleColumnResize: ( sheetType: SheetType, colIndex: number, - newWidth: number, + newWidth: number ) => void mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек } @@ -681,11 +735,12 @@ const Spreadsheet = ({ className="flex min-w-0 flex-col overflow-hidden" style={{ flexBasis: `${spreadsheetWidths[SHEET_NAMES[sheetType]] * 100}%`, + transition: 'none', }} onClick={() => setActiveSheet(sheetType)} >
(containerRefs.current[sheetType] = el)} + ref={el => (containerRefs.current[sheetType] = el)} className="flex h-full flex-col overflow-hidden border bg-card shadow-sm" onKeyDown={handleKeyDown} tabIndex={0} @@ -709,7 +764,7 @@ const Spreadsheet = ({
(rowHeaderRefs.current[sheetType] = el)} + ref={el => (rowHeaderRefs.current[sheetType] = el)} className="overflow-y-hidden" style={{ height: gridSize.height }} > @@ -729,7 +784,7 @@ const Spreadsheet = ({
{/* Column Headers */}
(headerRefs.current[sheetType] = el)} + ref={el => (headerRefs.current[sheetType] = el)} className="column-headers overflow-x-hidden" >
@@ -772,7 +827,7 @@ const Spreadsheet = ({ {/* Grid */}
(gridRefs.current[sheetType] = el)} + ref={el => (gridRefs.current[sheetType] = el)} columnCount={COL_COUNT} rowCount={ROW_COUNT} columnWidth={(index: number) => widths[index]} @@ -780,7 +835,7 @@ const Spreadsheet = ({ height={gridSize.height} width={gridSize.width} itemData={itemData} - onScroll={(e) => onGridScroll(sheetType, e)} + onScroll={e => onGridScroll(sheetType, e)} > {CellRenderer} @@ -804,7 +859,7 @@ const DualSpreadsheet: FC = ({ { name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT }, ], initialData: templateData || {}, - }, + } ) const [activeSheet, setActiveSheet] = useState('report') @@ -820,9 +875,9 @@ const DualSpreadsheet: FC = ({ () => ({ report: Array(COL_COUNT).fill(96), calculations: Array(COL_COUNT).fill(96), - }), + }) ) - const gridRefs = useRef>({ + const gridRefs = useRef>({ report: null, calculations: null, }) @@ -844,7 +899,7 @@ const DualSpreadsheet: FC = ({ const initialTemplateValues = useRef>({}) const cellDataCache = useRef>>( - {}, + {} ) const lastRevision = useRef(-1) @@ -866,7 +921,7 @@ const DualSpreadsheet: FC = ({ console.log( '✅ Загружено значений шаблона:', - Object.keys(initialTemplateValues.current).length, + Object.keys(initialTemplateValues.current).length ) } else { console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены') @@ -900,7 +955,7 @@ const DualSpreadsheet: FC = ({ for (let col = 0; col < COL_COUNT; col++) { const cellKey = `${row}-${col}` const displayValue = String( - multiSheetEngine.getCellValue(sheetName, row, col) || '', + multiSheetEngine.getCellValue(sheetName, row, col) || '' ) allCellValues[cellKey] = displayValue.trim() } @@ -976,7 +1031,7 @@ const DualSpreadsheet: FC = ({ } if (lastRevision.current !== revision) { - Object.keys(cellDataCache.current).forEach((key) => { + Object.keys(cellDataCache.current).forEach(key => { if (!key.endsWith(`-${revision}`)) { delete cellDataCache.current[key] } @@ -987,7 +1042,7 @@ const DualSpreadsheet: FC = ({ cellDataCache.current[cacheKey] = cachedData return cachedData }, - [revision, multiSheetEngine, initialTemplateValues], + [revision, multiSheetEngine, initialTemplateValues] ) const formulaBarValue = useMemo(() => { @@ -999,7 +1054,7 @@ const DualSpreadsheet: FC = ({ return multiSheetEngine.getCellRawValue( sheetName, selectedCell.row, - selectedCell.col, + selectedCell.col ) } return '' @@ -1034,7 +1089,7 @@ const DualSpreadsheet: FC = ({ const rawValue = multiSheetEngine.getCellRawValue( sheetName, selectedCell.row, - selectedCell.col, + selectedCell.col ) setIsEditing(true) setEditingValue(rawValue) @@ -1049,7 +1104,7 @@ const DualSpreadsheet: FC = ({ const oldValue = multiSheetEngine.getCellRawValue( sheetName, selectedCell.row, - selectedCell.col, + selectedCell.col ) const templateValue = initialTemplateValues.current[cellName] ?? '' @@ -1066,7 +1121,7 @@ const DualSpreadsheet: FC = ({ sheetName, selectedCell.row, selectedCell.col, - editingValue, + editingValue ) updateRevision() multiSheetEngine.debouncedRecalc() @@ -1075,7 +1130,7 @@ const DualSpreadsheet: FC = ({ setEditingValue('') containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, - [activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision], + [activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision] ) const handleCellChange = useCallback( @@ -1084,7 +1139,7 @@ const DualSpreadsheet: FC = ({ setEditingValue(newValue) } }, - [isEditing], + [isEditing] ) const handleClearCell = useCallback(() => { @@ -1094,7 +1149,7 @@ const DualSpreadsheet: FC = ({ sheetName, selectedCell.row, selectedCell.col, - '', + '' ) updateRevision() multiSheetEngine.debouncedRecalc() @@ -1113,16 +1168,16 @@ const DualSpreadsheet: FC = ({ const newRow = Math.max( 0, - Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow), + Math.min(ROW_COUNT - 1, selectedCell.row + deltaRow) ) const newCol = Math.max( 0, - Math.min(COL_COUNT - 1, selectedCell.col + deltaCol), + Math.min(COL_COUNT - 1, selectedCell.col + deltaCol) ) setSelectedCell({ row: newRow, col: newCol }) }, - [selectedCell, isEditing, stopEditing], + [selectedCell, isEditing, stopEditing] ) const handleKeyDown = useCallback( @@ -1158,7 +1213,7 @@ const DualSpreadsheet: FC = ({ setIsEditing(true) } }, - [selectedCell, isEditing, moveSelection, startEditing, handleClearCell], + [selectedCell, isEditing, moveSelection, startEditing, handleClearCell] ) const handleCellClick = useCallback( @@ -1169,7 +1224,7 @@ const DualSpreadsheet: FC = ({ setSelectedCell({ row, col }) containerRefs.current[activeSheet]?.focus({ preventScroll: true }) }, - [isEditing, stopEditing, activeSheet], + [isEditing, stopEditing, activeSheet] ) const handleCellDoubleClick = useCallback( @@ -1177,22 +1232,22 @@ const DualSpreadsheet: FC = ({ setSelectedCell({ row, col }) startEditing() }, - [startEditing], + [startEditing] ) const handleColumnResize = useCallback( (sheetType: SheetType, colIndex: number, newWidth: number) => { setColumnWidths( - produce((draft) => { + produce(draft => { draft[sheetType][colIndex] = Math.max(60, newWidth) const grid = gridRefs.current[sheetType] if (grid) { grid.resetAfterColumnIndex(colIndex) } - }), + }) ) }, - [], + [] ) const handleFormulaBarChange = useCallback( @@ -1204,7 +1259,7 @@ const DualSpreadsheet: FC = ({ setEditingValue(newValue) } }, - [selectedCell, isEditing], + [selectedCell, isEditing] ) const handleFormulaBarFocus = useCallback(() => { @@ -1225,7 +1280,7 @@ const DualSpreadsheet: FC = ({ stopEditing(true) } }, - [isEditing, stopEditing], + [isEditing, stopEditing] ) const handleFormulaBarKeyDown = useCallback( @@ -1243,13 +1298,13 @@ const DualSpreadsheet: FC = ({ containerRefs.current[activeSheet]?.focus({ preventScroll: true }) } }, - [stopEditing, moveSelection, activeSheet], + [stopEditing, moveSelection, activeSheet] ) const onGridScroll = useCallback( ( sheetType: SheetType, - { scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number }, + { scrollLeft, scrollTop }: { scrollLeft: number; scrollTop: number } ) => { if (headerRefs.current[sheetType]) { headerRefs.current[sheetType]!.scrollLeft = scrollLeft @@ -1258,10 +1313,9 @@ const DualSpreadsheet: FC = ({ rowHeaderRefs.current[sheetType]!.scrollTop = scrollTop } }, - [], + [] ) - const [separatorRef, setSeparatorRef] = useState(null) const [spreadsheetWidths, setSpreadsheetWidths] = useState({ L: 0.5, R: 0.5, @@ -1283,9 +1337,22 @@ const DualSpreadsheet: FC = ({ templateId, autoSaveDelay: 2000, enableAutoSave: true, - }, + } ) + // Используем хук для перетаскивания разделителя + const handleDividerDrag = useCallback((newLeftPercentage: number) => { + // Используем requestAnimationFrame для более плавного обновления + requestAnimationFrame(() => { + setSpreadsheetWidths({ + L: newLeftPercentage, + R: 1 - newLeftPercentage, + }) + }) + }, []) + + const separatorRef = useDraggableDivider(handleDividerDrag) + // Загружаем сохраненные формулы при инициализации useEffect(() => { if (!templateId || isInitialized) return @@ -1293,7 +1360,7 @@ const DualSpreadsheet: FC = ({ // Проверяем, есть ли начальные данные const hasInitialData = Object.keys(templateData).length > 0 && - Object.values(templateData).some((sheet) => Object.keys(sheet).length > 0) + Object.values(templateData).some(sheet => Object.keys(sheet).length > 0) // Всегда загружаем сохраненные данные, если они есть const savedFormulas = loadSavedFormulas() @@ -1348,47 +1415,6 @@ const DualSpreadsheet: FC = ({ // --- Spreadsheet Component --- - useEffect(() => { - if (!separatorRef) return - - const handleMouseDown = (e: MouseEvent) => { - e.preventDefault() - document.body.style.cursor = 'col-resize' - - const handleMouseMove = (me: MouseEvent) => { - const container = separatorRef.parentElement - if (!container) return - const bounds = container.getBoundingClientRect() - const newLeftWidth = me.clientX - bounds.left - const totalWidth = bounds.width - - let newLeftPercentage = newLeftWidth / totalWidth - // Ограничиваем минимальную ширину (например 20%) - newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage)) - - setSpreadsheetWidths({ - L: newLeftPercentage, - R: 1 - newLeftPercentage, - }) - } - - const handleMouseUp = () => { - document.body.style.cursor = 'auto' - document.removeEventListener('mousemove', handleMouseMove) - document.removeEventListener('mouseup', handleMouseUp) - } - - document.addEventListener('mousemove', handleMouseMove) - document.addEventListener('mouseup', handleMouseUp) - } - - separatorRef.addEventListener('mousedown', handleMouseDown) - - return () => { - separatorRef.removeEventListener('mousedown', handleMouseDown) - } - }, [separatorRef]) - return (
= ({ onFocus={handleFormulaBarFocus} onBlur={handleFormulaBarBlur} onKeyDown={handleFormulaBarKeyDown} - formulaBarRef={(el) => { + formulaBarRef={el => { formulaBarRef.current = el }} isSaving={isSaving} @@ -1409,61 +1435,44 @@ const DualSpreadsheet: FC = ({ onManualSave={manualSave} onClearError={clearError} /> -
- +
+ {SHEET_TYPES.map((sheetType, index) => ( + + ))}
-
) diff --git a/src/components/TemplateManager/ExcelUploadPanel.tsx b/src/components/TemplateManager/ExcelUploadPanel.tsx new file mode 100644 index 0000000..8748a41 --- /dev/null +++ b/src/components/TemplateManager/ExcelUploadPanel.tsx @@ -0,0 +1,64 @@ +import { Button } from '@/components/ui/button' +import { FileText, Upload } from 'lucide-react' +import React, { useRef } from 'react' + +interface ExcelUploadPanelProps { + fileName?: string + isLoading: boolean + onUploadClick: () => void + onFileChange: (e: React.ChangeEvent) => void +} + +export const ExcelUploadPanel: React.FC = ({ + fileName, + isLoading, + onUploadClick, + onFileChange, +}) => { + const fileInputRef = useRef(null) + + return ( +
+ + +
+ + + {fileName || 'Файл не выбран'} + +
+ + +
+ ) +} diff --git a/src/components/TemplateManager/FormattingToolbar.tsx b/src/components/TemplateManager/FormattingToolbar.tsx new file mode 100644 index 0000000..4e69f79 --- /dev/null +++ b/src/components/TemplateManager/FormattingToolbar.tsx @@ -0,0 +1,61 @@ +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { + AlignCenter, + AlignLeft, + AlignRight, + Bold, + Grid, + Italic, + Palette, + Type, + Underline, +} from 'lucide-react' +import React from 'react' + +export const FormattingToolbar: React.FC = () => ( +
+
+ {[Bold, Italic, Underline].map((Icon, i) => ( + + ))} +
+ + + +
+ {[AlignLeft, AlignCenter, AlignRight].map((Icon, i) => ( + + ))} +
+ + + +
+ {[Palette, Type, Grid].map((Icon, i) => ( + + ))} +
+
+) diff --git a/src/components/TemplateManager/HeaderBar.tsx b/src/components/TemplateManager/HeaderBar.tsx new file mode 100644 index 0000000..656dc02 --- /dev/null +++ b/src/components/TemplateManager/HeaderBar.tsx @@ -0,0 +1,52 @@ +import { Button } from '@/components/ui/button' +import { Template } from '@/types/template' +import { ArrowLeft, FileText, Wrench } from 'lucide-react' +import React from 'react' +import { useNavigate } from 'react-router-dom' + +interface HeaderBarProps { + template: Template + onBack: () => void +} + +export const HeaderBar: React.FC = ({ template, onBack }) => { + const navigate = useNavigate() + + return ( +
+
+
+ +
+

{template.name}

+

+ Настройте Excel шаблон и значения отчета +

+
+
+
+ + +
+
+
+ ) +} diff --git a/src/components/TemplateManager/SpreadsheetViewer.tsx b/src/components/TemplateManager/SpreadsheetViewer.tsx new file mode 100644 index 0000000..de2ea53 --- /dev/null +++ b/src/components/TemplateManager/SpreadsheetViewer.tsx @@ -0,0 +1,26 @@ +import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet' +import { ExcelParseResult } from '@/services/excelSevice' +import React from 'react' + +interface SpreadsheetViewerProps { + data: Record + mergedCells: ExcelParseResult['mergedCells'] + dataVersion: number + templateId: number | string +} + +export const SpreadsheetViewer: React.FC = ({ + data, + mergedCells, + dataVersion, + templateId, +}) => ( +
+ +
+) diff --git a/src/components/TemplateManager/TemplateEditor.tsx b/src/components/TemplateManager/TemplateEditor.tsx index 20ab135..cfb25af 100644 --- a/src/components/TemplateManager/TemplateEditor.tsx +++ b/src/components/TemplateManager/TemplateEditor.tsx @@ -1,25 +1,10 @@ -import { - AlignCenter, - AlignLeft, - AlignRight, - ArrowLeft, - Bold, - FileText, - Grid, - Italic, - Palette, - Type, - Underline, - Upload, - Wrench, -} from 'lucide-react' -import React, { useRef, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import * as XLSX from 'xlsx' -import { Template } from '../../types/template' -import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet' -import { Button } from '../ui/button' -import { Separator } from '../ui/separator' +import { parseExcelFile } from '@/services/excelSevice' +import { Template } from '@/types/template' +import React, { useState } from 'react' +import { ExcelUploadPanel } from './ExcelUploadPanel' +import { FormattingToolbar } from './FormattingToolbar' +import { HeaderBar } from './HeaderBar' +import { SpreadsheetViewer } from './SpreadsheetViewer' interface TemplateEditorProps { template: Template @@ -30,284 +15,56 @@ export const TemplateEditor: React.FC = ({ template, onBack, }) => { - const [editedTemplate, setEditedTemplate] = useState