ручной рефакторинг
This commit is contained in:
@@ -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>>
|
||||
@@ -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 (
|
||||
@@ -476,7 +530,7 @@ const FormulaBar = memo(
|
||||
</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,10 +1435,15 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
onManualSave={manualSave}
|
||||
onClearError={clearError}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 gap-1 overflow-hidden">
|
||||
<div
|
||||
className="flex min-w-0 flex-1 gap-1 overflow-hidden"
|
||||
style={{ transition: 'none' }}
|
||||
>
|
||||
{SHEET_TYPES.map((sheetType, index) => (
|
||||
<Spreadsheet
|
||||
sheetType="report"
|
||||
columnWidths={columnWidths.report}
|
||||
key={sheetType}
|
||||
sheetType={sheetType}
|
||||
columnWidths={columnWidths[sheetType]}
|
||||
spreadsheetWidths={spreadsheetWidths}
|
||||
getCachedCellData={getCachedCellData}
|
||||
selectedCell={selectedCell}
|
||||
@@ -1434,36 +1465,14 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||
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>
|
||||
)
|
||||
|
||||
64
src/components/TemplateManager/ExcelUploadPanel.tsx
Normal file
64
src/components/TemplateManager/ExcelUploadPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
src/components/TemplateManager/FormattingToolbar.tsx
Normal file
61
src/components/TemplateManager/FormattingToolbar.tsx
Normal 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>
|
||||
)
|
||||
52
src/components/TemplateManager/HeaderBar.tsx
Normal file
52
src/components/TemplateManager/HeaderBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/TemplateManager/SpreadsheetViewer.tsx
Normal file
26
src/components/TemplateManager/SpreadsheetViewer.tsx
Normal 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>
|
||||
)
|
||||
@@ -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) => ({
|
||||
const { data, mergedCells } = await parseExcelFile(file)
|
||||
setExcelData(data)
|
||||
setEditedTemplate(prev => ({
|
||||
...prev,
|
||||
excelFile: file,
|
||||
excelData: newData,
|
||||
mergedCells: mergedCells, // Сохраняем информацию об объединенных ячейках
|
||||
excelData: data,
|
||||
mergedCells,
|
||||
updatedAt: new Date(),
|
||||
}))
|
||||
|
||||
// Принудительно обновляем версию данных для перерендера
|
||||
setDataVersion((prev) => prev + 1)
|
||||
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при чтении Excel файла:', error)
|
||||
setDataVersion(v => v + 1)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Ошибка при чтении Excel файла')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
alert('Ошибка при загрузке файла')
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблицы - теперь занимают всё оставшееся пространство */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<DualSpreadsheet
|
||||
key={dataVersion}
|
||||
templateData={{ L: excelData }}
|
||||
mergedCells={editedTemplate.mergedCells}
|
||||
templateId={`template-${editedTemplate.id}`}
|
||||
<FormattingToolbar />
|
||||
<ExcelUploadPanel
|
||||
fileName={editedTemplate.excelFile?.name}
|
||||
isLoading={isLoading}
|
||||
onUploadClick={() => {}}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SpreadsheetViewer
|
||||
data={excelData}
|
||||
mergedCells={editedTemplate.mergedCells || []}
|
||||
dataVersion={dataVersion}
|
||||
templateId={editedTemplate.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
58
src/services/excelSevice.ts
Normal file
58
src/services/excelSevice.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -1,49 +1,49 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user