холст конструктор
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^4.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -55,7 +55,7 @@ const Layout: FC = () => {
|
||||
</nav>
|
||||
|
||||
{/* Основной контент */}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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,13 +182,7 @@ 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">
|
||||
@@ -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,
|
||||
}
|
||||
// --- Spreadsheet Component ---
|
||||
|
||||
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>
|
||||
useEffect(() => {
|
||||
if (!separatorRef) return;
|
||||
|
||||
<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 handleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
document.body.style.cursor = 'col-resize';
|
||||
|
||||
const handleMouseMove = (me: MouseEvent) => {
|
||||
const newWidth = startWidth + (me.clientX - startX)
|
||||
handleColumnResize(sheetType, i, newWidth)
|
||||
}
|
||||
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;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener(
|
||||
'mousemove',
|
||||
handleMouseMove,
|
||||
)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
let newLeftPercentage = newLeftWidth / totalWidth;
|
||||
// Ограничиваем минимальную ширину (например 20%)
|
||||
newLeftPercentage = Math.max(0.2, Math.min(0.8, newLeftPercentage));
|
||||
|
||||
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>
|
||||
setSpreadsheetWidths({
|
||||
L: newLeftPercentage,
|
||||
R: 1 - newLeftPercentage,
|
||||
});
|
||||
};
|
||||
|
||||
{/* 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 handleMouseUp = () => {
|
||||
document.body.style.cursor = 'auto';
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Мемоизированные компоненты листов
|
||||
const memoizedReportSheet = useMemo(
|
||||
() => renderSpreadsheet('report'),
|
||||
[renderSpreadsheet, revision], // Добавляем revision для перерендера
|
||||
)
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const memoizedCalculationsSheet = useMemo(
|
||||
() => renderSpreadsheet('calculations'),
|
||||
[renderSpreadsheet, revision], // Добавляем revision для перерендера
|
||||
)
|
||||
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,13 +968,61 @@ 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>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}>
|
||||
|
||||
256
src/components/TemplateManager/VisualLayoutEditor.tsx
Normal file
256
src/components/TemplateManager/VisualLayoutEditor.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
Edit,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Hash,
|
||||
Move,
|
||||
Trash2,
|
||||
Type
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Rnd } from 'react-rnd';
|
||||
import { ElementLayout, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
interface VisualLayoutEditorProps {
|
||||
elements: TemplateElement[];
|
||||
layoutSettings: FormLayoutSettings;
|
||||
onElementUpdate: (elementId: string, updates: Partial<TemplateElement>) => void;
|
||||
onElementDelete: (elementId: string) => void;
|
||||
onLayoutSettingsChange: (settings: FormLayoutSettings) => void;
|
||||
onElementEdit?: (element: TemplateElement) => void;
|
||||
}
|
||||
|
||||
interface DraggableElementProps {
|
||||
element: TemplateElement;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onUpdate: (layout: ElementLayout) => void;
|
||||
onDelete: () => void;
|
||||
onEdit?: () => void;
|
||||
gridSize: number;
|
||||
}
|
||||
|
||||
const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
||||
text: <Type className="h-4 w-4" />,
|
||||
textarea: <FileText className="h-4 w-4" />,
|
||||
number: <Hash className="h-4 w-4" />,
|
||||
date: <Calendar className="h-4 w-4" />,
|
||||
select: <ChevronDown className="h-4 w-4" />,
|
||||
radio: <CheckSquare className="h-4 w-4" />,
|
||||
checkbox: <CheckSquare className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const ELEMENT_COLORS: Record<ElementType, string> = {
|
||||
text: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||
textarea: 'bg-green-100 border-green-300 text-green-800',
|
||||
number: 'bg-purple-100 border-purple-300 text-purple-800',
|
||||
date: 'bg-orange-100 border-orange-300 text-orange-800',
|
||||
select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||
radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
||||
checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
||||
};
|
||||
|
||||
const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
||||
element,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onEdit,
|
||||
gridSize,
|
||||
}) => {
|
||||
const layout = element.layout || { x: 50, y: 50, width: 300, height: 80, zIndex: 1 };
|
||||
const elementColor = ELEMENT_COLORS[element.type];
|
||||
|
||||
const handleDragStop = useCallback((_e: any, d: { x: number; y: number }) => {
|
||||
// Вызываем onUpdate только если позиция действительно изменилась,
|
||||
// чтобы избежать ложных срабатываний при клике.
|
||||
if (d.x !== layout.x || d.y !== layout.y) {
|
||||
onUpdate({ ...layout, x: d.x, y: d.y });
|
||||
}
|
||||
}, [layout, onUpdate]);
|
||||
|
||||
const handleResizeStop = useCallback((_e: any, _dir: any, ref: HTMLElement, _delta: any, pos: { x: number; y: number }) => {
|
||||
onUpdate({
|
||||
...layout,
|
||||
width: parseInt(ref.style.width, 10),
|
||||
height: parseInt(ref.style.height, 10),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
});
|
||||
}, [layout, onUpdate]);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}, [onDelete]);
|
||||
|
||||
const handleEditClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.();
|
||||
}, [onEdit]);
|
||||
|
||||
return (
|
||||
<Rnd
|
||||
dragGrid={[gridSize, gridSize]}
|
||||
resizeGrid={[gridSize, gridSize]}
|
||||
default={{
|
||||
x: layout.x,
|
||||
y: layout.y,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
}}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleResizeStop}
|
||||
minWidth={150}
|
||||
minHeight={40}
|
||||
bounds="parent"
|
||||
className={`transition-shadow duration-200 group ${isSelected ? 'ring-2 ring-blue-500 ring-offset-2 z-20' : 'z-10'}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className={`h-full rounded-lg border-2 p-3 cursor-move ${elementColor} ${isSelected ? 'shadow-xl' : 'shadow-md'} transition-all duration-200`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{ELEMENT_ICONS[element.type]}
|
||||
<span className="font-medium text-sm truncate">{element.label || `${element.type} элемент`}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity hover:bg-blue-100"
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
<Edit className="h-3 w-3 text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-100"
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs opacity-75 space-y-1">
|
||||
{element.required && <div className="text-red-700 font-medium">• Обязательное</div>}
|
||||
{element.targetCells && element.targetCells.length > 0 && (
|
||||
<div className="font-mono truncate">
|
||||
{element.targetCells.map((cell, i) => <span key={i} className="mr-1.5 p-1 bg-black/5 rounded-sm">{cell.sheet}!{cell.cell}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Rnd>
|
||||
);
|
||||
});
|
||||
|
||||
export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
||||
elements,
|
||||
layoutSettings,
|
||||
onElementUpdate,
|
||||
onElementDelete,
|
||||
onLayoutSettingsChange,
|
||||
onElementEdit,
|
||||
}) => {
|
||||
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
|
||||
|
||||
const canvasSize = useMemo(() => {
|
||||
const PADDING_Y = 200;
|
||||
|
||||
const baseWidth = 1200;
|
||||
const baseHeight = 800;
|
||||
|
||||
if (elements.length === 0) {
|
||||
return { width: baseWidth, height: baseHeight };
|
||||
}
|
||||
|
||||
const contentHeight = Math.max(
|
||||
0,
|
||||
...elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
|
||||
);
|
||||
|
||||
return {
|
||||
width: baseWidth,
|
||||
height: Math.max(baseHeight, contentHeight + PADDING_Y),
|
||||
};
|
||||
}, [elements]);
|
||||
|
||||
const GridPattern = useMemo(() => {
|
||||
if (!layoutSettings.showGrid) return null;
|
||||
const gridSize = layoutSettings.gridSize;
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #e5e7eb 1px, transparent 1px), linear-gradient(to bottom, #e5e7eb 1px, transparent 1px)`,
|
||||
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [layoutSettings.showGrid, layoutSettings.gridSize]);
|
||||
|
||||
const handleElementUpdateCallback = useCallback((elementId: string, layout: ElementLayout) => {
|
||||
onElementUpdate(elementId, { layout });
|
||||
}, [onElementUpdate]);
|
||||
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setSelectedElementId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleGrid = useCallback(() => {
|
||||
onLayoutSettingsChange({ ...layoutSettings, showGrid: !layoutSettings.showGrid });
|
||||
}, [layoutSettings, onLayoutSettingsChange]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-gray-50">
|
||||
<div className="flex items-center justify-between p-2 bg-white border-b shrink-0">
|
||||
<span className="text-sm text-gray-600 px-2">{elements.length} элементов на холсте</span>
|
||||
<Button variant="outline" size="sm" onClick={toggleGrid}>
|
||||
{layoutSettings.showGrid ? <EyeOff className="h-4 w-4 mr-2" /> : <Eye className="h-4 w-4 mr-2" />}
|
||||
{layoutSettings.showGrid ? 'Скрыть сетку' : 'Показать сетку'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative overflow-auto" onClick={handleCanvasClick}>
|
||||
<div
|
||||
className="relative bg-white shadow-lg mx-auto my-8"
|
||||
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||
>
|
||||
{GridPattern}
|
||||
{elements.map((element) => (
|
||||
<DraggableElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElementId === element.id}
|
||||
onSelect={() => setSelectedElementId(element.id)}
|
||||
onUpdate={(layout) => handleElementUpdateCallback(element.id, layout)}
|
||||
onDelete={() => onElementDelete(element.id)}
|
||||
onEdit={onElementEdit ? () => onElementEdit(element) : undefined}
|
||||
gridSize={layoutSettings.gridSize}
|
||||
/>
|
||||
))}
|
||||
{elements.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center text-gray-400">
|
||||
<Move className="h-16 w-16 mx-auto mb-4" />
|
||||
<p className="text-xl font-medium mb-2">Холст пуст</p>
|
||||
<p className="text-sm">Добавьте свой первый элемент</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
src/components/ui/badge.tsx
Normal file
38
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
excel: "border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
required: "border-transparent bg-red-100 text-red-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react';
|
||||
import { 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]);
|
||||
};
|
||||
|
||||
|
||||
118
src/lib/utils.ts
118
src/lib/utils.ts
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 layoutSettings = template.layoutSettings || {
|
||||
columns: 2,
|
||||
grouping: 'none',
|
||||
elementSpacing: 'normal',
|
||||
showLabels: true,
|
||||
enableDragDrop: false,
|
||||
};
|
||||
const canvasSize = useMemo(() => {
|
||||
const PADDING_Y = 200;
|
||||
const baseWidth = 1200;
|
||||
const baseHeight = 800;
|
||||
|
||||
// Группировка элементов
|
||||
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 (template.elements.length === 0) {
|
||||
return { width: baseWidth, height: baseHeight };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const contentHeight = Math.max(
|
||||
0,
|
||||
...template.elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
|
||||
);
|
||||
|
||||
return [['Все элементы', sortedElements]];
|
||||
};
|
||||
return {
|
||||
width: baseWidth,
|
||||
height: Math.max(baseHeight, contentHeight + PADDING_Y),
|
||||
};
|
||||
}, [template.elements]);
|
||||
|
||||
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 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>
|
||||
|
||||
@@ -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 // позиция и размеры в визуальном редакторе
|
||||
}
|
||||
|
||||
// Типы шаблонов
|
||||
|
||||
36
yarn.lock
36
yarn.lock
@@ -1084,11 +1084,16 @@ chokidar@^3.5.3:
|
||||
|
||||
class-variance-authority@^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==
|
||||
dependencies:
|
||||
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:
|
||||
version "2.1.1"
|
||||
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"
|
||||
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:
|
||||
version "13.1.1"
|
||||
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"
|
||||
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:
|
||||
version "4.5.0"
|
||||
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:
|
||||
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==
|
||||
dependencies:
|
||||
clsx "^2.1.1"
|
||||
@@ -2106,6 +2124,15 @@ react-resizable@^3.0.5:
|
||||
prop-types "15.x"
|
||||
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:
|
||||
version "6.14.1"
|
||||
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"
|
||||
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:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
|
||||
|
||||
Reference in New Issue
Block a user