элементы и передвижение

This commit is contained in:
2025-07-23 17:50:44 +03:00
parent 907b3e315e
commit 7c7e0db426
9 changed files with 100 additions and 193 deletions

View File

@@ -11,6 +11,7 @@ import {
SelectValue,
} from '@/component/ui/select'
import { ElementDefinition } from '@/entitiy/element/model/interface'
import { cn } from '@/lib/utils'
import {
AlertCircle,
Check,
@@ -255,8 +256,7 @@ function CalibrationCondInfo({
return (
<>
{/* Компактное отображение */}
<div className="flex items-center gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/50 px-3 py-2">
<div className="flex items-center gap-3 text-xs">
{fieldConfigs.map(({ key, icon: Icon, unit }) => (
<div key={key} className="flex items-center gap-1">
@@ -268,19 +268,24 @@ function CalibrationCondInfo({
</div>
))}
</div>
<div className="flex items-center gap-2">
{isOutdated && (
<div className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
)}
<Button
variant="ghost"
size="icon"
onClick={openModal}
className={`h-6 w-6 p-0 ${
isOutdated ? 'text-yellow-600 hover:text-yellow-700' : ''
}`}
className={cn(
'h-6 w-6 p-0',
isOutdated && 'text-yellow-600 hover:text-yellow-700'
)}
>
<Edit3 className="h-3 w-3" />
</Button>
{isOutdated && (
<div className="h-2 w-2 animate-pulse rounded-full bg-yellow-500" />
)}
</div>
</div>
{/* Модальное окно */}
@@ -729,8 +734,6 @@ export const calibrationConditionsDefinition: ElementDefinition<
},
Preview: ({ config }) => (
<div className="space-y-3">
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<CalibrationCondInfo
conditions={
config.defaultConditions || {
@@ -746,7 +749,6 @@ export const calibrationConditionsDefinition: ElementDefinition<
onUpdate={async () => true}
/>
</div>
</div>
),
Render: ({ config, value, onChange }) => {
// Если значение не установлено, используем defaultConditions из конфигурации

View File

@@ -773,7 +773,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const autoSaveOptions = useMemo(
() => ({
templateId,
autoSaveDelay: 2000,
autoSaveDelay: 4000,
enableAutoSave: enableAutoSave && userEdited,
}),
[templateId, enableAutoSave, userEdited]
@@ -824,7 +824,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
if (fileData && Object.keys(fileData.data).length > 0) {
finalBaseData = { [SHEET_NAMES.report]: fileData.data }
setBaseFileData(finalBaseData)
// Сохраняем baseline, чтобы в листы попадали только пользовательские изменения
setTemplateData(finalBaseData)
}

View File

@@ -3,11 +3,7 @@ import {
getElementDefinition,
} from '@/entitiy/element/model/interface'
import { useToast } from '@/lib/hooks/useToast'
import {
autoArrangeElements,
findFreePosition,
generateDefaultLayout,
} from '@/lib/utils'
import { generateDefaultLayout } from '@/lib/utils'
import {
CellTarget,
ElementType,
@@ -591,15 +587,8 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
}
// Генерируем layout для нового элемента
const existingLayouts = elements.map(e => e.layout).filter(Boolean)
const defaultLayout = generateDefaultLayout(elements.length)
const layout = findFreePosition(
defaultLayout,
existingLayouts as any[],
layoutSettings.gridSize
)
// Используем targetCells из формы или генерируем через mapToCells
let targetCells = formData.targetCells || []
if (!targetCells.length) {
@@ -609,14 +598,14 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
const newElement: TemplateElement = {
id: Date.now().toString(),
type: formData.type as ElementType,
name: formData.label?.toLowerCase().replace(/\s+/g, '_') || '',
name: formData.label || '',
label: formData.label,
targetCells,
placeholder: formData.placeholder,
required: formData.required,
options: formData.options,
order: elements.length,
layout,
layout: defaultLayout,
}
onElementAdd(newElement)
@@ -666,21 +655,6 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
resetForm()
}
const handleAutoArrange = () => {
const arrangedElements = autoArrangeElements(
elements,
layoutSettings.gridSize
)
arrangedElements.forEach((element, index) => {
onElementUpdate(element.id, {
...element,
layout: element.layout,
order: index,
})
})
}
const handleDeleteAndSelect = (elementId: string) => {
onElementDelete(elementId)
setEditingElement(null)
@@ -697,7 +671,6 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
<ElementFormulaBar
elementCount={elements.length}
onAddElement={() => setIsAddDialogOpen(true)}
onAutoArrange={handleAutoArrange}
disabled={disabled}
hasUnsavedChanges={hasUnsavedChanges}
isSaving={isSaving}

View File

@@ -30,7 +30,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
</div>
<div className="flex gap-2">
<Button
variant="secondary"
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
className="flex items-center gap-1 hover:bg-muted"
@@ -39,7 +39,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
<span className="text-sm">Редактор интерфейса</span>
</Button>
<Button
variant="secondary"
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/protocols`)}
className="flex items-center gap-1 hover:bg-muted"

View File

@@ -21,7 +21,7 @@ const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
radio: { width: 160, height: 80 },
checkbox: { width: 140, height: 40 },
standards: { width: 200, height: 50 },
calibration_conditions: { width: 250, height: 60 },
calibration_conditions: { width: 350, height: 50 },
button_group: { width: 180, height: 80 },
}

View File

@@ -65,93 +65,6 @@ export function generateDefaultLayout(order: number): ElementLayout {
}
}
// Автоматическое размещение элементов без пересечений
export function autoArrangeElements(
elements: TemplateElement[],
gridSize: number = 20
): TemplateElement[] {
const arranged = [...elements]
const cols = 3 // Количество колонок
const itemWidth = 300
const itemHeight = 80
const marginX = 20
const marginY = 20
const startX = 50
const startY = 50
arranged.forEach((element, index) => {
const col = index % cols
const row = Math.floor(index / cols)
const x = startX + col * (itemWidth + marginX)
const y = startY + row * (itemHeight + marginY)
// Выравниваем по сетке
const alignedX = Math.round(x / gridSize) * gridSize
const alignedY = Math.round(y / gridSize) * gridSize
element.layout = {
...element.layout,
x: alignedX,
y: alignedY,
width: itemWidth,
height: itemHeight,
}
})
return arranged
}
// Проверка пересечения двух элементов
export function checkElementOverlap(
element1: ElementLayout,
element2: ElementLayout
): boolean {
return !(
element1.x + element1.width <= element2.x ||
element2.x + element2.width <= element1.x ||
element1.y + element1.height <= element2.y ||
element2.y + element2.height <= 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,
}
}
// Валидация адреса ячейки - теперь использует централизованную утилиту
export function validateCellAddress(cell: string): boolean {
return parseCellAddress(cell) !== null

View File

@@ -3,7 +3,7 @@ import { Button } from '@/component/ui/button'
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
import { useDelayedSave } from '@/hook/useDelayedSave'
import { Template, TemplateElement } from '@/type/template'
import { ArrowLeft, FileText, Wrench } from 'lucide-react'
import { ArrowLeft, FileText, Settings, Wrench } from 'lucide-react'
import { FC, useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
@@ -151,9 +151,9 @@ export const ElementsCreation: FC = () => {
return (
<div className="flex h-screen flex-col">
<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-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
@@ -174,13 +174,11 @@ export const ElementsCreation: FC = () => {
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/templates/${selectedTemplate.id}/elements`)
}
className="flex items-center gap-2"
onClick={() => navigate(`/templates/${selectedTemplate.id}/edit`)}
className="flex items-center gap-2 hover:bg-muted"
>
<Wrench className="h-3.5 w-3.5" />
Элементы интерфейса
<Settings className="h-3.5 w-3.5" />
Настройки шаблона
</Button>
<Button
variant="outline"
@@ -188,15 +186,14 @@ export const ElementsCreation: FC = () => {
onClick={() =>
navigate(`/templates/${selectedTemplate.id}/protocols`)
}
className="flex items-center gap-2"
className="flex items-center gap-1 hover:bg-muted"
>
<FileText className="h-3.5 w-3.5" />
Создать протокол
<FileText className="h-4 w-4" />
<span className="text-sm">Создать протокол</span>
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
<ElementConstructor
template={selectedTemplate}

View File

@@ -6,7 +6,7 @@ import { cellAddressToCoordinates } from '@/lib/cell-utils'
import { useToast } from '@/lib/hooks/useToast'
import { getLatestFileForTemplate } from '@/service/fileApiService'
import { Template, TemplateElement } from '@/type/template'
import { ArrowLeft, FileText, Grid, Save } from 'lucide-react'
import { ArrowLeft, FileText, Grid, Save, Settings, Wrench } from 'lucide-react'
import { FC, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
@@ -44,11 +44,10 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
)
return (
<div className="space-y-3">
<div className="relative space-y-3">
{/* Показываем индикатор ячеек для всех элементов */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="flex justify-end">
<div className="group/cells relative">
<div className="group/cells absolute right-0 top-0">
<Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<div className="pointer-events-none absolute right-0 top-full z-50 mt-2 whitespace-nowrap rounded bg-gray-900 p-2 text-xs text-white opacity-0 shadow-lg transition-opacity group-hover/cells:opacity-100">
<div className="mb-1 font-medium">Целевые ячейки:</div>
@@ -64,7 +63,6 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
))}
</div>
</div>
</div>
)}
{/* Рендер элемента через единый интерфейс */}
@@ -78,6 +76,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [showSpreadsheet, setShowSpreadsheet] = useState(false)
const engineRef = useRef<any>(null)
const toast = useToast()
const navigate = useNavigate()
const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({
@@ -266,7 +265,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
return (
<div className="flex h-screen flex-col bg-gray-50">
<div className="flex-shrink-0 border-b bg-card px-4 py-2">
<div className="flex items-center justify-between">
<div className="relative flex items-center">
{/* Левая часть: кнопка назад и заголовок */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
@@ -280,15 +280,40 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</span>
</div>
</div>
<div className="flex gap-2">
{/* Кнопки справа */}
<div className="absolute right-0 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/edit`)}
className="flex items-center gap-1 hover:bg-muted"
>
<Settings className="h-4 w-4" />
<span className="text-sm">Редактор шаблона</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)}
className="flex items-center gap-1 hover:bg-muted"
>
<Wrench className="h-4 w-4" />
<span className="text-sm">Редактор интерфейса</span>
</Button>
</div>
{/* Центрированные кнопки */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-2">
<Button
variant={showSpreadsheet ? 'default' : 'outline'}
size="icon"
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
aria-label={
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
}
>
<Grid className="mr-2 h-4 w-4" />
{showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'}
<Grid className="h-4 w-4" />
</Button>
<Button onClick={handleSave} disabled={!canSave}>
<Button variant="outline" onClick={handleSave} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" />
Сохранить протокол
</Button>

View File

@@ -23,8 +23,6 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
}) => {
const navigate = useNavigate()
// const isConfigured =
// template.elements.length > 0 && template.excelFile !== undefined
const isConfigured = true // TODO: remove
return (
@@ -37,12 +35,11 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
: undefined
}
className={clsx(
'group relative shadow-sm transition-all duration-200',
selectionMode && 'hover:cursor-pointer',
selectionMode &&
isSelected &&
'border-2 border-primary ring-4 ring-primary/30 ring-offset-2',
'hover:border-2 hover:border-primary hover:shadow-lg'
'hover:border-2 hover:border-primary'
)}
>
{selectionMode && (
@@ -98,6 +95,7 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
<div className="mt-2 flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-9 flex-1"
disabled={selectionMode || !isConfigured}
onClick={e => {