холст конструктор

This commit is contained in:
2025-07-16 17:25:33 +03:00
parent b77303fa97
commit 6933fab3bb
13 changed files with 1243 additions and 996 deletions

View File

@@ -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",

View File

@@ -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>

View File

@@ -182,13 +182,7 @@ 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">
@@ -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 */} useEffect(() => {
<div className="flex flex-1"> if (!separatorRef) return;
{/* 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"> const handleMouseDown = (e: MouseEvent) => {
{/* Column Headers */} e.preventDefault();
<div document.body.style.cursor = 'col-resize';
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 handleMouseMove = (me: MouseEvent) => {
const newWidth = startWidth + (me.clientX - startX) const container = separatorRef.parentElement;
handleColumnResize(sheetType, i, newWidth) 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 = () => { const handleMouseUp = () => {
document.removeEventListener( document.body.style.cursor = 'auto';
'mousemove', document.removeEventListener('mousemove', handleMouseMove);
handleMouseMove, document.removeEventListener('mouseup', handleMouseUp);
) };
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp) 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 */} separatorRef.addEventListener('mousedown', handleMouseDown);
<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,
],
)
// Мемоизированные компоненты листов return () => {
const memoizedReportSheet = useMemo( separatorRef.removeEventListener('mousedown', handleMouseDown);
() => renderSpreadsheet('report'), };
[renderSpreadsheet, revision], // Добавляем revision для перерендера }, [separatorRef]);
)
const memoizedCalculationsSheet = useMemo(
() => renderSpreadsheet('calculations'),
[renderSpreadsheet, revision], // Добавляем revision для перерендера
)
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,13 +968,61 @@ 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> </div>
{/* Двойная панель */} <Spreadsheet
<div className="flex flex-1 gap-1 overflow-hidden p-1"> sheetType="calculations"
<div className="min-w-0 flex-1">{memoizedReportSheet}</div> columnWidths={columnWidths.calculations}
<div className="min-w-0 flex-1">{memoizedCalculationsSheet}</div> 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>
</div> </div>
) )

View File

@@ -1,28 +1,20 @@
import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { Calendar, CheckSquare, ChevronDown, FileText, Hash, LayoutGrid, Plus, Settings, Trash2, Type } from 'lucide-react';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Calendar, CheckSquare, ChevronDown, Edit, FileText, GripVertical, Hash, LayoutGrid, Plus, Trash2, Type } from 'lucide-react';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { autoArrangeElements, findFreePosition, generateDefaultLayout } from '../../lib/utils';
import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template'; import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '../ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Textarea } from '../ui/textarea';
import { VisualLayoutEditor } from './VisualLayoutEditor';
interface ElementFormProps { interface ElementFormProps {
formData: Partial<TemplateElement>; formData: Partial<TemplateElement>;
setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>; setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>;
} }
interface SortableElementProps {
element: TemplateElement;
onEdit: (element: TemplateElement) => void;
onDelete: (elementId: string) => void;
}
const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.ReactNode }> = [ const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.ReactNode }> = [
{ type: 'text', label: 'Текстовое поле', icon: <Type className="h-4 w-4" /> }, { type: 'text', label: 'Текстовое поле', icon: <Type className="h-4 w-4" /> },
{ type: 'textarea', label: 'Многострочный текст', icon: <FileText className="h-4 w-4" /> }, { type: 'textarea', label: 'Многострочный текст', icon: <FileText className="h-4 w-4" /> },
@@ -33,90 +25,6 @@ const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.React
{ type: 'date', label: 'Дата', icon: <Calendar className="h-4 w-4" /> }, { type: 'date', label: 'Дата', icon: <Calendar className="h-4 w-4" /> },
]; ];
const SortableElement: React.FC<SortableElementProps> = ({ element, onEdit, onDelete }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: element.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<Card ref={setNodeRef} style={style} className="mb-4 bg-white border border-gray-200 hover:border-gray-300 transition-colors">
<CardHeader className="flex flex-row items-center justify-between p-4">
<div className="flex items-center gap-3 flex-1">
<div
className="cursor-grab active:cursor-grabbing p-1 rounded hover:bg-gray-100"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4 text-gray-400" />
</div>
<div className="flex items-center gap-2">
{ELEMENT_TYPES.find(t => t.type === element.type)?.icon}
<div>
<p className="font-semibold text-sm">{element.label}</p>
<p className="text-xs text-gray-500">{element.name}</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-xs text-gray-500">
{element.targetCells?.length || 0} ячеек
</div>
<Button variant="ghost" size="icon" onClick={() => onEdit(element)}>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-600"
onClick={() => onDelete(element.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
<div className="text-sm text-gray-600">
<div className="mb-2">
<strong>Тип:</strong> {ELEMENT_TYPES.find(t => t.type === element.type)?.label}
</div>
<div className="mb-2">
<strong>Ячейки:</strong>
<div className="mt-1 flex flex-wrap gap-1">
{element.targetCells?.map((target, index) => (
<span
key={index}
className="inline-block px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded"
>
{target.sheet}!{target.cell}
{target.displayName && ` (${target.displayName})`}
</span>
)) || (
<span className="text-gray-400 text-xs">Ячейки не настроены</span>
)}
</div>
</div>
{element.category && (
<div>
<strong>Категория:</strong> {element.category}
</div>
)}
</div>
</CardContent>
</Card>
);
};
const CellTargetEditor: React.FC<{ const CellTargetEditor: React.FC<{
targets: CellTarget[]; targets: CellTarget[];
onChange: (targets: CellTarget[]) => void; onChange: (targets: CellTarget[]) => void;
@@ -206,6 +114,124 @@ const CellTargetEditor: React.FC<{
); );
}; };
// Live Preview компонент
const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({ element }) => {
const renderPreviewInput = () => {
switch (element.type) {
case 'text':
return (
<Input
placeholder={element.placeholder || 'Введите текст'}
disabled
className="w-full"
/>
);
case 'textarea':
return (
<Textarea
placeholder={element.placeholder || 'Введите текст'}
disabled
rows={3}
className="w-full resize-none"
/>
);
case 'number':
return (
<Input
type="number"
placeholder={element.placeholder || '0'}
disabled
className="w-full"
/>
);
case 'date':
return (
<Input
type="date"
disabled
className="w-full"
/>
);
case 'select':
return (
<Select disabled>
<SelectTrigger className="w-full">
<span className="text-gray-500">
{element.placeholder || 'Выберите значение'}
</span>
</SelectTrigger>
</Select>
);
case 'radio':
return (
<div className="space-y-2">
{element.options?.length ? element.options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<div className="w-4 h-4 border border-gray-300 rounded-full" />
<span className="text-sm">{option.label}</span>
</div>
)) : (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border border-gray-300 rounded-full" />
<span className="text-sm text-gray-500">Вариант 1</span>
</div>
)}
</div>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border border-gray-300 rounded" />
<span className="text-sm">
{element.placeholder || element.label || 'Отметить'}
</span>
</div>
);
default:
return (
<div className="p-3 border border-gray-200 rounded bg-gray-50 text-center text-sm text-gray-500">
Выберите тип элемента
</div>
);
}
};
return (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="p-4 border border-gray-200 rounded-lg bg-white">
{element.label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{element.label}
{element.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{renderPreviewInput()}
{element.targetCells && element.targetCells.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{element.targetCells.map((target, index) => (
<span
key={index}
className="inline-block px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded"
title={target.displayName}
>
{target.sheet}!{target.cell}
</span>
))}
</div>
)}
</div>
</div>
);
};
const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => { const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const handleAddOption = () => { const handleAddOption = () => {
setFormData(prev => ({ setFormData(prev => ({
@@ -231,8 +257,9 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const needsOptions = formData.type === 'select' || formData.type === 'radio'; const needsOptions = formData.type === 'select' || formData.type === 'radio';
return ( return (
<div className="grid grid-cols-2 gap-6">
{/* Форма настроек */}
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Тип элемента</label> <label className="text-sm font-medium">Тип элемента</label>
<Select <Select
@@ -255,24 +282,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
</Select> </Select>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium">Ширина элемента</label>
<Select
value={formData.width || 'auto'}
onValueChange={value => setFormData(prev => ({ ...prev, width: value as 'auto' | 'half' | 'full' }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Авто</SelectItem>
<SelectItem value="half">Половина ширины</SelectItem>
<SelectItem value="full">Полная ширина</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Имя поля</label> <label className="text-sm font-medium">Имя поля</label>
@@ -293,20 +302,6 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
</div> </div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium">Категория (для группировки)</label>
<Input
placeholder="Основные данные, Дополнительно..."
value={formData.category}
onChange={e => setFormData(prev => ({ ...prev, category: e.target.value }))}
/>
</div>
<CellTargetEditor
targets={formData.targetCells || []}
onChange={(targets) => setFormData(prev => ({ ...prev, targetCells: targets }))}
/>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Подсказка</label> <label className="text-sm font-medium">Подсказка</label>
<Input <Input
@@ -327,6 +322,11 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
</label> </label>
</div> </div>
<CellTargetEditor
targets={formData.targetCells || []}
onChange={(targets) => setFormData(prev => ({ ...prev, targetCells: targets }))}
/>
{needsOptions && ( {needsOptions && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -358,241 +358,51 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
</div> </div>
)} )}
</div> </div>
{/* Live Preview */}
<div className="sticky top-0">
<ElementPreview element={formData} />
</div>
</div>
); );
}; };
interface LayoutSettingsProps { interface CanvasSettingsProps {
settings: FormLayoutSettings; settings: FormLayoutSettings;
onChange: (settings: FormLayoutSettings) => void; onChange: (settings: FormLayoutSettings) => void;
} }
const LayoutSettings: React.FC<LayoutSettingsProps> = ({ settings, onChange }) => { const CanvasSettings: React.FC<CanvasSettingsProps> = ({ settings, onChange }) => {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">Количество столбцов</label> <label className="text-sm font-medium">Размер сетки (px)</label>
<Select <Select
value={settings.columns.toString()} value={settings.gridSize.toString()}
onValueChange={(value) => onChange({ ...settings, columns: parseInt(value) })} onValueChange={(value) => onChange({ ...settings, gridSize: parseInt(value) })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="1">1 столбец</SelectItem> <SelectItem value="10">10px</SelectItem>
<SelectItem value="2">2 столбца</SelectItem> <SelectItem value="20">20px</SelectItem>
<SelectItem value="3">3 столбца</SelectItem> <SelectItem value="25">25px</SelectItem>
<SelectItem value="4">4 столбца</SelectItem> <SelectItem value="50">50px</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Группировка элементов</label>
<RadioGroup
value={settings.grouping}
onValueChange={(value) => onChange({ ...settings, grouping: value as FormLayoutSettings['grouping'] })}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="none" id="none" />
<label htmlFor="none" className="text-sm">Без группировки</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="by-type" id="by-type" />
<label htmlFor="by-type" className="text-sm">По типу элемента</label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="by-category" id="by-category" />
<label htmlFor="by-category" className="text-sm">По категориям</label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Расстояние между элементами</label>
<Select
value={settings.elementSpacing}
onValueChange={(value) => onChange({ ...settings, elementSpacing: value as FormLayoutSettings['elementSpacing'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="compact">Компактно</SelectItem>
<SelectItem value="normal">Обычно</SelectItem>
<SelectItem value="spacious">Просторно</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="showLabels" id="showGrid"
checked={settings.showLabels} checked={settings.showGrid}
onCheckedChange={(checked) => onChange({ ...settings, showLabels: !!checked })} onCheckedChange={(checked) => onChange({ ...settings, showGrid: !!checked })}
/> />
<label htmlFor="showLabels" className="text-sm font-medium"> <label htmlFor="showGrid" className="text-sm font-medium">
Показывать подписи элементов Показывать сетку
</label> </label>
</div> </div>
<div className="flex items-center space-x-2">
<Checkbox
id="enableDragDrop"
checked={settings.enableDragDrop}
onCheckedChange={(checked) => onChange({ ...settings, enableDragDrop: !!checked })}
/>
<label htmlFor="enableDragDrop" className="text-sm font-medium">
Включить перетаскивание элементов
</label>
</div>
</div>
);
};
interface FormPreviewProps {
elements: TemplateElement[];
layoutSettings: FormLayoutSettings;
}
const FormPreview: React.FC<FormPreviewProps> = ({ elements, layoutSettings }) => {
// Группировка элементов для предварительного просмотра
const groupedElements = () => {
const sortedElements = [...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 = () => {
switch (layoutSettings.elementSpacing) {
case 'compact':
return 'gap-3';
case 'spacious':
return 'gap-8';
default:
return 'gap-6';
}
};
const getGridCols = () => {
switch (layoutSettings.columns) {
case 1:
return 'grid-cols-1';
case 3:
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: 'Текстовые поля',
textarea: 'Многострочный текст',
number: 'Числовые поля',
date: 'Даты',
select: 'Выпадающие списки',
radio: 'Переключатели',
checkbox: 'Флажки',
};
const renderPreviewElement = (element: TemplateElement) => {
const getWidthClass = () => {
switch (element.width) {
case 'half':
return 'col-span-1';
case 'full':
return 'col-span-full';
default:
return 'col-span-1';
}
};
return (
<div key={element.id} className={`space-y-2 ${getWidthClass()}`}>
{layoutSettings.showLabels && (
<label className="text-sm font-medium text-gray-700">
{element.label}
{element.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<div className="p-2 border border-gray-200 rounded bg-gray-50 text-sm text-gray-500">
{element.type === 'text' && 'Текстовое поле'}
{element.type === 'textarea' && 'Многострочный текст'}
{element.type === 'number' && 'Числовое поле'}
{element.type === 'date' && 'Выбор даты'}
{element.type === 'select' && `Выпадающий список (${element.options?.length || 0} вариантов)`}
{element.type === 'radio' && `Радиокнопки (${element.options?.length || 0} вариантов)`}
{element.type === 'checkbox' && 'Флажок'}
</div>
{element.targetCells && element.targetCells.length > 0 && (
<div className="flex flex-wrap gap-1">
{element.targetCells.map((target, index) => (
<span
key={index}
className="inline-block px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded"
title={target.displayName}
>
{target.sheet}!{target.cell}
</span>
))}
</div>
)}
</div>
);
};
return (
<div className="space-y-6 max-h-96 overflow-y-auto">
{elements.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">Добавьте элементы для предварительного просмотра</p>
</div>
) : (
groupedElements().map(([groupName, groupElements]) => (
<Card key={groupName} className="border-dashed">
<CardHeader className="pb-3">
<CardTitle className="text-base">
{layoutSettings.grouping === 'by-type'
? typeLabels[groupName as ElementType] || groupName
: groupName
}
</CardTitle>
{layoutSettings.grouping !== 'none' && (
<p className="text-xs text-gray-500">
{groupElements.length} элемент{groupElements.length > 1 ? 'ов' : ''}
</p>
)}
</CardHeader>
<CardContent>
<div className={`grid ${getGridCols()} ${getSpacingClass()}`}>
{groupElements.map(renderPreviewElement)}
</div>
</CardContent>
</Card>
))
)}
</div> </div>
); );
}; };
@@ -617,8 +427,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
onLayoutSettingsChange, onLayoutSettingsChange,
}) => { }) => {
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isLayoutDialogOpen, setIsLayoutDialogOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [editingElement, setEditingElement] = useState<TemplateElement | null>(null); const [editingElement, setEditingElement] = useState<TemplateElement | null>(null);
const [formData, setFormData] = useState<Partial<TemplateElement>>({ const [formData, setFormData] = useState<Partial<TemplateElement>>({
type: 'text', type: 'text',
@@ -628,17 +437,8 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
placeholder: '', placeholder: '',
required: false, required: false,
options: [], options: [],
width: 'auto',
category: '',
}); });
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
type: 'text', type: 'text',
@@ -648,14 +448,22 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
placeholder: '', placeholder: '',
required: false, required: false,
options: [], options: [],
width: 'auto',
category: '',
}); });
}; };
const handleAddElement = () => { const handleAddElement = () => {
if (!formData.name || !formData.label || !formData.targetCells?.length) return; if (!formData.name || !formData.label || !formData.targetCells?.length) return;
// Генерируем layout для нового элемента
const existingLayouts = elements.map(e => e.layout).filter(Boolean);
const defaultLayout = generateDefaultLayout(elements.length);
const layout = findFreePosition(
defaultLayout,
existingLayouts as any[],
layoutSettings.gridSize
);
const newElement: TemplateElement = { const newElement: TemplateElement = {
id: Date.now().toString(), id: Date.now().toString(),
type: formData.type as ElementType, type: formData.type as ElementType,
@@ -666,8 +474,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
required: formData.required, required: formData.required,
options: formData.options, options: formData.options,
order: elements.length, order: elements.length,
category: formData.category, layout,
width: formData.width,
}; };
onElementAdd(newElement); onElementAdd(newElement);
@@ -693,157 +500,122 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
resetForm(); resetForm();
}; };
const handleDragEnd = (event: any) => { const handleAutoArrange = () => {
const { active, over } = event; const arrangedElements = autoArrangeElements(
elements,
layoutSettings.gridSize
);
if (active.id !== over.id) { arrangedElements.forEach((element, index) => {
const oldIndex = elements.findIndex((item) => item.id === active.id); onElementUpdate(element.id, { layout: element.layout, order: index });
const newIndex = elements.findIndex((item) => item.id === over.id); });
};
const reorderedElements = arrayMove(elements, oldIndex, newIndex);
onElementsReorder(reorderedElements); const handleDeleteAndSelect = (elementId: string) => {
} onElementDelete(elementId);
setEditingElement(null);
resetForm();
};
const reordered = (reorderedElements: TemplateElement[]) => {
onElementsReorder(reorderedElements);
onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
}; };
// Сортировка элементов по порядку
const sortedElements = [...elements].sort((a, b) => (a.order || 0) - (b.order || 0));
return ( return (
<div className="h-full flex flex-col bg-gray-50"> <div className="flex h-full w-full">
<div className="flex items-center justify-between p-4 bg-white border-b"> {/* Main Content */}
<h3 className="text-lg font-semibold text-gray-800">Конструктор элементов</h3> <div className="flex-1 flex flex-col bg-gray-50">
<div className="flex gap-2"> <div className="flex items-center justify-between p-2 border-b bg-white">
<Dialog open={isPreviewOpen} onOpenChange={setIsPreviewOpen}> <div className='px-2'>
<DialogTrigger asChild> <h3 className="text-sm font-medium">Холст</h3>
<Button variant="outline" size="sm"> <p className="text-xs text-gray-500">Перетаскивайте элементы для настройки их расположения</p>
<FileText className="h-4 w-4 mr-2" />
Предпросмотр формы
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[800px] max-h-[90vh]">
<DialogHeader>
<DialogTitle>Предпросмотр формы создания протокола</DialogTitle>
<DialogDescription>
Так будет выглядеть форма для пользователей при создании протокола
</DialogDescription>
</DialogHeader>
<FormPreview elements={elements} layoutSettings={layoutSettings} />
<div className="flex justify-end mt-4">
<Button onClick={() => setIsPreviewOpen(false)}>Закрыть</Button>
</div> </div>
</DialogContent> <div className="flex items-center gap-2">
</Dialog>
<Dialog open={isLayoutDialogOpen} onOpenChange={setIsLayoutDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<LayoutGrid className="h-4 w-4 mr-2" />
Настройки отображения
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Настройки отображения элементов</DialogTitle>
<DialogDescription>
Настройте внешний вид формы создания протокола
</DialogDescription>
</DialogHeader>
<LayoutSettings settings={layoutSettings} onChange={onLayoutSettingsChange} />
<div className="flex justify-end mt-4">
<Button onClick={() => setIsLayoutDialogOpen(false)}>Готово</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" onClick={() => resetForm()}> <Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Добавить элемент Добавить элемент
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Добавить элемент</DialogTitle> <DialogTitle>Добавить новый элемент</DialogTitle>
<DialogDescription> <DialogDescription>
Создайте новый элемент формы с привязкой к ячейкам Excel Настройте параметры нового элемента формы.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ElementForm formData={formData} setFormData={setFormData} /> <ElementForm formData={formData} setFormData={setFormData} />
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}> <Button onClick={() => setIsAddDialogOpen(false)} variant="outline">
Отмена Отмена
</Button> </Button>
<Button onClick={handleAddElement} disabled={!formData.name || !formData.label || !formData.targetCells?.length}> <Button onClick={handleAddElement}>
Добавить <Plus className="h-4 w-4 mr-2" />
Добавить элемент
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4"> {editingElement && (
{elements.length === 0 ? ( <Dialog open={!!editingElement} onOpenChange={() => handleCancelEdit()}>
<div className="flex flex-col items-center justify-center py-12 text-center"> <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<div className="rounded-full bg-gray-100 p-6 mb-4">
<Plus className="h-12 w-12 text-gray-400" />
</div>
<h3 className="text-xl font-medium text-gray-900 mb-2">Нет элементов интерфейса</h3>
<p className="text-gray-500 mb-6 max-w-md">
Создайте элементы формы для связи с ячейками Excel. Элементы будут отображаться
пользователям при создании протокола.
</p>
<Button onClick={() => setIsAddDialogOpen(true)} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать первый элемент
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortedElements.map(e => e.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-4">
{sortedElements.map(element => (
<SortableElement
key={element.id}
element={element}
onEdit={handleEditElement}
onDelete={onElementDelete}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Dialog для редактирования элемента */}
<Dialog
open={!!editingElement}
onOpenChange={isOpen => !isOpen && handleCancelEdit()}
>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Редактировать элемент</DialogTitle> <DialogTitle>Редактировать элемент</DialogTitle>
<DialogDescription> <DialogDescription>
Измените настройки элемента формы Измените параметры элемента формы.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<ElementForm formData={formData} setFormData={setFormData} /> <ElementForm formData={formData} setFormData={setFormData} />
<div className="flex justify-end gap-2 mt-4"> <div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleCancelEdit}> <Button onClick={handleCancelEdit} variant="outline">
Отмена Отмена
</Button> </Button>
<Button onClick={handleUpdateElement} disabled={!formData.name || !formData.label || !formData.targetCells?.length}> <Button onClick={handleUpdateElement}>
Сохранить Сохранить изменения
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)}
<Button onClick={handleAutoArrange} variant="outline" size="sm">
<LayoutGrid className="h-4 w-4 mr-2" />
Расставить автоматически
</Button>
<Dialog open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Настройки холста
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Настройки холста</DialogTitle>
<DialogDescription>
Настройте отображение сетки и другие параметры.
</DialogDescription>
</DialogHeader>
<CanvasSettings settings={layoutSettings} onChange={onLayoutSettingsChange} />
</DialogContent>
</Dialog>
</div>
</div>
<div className="flex-1 overflow-hidden">
<VisualLayoutEditor
elements={elements}
layoutSettings={layoutSettings}
onElementUpdate={onElementUpdate}
onElementDelete={handleDeleteAndSelect}
onLayoutSettingsChange={onLayoutSettingsChange}
onElementEdit={handleEditElement}
/>
</div>
</div>
</div> </div>
); );
}; };

View File

@@ -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}>

View 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>
);
};

View 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 }

View File

@@ -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]);
}; };

View File

@@ -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
}; };
} }

View File

@@ -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>

View File

@@ -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 layoutSettings = template.layoutSettings || { const canvasSize = useMemo(() => {
columns: 2, const PADDING_Y = 200;
grouping: 'none', const baseWidth = 1200;
elementSpacing: 'normal', const baseHeight = 800;
showLabels: true,
enableDragDrop: false,
};
// Группировка элементов if (template.elements.length === 0) {
const groupedElements = () => { return { width: baseWidth, height: baseHeight };
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 contentHeight = Math.max(
const groups: Record<string, TemplateElement[]> = {}; 0,
sortedElements.forEach(element => { ...template.elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
const type = element.type; );
if (!groups[type]) groups[type] = [];
groups[type].push(element);
});
return Object.entries(groups);
}
return [['Все элементы', sortedElements]]; return {
width: baseWidth,
height: Math.max(baseHeight, contentHeight + PADDING_Y),
}; };
}, [template.elements]);
const getSpacingClass = () => { // Проверяем есть ли кнопка сохранения (есть ли название протокола)
switch (layoutSettings.elementSpacing) { const protocolNameElement = template.elements.find(el =>
case 'compact': el.name === 'protocolName' || el.label === 'Название протокола'
return 'gap-3'; );
case 'spacious': const canSave = protocolNameElement ? !!formData[protocolNameElement.id]?.trim() : false;
return 'gap-8';
default:
return 'gap-6';
}
};
const getGridCols = () => {
switch (layoutSettings.columns) {
case 1:
return 'grid-cols-1';
case 3:
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: 'Текстовые поля',
textarea: 'Многострочный текст',
number: 'Числовые поля',
date: 'Даты',
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,34 +235,39 @@ 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,
}}
>
<FormElement
element={element}
value={formData[element.id]}
onChange={(value) => handleFieldChange(element.id, value)}
/> />
</div> </div>
</CardContent> );
</Card> })}
{/* Элементы формы */} {template.elements.length === 0 && (
{template.elements.length === 0 ? ( <div className="absolute inset-0 flex items-center justify-center">
<Card> <div className="text-center text-gray-400">
<CardContent className="py-12"> <FileText className="h-16 w-16 mx-auto mb-4" />
<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> <h3 className="text-lg font-medium text-gray-900 mb-2">Нет элементов формы</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
В этом шаблоне пока не созданы элементы формы В этом шаблоне пока не созданы элементы формы
@@ -320,40 +276,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
Настроить шаблон Настроить шаблон
</Button> </Button>
</div> </div>
</CardContent>
</Card>
) : (
groupedElements().map(([groupName, elements]) => (
<Card key={groupName}>
<CardHeader>
<CardTitle className="text-lg">
{layoutSettings.grouping === 'by-type'
? typeLabels[groupName as ElementType] || groupName
: groupName
}
</CardTitle>
{layoutSettings.grouping !== 'none' && (
<CardDescription>
{elements.length} элемент{elements.length > 1 ? 'ов' : ''}
</CardDescription>
)}
</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> </div>
</CardContent>
</Card>
))
)} )}
</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>

View File

@@ -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' // ширина элемента в форме
} }
// Типы шаблонов // Типы шаблонов

View File

@@ -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"