холст конструктор
This commit is contained in:
@@ -34,6 +34,7 @@
|
|||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-grid-layout": "^1.5.2",
|
"react-grid-layout": "^1.5.2",
|
||||||
|
"react-rnd": "^10.5.2",
|
||||||
"react-window": "^1.8.11",
|
"react-window": "^1.8.11",
|
||||||
"redux": "^4.2.1",
|
"redux": "^4.2.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const Layout: FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Основной контент */}
|
{/* Основной контент */}
|
||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { produce } from 'immer'
|
import { produce } from 'immer'
|
||||||
import {
|
import {
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
FC,
|
FC,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { VariableSizeGrid } from 'react-window'
|
import { VariableSizeGrid } from 'react-window'
|
||||||
import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'
|
import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'
|
||||||
@@ -182,14 +182,8 @@ const FormulaBar = memo(
|
|||||||
onKeyDown,
|
onKeyDown,
|
||||||
formulaBarRef,
|
formulaBarRef,
|
||||||
}: FormulaBarProps) => {
|
}: FormulaBarProps) => {
|
||||||
if (activeSheet !== sheetType) {
|
// Теперь FormulaBar всегда отображается, так как он один
|
||||||
return (
|
|
||||||
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
|
|
||||||
<div className="flex h-[28px] items-center" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
|
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -324,7 +318,214 @@ const CellRenderer = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main Component ---
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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,
|
||||||
|
}: SpreadsheetProps) => {
|
||||||
|
const widths = columnWidths
|
||||||
|
const cachedData = getCachedCellData(sheetType)
|
||||||
|
|
||||||
|
const itemData = {
|
||||||
|
sheetType,
|
||||||
|
widths,
|
||||||
|
selectedCell,
|
||||||
|
activeSheet,
|
||||||
|
isEditing,
|
||||||
|
editingValue,
|
||||||
|
cachedData,
|
||||||
|
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}%` }}
|
||||||
|
onClick={() => setActiveSheet(sheetType)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={(el) => (containerRefs.current[sheetType] = el)}
|
||||||
|
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex-shrink-0 border-b bg-muted/50 px-3 py-1">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${
|
||||||
|
sheetType === 'report' ? 'bg-primary' : 'bg-emerald-500'
|
||||||
|
}`}
|
||||||
|
></div>
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">
|
||||||
|
{sheetType === 'report' ? 'Отчет' : 'Расчеты'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spreadsheet Body */}
|
||||||
|
<div className="flex flex-1">
|
||||||
|
{/* Row Headers */}
|
||||||
|
<div className="flex-shrink-0 bg-muted">
|
||||||
|
<div className="flex h-7 w-10 items-center justify-center border-b border-r border-border" />
|
||||||
|
<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="overflow-x-hidden column-headers"
|
||||||
|
>
|
||||||
|
<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="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary hover:bg-opacity-50 resizer"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<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> = ({ templateData = {} }) => {
|
const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
||||||
const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine(
|
const { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine(
|
||||||
{
|
{
|
||||||
@@ -360,10 +561,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
calculations: null,
|
calculations: null,
|
||||||
})
|
})
|
||||||
const activeCellInputRef = useRef<HTMLInputElement>(null)
|
const activeCellInputRef = useRef<HTMLInputElement>(null)
|
||||||
const formulaBarRefs = useRef<Record<SheetType, HTMLInputElement | null>>({
|
const formulaBarRef = useRef<HTMLInputElement | null>(null);
|
||||||
report: null,
|
|
||||||
calculations: null,
|
|
||||||
})
|
|
||||||
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
|
||||||
report: null,
|
report: null,
|
||||||
calculations: null,
|
calculations: null,
|
||||||
@@ -374,13 +572,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
})
|
})
|
||||||
const initialTemplateValues = useRef<Record<string, string>>({})
|
const initialTemplateValues = useRef<Record<string, string>>({})
|
||||||
|
|
||||||
// Кэш для данных ячеек
|
|
||||||
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
|
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
const lastRevision = useRef<number>(-1)
|
const lastRevision = useRef<number>(-1)
|
||||||
|
|
||||||
// Загрузка данных шаблона один раз при инициализации
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reportTemplate = templateData?.[SHEET_NAMES.report]
|
const reportTemplate = templateData?.[SHEET_NAMES.report]
|
||||||
if (reportTemplate) {
|
if (reportTemplate) {
|
||||||
@@ -390,9 +586,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
}
|
}
|
||||||
}, [templateData])
|
}, [templateData])
|
||||||
|
|
||||||
// --- Синхронизация UI ---
|
|
||||||
|
|
||||||
// Синхронизация прокрутки грида при изменении выделенной ячейки
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedCell && activeSheet) {
|
if (selectedCell && activeSheet) {
|
||||||
const grid = gridRefs.current[activeSheet]
|
const grid = gridRefs.current[activeSheet]
|
||||||
@@ -404,12 +597,10 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
}
|
}
|
||||||
}, [selectedCell, activeSheet])
|
}, [selectedCell, activeSheet])
|
||||||
|
|
||||||
// Функция для получения кэшированных данных ячеек
|
|
||||||
const getCachedCellData = useCallback(
|
const getCachedCellData = useCallback(
|
||||||
(sheetType: SheetType): Record<string, CachedCellData> => {
|
(sheetType: SheetType): Record<string, CachedCellData> => {
|
||||||
const cacheKey = `${sheetType}-${revision}`
|
const cacheKey = `${sheetType}-${revision}`
|
||||||
|
|
||||||
// Если данные уже кэшированы для этой ревизии, возвращаем их
|
|
||||||
if (cellDataCache.current[cacheKey]) {
|
if (cellDataCache.current[cacheKey]) {
|
||||||
return cellDataCache.current[cacheKey]
|
return cellDataCache.current[cacheKey]
|
||||||
}
|
}
|
||||||
@@ -417,7 +608,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
const sheetName = SHEET_NAMES[sheetType]
|
const sheetName = SHEET_NAMES[sheetType]
|
||||||
const cachedData: Record<string, CachedCellData> = {}
|
const cachedData: Record<string, CachedCellData> = {}
|
||||||
|
|
||||||
// Предварительно получаем все значения для оптимизации
|
|
||||||
const allCellValues: Record<string, string> = {}
|
const allCellValues: Record<string, string> = {}
|
||||||
for (let row = 0; row < ROW_COUNT; row++) {
|
for (let row = 0; row < ROW_COUNT; row++) {
|
||||||
for (let col = 0; col < COL_COUNT; col++) {
|
for (let col = 0; col < COL_COUNT; col++) {
|
||||||
@@ -429,7 +619,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вычисляем hasRightContent для всех ячеек сразу
|
|
||||||
const rightContentMap: Record<string, boolean> = {}
|
const rightContentMap: Record<string, boolean> = {}
|
||||||
for (let row = 0; row < ROW_COUNT; row++) {
|
for (let row = 0; row < ROW_COUNT; row++) {
|
||||||
let hasContentOnRight = false
|
let hasContentOnRight = false
|
||||||
@@ -442,7 +631,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формируем кэшированные данные
|
|
||||||
for (let row = 0; row < ROW_COUNT; row++) {
|
for (let row = 0; row < ROW_COUNT; row++) {
|
||||||
for (let col = 0; col < COL_COUNT; col++) {
|
for (let col = 0; col < COL_COUNT; col++) {
|
||||||
const cellKey = `${row}-${col}`
|
const cellKey = `${row}-${col}`
|
||||||
@@ -465,7 +653,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Очищаем старые кэши для экономии памяти
|
|
||||||
if (lastRevision.current !== revision) {
|
if (lastRevision.current !== revision) {
|
||||||
Object.keys(cellDataCache.current).forEach((key) => {
|
Object.keys(cellDataCache.current).forEach((key) => {
|
||||||
if (!key.endsWith(`-${revision}`)) {
|
if (!key.endsWith(`-${revision}`)) {
|
||||||
@@ -481,7 +668,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
[revision, multiSheetEngine, initialTemplateValues],
|
[revision, multiSheetEngine, initialTemplateValues],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Синхронизация строки формул
|
|
||||||
const formulaBarValue = useMemo(() => {
|
const formulaBarValue = useMemo(() => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return editingValue
|
return editingValue
|
||||||
@@ -504,7 +690,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
revision,
|
revision,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Фокус на инпуте при начале редактирования
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditing && activeCellInputRef.current) {
|
if (isEditing && activeCellInputRef.current) {
|
||||||
const input = activeCellInputRef.current
|
const input = activeCellInputRef.current
|
||||||
@@ -514,8 +699,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
}
|
}
|
||||||
}, [isEditing])
|
}, [isEditing])
|
||||||
|
|
||||||
// --- Функции-обработчики ---
|
|
||||||
|
|
||||||
const startEditing = useCallback(() => {
|
const startEditing = useCallback(() => {
|
||||||
if (!selectedCell) return
|
if (!selectedCell) return
|
||||||
const sheetName = SHEET_NAMES[activeSheet]
|
const sheetName = SHEET_NAMES[activeSheet]
|
||||||
@@ -538,13 +721,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
selectedCell.col,
|
selectedCell.col,
|
||||||
editingValue,
|
editingValue,
|
||||||
)
|
)
|
||||||
// Немедленно обновляем интерфейс для быстрого отклика
|
|
||||||
updateRevision()
|
updateRevision()
|
||||||
// Запускаем отложенный пересчет для формул
|
|
||||||
multiSheetEngine.debouncedRecalc()
|
multiSheetEngine.debouncedRecalc()
|
||||||
}
|
}
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setEditingValue('') // Очищаем временное значение
|
setEditingValue('')
|
||||||
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
||||||
},
|
},
|
||||||
[activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision],
|
[activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision],
|
||||||
@@ -559,30 +740,22 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
[isEditing],
|
[isEditing],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Оптимизированная функция очистки ячейки
|
|
||||||
const handleClearCell = useCallback(() => {
|
const handleClearCell = useCallback(() => {
|
||||||
if (!selectedCell) return
|
if (!selectedCell) return
|
||||||
const sheetName = SHEET_NAMES[activeSheet]
|
const sheetName = SHEET_NAMES[activeSheet]
|
||||||
|
|
||||||
// Используем версию без немедленного пересчета для быстрой реакции
|
|
||||||
multiSheetEngine.setCellValueWithoutRecalc(
|
multiSheetEngine.setCellValueWithoutRecalc(
|
||||||
sheetName,
|
sheetName,
|
||||||
selectedCell.row,
|
selectedCell.row,
|
||||||
selectedCell.col,
|
selectedCell.col,
|
||||||
'',
|
'',
|
||||||
)
|
)
|
||||||
|
|
||||||
// Немедленно обновляем интерфейс для быстрого отклика
|
|
||||||
updateRevision()
|
updateRevision()
|
||||||
|
|
||||||
// Запускаем отложенный пересчет
|
|
||||||
multiSheetEngine.debouncedRecalc()
|
multiSheetEngine.debouncedRecalc()
|
||||||
}, [activeSheet, selectedCell, multiSheetEngine, updateRevision])
|
}, [activeSheet, selectedCell, multiSheetEngine, updateRevision])
|
||||||
|
|
||||||
const moveSelection = useCallback(
|
const moveSelection = useCallback(
|
||||||
(deltaRow: number, deltaCol: number) => {
|
(deltaRow: number, deltaCol: number) => {
|
||||||
if (!selectedCell) {
|
if (!selectedCell) {
|
||||||
// Если ничего не выбрано, выбираем A1
|
|
||||||
setSelectedCell({ row: 0, col: 0 })
|
setSelectedCell({ row: 0, col: 0 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -608,7 +781,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
// Логика внутри Cell компонента
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,7 +807,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
keyHandlers[e.key]()
|
keyHandlers[e.key]()
|
||||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setEditingValue(e.key) // Начинаем редактирование с введенного символа
|
setEditingValue(e.key)
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -666,7 +838,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
setColumnWidths(
|
setColumnWidths(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft[sheetType][colIndex] = Math.max(60, newWidth)
|
draft[sheetType][colIndex] = Math.max(60, newWidth)
|
||||||
// Принудительно пересчитываем layout грида
|
|
||||||
const grid = gridRefs.current[sheetType]
|
const grid = gridRefs.current[sheetType]
|
||||||
if (grid) {
|
if (grid) {
|
||||||
grid.resetAfterColumnIndex(colIndex)
|
grid.resetAfterColumnIndex(colIndex)
|
||||||
@@ -677,7 +848,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Formula Bar Specific Handlers ---
|
|
||||||
const handleFormulaBarChange = useCallback(
|
const handleFormulaBarChange = useCallback(
|
||||||
(newValue: string) => {
|
(newValue: string) => {
|
||||||
if (selectedCell) {
|
if (selectedCell) {
|
||||||
@@ -708,7 +878,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
stopEditing(true)
|
stopEditing(true)
|
||||||
moveSelection(1, 0)
|
moveSelection(1, 0)
|
||||||
// Предотвращаем прокрутку страницы
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -736,172 +905,61 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Render Functions ---
|
const [separatorRef, setSeparatorRef] = useState<HTMLDivElement | null>(null)
|
||||||
const renderSpreadsheet = useCallback(
|
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
|
||||||
(sheetType: SheetType) => {
|
L: 0.5,
|
||||||
const widths = columnWidths[sheetType]
|
R: 0.5,
|
||||||
const cachedData = getCachedCellData(sheetType)
|
})
|
||||||
|
|
||||||
const itemData = {
|
// --- Spreadsheet Component ---
|
||||||
sheetType,
|
|
||||||
widths,
|
|
||||||
selectedCell,
|
|
||||||
activeSheet,
|
|
||||||
isEditing,
|
|
||||||
editingValue,
|
|
||||||
cachedData,
|
|
||||||
handleCellClick,
|
|
||||||
handleCellDoubleClick,
|
|
||||||
handleCellChange,
|
|
||||||
stopEditing,
|
|
||||||
setActiveSheet,
|
|
||||||
activeCellInputRef,
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={(el) => (containerRefs.current[sheetType] = el)}
|
|
||||||
className="flex h-full flex-col overflow-hidden rounded-lg border bg-card shadow-sm"
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex-shrink-0 border-b bg-muted/50 px-3 py-1">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
className={`h-2 w-2 rounded-full ${
|
|
||||||
sheetType === 'report' ? 'bg-primary' : 'bg-emerald-500'
|
|
||||||
}`}
|
|
||||||
></div>
|
|
||||||
<h3 className="text-xs font-medium text-muted-foreground">
|
|
||||||
{sheetType === 'report' ? 'Отчет' : 'Расчеты'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spreadsheet Body */}
|
|
||||||
<div className="flex flex-1">
|
|
||||||
{/* Row Headers */}
|
|
||||||
<div className="flex-shrink-0 bg-muted">
|
|
||||||
<div className="flex h-7 w-10 items-center justify-center border-b border-r border-border" />
|
|
||||||
<div
|
|
||||||
ref={(el) => (rowHeaderRefs.current[sheetType] = el)}
|
|
||||||
className="overflow-y-hidden"
|
|
||||||
style={{ height: 600 }} // This should be dynamic based on container size
|
|
||||||
>
|
|
||||||
<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="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="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 */}
|
|
||||||
<VariableSizeGrid
|
|
||||||
ref={(el) => (gridRefs.current[sheetType] = el)}
|
|
||||||
columnCount={COL_COUNT}
|
|
||||||
rowCount={ROW_COUNT}
|
|
||||||
columnWidth={(index: number) => widths[index]}
|
|
||||||
rowHeight={() => ROW_HEIGHT}
|
|
||||||
height={600} // This should be dynamic based on container size
|
|
||||||
width={800} // This should be dynamic based on container size
|
|
||||||
itemData={itemData}
|
|
||||||
onScroll={(e) => onGridScroll(sheetType, e)}
|
|
||||||
>
|
|
||||||
{CellRenderer}
|
|
||||||
</VariableSizeGrid>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[
|
|
||||||
columnWidths,
|
|
||||||
getCachedCellData,
|
|
||||||
handleKeyDown,
|
|
||||||
handleColumnResize,
|
|
||||||
selectedCell,
|
|
||||||
activeSheet,
|
|
||||||
isEditing,
|
|
||||||
editingValue,
|
|
||||||
handleCellClick,
|
|
||||||
handleCellDoubleClick,
|
|
||||||
handleCellChange,
|
|
||||||
stopEditing,
|
|
||||||
setActiveSheet,
|
|
||||||
onGridScroll,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
// Мемоизированные компоненты листов
|
|
||||||
const memoizedReportSheet = useMemo(
|
|
||||||
() => renderSpreadsheet('report'),
|
|
||||||
[renderSpreadsheet, revision], // Добавляем revision для перерендера
|
|
||||||
)
|
|
||||||
|
|
||||||
const memoizedCalculationsSheet = useMemo(
|
|
||||||
() => renderSpreadsheet('calculations'),
|
|
||||||
[renderSpreadsheet, revision], // Добавляем revision для перерендера
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<div className="flex-shrink-0">
|
<FormulaBar
|
||||||
<FormulaBar
|
|
||||||
sheetType={activeSheet}
|
|
||||||
activeSheet={activeSheet}
|
activeSheet={activeSheet}
|
||||||
|
sheetType={activeSheet} // Передаем activeSheet как текущий sheetType
|
||||||
selectedCell={selectedCell}
|
selectedCell={selectedCell}
|
||||||
formulaBarValue={formulaBarValue}
|
formulaBarValue={formulaBarValue}
|
||||||
isCalculating={multiSheetEngine.isCalculating}
|
isCalculating={multiSheetEngine.isCalculating}
|
||||||
@@ -910,16 +968,64 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
onFocus={handleFormulaBarFocus}
|
onFocus={handleFormulaBarFocus}
|
||||||
onBlur={handleFormulaBarBlur}
|
onBlur={handleFormulaBarBlur}
|
||||||
onKeyDown={handleFormulaBarKeyDown}
|
onKeyDown={handleFormulaBarKeyDown}
|
||||||
formulaBarRef={(el) => (formulaBarRefs.current[activeSheet] = el)}
|
formulaBarRef={formulaBarRef}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 min-w-0 gap-1 overflow-hidden p-1">
|
||||||
|
<Spreadsheet
|
||||||
|
sheetType="report"
|
||||||
|
columnWidths={columnWidths.report}
|
||||||
|
spreadsheetWidths={spreadsheetWidths}
|
||||||
|
getCachedCellData={getCachedCellData}
|
||||||
|
selectedCell={selectedCell}
|
||||||
|
activeSheet={activeSheet}
|
||||||
|
isEditing={isEditing}
|
||||||
|
editingValue={editingValue}
|
||||||
|
handleCellClick={handleCellClick}
|
||||||
|
handleCellDoubleClick={handleCellDoubleClick}
|
||||||
|
handleCellChange={handleCellChange}
|
||||||
|
stopEditing={stopEditing}
|
||||||
|
setActiveSheet={setActiveSheet}
|
||||||
|
activeCellInputRef={activeCellInputRef}
|
||||||
|
containerRefs={containerRefs}
|
||||||
|
handleKeyDown={handleKeyDown}
|
||||||
|
headerRefs={headerRefs}
|
||||||
|
rowHeaderRefs={rowHeaderRefs}
|
||||||
|
gridRefs={gridRefs}
|
||||||
|
onGridScroll={onGridScroll}
|
||||||
|
handleColumnResize={handleColumnResize}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={setSeparatorRef}
|
||||||
|
className="flex items-center justify-center w-2 cursor-col-resize bg-border hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-4 bg-muted-foreground/50 rounded-full" />
|
||||||
|
</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}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{/* Двойная панель */}
|
|
||||||
<div className="flex flex-1 gap-1 overflow-hidden p-1">
|
|
||||||
<div className="min-w-0 flex-1">{memoizedReportSheet}</div>
|
|
||||||
<div className="min-w-0 flex-1">{memoizedCalculationsSheet}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DualSpreadsheet
|
export default DualSpreadsheet
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background">
|
<div className="flex h-screen flex-col bg-background">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="flex-shrink-0 border-b bg-card px-4 py-3">
|
<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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
|||||||
256
src/components/TemplateManager/VisualLayoutEditor.tsx
Normal file
256
src/components/TemplateManager/VisualLayoutEditor.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CheckSquare,
|
||||||
|
ChevronDown,
|
||||||
|
Edit,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
FileText,
|
||||||
|
Hash,
|
||||||
|
Move,
|
||||||
|
Trash2,
|
||||||
|
Type
|
||||||
|
} from 'lucide-react';
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Rnd } from 'react-rnd';
|
||||||
|
import { ElementLayout, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
|
||||||
|
interface VisualLayoutEditorProps {
|
||||||
|
elements: TemplateElement[];
|
||||||
|
layoutSettings: FormLayoutSettings;
|
||||||
|
onElementUpdate: (elementId: string, updates: Partial<TemplateElement>) => void;
|
||||||
|
onElementDelete: (elementId: string) => void;
|
||||||
|
onLayoutSettingsChange: (settings: FormLayoutSettings) => void;
|
||||||
|
onElementEdit?: (element: TemplateElement) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggableElementProps {
|
||||||
|
element: TemplateElement;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onUpdate: (layout: ElementLayout) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
gridSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
||||||
|
text: <Type className="h-4 w-4" />,
|
||||||
|
textarea: <FileText className="h-4 w-4" />,
|
||||||
|
number: <Hash className="h-4 w-4" />,
|
||||||
|
date: <Calendar className="h-4 w-4" />,
|
||||||
|
select: <ChevronDown className="h-4 w-4" />,
|
||||||
|
radio: <CheckSquare className="h-4 w-4" />,
|
||||||
|
checkbox: <CheckSquare className="h-4 w-4" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELEMENT_COLORS: Record<ElementType, string> = {
|
||||||
|
text: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||||
|
textarea: 'bg-green-100 border-green-300 text-green-800',
|
||||||
|
number: 'bg-purple-100 border-purple-300 text-purple-800',
|
||||||
|
date: 'bg-orange-100 border-orange-300 text-orange-800',
|
||||||
|
select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||||
|
radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
||||||
|
checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
||||||
|
element,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
gridSize,
|
||||||
|
}) => {
|
||||||
|
const layout = element.layout || { x: 50, y: 50, width: 300, height: 80, zIndex: 1 };
|
||||||
|
const elementColor = ELEMENT_COLORS[element.type];
|
||||||
|
|
||||||
|
const handleDragStop = useCallback((_e: any, d: { x: number; y: number }) => {
|
||||||
|
// Вызываем onUpdate только если позиция действительно изменилась,
|
||||||
|
// чтобы избежать ложных срабатываний при клике.
|
||||||
|
if (d.x !== layout.x || d.y !== layout.y) {
|
||||||
|
onUpdate({ ...layout, x: d.x, y: d.y });
|
||||||
|
}
|
||||||
|
}, [layout, onUpdate]);
|
||||||
|
|
||||||
|
const handleResizeStop = useCallback((_e: any, _dir: any, ref: HTMLElement, _delta: any, pos: { x: number; y: number }) => {
|
||||||
|
onUpdate({
|
||||||
|
...layout,
|
||||||
|
width: parseInt(ref.style.width, 10),
|
||||||
|
height: parseInt(ref.style.height, 10),
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
});
|
||||||
|
}, [layout, onUpdate]);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}, [onDelete]);
|
||||||
|
|
||||||
|
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit?.();
|
||||||
|
}, [onEdit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rnd
|
||||||
|
dragGrid={[gridSize, gridSize]}
|
||||||
|
resizeGrid={[gridSize, gridSize]}
|
||||||
|
default={{
|
||||||
|
x: layout.x,
|
||||||
|
y: layout.y,
|
||||||
|
width: layout.width,
|
||||||
|
height: layout.height,
|
||||||
|
}}
|
||||||
|
onDragStop={handleDragStop}
|
||||||
|
onResizeStop={handleResizeStop}
|
||||||
|
minWidth={150}
|
||||||
|
minHeight={40}
|
||||||
|
bounds="parent"
|
||||||
|
className={`transition-shadow duration-200 group ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 z-20' : 'z-10'}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className={`h-full rounded-lg border-2 p-3 cursor-move ${elementColor} ${isSelected ? 'shadow-xl' : 'shadow-md'} transition-all duration-200`}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ELEMENT_ICONS[element.type]}
|
||||||
|
<span className="font-medium text-sm truncate">{element.label || `${element.type} элемент`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-100"
|
||||||
|
onClick={handleEditClick}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3 text-blue-600" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-100"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-75 space-y-1">
|
||||||
|
{element.required && <div className="text-red-700 font-medium">• Обязательное</div>}
|
||||||
|
{element.targetCells && element.targetCells.length > 0 && (
|
||||||
|
<div className="font-mono truncate">
|
||||||
|
{element.targetCells.map((cell, i) => <span key={i} className="mr-1.5 p-1 bg-black/5 rounded-sm">{cell.sheet}!{cell.cell}</span>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Rnd>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
||||||
|
elements,
|
||||||
|
layoutSettings,
|
||||||
|
onElementUpdate,
|
||||||
|
onElementDelete,
|
||||||
|
onLayoutSettingsChange,
|
||||||
|
onElementEdit,
|
||||||
|
}) => {
|
||||||
|
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canvasSize = useMemo(() => {
|
||||||
|
const PADDING_Y = 200;
|
||||||
|
|
||||||
|
const baseWidth = 1200;
|
||||||
|
const baseHeight = 800;
|
||||||
|
|
||||||
|
if (elements.length === 0) {
|
||||||
|
return { width: baseWidth, height: baseHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentHeight = Math.max(
|
||||||
|
0,
|
||||||
|
...elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: baseWidth,
|
||||||
|
height: Math.max(baseHeight, contentHeight + PADDING_Y),
|
||||||
|
};
|
||||||
|
}, [elements]);
|
||||||
|
|
||||||
|
const GridPattern = useMemo(() => {
|
||||||
|
if (!layoutSettings.showGrid) return null;
|
||||||
|
const gridSize = layoutSettings.gridSize;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(to right, #e5e7eb 1px, transparent 1px), linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`,
|
||||||
|
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [layoutSettings.showGrid, layoutSettings.gridSize]);
|
||||||
|
|
||||||
|
const handleElementUpdateCallback = useCallback((elementId: string, layout: ElementLayout) => {
|
||||||
|
onElementUpdate(elementId, { layout });
|
||||||
|
}, [onElementUpdate]);
|
||||||
|
|
||||||
|
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
setSelectedElementId(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleGrid = useCallback(() => {
|
||||||
|
onLayoutSettingsChange({ ...layoutSettings, showGrid: !layoutSettings.showGrid });
|
||||||
|
}, [layoutSettings, onLayoutSettingsChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-50">
|
||||||
|
<div className="flex items-center justify-between p-2 bg-white border-b shrink-0">
|
||||||
|
<span className="text-sm text-gray-600 px-2">{elements.length} элементов на холсте</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={toggleGrid}>
|
||||||
|
{layoutSettings.showGrid ? <EyeOff className="h-4 w-4 mr-2" /> : <Eye className="h-4 w-4 mr-2" />}
|
||||||
|
{layoutSettings.showGrid ? 'Скрыть сетку' : 'Показать сетку'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 relative overflow-auto" onClick={handleCanvasClick}>
|
||||||
|
<div
|
||||||
|
className="relative bg-white shadow-lg mx-auto my-8"
|
||||||
|
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||||
|
>
|
||||||
|
{GridPattern}
|
||||||
|
{elements.map((element) => (
|
||||||
|
<DraggableElement
|
||||||
|
key={element.id}
|
||||||
|
element={element}
|
||||||
|
isSelected={selectedElementId === element.id}
|
||||||
|
onSelect={() => setSelectedElementId(element.id)}
|
||||||
|
onUpdate={(layout) => handleElementUpdateCallback(element.id, layout)}
|
||||||
|
onDelete={() => onElementDelete(element.id)}
|
||||||
|
onEdit={onElementEdit ? () => onElementEdit(element) : undefined}
|
||||||
|
gridSize={layoutSettings.gridSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{elements.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
<Move className="h-16 w-16 mx-auto mb-4" />
|
||||||
|
<p className="text-xl font-medium mb-2">Холст пуст</p>
|
||||||
|
<p className="text-sm">Добавьте свой первый элемент</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
src/components/ui/badge.tsx
Normal file
38
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
excel: "border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||||
|
required: "border-transparent bg-red-100 text-red-800",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { createContext, ReactNode, useContext, useState } from 'react';
|
import React, { createContext, ReactNode, useContext, useState } from 'react';
|
||||||
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils';
|
import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils';
|
||||||
import { Template } from '../types/template';
|
import { Template, TemplateElement } from '../types/template';
|
||||||
|
|
||||||
interface TemplateContextType {
|
interface TemplateContextType {
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
@@ -24,6 +24,28 @@ interface TemplateProviderProps {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция создания элемента названия протокола
|
||||||
|
function createProtocolNameElement(): TemplateElement {
|
||||||
|
return {
|
||||||
|
id: 'protocol-name-' + Date.now(),
|
||||||
|
type: 'text',
|
||||||
|
name: 'protocolName',
|
||||||
|
label: 'Название протокола',
|
||||||
|
placeholder: 'Введите название протокола',
|
||||||
|
required: true,
|
||||||
|
targetCells: [{ sheet: 'L', cell: 'A1', displayName: 'Название протокола' }],
|
||||||
|
options: [],
|
||||||
|
order: 0,
|
||||||
|
layout: {
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 400,
|
||||||
|
height: 80,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Функция миграции шаблона
|
// Функция миграции шаблона
|
||||||
function migrateTemplate(template: any): Template {
|
function migrateTemplate(template: any): Template {
|
||||||
return {
|
return {
|
||||||
@@ -37,7 +59,22 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({ children })
|
|||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
|
||||||
const addTemplate = (template: Template) => {
|
const addTemplate = (template: Template) => {
|
||||||
const migratedTemplate = migrateTemplate(template);
|
// Проверяем есть ли уже элемент названия протокола
|
||||||
|
const hasProtocolName = template.elements.some(el =>
|
||||||
|
el.name === 'protocolName' || el.label === 'Название протокола'
|
||||||
|
);
|
||||||
|
|
||||||
|
let templateWithProtocolName = template;
|
||||||
|
if (!hasProtocolName) {
|
||||||
|
// Добавляем элемент названия протокола как первый элемент
|
||||||
|
const protocolNameElement = createProtocolNameElement();
|
||||||
|
templateWithProtocolName = {
|
||||||
|
...template,
|
||||||
|
elements: [protocolNameElement, ...template.elements.map(el => ({ ...el, order: el.order + 1 }))]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const migratedTemplate = migrateTemplate(templateWithProtocolName);
|
||||||
setTemplates(prev => [...prev, migratedTemplate]);
|
setTemplates(prev => [...prev, migratedTemplate]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
118
src/lib/utils.ts
118
src/lib/utils.ts
@@ -1,6 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import { CellTarget, FormLayoutSettings, TemplateElement } from "../types/template";
|
import { CellTarget, ElementLayout, FormLayoutSettings, TemplateElement } from "../types/template";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -32,19 +32,121 @@ export function migrateTemplateElement(element: any): TemplateElement {
|
|||||||
...element,
|
...element,
|
||||||
targetCells,
|
targetCells,
|
||||||
order: element.order || 0,
|
order: element.order || 0,
|
||||||
category: element.category || '',
|
layout: element.layout || generateDefaultLayout(element.order || 0),
|
||||||
width: element.width || 'auto',
|
|
||||||
} as TemplateElement;
|
} as TemplateElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение настроек отображения по умолчанию
|
// Получение настроек отображения по умолчанию
|
||||||
export function getDefaultLayoutSettings(): FormLayoutSettings {
|
export function getDefaultLayoutSettings(): FormLayoutSettings {
|
||||||
return {
|
return {
|
||||||
columns: 2,
|
canvasWidth: 1200,
|
||||||
grouping: 'none',
|
canvasHeight: 800,
|
||||||
elementSpacing: 'normal',
|
gridSize: 20,
|
||||||
showLabels: true,
|
showGrid: true,
|
||||||
enableDragDrop: true,
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерация позиции по умолчанию для нового элемента
|
||||||
|
export function generateDefaultLayout(order: number = 0): ElementLayout {
|
||||||
|
const cols = 4; // Количество колонок для автоматического размещения
|
||||||
|
const elementWidth = 300;
|
||||||
|
const elementHeight = 80;
|
||||||
|
const padding = 50;
|
||||||
|
|
||||||
|
const col = order % cols;
|
||||||
|
const row = Math.floor(order / cols);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: col * (elementWidth + padding) + padding,
|
||||||
|
y: row * (elementHeight + padding) + padding,
|
||||||
|
width: elementWidth,
|
||||||
|
height: elementHeight,
|
||||||
|
zIndex: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое размещение элементов на холсте
|
||||||
|
export function autoArrangeElements(
|
||||||
|
elements: TemplateElement[],
|
||||||
|
gridSize: number = 20
|
||||||
|
): TemplateElement[] {
|
||||||
|
const elementWidth = 300;
|
||||||
|
const elementHeight = 80;
|
||||||
|
const padding = 50;
|
||||||
|
const cols = 4; // Фиксированное количество колонок
|
||||||
|
|
||||||
|
return elements.map((element, index) => {
|
||||||
|
const col = index % cols;
|
||||||
|
const row = Math.floor(index / cols);
|
||||||
|
|
||||||
|
const x = col * (elementWidth + padding) + padding;
|
||||||
|
const y = row * (elementHeight + padding) + padding;
|
||||||
|
|
||||||
|
// Привязка к сетке
|
||||||
|
const snappedX = Math.round(x / gridSize) * gridSize;
|
||||||
|
const snappedY = Math.round(y / gridSize) * gridSize;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
layout: {
|
||||||
|
x: snappedX,
|
||||||
|
y: snappedY,
|
||||||
|
width: elementWidth,
|
||||||
|
height: elementHeight,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка пересечения элементов
|
||||||
|
export function checkElementOverlap(
|
||||||
|
element1: ElementLayout,
|
||||||
|
element2: ElementLayout,
|
||||||
|
threshold: number = 5
|
||||||
|
): boolean {
|
||||||
|
return !(
|
||||||
|
element1.x + element1.width + threshold < element2.x ||
|
||||||
|
element2.x + element2.width + threshold < element1.x ||
|
||||||
|
element1.y + element1.height + threshold < element2.y ||
|
||||||
|
element2.y + element2.height + threshold < element1.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск свободного места для элемента (поиск в разумных пределах)
|
||||||
|
export function findFreePosition(
|
||||||
|
newElement: ElementLayout,
|
||||||
|
existingElements: ElementLayout[],
|
||||||
|
gridSize: number = 20
|
||||||
|
): ElementLayout {
|
||||||
|
const step = gridSize;
|
||||||
|
const maxWidth = 1600; // Максимальная ширина поиска
|
||||||
|
const maxHeight = 1200; // Максимальная высота поиска
|
||||||
|
const maxAttempts = 500;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
for (let y = 50; y <= maxHeight - newElement.height; y += step) {
|
||||||
|
for (let x = 50; x <= maxWidth - newElement.width; x += step) {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > maxAttempts) break;
|
||||||
|
|
||||||
|
const testPosition = { ...newElement, x, y };
|
||||||
|
const hasOverlap = existingElements.some(existing =>
|
||||||
|
checkElementOverlap(testPosition, existing)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasOverlap) {
|
||||||
|
return testPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (attempts > maxAttempts) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не нашли свободное место, размещаем в правом нижнем углу
|
||||||
|
return {
|
||||||
|
...newElement,
|
||||||
|
x: 50,
|
||||||
|
y: existingElements.length * 100 + 50
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,17 +95,14 @@ export const ElementsCreation: FC = () => {
|
|||||||
|
|
||||||
// Настройки отображения по умолчанию
|
// Настройки отображения по умолчанию
|
||||||
const defaultLayoutSettings: FormLayoutSettings = {
|
const defaultLayoutSettings: FormLayoutSettings = {
|
||||||
columns: 2,
|
gridSize: 20,
|
||||||
grouping: 'none',
|
showGrid: true,
|
||||||
elementSpacing: 'normal',
|
|
||||||
showLabels: true,
|
|
||||||
enableDragDrop: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (selectedTemplate) {
|
if (selectedTemplate) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-gray-50">
|
<div className="h-full">
|
||||||
<div className="bg-white border-b border-gray-200 p-4">
|
<div className="bg-white border-b border-gray-200 p-4 sticky top-0 z-50">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" onClick={handleBack}>
|
<Button variant="ghost" onClick={handleBack}>
|
||||||
@@ -116,7 +113,7 @@ export const ElementsCreation: FC = () => {
|
|||||||
Конструктор элементов: {selectedTemplate.name}
|
Конструктор элементов: {selectedTemplate.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
Создайте элементы формы для связи с ячейками шаблона. Используйте drag & drop для изменения порядка.
|
Создайте элементы формы и разместите их на холсте перетаскиванием
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +128,7 @@ export const ElementsCreation: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="h-[calc(100vh-104px)]">
|
||||||
<ElementConstructor
|
<ElementConstructor
|
||||||
elements={selectedTemplate.elements}
|
elements={selectedTemplate.elements}
|
||||||
layoutSettings={selectedTemplate.layoutSettings || defaultLayoutSettings}
|
layoutSettings={selectedTemplate.layoutSettings || defaultLayoutSettings}
|
||||||
@@ -192,17 +189,6 @@ export const ElementsCreation: FC = () => {
|
|||||||
<Wrench className="h-4 w-4" />
|
<Wrench className="h-4 w-4" />
|
||||||
<span>{template.elements.length} элементов создано</span>
|
<span>{template.elements.length} элементов создано</span>
|
||||||
</div>
|
</div>
|
||||||
{template.layoutSettings && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
|
||||||
{template.layoutSettings.columns} столбца
|
|
||||||
</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
|
||||||
{template.layoutSettings.grouping === 'none' ? 'Без группировки' :
|
|
||||||
template.layoutSettings.grouping === 'by-type' ? 'По типу' : 'По категориям'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
Обновлен: {template.updatedAt.toLocaleDateString('ru-RU')}
|
Обновлен: {template.updatedAt.toLocaleDateString('ru-RU')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ArrowLeft, Download, FileText, Plus, Save } from 'lucide-react';
|
import { ArrowLeft, Download, FileText, Plus, Save } from 'lucide-react';
|
||||||
import { FC, useState } from 'react';
|
import { FC, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Badge } from '../../../../components/ui/badge';
|
||||||
import { Button } from '../../../../components/ui/button';
|
import { Button } from '../../../../components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
|
||||||
import { Checkbox } from '../../../../components/ui/checkbox';
|
import { Checkbox } from '../../../../components/ui/checkbox';
|
||||||
@@ -9,7 +10,7 @@ import { RadioGroup, RadioGroupItem } from '../../../../components/ui/radio-grou
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../../components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../../components/ui/select';
|
||||||
import { Textarea } from '../../../../components/ui/textarea';
|
import { Textarea } from '../../../../components/ui/textarea';
|
||||||
import { useTemplateContext } from '../../../../contexts/TemplateContext';
|
import { useTemplateContext } from '../../../../contexts/TemplateContext';
|
||||||
import { ElementType, Template, TemplateElement } from '../../../../types/template';
|
import { Template, TemplateElement } from '../../../../types/template';
|
||||||
|
|
||||||
interface ProtocolFormProps {
|
interface ProtocolFormProps {
|
||||||
template: Template;
|
template: Template;
|
||||||
@@ -21,11 +22,9 @@ interface FormElementProps {
|
|||||||
element: TemplateElement;
|
element: TemplateElement;
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
showLabel?: boolean;
|
|
||||||
width?: 'auto' | 'half' | 'full';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormElement: FC<FormElementProps> = ({ element, value, onChange, showLabel = true, width = 'auto' }) => {
|
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
@@ -119,36 +118,30 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange, showLabel
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWidthClass = () => {
|
|
||||||
switch (width) {
|
|
||||||
case 'half':
|
|
||||||
return 'col-span-1';
|
|
||||||
case 'full':
|
|
||||||
return 'col-span-full';
|
|
||||||
default:
|
|
||||||
return 'col-span-1';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${getWidthClass()}`}>
|
<div className="space-y-3">
|
||||||
{showLabel && (
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm font-medium text-gray-700">
|
<label className="text-sm font-medium text-gray-900">
|
||||||
{element.label}
|
{element.label}
|
||||||
{element.required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</label>
|
</label>
|
||||||
)}
|
{element.required && (
|
||||||
|
<Badge variant="required" className="text-xs">
|
||||||
|
!
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{renderInput()}
|
{renderInput()}
|
||||||
{element.targetCells && element.targetCells.length > 0 && (
|
{element.targetCells && element.targetCells.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{element.targetCells.map((target, index) => (
|
{element.targetCells.map((target, index) => (
|
||||||
<span
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
className="inline-block px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded text-center"
|
variant="excel"
|
||||||
|
className="text-xs font-mono"
|
||||||
title={target.displayName}
|
title={target.displayName}
|
||||||
>
|
>
|
||||||
{target.sheet}!{target.cell}
|
{target.sheet}!{target.cell}
|
||||||
</span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -158,7 +151,6 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange, showLabel
|
|||||||
|
|
||||||
const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
const [protocolName, setProtocolName] = useState('');
|
|
||||||
|
|
||||||
const handleFieldChange = (elementId: string, value: any) => {
|
const handleFieldChange = (elementId: string, value: any) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -168,6 +160,12 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
// Ищем элемент с названием протокола (должен иметь специальный тип или название)
|
||||||
|
const protocolNameElement = template.elements.find(el =>
|
||||||
|
el.name === 'protocolName' || el.label === 'Название протокола'
|
||||||
|
);
|
||||||
|
const protocolName = protocolNameElement ? formData[protocolNameElement.id] : 'Новый протокол';
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
protocolName,
|
protocolName,
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
@@ -178,84 +176,40 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
// Логика экспорта в Excel будет реализована позже
|
console.log('Экспорт в Excel:', { formData });
|
||||||
console.log('Экспорт в Excel:', { protocolName, formData });
|
|
||||||
alert('Функция экспорта будет реализована в следующих версиях');
|
alert('Функция экспорта будет реализована в следующих версиях');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canvasSize = useMemo(() => {
|
||||||
|
const PADDING_Y = 200;
|
||||||
|
const baseWidth = 1200;
|
||||||
|
const baseHeight = 800;
|
||||||
|
|
||||||
const layoutSettings = template.layoutSettings || {
|
if (template.elements.length === 0) {
|
||||||
columns: 2,
|
return { width: baseWidth, height: baseHeight };
|
||||||
grouping: 'none',
|
|
||||||
elementSpacing: 'normal',
|
|
||||||
showLabels: true,
|
|
||||||
enableDragDrop: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Группировка элементов
|
|
||||||
const groupedElements = () => {
|
|
||||||
const sortedElements = [...template.elements].sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
||||||
|
|
||||||
if (layoutSettings.grouping === 'by-category') {
|
|
||||||
const groups: Record<string, TemplateElement[]> = {};
|
|
||||||
sortedElements.forEach(element => {
|
|
||||||
const category = element.category || 'Без категории';
|
|
||||||
if (!groups[category]) groups[category] = [];
|
|
||||||
groups[category].push(element);
|
|
||||||
});
|
|
||||||
return Object.entries(groups);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (layoutSettings.grouping === 'by-type') {
|
|
||||||
const groups: Record<string, TemplateElement[]> = {};
|
|
||||||
sortedElements.forEach(element => {
|
|
||||||
const type = element.type;
|
|
||||||
if (!groups[type]) groups[type] = [];
|
|
||||||
groups[type].push(element);
|
|
||||||
});
|
|
||||||
return Object.entries(groups);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [['Все элементы', sortedElements]];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSpacingClass = () => {
|
const contentHeight = Math.max(
|
||||||
switch (layoutSettings.elementSpacing) {
|
0,
|
||||||
case 'compact':
|
...template.elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
|
||||||
return 'gap-3';
|
);
|
||||||
case 'spacious':
|
|
||||||
return 'gap-8';
|
|
||||||
default:
|
|
||||||
return 'gap-6';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGridCols = () => {
|
return {
|
||||||
switch (layoutSettings.columns) {
|
width: baseWidth,
|
||||||
case 1:
|
height: Math.max(baseHeight, contentHeight + PADDING_Y),
|
||||||
return 'grid-cols-1';
|
};
|
||||||
case 3:
|
}, [template.elements]);
|
||||||
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3';
|
|
||||||
case 4:
|
|
||||||
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4';
|
|
||||||
default:
|
|
||||||
return 'grid-cols-1 md:grid-cols-2';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeLabels: Record<ElementType, string> = {
|
// Проверяем есть ли кнопка сохранения (есть ли название протокола)
|
||||||
text: 'Текстовые поля',
|
const protocolNameElement = template.elements.find(el =>
|
||||||
textarea: 'Многострочный текст',
|
el.name === 'protocolName' || el.label === 'Название протокола'
|
||||||
number: 'Числовые поля',
|
);
|
||||||
date: 'Даты',
|
const canSave = protocolNameElement ? !!formData[protocolNameElement.id]?.trim() : false;
|
||||||
select: 'Выпадающие списки',
|
|
||||||
radio: 'Переключатели',
|
|
||||||
checkbox: 'Флажки',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-gray-50">
|
<div className="flex flex-col h-screen bg-gray-50">
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div className="bg-white border-b border-gray-200 p-4">
|
<div className="bg-white border-b border-gray-200 p-4 shrink-0">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" onClick={onBack}>
|
<Button variant="ghost" onClick={onBack}>
|
||||||
@@ -266,9 +220,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
<h1 className="text-xl font-semibold text-gray-800">
|
<h1 className="text-xl font-semibold text-gray-800">
|
||||||
Создание протокола: {template.name}
|
Создание протокола: {template.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Заполните форму для создания нового протокола
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -276,7 +227,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Экспорт в Excel
|
Экспорт в Excel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={!protocolName.trim()}>
|
<Button onClick={handleSave} disabled={!canSave}>
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2" />
|
||||||
Сохранить протокол
|
Сохранить протокол
|
||||||
</Button>
|
</Button>
|
||||||
@@ -284,77 +235,49 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Форма */}
|
{/* Полноэкранная форма */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="max-w-6xl mx-auto space-y-6">
|
<div
|
||||||
{/* Название протокола */}
|
className="relative bg-white shadow-lg mx-auto my-8"
|
||||||
<Card>
|
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle>Основная информация</CardTitle>
|
{template.elements.map((element) => {
|
||||||
</CardHeader>
|
const layout = element.layout || { x: 50, y: 50, width: 300, height: 80 };
|
||||||
<CardContent>
|
return (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
<label className="text-sm font-medium text-gray-700">
|
key={element.id}
|
||||||
Название протокола <span className="text-red-500">*</span>
|
className="absolute"
|
||||||
</label>
|
style={{
|
||||||
<Input
|
left: layout.x,
|
||||||
placeholder="Введите название протокола"
|
top: layout.y,
|
||||||
value={protocolName}
|
width: layout.width,
|
||||||
onChange={(e) => setProtocolName(e.target.value)}
|
minHeight: layout.height,
|
||||||
/>
|
zIndex: layout.zIndex || 1,
|
||||||
</div>
|
}}
|
||||||
</CardContent>
|
>
|
||||||
</Card>
|
<FormElement
|
||||||
|
element={element}
|
||||||
{/* Элементы формы */}
|
value={formData[element.id]}
|
||||||
{template.elements.length === 0 ? (
|
onChange={(value) => handleFieldChange(element.id, value)}
|
||||||
<Card>
|
/>
|
||||||
<CardContent className="py-12">
|
|
||||||
<div className="text-center">
|
|
||||||
<FileText className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет элементов формы</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
В этом шаблоне пока не созданы элементы формы
|
|
||||||
</p>
|
|
||||||
<Button variant="outline" onClick={() => window.history.back()}>
|
|
||||||
Настроить шаблон
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
})}
|
||||||
) : (
|
|
||||||
groupedElements().map(([groupName, elements]) => (
|
{template.elements.length === 0 && (
|
||||||
<Card key={groupName}>
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<CardHeader>
|
<div className="text-center text-gray-400">
|
||||||
<CardTitle className="text-lg">
|
<FileText className="h-16 w-16 mx-auto mb-4" />
|
||||||
{layoutSettings.grouping === 'by-type'
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет элементов формы</h3>
|
||||||
? typeLabels[groupName as ElementType] || groupName
|
<p className="text-gray-600 mb-4">
|
||||||
: groupName
|
В этом шаблоне пока не созданы элементы формы
|
||||||
}
|
</p>
|
||||||
</CardTitle>
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
{layoutSettings.grouping !== 'none' && (
|
Настроить шаблон
|
||||||
<CardDescription>
|
</Button>
|
||||||
{elements.length} элемент{elements.length > 1 ? 'ов' : ''}
|
</div>
|
||||||
</CardDescription>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className={`grid ${getGridCols()} ${getSpacingClass()}`}>
|
|
||||||
{elements.map((element) => (
|
|
||||||
<FormElement
|
|
||||||
key={element.id}
|
|
||||||
element={element}
|
|
||||||
value={formData[element.id]}
|
|
||||||
onChange={(value) => handleFieldChange(element.id, value)}
|
|
||||||
showLabel={layoutSettings.showLabels}
|
|
||||||
width={element.width}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -437,17 +360,6 @@ export const ProtocolCreation: FC = () => {
|
|||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
<span>{template.elements.length} элементов для заполнения</span>
|
<span>{template.elements.length} элементов для заполнения</span>
|
||||||
</div>
|
</div>
|
||||||
{template.layoutSettings && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
|
||||||
{template.layoutSettings.columns} столбца
|
|
||||||
</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
|
||||||
{template.layoutSettings.grouping === 'none' ? 'Без группировки' :
|
|
||||||
template.layoutSettings.grouping === 'by-type' ? 'По типу' : 'По категориям'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
|
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,13 +20,19 @@ export interface CellTarget {
|
|||||||
displayName?: string // отображаемое имя для пользователя
|
displayName?: string // отображаемое имя для пользователя
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Позиция и размеры элемента в визуальном редакторе
|
||||||
|
export interface ElementLayout {
|
||||||
|
x: number // позиция по X в пикселях
|
||||||
|
y: number // позиция по Y в пикселях
|
||||||
|
width: number // ширина в пикселях
|
||||||
|
height: number // высота в пикселях
|
||||||
|
zIndex?: number // Z-index для наложения элементов
|
||||||
|
}
|
||||||
|
|
||||||
// Настройки отображения элементов в форме
|
// Настройки отображения элементов в форме
|
||||||
export interface FormLayoutSettings {
|
export interface FormLayoutSettings {
|
||||||
columns: number // количество столбцов в сетке
|
gridSize: number // размер сетки для привязки (в пикселях)
|
||||||
grouping: 'none' | 'by-type' | 'by-category' // группировка элементов
|
showGrid: boolean // показывать сетку
|
||||||
elementSpacing: 'compact' | 'normal' | 'spacious' // расстояние между элементами
|
|
||||||
showLabels: boolean // показывать ли подписи элементов
|
|
||||||
enableDragDrop: boolean // включить drag & drop для изменения порядка
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateElement {
|
export interface TemplateElement {
|
||||||
@@ -40,8 +46,7 @@ export interface TemplateElement {
|
|||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
order?: number // порядок отображения в форме
|
order?: number // порядок отображения в форме
|
||||||
category?: string // категория для группировки
|
layout?: ElementLayout // позиция и размеры в визуальном редакторе
|
||||||
width?: 'auto' | 'half' | 'full' // ширина элемента в форме
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Типы шаблонов
|
// Типы шаблонов
|
||||||
|
|||||||
36
yarn.lock
36
yarn.lock
@@ -1084,11 +1084,16 @@ chokidar@^3.5.3:
|
|||||||
|
|
||||||
class-variance-authority@^0.7.1:
|
class-variance-authority@^0.7.1:
|
||||||
version "0.7.1"
|
version "0.7.1"
|
||||||
resolved "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz"
|
resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787"
|
||||||
integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==
|
integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx "^2.1.1"
|
clsx "^2.1.1"
|
||||||
|
|
||||||
|
clsx@^1.1.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
|
||||||
|
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
|
||||||
|
|
||||||
clsx@^2.1.1:
|
clsx@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
|
resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz"
|
||||||
@@ -1999,6 +2004,11 @@ raf-schd@^4.0.2:
|
|||||||
resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz"
|
resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz"
|
||||||
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
|
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
|
||||||
|
|
||||||
|
re-resizable@6.11.2:
|
||||||
|
version "6.11.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.11.2.tgz#2e8f7119ca3881d5b5aea0ffa014a80e5c1252b3"
|
||||||
|
integrity sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==
|
||||||
|
|
||||||
react-beautiful-dnd@^13.1.1:
|
react-beautiful-dnd@^13.1.1:
|
||||||
version "13.1.1"
|
version "13.1.1"
|
||||||
resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz"
|
resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz"
|
||||||
@@ -2020,6 +2030,14 @@ react-dom@^18.2.0:
|
|||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
scheduler "^0.23.0"
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
|
react-draggable@4.4.6:
|
||||||
|
version "4.4.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.6.tgz#63343ee945770881ca1256a5b6fa5c9f5983fe1e"
|
||||||
|
integrity sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==
|
||||||
|
dependencies:
|
||||||
|
clsx "^1.1.1"
|
||||||
|
prop-types "^15.8.1"
|
||||||
|
|
||||||
react-draggable@^4.0.3, react-draggable@^4.4.6:
|
react-draggable@^4.0.3, react-draggable@^4.4.6:
|
||||||
version "4.5.0"
|
version "4.5.0"
|
||||||
resolved "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz"
|
resolved "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz"
|
||||||
@@ -2030,7 +2048,7 @@ react-draggable@^4.0.3, react-draggable@^4.4.6:
|
|||||||
|
|
||||||
react-grid-layout@^1.5.2:
|
react-grid-layout@^1.5.2:
|
||||||
version "1.5.2"
|
version "1.5.2"
|
||||||
resolved "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz"
|
resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-1.5.2.tgz#d5a6775446ce540c0df3985c41b5d64622fc6f87"
|
||||||
integrity sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==
|
integrity sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==
|
||||||
dependencies:
|
dependencies:
|
||||||
clsx "^2.1.1"
|
clsx "^2.1.1"
|
||||||
@@ -2106,6 +2124,15 @@ react-resizable@^3.0.5:
|
|||||||
prop-types "15.x"
|
prop-types "15.x"
|
||||||
react-draggable "^4.0.3"
|
react-draggable "^4.0.3"
|
||||||
|
|
||||||
|
react-rnd@^10.5.2:
|
||||||
|
version "10.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-rnd/-/react-rnd-10.5.2.tgz#47a22c104fb640dae71f149e2c005c879de833bd"
|
||||||
|
integrity sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==
|
||||||
|
dependencies:
|
||||||
|
re-resizable "6.11.2"
|
||||||
|
react-draggable "4.4.6"
|
||||||
|
tslib "2.6.2"
|
||||||
|
|
||||||
react-router-dom@^6.14.1:
|
react-router-dom@^6.14.1:
|
||||||
version "6.14.1"
|
version "6.14.1"
|
||||||
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.14.1.tgz"
|
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.14.1.tgz"
|
||||||
@@ -2375,6 +2402,11 @@ ts-interface-checker@^0.1.9:
|
|||||||
resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
|
resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz"
|
||||||
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
|
||||||
|
|
||||||
|
tslib@2.6.2:
|
||||||
|
version "2.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||||
|
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||||
|
|
||||||
tslib@^1.8.1:
|
tslib@^1.8.1:
|
||||||
version "1.14.1"
|
version "1.14.1"
|
||||||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user