Разложили dualspreadsheet
This commit is contained in:
@@ -1,851 +1,21 @@
|
|||||||
import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave'
|
import { useFormulaAutoSave } from '@/hooks/useFormulaAutoSave'
|
||||||
import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine'
|
import { useMultiSheetEngine } from '@/hooks/useMultiSheetEngine'
|
||||||
import { MergedCell } from '@/types/template'
|
|
||||||
import { produce } from 'immer'
|
import { produce } from 'immer'
|
||||||
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// Импорты из новых модулей
|
||||||
|
import { FormulaBar, Spreadsheet } from './components'
|
||||||
|
import { useDraggableDivider } from './hooks/useDraggableDivider'
|
||||||
import {
|
import {
|
||||||
CSSProperties,
|
CachedCellData,
|
||||||
FC,
|
COL_COUNT,
|
||||||
memo,
|
DualSpreadsheetProps,
|
||||||
useCallback,
|
ROW_COUNT,
|
||||||
useEffect,
|
SHEET_NAMES,
|
||||||
useMemo,
|
SHEET_TYPES,
|
||||||
useRef,
|
SheetType,
|
||||||
useState,
|
} from './types'
|
||||||
} from 'react'
|
import { getColumnLabel } from './utils'
|
||||||
import { VariableSizeGrid } from 'react-window'
|
|
||||||
|
|
||||||
const ROW_COUNT = 90
|
|
||||||
const COL_COUNT = 20
|
|
||||||
const ROW_HEIGHT = 28
|
|
||||||
|
|
||||||
const SHEET_NAMES = {
|
|
||||||
report: 'L',
|
|
||||||
calculations: 'R',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
type SheetType = keyof typeof SHEET_NAMES
|
|
||||||
|
|
||||||
// Добавляем массив типов листов для итерации
|
|
||||||
const SHEET_TYPES: SheetType[] = ['report', 'calculations']
|
|
||||||
|
|
||||||
// --- Helper Functions ---
|
|
||||||
const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
|
|
||||||
|
|
||||||
// Функция для преобразования адреса ячейки в координаты
|
|
||||||
const cellAddressToCoords = (address: string): { row: number; col: number } => {
|
|
||||||
const match = address.match(/^([A-Z]+)(\d+)$/)
|
|
||||||
if (!match) return { row: 0, col: 0 }
|
|
||||||
|
|
||||||
const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы
|
|
||||||
const row = parseInt(match[2]) - 1
|
|
||||||
|
|
||||||
return { row, col }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Функция для проверки, является ли ячейка частью объединенной группы
|
|
||||||
const findMergedCellInfo = (
|
|
||||||
row: number,
|
|
||||||
col: number,
|
|
||||||
mergedCells: MergedCell[]
|
|
||||||
): {
|
|
||||||
isMerged: boolean
|
|
||||||
isMainCell: boolean
|
|
||||||
mergedCell?: MergedCell
|
|
||||||
spans?: { rowSpan: number; colSpan: number }
|
|
||||||
} => {
|
|
||||||
for (const merged of mergedCells) {
|
|
||||||
const fromCoords = cellAddressToCoords(merged.from)
|
|
||||||
const toCoords = cellAddressToCoords(merged.to)
|
|
||||||
|
|
||||||
// Проверяем, попадает ли текущая ячейка в диапазон объединения
|
|
||||||
if (
|
|
||||||
row >= fromCoords.row &&
|
|
||||||
row <= toCoords.row &&
|
|
||||||
col >= fromCoords.col &&
|
|
||||||
col <= toCoords.col
|
|
||||||
) {
|
|
||||||
const isMainCell = row === fromCoords.row && col === fromCoords.col
|
|
||||||
const rowSpan = toCoords.row - fromCoords.row + 1
|
|
||||||
const colSpan = toCoords.col - fromCoords.col + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
isMerged: true,
|
|
||||||
isMainCell,
|
|
||||||
mergedCell: merged,
|
|
||||||
spans: { rowSpan, colSpan },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>>
|
|
||||||
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
|
|
||||||
templateId?: string // Добавляем ID шаблона для сохранения
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кэшированные данные ячеек для оптимизации
|
|
||||||
interface CachedCellData {
|
|
||||||
displayValue: string
|
|
||||||
rawValue: string
|
|
||||||
isModified: boolean
|
|
||||||
hasRightContent: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Child Components ---
|
|
||||||
interface CellProps {
|
|
||||||
sheetType: SheetType
|
|
||||||
rowIndex: number
|
|
||||||
colIndex: number
|
|
||||||
columnWidth: number
|
|
||||||
columnWidths: number[] // Добавляем массив всех ширин колонок
|
|
||||||
isSelected: boolean
|
|
||||||
isEditing: boolean
|
|
||||||
displayValue: string // Отображаемое значение (вычисленное)
|
|
||||||
rawValue: string // "Сырое" значение (формула)
|
|
||||||
isModified: boolean
|
|
||||||
hasRightContent: boolean // Есть ли содержимое в ячейках справа
|
|
||||||
onCellClick: (row: number, col: number) => void
|
|
||||||
onCellDoubleClick: (row: number, col: number) => void
|
|
||||||
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
|
|
||||||
stopEditing: (save: boolean) => void
|
|
||||||
setActiveSheet: (type: SheetType) => void
|
|
||||||
activeCellRef: React.RefObject<HTMLInputElement>
|
|
||||||
style: CSSProperties // Добавляем style для react-window
|
|
||||||
mergedCellInfo?: {
|
|
||||||
// Информация об объединенных ячейках
|
|
||||||
isMerged: boolean
|
|
||||||
isMainCell: boolean
|
|
||||||
rowSpan?: number
|
|
||||||
colSpan?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const Cell = memo(
|
|
||||||
({
|
|
||||||
sheetType,
|
|
||||||
rowIndex,
|
|
||||||
colIndex,
|
|
||||||
columnWidth,
|
|
||||||
columnWidths,
|
|
||||||
isSelected,
|
|
||||||
isEditing,
|
|
||||||
displayValue,
|
|
||||||
rawValue,
|
|
||||||
isModified,
|
|
||||||
hasRightContent,
|
|
||||||
onCellClick,
|
|
||||||
onCellDoubleClick,
|
|
||||||
onCellValueChange,
|
|
||||||
stopEditing,
|
|
||||||
setActiveSheet,
|
|
||||||
activeCellRef,
|
|
||||||
style, // Добавляем style для react-window
|
|
||||||
mergedCellInfo,
|
|
||||||
}: CellProps) => {
|
|
||||||
// Если ячейка объединена, но не является основной, не рендерим её
|
|
||||||
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${
|
|
||||||
isModified ? 'bg-yellow-400 !important' : ''
|
|
||||||
}`
|
|
||||||
|
|
||||||
// Отладка стилей для измененных ячеек
|
|
||||||
if (isModified) {
|
|
||||||
console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, {
|
|
||||||
className,
|
|
||||||
isModified,
|
|
||||||
isSelected,
|
|
||||||
displayValue,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Для объединенных ячеек нужно рассчитать правильные размеры
|
|
||||||
let cellStyle: CSSProperties = {
|
|
||||||
...style,
|
|
||||||
overflow: 'visible',
|
|
||||||
padding: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если это основная ячейка объединенной группы, расширяем её размеры
|
|
||||||
if (
|
|
||||||
mergedCellInfo?.isMainCell &&
|
|
||||||
mergedCellInfo.rowSpan &&
|
|
||||||
mergedCellInfo.colSpan
|
|
||||||
) {
|
|
||||||
const { rowSpan, colSpan } = mergedCellInfo
|
|
||||||
|
|
||||||
// Рассчитываем общую ширину для объединенной ячейки
|
|
||||||
let totalWidth = 0
|
|
||||||
for (let i = 0; i < colSpan; i++) {
|
|
||||||
const currentColIndex = colIndex + i
|
|
||||||
if (currentColIndex < columnWidths.length) {
|
|
||||||
totalWidth += columnWidths[currentColIndex]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Рассчитываем общую высоту для объединенной ячейки
|
|
||||||
const totalHeight = ROW_HEIGHT * rowSpan
|
|
||||||
|
|
||||||
cellStyle = {
|
|
||||||
...cellStyle,
|
|
||||||
width: totalWidth,
|
|
||||||
height: totalHeight,
|
|
||||||
zIndex: 10, // Поднимаем выше обычных ячеек
|
|
||||||
backgroundColor: isSelected
|
|
||||||
? 'hsl(var(--accent))'
|
|
||||||
: isModified
|
|
||||||
? '#fef3c7'
|
|
||||||
: 'white',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Используем обычное значение displayValue для всех ячеек, включая объединенные
|
|
||||||
const cellDisplayValue = displayValue
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
setActiveSheet(sheetType)
|
|
||||||
onCellClick(rowIndex, colIndex)
|
|
||||||
}, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex])
|
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
|
||||||
() => onCellDoubleClick(rowIndex, colIndex),
|
|
||||||
[onCellDoubleClick, rowIndex, colIndex]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={className}
|
|
||||||
style={cellStyle}
|
|
||||||
onClick={handleClick}
|
|
||||||
onDoubleClick={handleDoubleClick}
|
|
||||||
>
|
|
||||||
{isEditing && isSelected ? (
|
|
||||||
<input
|
|
||||||
ref={activeCellRef}
|
|
||||||
type="text"
|
|
||||||
value={rawValue} // Во время редактирования показываем текущее редактируемое значение
|
|
||||||
onChange={e => onCellValueChange(e.target.value)}
|
|
||||||
onBlur={e => {
|
|
||||||
// Проверяем, не переходит ли фокус в formula bar
|
|
||||||
const relatedTarget = e.relatedTarget as HTMLElement
|
|
||||||
if (relatedTarget && relatedTarget.closest('.formula-bar')) {
|
|
||||||
return // Не завершаем редактирование
|
|
||||||
}
|
|
||||||
stopEditing(true)
|
|
||||||
}}
|
|
||||||
onKeyDown={e => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
stopEditing(true)
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault()
|
|
||||||
stopEditing(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="absolute left-0 top-0 z-20 box-border h-full w-full border-2 border-primary px-2 py-1"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="relative flex h-full w-full items-center px-2"
|
|
||||||
style={{
|
|
||||||
overflow: hasRightContent ? 'hidden' : 'visible',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
...(isSelected
|
|
||||||
? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' }
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
overflow: hasRightContent ? 'hidden' : 'visible',
|
|
||||||
textOverflow: hasRightContent ? 'ellipsis' : 'clip',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
maxWidth: hasRightContent ? `${columnWidth - 16}px` : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cellDisplayValue}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
interface FormulaBarProps {
|
|
||||||
sheetType: SheetType
|
|
||||||
activeSheet: SheetType
|
|
||||||
selectedCell: { row: number; col: number } | null
|
|
||||||
formulaBarValue: string
|
|
||||||
isCalculating: boolean
|
|
||||||
isEditing: boolean
|
|
||||||
onValueChange: (value: string) => void
|
|
||||||
onFocus: () => void
|
|
||||||
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
|
|
||||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
|
||||||
formulaBarRef: (el: HTMLInputElement | null) => void
|
|
||||||
// Обновляем props для сохранения
|
|
||||||
isSaving?: boolean
|
|
||||||
hasUnsavedChanges?: boolean
|
|
||||||
lastSaveError?: string | null
|
|
||||||
onManualSave?: () => void
|
|
||||||
onClearError?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormulaBar = memo(
|
|
||||||
({
|
|
||||||
selectedCell,
|
|
||||||
formulaBarValue,
|
|
||||||
isCalculating,
|
|
||||||
isEditing,
|
|
||||||
onValueChange,
|
|
||||||
onFocus,
|
|
||||||
onBlur,
|
|
||||||
onKeyDown,
|
|
||||||
formulaBarRef,
|
|
||||||
isSaving = false,
|
|
||||||
hasUnsavedChanges = false,
|
|
||||||
lastSaveError = null,
|
|
||||||
onManualSave,
|
|
||||||
onClearError,
|
|
||||||
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
|
|
||||||
return (
|
|
||||||
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="flex min-w-[50px] items-center justify-center rounded-md border bg-muted px-2 py-1">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
{selectedCell
|
|
||||||
? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}`
|
|
||||||
: 'A1'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 items-center space-x-2">
|
|
||||||
<div className="flex items-center text-muted-foreground">
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={formulaBarRef}
|
|
||||||
type="text"
|
|
||||||
className={`flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
|
||||||
isEditing ? 'ring-2 ring-ring ring-offset-2' : ''
|
|
||||||
} ${isCalculating ? 'animate-pulse' : ''}`}
|
|
||||||
placeholder="Введите формулу или значение..."
|
|
||||||
value={formulaBarValue}
|
|
||||||
onChange={e => onValueChange(e.target.value)}
|
|
||||||
onFocus={onFocus}
|
|
||||||
onBlur={e => {
|
|
||||||
// Проверяем, не переходит ли фокус в ячейку редактирования
|
|
||||||
const relatedTarget = e.relatedTarget as HTMLElement
|
|
||||||
if (
|
|
||||||
relatedTarget &&
|
|
||||||
relatedTarget.closest('input[type="text"]')
|
|
||||||
) {
|
|
||||||
return // Не завершаем редактирование
|
|
||||||
}
|
|
||||||
onBlur(e)
|
|
||||||
}}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
/>
|
|
||||||
{(isCalculating || isSaving) && (
|
|
||||||
<div className="flex items-center text-muted-foreground">
|
|
||||||
<svg
|
|
||||||
className="h-3.5 w-3.5 animate-spin"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span className="ml-1 text-xs">
|
|
||||||
{isSaving ? 'Сохранение...' : 'Расчет...'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Ошибка сохранения */}
|
|
||||||
{lastSaveError && (
|
|
||||||
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
|
|
||||||
<svg
|
|
||||||
className="h-3 w-3 text-destructive"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
className="text-xs text-destructive"
|
|
||||||
title={lastSaveError}
|
|
||||||
>
|
|
||||||
Ошибка сохранения
|
|
||||||
</span>
|
|
||||||
{onClearError && (
|
|
||||||
<button
|
|
||||||
onClick={onClearError}
|
|
||||||
className="ml-1 text-destructive/60 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-3 w-3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Кнопка ручного сохранения */}
|
|
||||||
{onManualSave && (
|
|
||||||
<button
|
|
||||||
onClick={onManualSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
|
||||||
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'
|
|
||||||
} disabled:opacity-50`}
|
|
||||||
title={
|
|
||||||
lastSaveError
|
|
||||||
? 'Повторить сохранение'
|
|
||||||
: hasUnsavedChanges
|
|
||||||
? 'Есть несохраненные изменения'
|
|
||||||
: 'Все изменения сохранены'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-3 w-3"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{lastSaveError
|
|
||||||
? 'Повторить'
|
|
||||||
: hasUnsavedChanges
|
|
||||||
? 'Сохранить'
|
|
||||||
: 'Сохранено'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Рендер-проп для ячеек грида
|
|
||||||
const CellRenderer = ({
|
|
||||||
columnIndex,
|
|
||||||
rowIndex,
|
|
||||||
style,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
columnIndex: number
|
|
||||||
rowIndex: number
|
|
||||||
style: CSSProperties
|
|
||||||
data: any
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
sheetType,
|
|
||||||
widths,
|
|
||||||
selectedCell,
|
|
||||||
activeSheet,
|
|
||||||
isEditing,
|
|
||||||
editingValue,
|
|
||||||
cachedData,
|
|
||||||
mergedCells,
|
|
||||||
handleCellClick,
|
|
||||||
handleCellDoubleClick,
|
|
||||||
handleCellChange,
|
|
||||||
stopEditing,
|
|
||||||
setActiveSheet,
|
|
||||||
activeCellInputRef,
|
|
||||||
} = data
|
|
||||||
|
|
||||||
const isCellSelected =
|
|
||||||
selectedCell?.row === rowIndex &&
|
|
||||||
selectedCell?.col === columnIndex &&
|
|
||||||
activeSheet === sheetType
|
|
||||||
const isCellEditing = isCellSelected && isEditing
|
|
||||||
|
|
||||||
const cellKey = `${rowIndex}-${columnIndex}`
|
|
||||||
const cellData = cachedData[cellKey]
|
|
||||||
|
|
||||||
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
|
|
||||||
const displayValue = isCellEditing
|
|
||||||
? '' // Не показывать вычисленное значение во время редактирования
|
|
||||||
: cellData?.displayValue || ''
|
|
||||||
|
|
||||||
// Отладка для измененных ячеек
|
|
||||||
if (cellData?.isModified) {
|
|
||||||
console.log(
|
|
||||||
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
|
|
||||||
{
|
|
||||||
cellKey,
|
|
||||||
isModified: cellData.isModified,
|
|
||||||
displayValue,
|
|
||||||
rawValue,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем информацию об объединенных ячейках только для листа отчета
|
|
||||||
const mergedCellInfo =
|
|
||||||
sheetType === 'report' && mergedCells
|
|
||||||
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
|
|
||||||
: { isMerged: false, isMainCell: false }
|
|
||||||
|
|
||||||
const mergedCellProps = mergedCellInfo.isMerged
|
|
||||||
? {
|
|
||||||
isMerged: true,
|
|
||||||
isMainCell: mergedCellInfo.isMainCell,
|
|
||||||
rowSpan: mergedCellInfo.spans?.rowSpan,
|
|
||||||
colSpan: mergedCellInfo.spans?.colSpan,
|
|
||||||
// Убираем mergedValue - будем использовать актуальные данные из движка
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Cell
|
|
||||||
style={style}
|
|
||||||
sheetType={sheetType}
|
|
||||||
rowIndex={rowIndex}
|
|
||||||
colIndex={columnIndex}
|
|
||||||
columnWidth={widths[columnIndex]}
|
|
||||||
columnWidths={widths}
|
|
||||||
isSelected={isCellSelected}
|
|
||||||
isEditing={isCellEditing}
|
|
||||||
displayValue={displayValue}
|
|
||||||
rawValue={rawValue}
|
|
||||||
isModified={cellData?.isModified || false}
|
|
||||||
hasRightContent={cellData?.hasRightContent || false}
|
|
||||||
onCellClick={handleCellClick}
|
|
||||||
onCellDoubleClick={handleCellDoubleClick}
|
|
||||||
onCellValueChange={handleCellChange}
|
|
||||||
stopEditing={stopEditing}
|
|
||||||
setActiveSheet={setActiveSheet}
|
|
||||||
activeCellRef={activeCellInputRef}
|
|
||||||
mergedCellInfo={mergedCellProps}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpreadsheetProps {
|
|
||||||
sheetType: SheetType
|
|
||||||
columnWidths: number[]
|
|
||||||
spreadsheetWidths: { L: number; R: number }
|
|
||||||
getCachedCellData: (sheetType: SheetType) => Record<string, CachedCellData>
|
|
||||||
selectedCell: { row: number; col: number } | null
|
|
||||||
activeSheet: SheetType
|
|
||||||
isEditing: boolean
|
|
||||||
editingValue: string
|
|
||||||
handleCellClick: (row: number, col: number) => void
|
|
||||||
handleCellDoubleClick: (row: number, col: number) => void
|
|
||||||
handleCellChange: (newValue: string) => void
|
|
||||||
stopEditing: (save: boolean) => void
|
|
||||||
setActiveSheet: (type: SheetType) => void
|
|
||||||
activeCellInputRef: React.RefObject<HTMLInputElement>
|
|
||||||
containerRefs: React.MutableRefObject<
|
|
||||||
Record<SheetType, HTMLDivElement | null>
|
|
||||||
>
|
|
||||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
|
||||||
headerRefs: React.MutableRefObject<Record<SheetType, HTMLDivElement | null>>
|
|
||||||
rowHeaderRefs: React.MutableRefObject<
|
|
||||||
Record<SheetType, HTMLDivElement | null>
|
|
||||||
>
|
|
||||||
gridRefs: React.MutableRefObject<Record<SheetType, any>>
|
|
||||||
onGridScroll: (
|
|
||||||
sheetType: SheetType,
|
|
||||||
params: { scrollLeft: number; scrollTop: number }
|
|
||||||
) => void
|
|
||||||
handleColumnResize: (
|
|
||||||
sheetType: SheetType,
|
|
||||||
colIndex: number,
|
|
||||||
newWidth: number
|
|
||||||
) => void
|
|
||||||
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Spreadsheet Component ---
|
|
||||||
const Spreadsheet = ({
|
|
||||||
sheetType,
|
|
||||||
columnWidths,
|
|
||||||
spreadsheetWidths,
|
|
||||||
getCachedCellData,
|
|
||||||
selectedCell,
|
|
||||||
activeSheet,
|
|
||||||
isEditing,
|
|
||||||
editingValue,
|
|
||||||
handleCellClick,
|
|
||||||
handleCellDoubleClick,
|
|
||||||
handleCellChange,
|
|
||||||
stopEditing,
|
|
||||||
setActiveSheet,
|
|
||||||
activeCellInputRef,
|
|
||||||
containerRefs,
|
|
||||||
handleKeyDown,
|
|
||||||
headerRefs,
|
|
||||||
rowHeaderRefs,
|
|
||||||
gridRefs,
|
|
||||||
onGridScroll,
|
|
||||||
handleColumnResize,
|
|
||||||
mergedCells,
|
|
||||||
}: SpreadsheetProps) => {
|
|
||||||
const widths = columnWidths
|
|
||||||
const cachedData = getCachedCellData(sheetType)
|
|
||||||
|
|
||||||
const itemData = {
|
|
||||||
sheetType,
|
|
||||||
widths,
|
|
||||||
selectedCell,
|
|
||||||
activeSheet,
|
|
||||||
isEditing,
|
|
||||||
editingValue,
|
|
||||||
cachedData,
|
|
||||||
mergedCells,
|
|
||||||
handleCellClick,
|
|
||||||
handleCellDoubleClick,
|
|
||||||
handleCellChange,
|
|
||||||
stopEditing,
|
|
||||||
setActiveSheet,
|
|
||||||
activeCellInputRef,
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const parent = parentRef.current
|
|
||||||
if (!parent) return
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
const { width, height } = parent.getBoundingClientRect()
|
|
||||||
const headerHeight =
|
|
||||||
parent.querySelector('.column-headers')?.clientHeight || 0
|
|
||||||
setGridSize({ width, height: height - headerHeight })
|
|
||||||
})
|
|
||||||
|
|
||||||
resizeObserver.observe(parent)
|
|
||||||
return () => resizeObserver.disconnect()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={parentRef}
|
|
||||||
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)}
|
|
||||||
className="flex h-full flex-col overflow-hidden border bg-card shadow-sm"
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{/* Spreadsheet Body */}
|
|
||||||
<div className="flex flex-1">
|
|
||||||
{/* Row Headers */}
|
|
||||||
<div className="flex-shrink-0 bg-muted">
|
|
||||||
<div className="relative flex h-7 w-10 items-center justify-center border-b border-r border-border">
|
|
||||||
{/* Компактное обозначение листа */}
|
|
||||||
<div className="absolute inset-0 flex items-end justify-end p-0.5">
|
|
||||||
<span
|
|
||||||
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
|
|
||||||
sheetType === 'report'
|
|
||||||
? 'bg-primary/70'
|
|
||||||
: 'bg-emerald-500/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{SHEET_NAMES[sheetType]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={el => (rowHeaderRefs.current[sheetType] = el)}
|
|
||||||
className="overflow-y-hidden"
|
|
||||||
style={{ height: gridSize.height }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
|
|
||||||
<div
|
|
||||||
key={rowIndex}
|
|
||||||
className="flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium text-muted-foreground"
|
|
||||||
>
|
|
||||||
{rowIndex + 1}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{/* Column Headers */}
|
|
||||||
<div
|
|
||||||
ref={el => (headerRefs.current[sheetType] = el)}
|
|
||||||
className="column-headers overflow-x-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex">
|
|
||||||
{Array.from({ length: COL_COUNT }).map((_, i) => {
|
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const startX = e.clientX
|
|
||||||
const startWidth = widths[i]
|
|
||||||
|
|
||||||
const handleMouseMove = (me: MouseEvent) => {
|
|
||||||
const newWidth = startWidth + (me.clientX - startX)
|
|
||||||
handleColumnResize(sheetType, i, newWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', handleMouseMove)
|
|
||||||
document.removeEventListener('mouseup', handleMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', handleMouseMove)
|
|
||||||
document.addEventListener('mouseup', handleMouseUp)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="relative flex h-7 flex-shrink-0 items-center justify-center border-b border-r border-border bg-muted text-center text-xs font-medium text-muted-foreground"
|
|
||||||
style={{ width: widths[i] }}
|
|
||||||
>
|
|
||||||
{getColumnLabel(i)}
|
|
||||||
<div
|
|
||||||
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
<VariableSizeGrid
|
|
||||||
ref={el => (gridRefs.current[sheetType] = el)}
|
|
||||||
columnCount={COL_COUNT}
|
|
||||||
rowCount={ROW_COUNT}
|
|
||||||
columnWidth={(index: number) => widths[index]}
|
|
||||||
rowHeight={() => ROW_HEIGHT}
|
|
||||||
height={gridSize.height}
|
|
||||||
width={gridSize.width}
|
|
||||||
itemData={itemData}
|
|
||||||
onScroll={e => onGridScroll(sheetType, e)}
|
|
||||||
>
|
|
||||||
{CellRenderer}
|
|
||||||
</VariableSizeGrid>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
||||||
templateData = {},
|
templateData = {},
|
||||||
@@ -1413,8 +583,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
|
|||||||
isInitialized,
|
isInitialized,
|
||||||
])
|
])
|
||||||
|
|
||||||
// --- Spreadsheet Component ---
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<FormulaBar
|
<FormulaBar
|
||||||
|
|||||||
157
src/components/DualSpreadsheet/components/Cell.tsx
Normal file
157
src/components/DualSpreadsheet/components/Cell.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { CSSProperties, memo, useCallback } from 'react'
|
||||||
|
import { CellProps, ROW_HEIGHT } from '../types'
|
||||||
|
|
||||||
|
export const Cell = memo(
|
||||||
|
({
|
||||||
|
sheetType,
|
||||||
|
rowIndex,
|
||||||
|
colIndex,
|
||||||
|
columnWidth,
|
||||||
|
columnWidths,
|
||||||
|
isSelected,
|
||||||
|
isEditing,
|
||||||
|
displayValue,
|
||||||
|
rawValue,
|
||||||
|
isModified,
|
||||||
|
hasRightContent,
|
||||||
|
onCellClick,
|
||||||
|
onCellDoubleClick,
|
||||||
|
onCellValueChange,
|
||||||
|
stopEditing,
|
||||||
|
setActiveSheet,
|
||||||
|
activeCellRef,
|
||||||
|
style, // Добавляем style для react-window
|
||||||
|
mergedCellInfo,
|
||||||
|
}: CellProps) => {
|
||||||
|
// Если ячейка объединена, но не является основной, не рендерим её
|
||||||
|
if (mergedCellInfo?.isMerged && !mergedCellInfo.isMainCell) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const className = `border border-border ${isSelected ? 'bg-accent' : ''} ${
|
||||||
|
isModified ? 'bg-yellow-400 !important' : ''
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Отладка стилей для измененных ячеек
|
||||||
|
if (isModified) {
|
||||||
|
console.log(`🎨 Стили для измененной ячейки ${rowIndex},${colIndex}:`, {
|
||||||
|
className,
|
||||||
|
isModified,
|
||||||
|
isSelected,
|
||||||
|
displayValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для объединенных ячеек нужно рассчитать правильные размеры
|
||||||
|
let cellStyle: CSSProperties = {
|
||||||
|
...style,
|
||||||
|
overflow: 'visible',
|
||||||
|
padding: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если это основная ячейка объединенной группы, расширяем её размеры
|
||||||
|
if (
|
||||||
|
mergedCellInfo?.isMainCell &&
|
||||||
|
mergedCellInfo.rowSpan &&
|
||||||
|
mergedCellInfo.colSpan
|
||||||
|
) {
|
||||||
|
const { rowSpan, colSpan } = mergedCellInfo
|
||||||
|
|
||||||
|
// Рассчитываем общую ширину для объединенной ячейки
|
||||||
|
let totalWidth = 0
|
||||||
|
for (let i = 0; i < colSpan; i++) {
|
||||||
|
const currentColIndex = colIndex + i
|
||||||
|
if (currentColIndex < columnWidths.length) {
|
||||||
|
totalWidth += columnWidths[currentColIndex]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рассчитываем общую высоту для объединенной ячейки
|
||||||
|
const totalHeight = ROW_HEIGHT * rowSpan
|
||||||
|
|
||||||
|
cellStyle = {
|
||||||
|
...cellStyle,
|
||||||
|
width: totalWidth,
|
||||||
|
height: totalHeight,
|
||||||
|
zIndex: 10, // Поднимаем выше обычных ячеек
|
||||||
|
backgroundColor: isSelected
|
||||||
|
? 'hsl(var(--accent))'
|
||||||
|
: isModified
|
||||||
|
? '#fef3c7'
|
||||||
|
: 'white',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем обычное значение displayValue для всех ячеек, включая объединенные
|
||||||
|
const cellDisplayValue = displayValue
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
setActiveSheet(sheetType)
|
||||||
|
onCellClick(rowIndex, colIndex)
|
||||||
|
}, [setActiveSheet, sheetType, onCellClick, rowIndex, colIndex])
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(
|
||||||
|
() => onCellDoubleClick(rowIndex, colIndex),
|
||||||
|
[onCellDoubleClick, rowIndex, colIndex]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={cellStyle}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDoubleClick={handleDoubleClick}
|
||||||
|
>
|
||||||
|
{isEditing && isSelected ? (
|
||||||
|
<input
|
||||||
|
ref={activeCellRef}
|
||||||
|
type="text"
|
||||||
|
value={rawValue} // Во время редактирования показываем текущее редактируемое значение
|
||||||
|
onChange={e => onCellValueChange(e.target.value)}
|
||||||
|
onBlur={e => {
|
||||||
|
// Проверяем, не переходит ли фокус в formula bar
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement
|
||||||
|
if (relatedTarget && relatedTarget.closest('.formula-bar')) {
|
||||||
|
return // Не завершаем редактирование
|
||||||
|
}
|
||||||
|
stopEditing(true)
|
||||||
|
}}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
stopEditing(true)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
stopEditing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute left-0 top-0 z-20 box-border h-full w-full border-2 border-primary px-2 py-1"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative flex h-full w-full items-center px-2"
|
||||||
|
style={{
|
||||||
|
overflow: hasRightContent ? 'hidden' : 'visible',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
...(isSelected
|
||||||
|
? { boxShadow: 'inset 0 0 0 2px hsl(var(--primary))' }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
overflow: hasRightContent ? 'hidden' : 'visible',
|
||||||
|
textOverflow: hasRightContent ? 'ellipsis' : 'clip',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: hasRightContent ? `${columnWidth - 16}px` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cellDisplayValue}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
95
src/components/DualSpreadsheet/components/CellRenderer.tsx
Normal file
95
src/components/DualSpreadsheet/components/CellRenderer.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { CellRendererProps } from '../types'
|
||||||
|
import { findMergedCellInfo } from '../utils'
|
||||||
|
import { Cell } from './Cell'
|
||||||
|
|
||||||
|
// Рендер-проп для ячеек грида
|
||||||
|
export const CellRenderer = ({
|
||||||
|
columnIndex,
|
||||||
|
rowIndex,
|
||||||
|
style,
|
||||||
|
data,
|
||||||
|
}: CellRendererProps) => {
|
||||||
|
const {
|
||||||
|
sheetType,
|
||||||
|
widths,
|
||||||
|
selectedCell,
|
||||||
|
activeSheet,
|
||||||
|
isEditing,
|
||||||
|
editingValue,
|
||||||
|
cachedData,
|
||||||
|
mergedCells,
|
||||||
|
handleCellClick,
|
||||||
|
handleCellDoubleClick,
|
||||||
|
handleCellChange,
|
||||||
|
stopEditing,
|
||||||
|
setActiveSheet,
|
||||||
|
activeCellInputRef,
|
||||||
|
} = data
|
||||||
|
|
||||||
|
const isCellSelected =
|
||||||
|
selectedCell?.row === rowIndex &&
|
||||||
|
selectedCell?.col === columnIndex &&
|
||||||
|
activeSheet === sheetType
|
||||||
|
const isCellEditing = isCellSelected && isEditing
|
||||||
|
|
||||||
|
const cellKey = `${rowIndex}-${columnIndex}`
|
||||||
|
const cellData = cachedData[cellKey]
|
||||||
|
|
||||||
|
const rawValue = isCellEditing ? editingValue : cellData?.rawValue || ''
|
||||||
|
const displayValue = isCellEditing
|
||||||
|
? '' // Не показывать вычисленное значение во время редактирования
|
||||||
|
: cellData?.displayValue || ''
|
||||||
|
|
||||||
|
// Отладка для измененных ячеек
|
||||||
|
if (cellData?.isModified) {
|
||||||
|
console.log(
|
||||||
|
`🔍 CellRenderer: Измененная ячейка ${rowIndex},${columnIndex}:`,
|
||||||
|
{
|
||||||
|
cellKey,
|
||||||
|
isModified: cellData.isModified,
|
||||||
|
displayValue,
|
||||||
|
rawValue,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию об объединенных ячейках только для листа отчета
|
||||||
|
const mergedCellInfo =
|
||||||
|
sheetType === 'report' && mergedCells
|
||||||
|
? findMergedCellInfo(rowIndex, columnIndex, mergedCells)
|
||||||
|
: { isMerged: false, isMainCell: false }
|
||||||
|
|
||||||
|
const mergedCellProps = mergedCellInfo.isMerged
|
||||||
|
? {
|
||||||
|
isMerged: true,
|
||||||
|
isMainCell: mergedCellInfo.isMainCell,
|
||||||
|
rowSpan: mergedCellInfo.spans?.rowSpan,
|
||||||
|
colSpan: mergedCellInfo.spans?.colSpan,
|
||||||
|
// Убираем mergedValue - будем использовать актуальные данные из движка
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cell
|
||||||
|
style={style}
|
||||||
|
sheetType={sheetType}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
colIndex={columnIndex}
|
||||||
|
columnWidth={widths[columnIndex]}
|
||||||
|
columnWidths={widths}
|
||||||
|
isSelected={isCellSelected}
|
||||||
|
isEditing={isCellEditing}
|
||||||
|
displayValue={displayValue}
|
||||||
|
rawValue={rawValue}
|
||||||
|
isModified={cellData?.isModified || false}
|
||||||
|
hasRightContent={cellData?.hasRightContent || false}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
|
onCellValueChange={handleCellChange}
|
||||||
|
stopEditing={stopEditing}
|
||||||
|
setActiveSheet={setActiveSheet}
|
||||||
|
activeCellRef={activeCellInputRef}
|
||||||
|
mergedCellInfo={mergedCellProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
src/components/DualSpreadsheet/components/FormulaBar.tsx
Normal file
186
src/components/DualSpreadsheet/components/FormulaBar.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { FormulaBarProps } from '../types'
|
||||||
|
import { getColumnLabel } from '../utils'
|
||||||
|
|
||||||
|
export const FormulaBar = memo(
|
||||||
|
({
|
||||||
|
selectedCell,
|
||||||
|
formulaBarValue,
|
||||||
|
isCalculating,
|
||||||
|
isEditing,
|
||||||
|
onValueChange,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onKeyDown,
|
||||||
|
formulaBarRef,
|
||||||
|
isSaving = false,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
lastSaveError = null,
|
||||||
|
onManualSave,
|
||||||
|
onClearError,
|
||||||
|
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
|
||||||
|
return (
|
||||||
|
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex min-w-[50px] items-center justify-center rounded-md border bg-muted px-2 py-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{selectedCell
|
||||||
|
? `${getColumnLabel(selectedCell.col)}${selectedCell.row + 1}`
|
||||||
|
: 'A1'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={formulaBarRef}
|
||||||
|
type="text"
|
||||||
|
className={`flex-1 rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||||
|
isEditing ? 'ring-2 ring-ring ring-offset-2' : ''
|
||||||
|
} ${isCalculating ? 'animate-pulse' : ''}`}
|
||||||
|
placeholder="Введите формулу или значение..."
|
||||||
|
value={formulaBarValue}
|
||||||
|
onChange={e => onValueChange(e.target.value)}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={e => {
|
||||||
|
// Проверяем, не переходит ли фокус в ячейку редактирования
|
||||||
|
const relatedTarget = e.relatedTarget as HTMLElement
|
||||||
|
if (
|
||||||
|
relatedTarget &&
|
||||||
|
relatedTarget.closest('input[type="text"]')
|
||||||
|
) {
|
||||||
|
return // Не завершаем редактирование
|
||||||
|
}
|
||||||
|
onBlur(e)
|
||||||
|
}}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
/>
|
||||||
|
{(isCalculating || isSaving) && (
|
||||||
|
<div className="flex items-center text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span className="ml-1 text-xs">
|
||||||
|
{isSaving ? 'Сохранение...' : 'Расчет...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Ошибка сохранения */}
|
||||||
|
{lastSaveError && (
|
||||||
|
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 text-destructive"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
className="text-xs text-destructive"
|
||||||
|
title={lastSaveError}
|
||||||
|
>
|
||||||
|
Ошибка сохранения
|
||||||
|
</span>
|
||||||
|
{onClearError && (
|
||||||
|
<button
|
||||||
|
onClick={onClearError}
|
||||||
|
className="ml-1 text-destructive/60 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Кнопка ручного сохранения */}
|
||||||
|
{onManualSave && (
|
||||||
|
<button
|
||||||
|
onClick={onManualSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
||||||
|
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'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
title={
|
||||||
|
lastSaveError
|
||||||
|
? 'Повторить сохранение'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? 'Есть несохраненные изменения'
|
||||||
|
: 'Все изменения сохранены'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{lastSaveError
|
||||||
|
? 'Повторить'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? 'Сохранить'
|
||||||
|
: 'Сохранено'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
188
src/components/DualSpreadsheet/components/Spreadsheet.tsx
Normal file
188
src/components/DualSpreadsheet/components/Spreadsheet.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { VariableSizeGrid } from 'react-window'
|
||||||
|
import { COL_COUNT, ROW_COUNT, SHEET_NAMES, SpreadsheetProps } from '../types'
|
||||||
|
import { getColumnLabel } from '../utils'
|
||||||
|
import { CellRenderer } from './CellRenderer'
|
||||||
|
|
||||||
|
// --- Spreadsheet Component ---
|
||||||
|
export const Spreadsheet = ({
|
||||||
|
sheetType,
|
||||||
|
columnWidths,
|
||||||
|
spreadsheetWidths,
|
||||||
|
getCachedCellData,
|
||||||
|
selectedCell,
|
||||||
|
activeSheet,
|
||||||
|
isEditing,
|
||||||
|
editingValue,
|
||||||
|
handleCellClick,
|
||||||
|
handleCellDoubleClick,
|
||||||
|
handleCellChange,
|
||||||
|
stopEditing,
|
||||||
|
setActiveSheet,
|
||||||
|
activeCellInputRef,
|
||||||
|
containerRefs,
|
||||||
|
handleKeyDown,
|
||||||
|
headerRefs,
|
||||||
|
rowHeaderRefs,
|
||||||
|
gridRefs,
|
||||||
|
onGridScroll,
|
||||||
|
handleColumnResize,
|
||||||
|
mergedCells,
|
||||||
|
}: SpreadsheetProps) => {
|
||||||
|
const widths = columnWidths
|
||||||
|
const cachedData = getCachedCellData(sheetType)
|
||||||
|
|
||||||
|
const itemData = {
|
||||||
|
sheetType,
|
||||||
|
widths,
|
||||||
|
selectedCell,
|
||||||
|
activeSheet,
|
||||||
|
isEditing,
|
||||||
|
editingValue,
|
||||||
|
cachedData,
|
||||||
|
mergedCells,
|
||||||
|
handleCellClick,
|
||||||
|
handleCellDoubleClick,
|
||||||
|
handleCellChange,
|
||||||
|
stopEditing,
|
||||||
|
setActiveSheet,
|
||||||
|
activeCellInputRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [gridSize, setGridSize] = useState({ width: 0, height: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parent = parentRef.current
|
||||||
|
if (!parent) return
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
const { width, height } = parent.getBoundingClientRect()
|
||||||
|
const headerHeight =
|
||||||
|
parent.querySelector('.column-headers')?.clientHeight || 0
|
||||||
|
setGridSize({ width, height: height - headerHeight })
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver.observe(parent)
|
||||||
|
return () => resizeObserver.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={parentRef}
|
||||||
|
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)}
|
||||||
|
className="flex h-full flex-col overflow-hidden border bg-card shadow-sm"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{/* Spreadsheet Body */}
|
||||||
|
<div className="flex flex-1">
|
||||||
|
{/* Row Headers */}
|
||||||
|
<div className="flex-shrink-0 bg-muted">
|
||||||
|
<div className="relative flex h-7 w-10 items-center justify-center border-b border-r border-border">
|
||||||
|
{/* Компактное обозначение листа */}
|
||||||
|
<div className="absolute inset-0 flex items-end justify-end p-0.5">
|
||||||
|
<span
|
||||||
|
className={`flex h-3 w-3 items-center justify-center rounded-sm text-[9px] font-medium text-white ${
|
||||||
|
sheetType === 'report'
|
||||||
|
? 'bg-primary/70'
|
||||||
|
: 'bg-emerald-500/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SHEET_NAMES[sheetType]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={el => (rowHeaderRefs.current[sheetType] = el)}
|
||||||
|
className="overflow-y-hidden"
|
||||||
|
style={{ height: gridSize.height }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{Array.from({ length: ROW_COUNT }).map((_, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={rowIndex}
|
||||||
|
className="flex h-7 w-10 items-center justify-center border-b border-r border-border text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
{rowIndex + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{/* Column Headers */}
|
||||||
|
<div
|
||||||
|
ref={el => (headerRefs.current[sheetType] = el)}
|
||||||
|
className="column-headers overflow-x-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{Array.from({ length: COL_COUNT }).map((_, i) => {
|
||||||
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const startX = e.clientX
|
||||||
|
const startWidth = widths[i]
|
||||||
|
|
||||||
|
const handleMouseMove = (me: MouseEvent) => {
|
||||||
|
const newWidth = startWidth + (me.clientX - startX)
|
||||||
|
handleColumnResize(sheetType, i, newWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
document.addEventListener('mouseup', handleMouseUp)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative flex h-7 flex-shrink-0 items-center justify-center border-b border-r border-border bg-muted text-center text-xs font-medium text-muted-foreground"
|
||||||
|
style={{ width: widths[i] }}
|
||||||
|
>
|
||||||
|
{getColumnLabel(i)}
|
||||||
|
<div
|
||||||
|
className="resizer absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
<VariableSizeGrid
|
||||||
|
ref={(el: VariableSizeGrid | null) =>
|
||||||
|
(gridRefs.current[sheetType] = el)
|
||||||
|
}
|
||||||
|
columnCount={COL_COUNT}
|
||||||
|
rowCount={ROW_COUNT}
|
||||||
|
columnWidth={(index: number) => widths[index]}
|
||||||
|
rowHeight={() => 28}
|
||||||
|
height={gridSize.height}
|
||||||
|
width={gridSize.width}
|
||||||
|
itemData={itemData}
|
||||||
|
onScroll={e => onGridScroll(sheetType, e)}
|
||||||
|
>
|
||||||
|
{CellRenderer}
|
||||||
|
</VariableSizeGrid>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
4
src/components/DualSpreadsheet/components/index.ts
Normal file
4
src/components/DualSpreadsheet/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Cell } from './Cell'
|
||||||
|
export { CellRenderer } from './CellRenderer'
|
||||||
|
export { FormulaBar } from './FormulaBar'
|
||||||
|
export { Spreadsheet } from './Spreadsheet'
|
||||||
52
src/components/DualSpreadsheet/hooks/useDraggableDivider.ts
Normal file
52
src/components/DualSpreadsheet/hooks/useDraggableDivider.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
// --- Хук для перетаскивания разделителя ---
|
||||||
|
export 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
|
||||||
|
}
|
||||||
146
src/components/DualSpreadsheet/types.ts
Normal file
146
src/components/DualSpreadsheet/types.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { MergedCell } from '@/types/template'
|
||||||
|
import { CSSProperties } from 'react'
|
||||||
|
import { VariableSizeGrid } from 'react-window'
|
||||||
|
|
||||||
|
export const ROW_COUNT = 90
|
||||||
|
export const COL_COUNT = 20
|
||||||
|
export const ROW_HEIGHT = 28
|
||||||
|
|
||||||
|
export const SHEET_NAMES = {
|
||||||
|
report: 'L',
|
||||||
|
calculations: 'R',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SheetType = keyof typeof SHEET_NAMES
|
||||||
|
|
||||||
|
// Добавляем массив типов листов для итерации
|
||||||
|
export const SHEET_TYPES: SheetType[] = ['report', 'calculations']
|
||||||
|
|
||||||
|
// Кэшированные данные ячеек для оптимизации
|
||||||
|
export interface CachedCellData {
|
||||||
|
displayValue: string
|
||||||
|
rawValue: string
|
||||||
|
isModified: boolean
|
||||||
|
hasRightContent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DualSpreadsheetProps {
|
||||||
|
templateData?: Record<string, Record<string, any>>
|
||||||
|
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
|
||||||
|
templateId?: string // Добавляем ID шаблона для сохранения
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellProps {
|
||||||
|
sheetType: SheetType
|
||||||
|
rowIndex: number
|
||||||
|
colIndex: number
|
||||||
|
columnWidth: number
|
||||||
|
columnWidths: number[] // Добавляем массив всех ширин колонок
|
||||||
|
isSelected: boolean
|
||||||
|
isEditing: boolean
|
||||||
|
displayValue: string // Отображаемое значение (вычисленное)
|
||||||
|
rawValue: string // "Сырое" значение (формула)
|
||||||
|
isModified: boolean
|
||||||
|
hasRightContent: boolean // Есть ли содержимое в ячейках справа
|
||||||
|
onCellClick: (row: number, col: number) => void
|
||||||
|
onCellDoubleClick: (row: number, col: number) => void
|
||||||
|
onCellValueChange: (newValue: string) => void // Изменение значения во время редактирования
|
||||||
|
stopEditing: (save: boolean) => void
|
||||||
|
setActiveSheet: (type: SheetType) => void
|
||||||
|
activeCellRef: React.RefObject<HTMLInputElement>
|
||||||
|
style: CSSProperties // Добавляем style для react-window
|
||||||
|
mergedCellInfo?: {
|
||||||
|
// Информация об объединенных ячейках
|
||||||
|
isMerged: boolean
|
||||||
|
isMainCell: boolean
|
||||||
|
rowSpan?: number
|
||||||
|
colSpan?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormulaBarProps {
|
||||||
|
sheetType: SheetType
|
||||||
|
activeSheet: SheetType
|
||||||
|
selectedCell: { row: number; col: number } | null
|
||||||
|
formulaBarValue: string
|
||||||
|
isCalculating: boolean
|
||||||
|
isEditing: boolean
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
onFocus: () => void
|
||||||
|
onBlur: (e: React.FocusEvent<HTMLInputElement>) => void
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||||
|
formulaBarRef: (el: HTMLInputElement | null) => void
|
||||||
|
// Обновляем props для сохранения
|
||||||
|
isSaving?: boolean
|
||||||
|
hasUnsavedChanges?: boolean
|
||||||
|
lastSaveError?: string | null
|
||||||
|
onManualSave?: () => void
|
||||||
|
onClearError?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellRendererData {
|
||||||
|
sheetType: SheetType
|
||||||
|
widths: number[]
|
||||||
|
selectedCell: { row: number; col: number } | null
|
||||||
|
activeSheet: SheetType
|
||||||
|
isEditing: boolean
|
||||||
|
editingValue: string
|
||||||
|
cachedData: Record<string, CachedCellData>
|
||||||
|
mergedCells?: MergedCell[]
|
||||||
|
handleCellClick: (row: number, col: number) => void
|
||||||
|
handleCellDoubleClick: (row: number, col: number) => void
|
||||||
|
handleCellChange: (newValue: string) => void
|
||||||
|
stopEditing: (save: boolean) => void
|
||||||
|
setActiveSheet: (type: SheetType) => void
|
||||||
|
activeCellInputRef: React.RefObject<HTMLInputElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CellRendererProps {
|
||||||
|
columnIndex: number
|
||||||
|
rowIndex: number
|
||||||
|
style: CSSProperties
|
||||||
|
data: CellRendererData
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpreadsheetProps {
|
||||||
|
sheetType: SheetType
|
||||||
|
columnWidths: number[]
|
||||||
|
spreadsheetWidths: { L: number; R: number }
|
||||||
|
getCachedCellData: (sheetType: SheetType) => Record<string, CachedCellData>
|
||||||
|
selectedCell: { row: number; col: number } | null
|
||||||
|
activeSheet: SheetType
|
||||||
|
isEditing: boolean
|
||||||
|
editingValue: string
|
||||||
|
handleCellClick: (row: number, col: number) => void
|
||||||
|
handleCellDoubleClick: (row: number, col: number) => void
|
||||||
|
handleCellChange: (newValue: string) => void
|
||||||
|
stopEditing: (save: boolean) => void
|
||||||
|
setActiveSheet: (type: SheetType) => void
|
||||||
|
activeCellInputRef: React.RefObject<HTMLInputElement>
|
||||||
|
containerRefs: React.MutableRefObject<
|
||||||
|
Record<SheetType, HTMLDivElement | null>
|
||||||
|
>
|
||||||
|
handleKeyDown: (e: React.KeyboardEvent) => void
|
||||||
|
headerRefs: React.MutableRefObject<Record<SheetType, HTMLDivElement | null>>
|
||||||
|
rowHeaderRefs: React.MutableRefObject<
|
||||||
|
Record<SheetType, HTMLDivElement | null>
|
||||||
|
>
|
||||||
|
gridRefs: React.MutableRefObject<Record<SheetType, VariableSizeGrid | null>>
|
||||||
|
onGridScroll: (
|
||||||
|
sheetType: SheetType,
|
||||||
|
params: { scrollLeft: number; scrollTop: number }
|
||||||
|
) => void
|
||||||
|
handleColumnResize: (
|
||||||
|
sheetType: SheetType,
|
||||||
|
colIndex: number,
|
||||||
|
newWidth: number
|
||||||
|
) => void
|
||||||
|
mergedCells?: MergedCell[] // Добавляем поддержку объединенных ячеек
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MergedCellInfo {
|
||||||
|
isMerged: boolean
|
||||||
|
isMainCell: boolean
|
||||||
|
mergedCell?: MergedCell
|
||||||
|
spans?: { rowSpan: number; colSpan: number }
|
||||||
|
}
|
||||||
51
src/components/DualSpreadsheet/utils.ts
Normal file
51
src/components/DualSpreadsheet/utils.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { MergedCell } from '@/types/template'
|
||||||
|
import { MergedCellInfo } from './types'
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
export const getColumnLabel = (index: number) => String.fromCharCode(65 + index)
|
||||||
|
|
||||||
|
// Функция для преобразования адреса ячейки в координаты
|
||||||
|
export const cellAddressToCoords = (
|
||||||
|
address: string
|
||||||
|
): { row: number; col: number } => {
|
||||||
|
const match = address.match(/^([A-Z]+)(\d+)$/)
|
||||||
|
if (!match) return { row: 0, col: 0 }
|
||||||
|
|
||||||
|
const col = match[1].charCodeAt(0) - 65 // Простое преобразование для одной буквы
|
||||||
|
const row = parseInt(match[2]) - 1
|
||||||
|
|
||||||
|
return { row, col }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для проверки, является ли ячейка частью объединенной группы
|
||||||
|
export const findMergedCellInfo = (
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
mergedCells: MergedCell[]
|
||||||
|
): MergedCellInfo => {
|
||||||
|
for (const merged of mergedCells) {
|
||||||
|
const fromCoords = cellAddressToCoords(merged.from)
|
||||||
|
const toCoords = cellAddressToCoords(merged.to)
|
||||||
|
|
||||||
|
// Проверяем, попадает ли текущая ячейка в диапазон объединения
|
||||||
|
if (
|
||||||
|
row >= fromCoords.row &&
|
||||||
|
row <= toCoords.row &&
|
||||||
|
col >= fromCoords.col &&
|
||||||
|
col <= toCoords.col
|
||||||
|
) {
|
||||||
|
const isMainCell = row === fromCoords.row && col === fromCoords.col
|
||||||
|
const rowSpan = toCoords.row - fromCoords.row + 1
|
||||||
|
const colSpan = toCoords.col - fromCoords.col + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMerged: true,
|
||||||
|
isMainCell,
|
||||||
|
mergedCell: merged,
|
||||||
|
spans: { rowSpan, colSpan },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isMerged: false, isMainCell: false }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user