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

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

@@ -55,7 +55,7 @@ const Layout: FC = () => {
</nav>
{/* Основной контент */}
<main className="flex-1 overflow-hidden">
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>

View File

@@ -1,13 +1,13 @@
import { produce } from 'immer'
import {
CSSProperties,
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
CSSProperties,
FC,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { VariableSizeGrid } from 'react-window'
import { useMultiSheetEngine } from '../../hooks/useMultiSheetEngine'
@@ -182,14 +182,8 @@ const FormulaBar = memo(
onKeyDown,
formulaBarRef,
}: FormulaBarProps) => {
if (activeSheet !== sheetType) {
return (
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
<div className="flex h-[28px] items-center" />
</div>
)
}
// Теперь FormulaBar всегда отображается, так как он один
return (
<div className="flex-shrink-0 border-b bg-background px-3 py-1">
<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 { revision, updateRevision, ...multiSheetEngine } = useMultiSheetEngine(
{
@@ -360,10 +561,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
calculations: null,
})
const activeCellInputRef = useRef<HTMLInputElement>(null)
const formulaBarRefs = useRef<Record<SheetType, HTMLInputElement | null>>({
report: null,
calculations: null,
})
const formulaBarRef = useRef<HTMLInputElement | null>(null);
const headerRefs = useRef<Record<SheetType, HTMLDivElement | null>>({
report: null,
calculations: null,
@@ -374,13 +572,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
})
const initialTemplateValues = useRef<Record<string, string>>({})
// Кэш для данных ячеек
const cellDataCache = useRef<Record<string, Record<string, CachedCellData>>>(
{},
)
const lastRevision = useRef<number>(-1)
// Загрузка данных шаблона один раз при инициализации
useEffect(() => {
const reportTemplate = templateData?.[SHEET_NAMES.report]
if (reportTemplate) {
@@ -390,9 +586,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
}
}, [templateData])
// --- Синхронизация UI ---
// Синхронизация прокрутки грида при изменении выделенной ячейки
useEffect(() => {
if (selectedCell && activeSheet) {
const grid = gridRefs.current[activeSheet]
@@ -404,12 +597,10 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
}
}, [selectedCell, activeSheet])
// Функция для получения кэшированных данных ячеек
const getCachedCellData = useCallback(
(sheetType: SheetType): Record<string, CachedCellData> => {
const cacheKey = `${sheetType}-${revision}`
// Если данные уже кэшированы для этой ревизии, возвращаем их
if (cellDataCache.current[cacheKey]) {
return cellDataCache.current[cacheKey]
}
@@ -417,7 +608,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
const sheetName = SHEET_NAMES[sheetType]
const cachedData: Record<string, CachedCellData> = {}
// Предварительно получаем все значения для оптимизации
const allCellValues: Record<string, string> = {}
for (let row = 0; row < ROW_COUNT; row++) {
for (let col = 0; col < COL_COUNT; col++) {
@@ -429,7 +619,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
}
}
// Вычисляем hasRightContent для всех ячеек сразу
const rightContentMap: Record<string, boolean> = {}
for (let row = 0; row < ROW_COUNT; row++) {
let hasContentOnRight = false
@@ -442,7 +631,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
}
}
// Формируем кэшированные данные
for (let row = 0; row < ROW_COUNT; row++) {
for (let col = 0; col < COL_COUNT; col++) {
const cellKey = `${row}-${col}`
@@ -465,7 +653,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
}
}
// Очищаем старые кэши для экономии памяти
if (lastRevision.current !== revision) {
Object.keys(cellDataCache.current).forEach((key) => {
if (!key.endsWith(`-${revision}`)) {
@@ -481,7 +668,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
[revision, multiSheetEngine, initialTemplateValues],
)
// Синхронизация строки формул
const formulaBarValue = useMemo(() => {
if (isEditing) {
return editingValue
@@ -504,7 +690,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
revision,
])
// Фокус на инпуте при начале редактирования
useEffect(() => {
if (isEditing && activeCellInputRef.current) {
const input = activeCellInputRef.current
@@ -514,8 +699,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
}
}, [isEditing])
// --- Функции-обработчики ---
const startEditing = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
@@ -538,13 +721,11 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
selectedCell.col,
editingValue,
)
// Немедленно обновляем интерфейс для быстрого отклика
updateRevision()
// Запускаем отложенный пересчет для формул
multiSheetEngine.debouncedRecalc()
}
setIsEditing(false)
setEditingValue('') // Очищаем временное значение
setEditingValue('')
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
},
[activeSheet, selectedCell, editingValue, multiSheetEngine, updateRevision],
@@ -559,30 +740,22 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
[isEditing],
)
// Оптимизированная функция очистки ячейки
const handleClearCell = useCallback(() => {
if (!selectedCell) return
const sheetName = SHEET_NAMES[activeSheet]
// Используем версию без немедленного пересчета для быстрой реакции
multiSheetEngine.setCellValueWithoutRecalc(
sheetName,
selectedCell.row,
selectedCell.col,
'',
)
// Немедленно обновляем интерфейс для быстрого отклика
updateRevision()
// Запускаем отложенный пересчет
multiSheetEngine.debouncedRecalc()
}, [activeSheet, selectedCell, multiSheetEngine, updateRevision])
const moveSelection = useCallback(
(deltaRow: number, deltaCol: number) => {
if (!selectedCell) {
// Если ничего не выбрано, выбираем A1
setSelectedCell({ row: 0, col: 0 })
return
}
@@ -608,7 +781,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isEditing) {
// Логика внутри Cell компонента
return
}
@@ -635,7 +807,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
keyHandlers[e.key]()
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
e.preventDefault()
setEditingValue(e.key) // Начинаем редактирование с введенного символа
setEditingValue(e.key)
setIsEditing(true)
}
},
@@ -666,7 +838,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
setColumnWidths(
produce((draft) => {
draft[sheetType][colIndex] = Math.max(60, newWidth)
// Принудительно пересчитываем layout грида
const grid = gridRefs.current[sheetType]
if (grid) {
grid.resetAfterColumnIndex(colIndex)
@@ -677,7 +848,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
[],
)
// --- Formula Bar Specific Handlers ---
const handleFormulaBarChange = useCallback(
(newValue: string) => {
if (selectedCell) {
@@ -708,7 +878,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
e.preventDefault()
stopEditing(true)
moveSelection(1, 0)
// Предотвращаем прокрутку страницы
setTimeout(() => {
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
}, 0)
@@ -736,172 +905,61 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
[],
)
// --- Render Functions ---
const renderSpreadsheet = useCallback(
(sheetType: SheetType) => {
const widths = columnWidths[sheetType]
const cachedData = getCachedCellData(sheetType)
const [separatorRef, setSeparatorRef] = useState<HTMLDivElement | null>(null)
const [spreadsheetWidths, setSpreadsheetWidths] = useState({
L: 0.5,
R: 0.5,
})
const itemData = {
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 для перерендера
)
// --- Spreadsheet Component ---
useEffect(() => {
if (!separatorRef) return;
const handleMouseDown = (e: MouseEvent) => {
e.preventDefault();
document.body.style.cursor = 'col-resize';
const handleMouseMove = (me: MouseEvent) => {
const container = separatorRef.parentElement;
if (!container) return;
const bounds = container.getBoundingClientRect();
const newLeftWidth = me.clientX - bounds.left;
const totalWidth = bounds.width;
let newLeftPercentage = newLeftWidth / totalWidth;
// Ограничиваем минимальную ширину (например 20%)
newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage));
setSpreadsheetWidths({
L: newLeftPercentage,
R: 1 - newLeftPercentage,
});
};
const handleMouseUp = () => {
document.body.style.cursor = 'auto';
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
separatorRef.addEventListener('mousedown', handleMouseDown);
return () => {
separatorRef.removeEventListener('mousedown', handleMouseDown);
};
}, [separatorRef]);
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex-shrink-0">
<FormulaBar
sheetType={activeSheet}
<div className="flex h-full flex-col bg-background">
<FormulaBar
activeSheet={activeSheet}
sheetType={activeSheet} // Передаем activeSheet как текущий sheetType
selectedCell={selectedCell}
formulaBarValue={formulaBarValue}
isCalculating={multiSheetEngine.isCalculating}
@@ -910,16 +968,64 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
onFocus={handleFormulaBarFocus}
onBlur={handleFormulaBarBlur}
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>
)
}
export default DualSpreadsheet
export default DualSpreadsheet

File diff suppressed because it is too large Load Diff

View File

@@ -85,7 +85,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
return (
<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 gap-3">
<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 { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils';
import { Template } from '../types/template';
import { Template, TemplateElement } from '../types/template';
interface TemplateContextType {
templates: Template[];
@@ -24,6 +24,28 @@ interface TemplateProviderProps {
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 {
return {
@@ -37,7 +59,22 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({ children })
const [templates, setTemplates] = useState<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]);
};

View File

@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx";
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[]) {
return twMerge(clsx(inputs))
@@ -32,19 +32,121 @@ export function migrateTemplateElement(element: any): TemplateElement {
...element,
targetCells,
order: element.order || 0,
category: element.category || '',
width: element.width || 'auto',
layout: element.layout || generateDefaultLayout(element.order || 0),
} as TemplateElement;
}
// Получение настроек отображения по умолчанию
export function getDefaultLayoutSettings(): FormLayoutSettings {
return {
columns: 2,
grouping: 'none',
elementSpacing: 'normal',
showLabels: true,
enableDragDrop: true,
canvasWidth: 1200,
canvasHeight: 800,
gridSize: 20,
showGrid: 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 = {
columns: 2,
grouping: 'none',
elementSpacing: 'normal',
showLabels: true,
enableDragDrop: true,
gridSize: 20,
showGrid: true,
};
if (selectedTemplate) {
return (
<div className="h-full bg-gray-50">
<div className="bg-white border-b border-gray-200 p-4">
<div className="h-full">
<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 gap-4">
<Button variant="ghost" onClick={handleBack}>
@@ -116,7 +113,7 @@ export const ElementsCreation: FC = () => {
Конструктор элементов: {selectedTemplate.name}
</h1>
<p className="text-sm text-gray-600 mt-1">
Создайте элементы формы для связи с ячейками шаблона. Используйте drag & drop для изменения порядка.
Создайте элементы формы и разместите их на холсте перетаскиванием
</p>
</div>
</div>
@@ -131,7 +128,7 @@ export const ElementsCreation: FC = () => {
</div>
</div>
<div className="flex-1">
<div className="h-[calc(100vh-104px)]">
<ElementConstructor
elements={selectedTemplate.elements}
layoutSettings={selectedTemplate.layoutSettings || defaultLayoutSettings}
@@ -192,17 +189,6 @@ export const ElementsCreation: FC = () => {
<Wrench className="h-4 w-4" />
<span>{template.elements.length} элементов создано</span>
</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">
Обновлен: {template.updatedAt.toLocaleDateString('ru-RU')}
</div>

View File

@@ -1,6 +1,7 @@
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 { Badge } from '../../../../components/ui/badge';
import { Button } from '../../../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
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 { Textarea } from '../../../../components/ui/textarea';
import { useTemplateContext } from '../../../../contexts/TemplateContext';
import { ElementType, Template, TemplateElement } from '../../../../types/template';
import { Template, TemplateElement } from '../../../../types/template';
interface ProtocolFormProps {
template: Template;
@@ -21,11 +22,9 @@ interface FormElementProps {
element: TemplateElement;
value: any;
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 = () => {
switch (element.type) {
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 (
<div className={`space-y-2 ${getWidthClass()}`}>
{showLabel && (
<label className="text-sm font-medium text-gray-700">
<div className="space-y-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-900">
{element.label}
{element.required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
{element.required && (
<Badge variant="required" className="text-xs">
!
</Badge>
)}
</div>
{renderInput()}
{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) => (
<span
<Badge
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}
>
{target.sheet}!{target.cell}
</span>
</Badge>
))}
</div>
)}
@@ -158,7 +151,6 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange, showLabel
const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [formData, setFormData] = useState<Record<string, any>>({});
const [protocolName, setProtocolName] = useState('');
const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({
@@ -168,6 +160,12 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
};
const handleSave = () => {
// Ищем элемент с названием протокола (должен иметь специальный тип или название)
const protocolNameElement = template.elements.find(el =>
el.name === 'protocolName' || el.label === 'Название протокола'
);
const protocolName = protocolNameElement ? formData[protocolNameElement.id] : 'Новый протокол';
const data = {
protocolName,
templateId: template.id,
@@ -178,84 +176,40 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
};
const handleExport = () => {
// Логика экспорта в Excel будет реализована позже
console.log('Экспорт в Excel:', { protocolName, formData });
console.log(кспорт в Excel:', { formData });
alert('Функция экспорта будет реализована в следующих версиях');
};
const canvasSize = useMemo(() => {
const PADDING_Y = 200;
const baseWidth = 1200;
const baseHeight = 800;
const layoutSettings = template.layoutSettings || {
columns: 2,
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);
if (template.elements.length === 0) {
return { width: baseWidth, height: baseHeight };
}
return [['Все элементы', sortedElements]];
};
const getSpacingClass = () => {
switch (layoutSettings.elementSpacing) {
case 'compact':
return 'gap-3';
case 'spacious':
return 'gap-8';
default:
return 'gap-6';
}
};
const contentHeight = Math.max(
0,
...template.elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
);
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';
}
};
return {
width: baseWidth,
height: Math.max(baseHeight, contentHeight + PADDING_Y),
};
}, [template.elements]);
const typeLabels: Record<ElementType, string> = {
text: 'Текстовые поля',
textarea: 'Многострочный текст',
number: 'Числовые поля',
date: 'Даты',
select: 'Выпадающие списки',
radio: 'Переключатели',
checkbox: 'Флажки',
};
// Проверяем есть ли кнопка сохранения (есть ли название протокола)
const protocolNameElement = template.elements.find(el =>
el.name === 'protocolName' || el.label === 'Название протокола'
);
const canSave = protocolNameElement ? !!formData[protocolNameElement.id]?.trim() : false;
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 gap-4">
<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">
Создание протокола: {template.name}
</h1>
<p className="text-sm text-gray-600 mt-1">
Заполните форму для создания нового протокола
</p>
</div>
</div>
<div className="flex gap-2">
@@ -276,7 +227,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<Download className="h-4 w-4 mr-2" />
Экспорт в Excel
</Button>
<Button onClick={handleSave} disabled={!protocolName.trim()}>
<Button onClick={handleSave} disabled={!canSave}>
<Save className="h-4 w-4 mr-2" />
Сохранить протокол
</Button>
@@ -284,77 +235,49 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</div>
</div>
{/* Форма */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-6xl mx-auto space-y-6">
{/* Название протокола */}
<Card>
<CardHeader>
<CardTitle>Основная информация</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Название протокола <span className="text-red-500">*</span>
</label>
<Input
placeholder="Введите название протокола"
value={protocolName}
onChange={(e) => setProtocolName(e.target.value)}
/>
</div>
</CardContent>
</Card>
{/* Элементы формы */}
{template.elements.length === 0 ? (
<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 className="flex-1 overflow-auto">
<div
className="relative bg-white shadow-lg mx-auto my-8"
style={{ width: canvasSize.width, height: canvasSize.height }}
>
{template.elements.map((element) => {
const layout = element.layout || { x: 50, y: 50, width: 300, height: 80 };
return (
<div
key={element.id}
className="absolute"
style={{
left: layout.x,
top: layout.y,
width: layout.width,
minHeight: layout.height,
zIndex: layout.zIndex || 1,
}}
>
<FormElement
element={element}
value={formData[element.id]}
onChange={(value) => handleFieldChange(element.id, value)}
/>
</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>
</CardContent>
</Card>
))
)}
);
})}
{template.elements.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<FileText className="h-16 w-16 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>
)}
</div>
</div>
</div>
@@ -437,17 +360,6 @@ export const ProtocolCreation: FC = () => {
<FileText className="h-4 w-4" />
<span>{template.elements.length} элементов для заполнения</span>
</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">
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
</div>

View File

@@ -20,13 +20,19 @@ export interface CellTarget {
displayName?: string // отображаемое имя для пользователя
}
// Позиция и размеры элемента в визуальном редакторе
export interface ElementLayout {
x: number // позиция по X в пикселях
y: number // позиция по Y в пикселях
width: number // ширина в пикселях
height: number // высота в пикселях
zIndex?: number // Z-index для наложения элементов
}
// Настройки отображения элементов в форме
export interface FormLayoutSettings {
columns: number // количество столбцов в сетке
grouping: 'none' | 'by-type' | 'by-category' // группировка элементов
elementSpacing: 'compact' | 'normal' | 'spacious' // расстояние между элементами
showLabels: boolean // показывать ли подписи элементов
enableDragDrop: boolean // включить drag & drop для изменения порядка
gridSize: number // размер сетки для привязки (в пикселях)
showGrid: boolean // показывать сетку
}
export interface TemplateElement {
@@ -40,8 +46,7 @@ export interface TemplateElement {
defaultValue?: string
placeholder?: string
order?: number // порядок отображения в форме
category?: string // категория для группировки
width?: 'auto' | 'half' | 'full' // ширина элемента в форме
layout?: ElementLayout // позиция и размеры в визуальном редакторе
}
// Типы шаблонов