ручной рефакторинг

This commit is contained in:
2025-07-20 09:11:46 +03:00
parent 1d92316db7
commit da7024cd08
8 changed files with 489 additions and 462 deletions

View File

@@ -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<HTMLDivElement>(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<string, Record<string, any>>
@@ -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(
)}
</div>
)
},
}
)
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
? 'Есть несохраненные изменения'
: 'Все изменения сохранены'
? 'Есть несохраненные изменения'
: 'Все изменения сохранены'
}
>
<svg
@@ -468,15 +522,15 @@ const FormulaBar = memo(
{lastSaveError
? 'Повторить'
: hasUnsavedChanges
? 'Сохранить'
: 'Сохранено'}
? 'Сохранить'
: 'Сохранено'}
</button>
)}
</div>
</div>
</div>
)
},
}
)
// Рендер-проп для ячеек грида
@@ -531,7 +585,7 @@ const CellRenderer = ({
isModified: cellData.isModified,
displayValue,
rawValue,
},
}
)
}
@@ -599,15 +653,15 @@ interface SpreadsheetProps {
rowHeaderRefs: React.MutableRefObject<
Record<SheetType, HTMLDivElement | null>
>
gridRefs: React.MutableRefObject<Record<SheetType, VariableSizeGrid | null>>
gridRefs: React.MutableRefObject<Record<SheetType, any>>
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)}
>
<div
ref={(el) => (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 = ({
</div>
</div>
<div
ref={(el) => (rowHeaderRefs.current[sheetType] = el)}
ref={el => (rowHeaderRefs.current[sheetType] = el)}
className="overflow-y-hidden"
style={{ height: gridSize.height }}
>
@@ -729,7 +784,7 @@ const Spreadsheet = ({
<div className="flex-1 overflow-hidden">
{/* Column Headers */}
<div
ref={(el) => (headerRefs.current[sheetType] = el)}
ref={el => (headerRefs.current[sheetType] = el)}
className="column-headers overflow-x-hidden"
>
<div className="flex">
@@ -772,7 +827,7 @@ const Spreadsheet = ({
{/* Grid */}
<div className="min-h-0 flex-1">
<VariableSizeGrid
ref={(el) => (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}
</VariableSizeGrid>
@@ -804,7 +859,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
{ name: SHEET_NAMES.calculations, rows: ROW_COUNT, cols: COL_COUNT },
],
initialData: templateData || {},
},
}
)
const [activeSheet, setActiveSheet] = useState<SheetType>('report')
@@ -820,9 +875,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
() => ({
report: Array(COL_COUNT).fill(96),
calculations: Array(COL_COUNT).fill(96),
}),
})
)
const gridRefs = useRef<Record<SheetType, VariableSizeGrid | null>>({
const gridRefs = useRef<Record<SheetType, any>>({
report: null,
calculations: null,
})
@@ -844,7 +899,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const initialTemplateValues = useRef<Record<string, string>>({})
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
{},
{}
)
const lastRevision = useRef<number>(-1)
@@ -866,7 +921,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
console.log(
'✅ Загружено значений шаблона:',
Object.keys(initialTemplateValues.current).length,
Object.keys(initialTemplateValues.current).length
)
} else {
console.log('⚠️ DualSpreadsheet: Данные шаблона отчета не найдены')
@@ -900,7 +955,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
}
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<DualSpreadsheetProps> = ({
cellDataCache.current[cacheKey] = cachedData
return cachedData
},
[revision, multiSheetEngine, initialTemplateValues],
[revision, multiSheetEngine, initialTemplateValues]
)
const formulaBarValue = useMemo(() => {
@@ -999,7 +1054,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
return multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col,
selectedCell.col
)
}
return ''
@@ -1034,7 +1089,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const rawValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col,
selectedCell.col
)
setIsEditing(true)
setEditingValue(rawValue)
@@ -1049,7 +1104,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const oldValue = multiSheetEngine.getCellRawValue(
sheetName,
selectedCell.row,
selectedCell.col,
selectedCell.col
)
const templateValue = initialTemplateValues.current[cellName] ?? ''
@@ -1066,7 +1121,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
sheetName,
selectedCell.row,
selectedCell.col,
editingValue,
editingValue
)
updateRevision()
multiSheetEngine.debouncedRecalc()
@@ -1075,7 +1130,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
setEditingValue(newValue)
}
},
[isEditing],
[isEditing]
)
const handleClearCell = useCallback(() => {
@@ -1094,7 +1149,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
sheetName,
selectedCell.row,
selectedCell.col,
'',
''
)
updateRevision()
multiSheetEngine.debouncedRecalc()
@@ -1113,16 +1168,16 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
setIsEditing(true)
}
},
[selectedCell, isEditing, moveSelection, startEditing, handleClearCell],
[selectedCell, isEditing, moveSelection, startEditing, handleClearCell]
)
const handleCellClick = useCallback(
@@ -1169,7 +1224,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
setEditingValue(newValue)
}
},
[selectedCell, isEditing],
[selectedCell, isEditing]
)
const handleFormulaBarFocus = useCallback(() => {
@@ -1225,7 +1280,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
stopEditing(true)
}
},
[isEditing, stopEditing],
[isEditing, stopEditing]
)
const handleFormulaBarKeyDown = useCallback(
@@ -1243,13 +1298,13 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
rowHeaderRefs.current[sheetType]!.scrollTop = scrollTop
}
},
[],
[]
)
const [separatorRef, setSeparatorRef] = useState<HTMLDivElement | null>(null)
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
L: 0.5,
R: 0.5,
@@ -1283,9 +1337,22 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
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<DualSpreadsheetProps> = ({
// Проверяем, есть ли начальные данные
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<DualSpreadsheetProps> = ({
// --- 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 (
<div className="flex h-full flex-col bg-background">
<FormulaBar
@@ -1400,7 +1426,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
onFocus={handleFormulaBarFocus}
onBlur={handleFormulaBarBlur}
onKeyDown={handleFormulaBarKeyDown}
formulaBarRef={(el) => {
formulaBarRef={el => {
formulaBarRef.current = el
}}
isSaving={isSaving}
@@ -1409,61 +1435,44 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
onManualSave={manualSave}
onClearError={clearError}
/>
<div className="flex min-w-0 flex-1 gap-1 overflow-hidden">
<Spreadsheet
sheetType="report"
columnWidths={columnWidths.report}
spreadsheetWidths={spreadsheetWidths}
getCachedCellData={getCachedCellData}
selectedCell={selectedCell}
activeSheet={activeSheet}
isEditing={isEditing}
editingValue={editingValue}
handleCellClick={handleCellClick}
handleCellDoubleClick={handleCellDoubleClick}
handleCellChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellInputRef={activeCellInputRef}
containerRefs={containerRefs}
handleKeyDown={handleKeyDown}
headerRefs={headerRefs}
rowHeaderRefs={rowHeaderRefs}
gridRefs={gridRefs}
onGridScroll={onGridScroll}
handleColumnResize={handleColumnResize}
mergedCells={mergedCells}
/>
<div
className="flex min-w-0 flex-1 gap-1 overflow-hidden"
style={{ transition: 'none' }}
>
{SHEET_TYPES.map((sheetType, index) => (
<Spreadsheet
key={sheetType}
sheetType={sheetType}
columnWidths={columnWidths[sheetType]}
spreadsheetWidths={spreadsheetWidths}
getCachedCellData={getCachedCellData}
selectedCell={selectedCell}
activeSheet={activeSheet}
isEditing={isEditing}
editingValue={editingValue}
handleCellClick={handleCellClick}
handleCellDoubleClick={handleCellDoubleClick}
handleCellChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellInputRef={activeCellInputRef}
containerRefs={containerRefs}
handleKeyDown={handleKeyDown}
headerRefs={headerRefs}
rowHeaderRefs={rowHeaderRefs}
gridRefs={gridRefs}
onGridScroll={onGridScroll}
handleColumnResize={handleColumnResize}
mergedCells={mergedCells}
/>
))}
<div
ref={setSeparatorRef}
ref={separatorRef}
className="flex w-2 cursor-col-resize items-center justify-center bg-border transition-colors hover:bg-primary/20"
style={{ userSelect: 'none' }}
>
<div className="h-4 w-0.5 rounded-full bg-muted-foreground/50" />
</div>
<Spreadsheet
sheetType="calculations"
columnWidths={columnWidths.calculations}
spreadsheetWidths={spreadsheetWidths}
getCachedCellData={getCachedCellData}
selectedCell={selectedCell}
activeSheet={activeSheet}
isEditing={isEditing}
editingValue={editingValue}
handleCellClick={handleCellClick}
handleCellDoubleClick={handleCellDoubleClick}
handleCellChange={handleCellChange}
stopEditing={stopEditing}
setActiveSheet={setActiveSheet}
activeCellInputRef={activeCellInputRef}
containerRefs={containerRefs}
handleKeyDown={handleKeyDown}
headerRefs={headerRefs}
rowHeaderRefs={rowHeaderRefs}
gridRefs={gridRefs}
onGridScroll={onGridScroll}
handleColumnResize={handleColumnResize}
mergedCells={mergedCells}
/>
</div>
</div>
)

View File

@@ -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<HTMLInputElement>) => void
}
export const ExcelUploadPanel: React.FC<ExcelUploadPanelProps> = ({
fileName,
isLoading,
onUploadClick,
onFileChange,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
return (
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={onFileChange}
className="hidden"
/>
<div
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 ${
fileName
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<FileText
className={`h-3 w-3 ${fileName ? 'text-emerald-600' : 'text-gray-400'}`}
/>
<span
className={`max-w-32 truncate text-xs font-medium ${
fileName ? 'text-emerald-700' : 'text-gray-500'
}`}
>
{fileName || 'Файл не выбран'}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="h-7 rounded-md px-2"
disabled={isLoading}
>
{isLoading ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Upload className="h-3 w-3" />
)}
</Button>
</div>
)
}

View File

@@ -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 = () => (
<div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
<div className="flex items-center">
{[Bold, Italic, Underline].map((Icon, i) => (
<Button
key={i}
variant="ghost"
size="sm"
className="h-6 w-6 rounded p-0"
>
<Icon className="h-3 w-3" />
</Button>
))}
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
<div className="flex items-center">
{[AlignLeft, AlignCenter, AlignRight].map((Icon, i) => (
<Button
key={i}
variant="ghost"
size="sm"
className="h-6 w-6 rounded p-0"
>
<Icon className="h-3 w-3" />
</Button>
))}
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
<div className="flex items-center">
{[Palette, Type, Grid].map((Icon, i) => (
<Button
key={i}
variant="ghost"
size="sm"
className="h-6 w-6 rounded p-0"
>
<Icon className="h-3 w-3" />
</Button>
))}
</div>
</div>
)

View File

@@ -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<HeaderBarProps> = ({ template, onBack }) => {
const navigate = useNavigate()
return (
<div className="flex-shrink-0 border-b bg-card px-4 py-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold">{template.name}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
Настройте Excel шаблон и значения отчета
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
className="flex items-center gap-2"
>
<Wrench className="h-3.5 w-3.5" />
Элементы интерфейса
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/protocols`)}
className="flex items-center gap-2"
>
<FileText className="h-3.5 w-3.5" />
Создать протокол
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
import { ExcelParseResult } from '@/services/excelSevice'
import React from 'react'
interface SpreadsheetViewerProps {
data: Record<string, any>
mergedCells: ExcelParseResult['mergedCells']
dataVersion: number
templateId: number | string
}
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
data,
mergedCells,
dataVersion,
templateId,
}) => (
<div className="min-h-0 flex-1">
<DualSpreadsheet
key={dataVersion}
templateData={{ L: data }}
mergedCells={mergedCells}
templateId={`template-${templateId}`}
/>
</div>
)

View File

@@ -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<TemplateEditorProps> = ({
template,
onBack,
}) => {
const [editedTemplate, setEditedTemplate] = useState<Template>(template)
const [editedTemplate, setEditedTemplate] = useState(template)
const [excelData, setExcelData] = useState<Record<string, any>>(
template.excelData || {},
template.excelData || {}
)
const [isLoading, setIsLoading] = useState(false)
const [dataVersion, setDataVersion] = useState(0) // Для принудительного
const fileInputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
console.log('No file selected')
return
}
const [dataVersion, setDataVersion] = useState(0)
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsLoading(true)
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer)
const workbook = XLSX.read(data, { type: 'array' })
// Читаем первый лист
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
// Получаем диапазон ячеек
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1')
// Преобразуем в формат для нашей системы
const formattedData: Record<string, any> = {}
const mergedCells: Array<{ from: string; to: string; value: any }> = []
// Обрабатываем объединенные ячейки
const merges = worksheet['!merges'] || []
// Сначала читаем обычные ячейки
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const cellAddress = XLSX.utils.encode_cell({ r: R, c: C })
const cell = worksheet[cellAddress]
if (
cell &&
cell.v !== undefined &&
cell.v !== null &&
cell.v !== ''
) {
formattedData[cellAddress] = cell.v
}
}
}
// Обрабатываем объединенные ячейки
merges.forEach((merge) => {
const startCell = XLSX.utils.encode_cell({
r: merge.s.r,
c: merge.s.c,
})
const endCell = XLSX.utils.encode_cell({ r: merge.e.r, c: merge.e.c })
const startCellData = worksheet[startCell]
if (startCellData && startCellData.v !== undefined) {
// Записываем значение в первую ячейку объединенного диапазона
formattedData[startCell] = startCellData.v
// Сохраняем информацию об объединении для UI
mergedCells.push({
from: startCell,
to: endCell,
value: startCellData.v,
})
// Заполняем остальные ячейки в объединенном диапазоне пустыми значениями
// но помечаем их как часть объединения
for (let R = merge.s.r; R <= merge.e.r; ++R) {
for (let C = merge.s.c; C <= merge.e.c; ++C) {
const cellAddr = XLSX.utils.encode_cell({ r: R, c: C })
if (cellAddr !== startCell) {
formattedData[cellAddr] = '' // Пустое значение для объединенных ячеек
}
}
}
}
})
// Обновляем состояние
const newData = { ...formattedData }
setExcelData(newData)
setEditedTemplate((prev) => ({
...prev,
excelFile: file,
excelData: newData,
mergedCells: mergedCells, // Сохраняем информацию об объединенных ячейках
updatedAt: new Date(),
}))
// Принудительно обновляем версию данных для перерендера
setDataVersion((prev) => prev + 1)
setIsLoading(false)
} catch (error) {
console.error('Ошибка при чтении Excel файла:', error)
alert('Ошибка при чтении Excel файла')
setIsLoading(false)
}
}
reader.onerror = () => {
alert('Ошибка при загрузке файла')
try {
const { data, mergedCells } = await parseExcelFile(file)
setExcelData(data)
setEditedTemplate(prev => ({
...prev,
excelFile: file,
excelData: data,
mergedCells,
updatedAt: new Date(),
}))
setDataVersion(v => v + 1)
} catch (err) {
console.error(err)
alert('Ошибка при чтении Excel файла')
} finally {
setIsLoading(false)
}
reader.readAsArrayBuffer(file)
}
const handleCreateElements = () => {
navigate(`/templates/${editedTemplate.id}/elements`)
}
return (
<div className="flex h-screen flex-col bg-background">
{/* Заголовок */}
<div className="flex-shrink-0 border-b bg-card px-4 py-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold">{editedTemplate.name}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
Настройте Excel шаблон и значения отчета
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCreateElements}
className="flex items-center gap-2"
>
<Wrench className="h-3.5 w-3.5" />
Элементы интерфейса
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/templates/${editedTemplate.id}/protocols`)
}
className="flex items-center gap-2"
>
<FileText className="h-3.5 w-3.5" />
Создать протокол
</Button>
</div>
</div>
</div>
{/* Компактная панель загрузки Excel и инструментов */}
<HeaderBar template={editedTemplate} onBack={onBack} />
<div className="flex-shrink-0 border-b bg-muted/30 px-4 py-2">
<div className="flex items-center justify-between">
{/* Левая часть - панель инструментов стилизации */}
<div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
{/* Группа текстового форматирования */}
<div className="flex items-center">
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Bold className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Italic className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Underline className="h-3 w-3" />
</Button>
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
{/* Группа выравнивания */}
<div className="flex items-center">
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<AlignLeft className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<AlignCenter className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<AlignRight className="h-3 w-3" />
</Button>
</div>
<Separator orientation="vertical" className="mx-0.5 h-4" />
{/* Группа цвета и стилей */}
<div className="flex items-center">
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Palette className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Type className="h-3 w-3" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 rounded p-0">
<Grid className="h-3 w-3" />
</Button>
</div>
</div>
{/* Правая часть - загрузка Excel */}
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
className="hidden"
/>
{/* Статус файла - всегда отображается */}
<div
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 ${
editedTemplate.excelFile
? 'border-emerald-200 bg-emerald-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<FileText
className={`h-3 w-3 ${
editedTemplate.excelFile
? 'text-emerald-600'
: 'text-gray-400'
}`}
/>
<span
className={`max-w-32 truncate text-xs font-medium ${
editedTemplate.excelFile
? 'text-emerald-700'
: 'text-gray-500'
}`}
>
{editedTemplate.excelFile
? editedTemplate.excelFile.name
: 'Файл не выбран'}
</span>
</div>
{/* Кнопка загрузки - всегда отображается */}
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="h-7 rounded-md px-2"
disabled={isLoading}
>
{isLoading ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Upload className="h-3 w-3" />
)}
</Button>
</div>
<FormattingToolbar />
<ExcelUploadPanel
fileName={editedTemplate.excelFile?.name}
isLoading={isLoading}
onUploadClick={() => {}}
onFileChange={handleFileChange}
/>
</div>
</div>
{/* Таблицы - теперь занимают всё оставшееся пространство */}
<div className="min-h-0 flex-1">
<DualSpreadsheet
key={dataVersion}
templateData={{ L: excelData }}
mergedCells={editedTemplate.mergedCells}
templateId={`template-${editedTemplate.id}`}
/>
</div>
<SpreadsheetViewer
data={excelData}
mergedCells={editedTemplate.mergedCells || []}
dataVersion={dataVersion}
templateId={editedTemplate.id}
/>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import * as XLSX from 'xlsx'
export interface ExcelParseResult {
data: Record<string, any>
mergedCells: Array<{ from: string; to: string; value: any }>
}
// fetch(/api/upload)
export async function parseExcelFile(file: File): Promise<ExcelParseResult> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = e => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer)
const workbook = XLSX.read(data, { type: 'array' })
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1')
const formattedData: Record<string, any> = {}
const mergedCells: ExcelParseResult['mergedCells'] = []
// Основные ячейки
for (let R = range.s.r; R <= range.e.r; ++R) {
for (let C = range.s.c; C <= range.e.c; ++C) {
const addr = XLSX.utils.encode_cell({ r: R, c: C })
const cell = worksheet[addr]
if (cell && cell.v != null && cell.v !== '') {
formattedData[addr] = cell.v
}
}
}
// Обработать объединения
;(worksheet['!merges'] || []).forEach(m => {
const from = XLSX.utils.encode_cell({ r: m.s.r, c: m.s.c })
const to = XLSX.utils.encode_cell({ r: m.e.r, c: m.e.c })
const val = worksheet[from]?.v
if (val != null) {
mergedCells.push({ from, to, value: val })
// остальные ячейки затираем пустым, чтобы UI знал об объединении
for (let R = m.s.r; R <= m.e.r; ++R) {
for (let C = m.s.c; C <= m.e.c; ++C) {
const addr = XLSX.utils.encode_cell({ r: R, c: C })
if (addr !== from) formattedData[addr] = ''
}
}
}
})
resolve({ data: formattedData, mergedCells })
} catch (err) {
reject(err)
}
}
reader.onerror = () => reject(new Error('Ошибка чтения файла'))
reader.readAsArrayBuffer(file)
})
}