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

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
onClick={() => navigate(`/templates/${template.id}/elements`)} onClick={() => navigate(`/templates/${template.id}/elements`)}
className="flex items-center gap-1 hover:bg-muted" 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> <span className="text-sm">Редактор интерфейса</span>
</Button> </Button>
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
onClick={() => navigate(`/templates/${template.id}/protocols`)} onClick={() => navigate(`/templates/${template.id}/protocols`)}
className="flex items-center gap-1 hover:bg-muted" 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 }, radio: { width: 160, height: 80 },
checkbox: { width: 140, height: 40 }, checkbox: { width: 140, height: 40 },
standards: { width: 200, height: 50 }, standards: { width: 200, height: 50 },
calibration_conditions: { width: 250, height: 60 }, calibration_conditions: { width: 350, height: 50 },
button_group: { width: 180, height: 80 }, 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 { export function validateCellAddress(cell: string): boolean {
return parseCellAddress(cell) !== null return parseCellAddress(cell) !== null

View File

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

View File

@@ -6,7 +6,7 @@ import { cellAddressToCoordinates } from '@/lib/cell-utils'
import { useToast } from '@/lib/hooks/useToast' import { useToast } from '@/lib/hooks/useToast'
import { getLatestFileForTemplate } from '@/service/fileApiService' import { getLatestFileForTemplate } from '@/service/fileApiService'
import { Template, TemplateElement } from '@/type/template' 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 { FC, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
@@ -44,25 +44,23 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
) )
return ( return (
<div className="space-y-3"> <div className="relative space-y-3">
{/* Показываем индикатор ячеек для всех элементов */} {/* Показываем индикатор ячеек для всех элементов */}
{element.targetCells && element.targetCells.length > 0 && ( {element.targetCells && element.targetCells.length > 0 && (
<div className="flex justify-end"> <div className="group/cells absolute right-0 top-0">
<div className="group/cells relative"> <Grid className="h-3 w-3 cursor-help text-gray-500 opacity-60 transition-opacity hover:opacity-100" />
<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="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>
<div className="mb-1 font-medium">Целевые ячейки:</div> {element.targetCells.map((cell, i) => (
{element.targetCells.map((cell, i) => ( <div key={i} className="font-mono">
<div key={i} className="font-mono"> {cell.sheet}!{cell.cell}
{cell.sheet}!{cell.cell} {cell.displayName && (
{cell.displayName && ( <span className="ml-1 text-gray-300">
<span className="ml-1 text-gray-300"> ({cell.displayName})
({cell.displayName}) </span>
</span> )}
)} </div>
</div> ))}
))}
</div>
</div> </div>
</div> </div>
)} )}
@@ -78,6 +76,7 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [showSpreadsheet, setShowSpreadsheet] = useState(false) const [showSpreadsheet, setShowSpreadsheet] = useState(false)
const engineRef = useRef<any>(null) const engineRef = useRef<any>(null)
const toast = useToast() const toast = useToast()
const navigate = useNavigate()
const handleFieldChange = (elementId: string, value: any) => { const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({ setFormData(prev => ({
@@ -266,7 +265,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
return ( return (
<div className="flex h-screen flex-col bg-gray-50"> <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-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"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={onBack}> <Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@@ -280,15 +280,40 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</span> </span>
</div> </div>
</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 <Button
variant={showSpreadsheet ? 'default' : 'outline'} variant={showSpreadsheet ? 'default' : 'outline'}
size="icon"
onClick={() => setShowSpreadsheet(!showSpreadsheet)} onClick={() => setShowSpreadsheet(!showSpreadsheet)}
aria-label={
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
}
> >
<Grid className="mr-2 h-4 w-4" /> <Grid className="h-4 w-4" />
{showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'}
</Button> </Button>
<Button onClick={handleSave} disabled={!canSave}> <Button variant="outline" onClick={handleSave} disabled={!canSave}>
<Save className="mr-2 h-4 w-4" /> <Save className="mr-2 h-4 w-4" />
Сохранить протокол Сохранить протокол
</Button> </Button>

View File

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