переименования папок
This commit is contained in:
970
src/component/TemplateManager/ElementConstructor.tsx
Normal file
970
src/component/TemplateManager/ElementConstructor.tsx
Normal file
@@ -0,0 +1,970 @@
|
||||
import {
|
||||
getAvailableElementTypes,
|
||||
getElementDefinition,
|
||||
} from '@/lib/element-registry'
|
||||
import {
|
||||
autoArrangeElements,
|
||||
findFreePosition,
|
||||
generateDefaultLayout,
|
||||
} from '@/lib/utils'
|
||||
import {
|
||||
CellTarget,
|
||||
ElementType,
|
||||
FormLayoutSettings,
|
||||
Template,
|
||||
TemplateElement,
|
||||
} from '@/type/template'
|
||||
import {
|
||||
Calendar,
|
||||
Droplets,
|
||||
Edit3,
|
||||
Gauge,
|
||||
Plus,
|
||||
Radio,
|
||||
Thermometer,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Checkbox } from '../ui/checkbox'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select'
|
||||
import { Textarea } from '../ui/textarea'
|
||||
import { VisualLayoutEditor } from './VisualLayoutEditor'
|
||||
|
||||
interface ElementFormProps {
|
||||
formData: Partial<TemplateElement>
|
||||
setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>
|
||||
}
|
||||
|
||||
const CellTargetEditor: React.FC<{
|
||||
targets: CellTarget[]
|
||||
onChange: (targets: CellTarget[]) => void
|
||||
}> = ({ targets, onChange }) => {
|
||||
const handleAddTarget = () => {
|
||||
onChange([...targets, { sheet: 'L', cell: '' }])
|
||||
}
|
||||
|
||||
const handleUpdateTarget = (
|
||||
index: number,
|
||||
field: keyof CellTarget,
|
||||
value: string
|
||||
) => {
|
||||
const updatedTargets = targets.map((target, i) =>
|
||||
i === index ? { ...target, [field]: value } : target
|
||||
)
|
||||
onChange(updatedTargets)
|
||||
}
|
||||
|
||||
const handleRemoveTarget = (index: number) => {
|
||||
onChange(targets.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Целевые ячейки</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddTarget}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{targets.map((target, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50/80 p-2">
|
||||
<div className="mb-1.5 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Лист
|
||||
</label>
|
||||
<Select
|
||||
value={target.sheet}
|
||||
onValueChange={value =>
|
||||
handleUpdateTarget(index, 'sheet', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="L">L (Левый)</SelectItem>
|
||||
<SelectItem value="R">R (Правый)</SelectItem>
|
||||
<SelectItem value="Report">Report</SelectItem>
|
||||
<SelectItem value="Calculations">Calculations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600">
|
||||
Ячейка
|
||||
</label>
|
||||
<Input
|
||||
placeholder="A1, B5..."
|
||||
value={target.cell}
|
||||
onChange={e =>
|
||||
handleUpdateTarget(index, 'cell', e.target.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveTarget(index)}
|
||||
className="h-5 px-1 text-xs text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="mr-1 h-2.5 w-2.5" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{targets.length === 0 && (
|
||||
<p className="py-3 text-center text-xs text-gray-500">
|
||||
Добавьте ячейки для связи с элементом
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Live Preview компонент
|
||||
const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
|
||||
element,
|
||||
}) => {
|
||||
const elementDefinition = element.type
|
||||
? getElementDefinition(element.type)
|
||||
: null
|
||||
|
||||
if (elementDefinition && elementDefinition.Preview) {
|
||||
return <elementDefinition.Preview config={element as any} />
|
||||
}
|
||||
|
||||
// Fallback для старых элементов
|
||||
const renderPreviewInput = () => {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
placeholder={element.placeholder || 'Введите текст'}
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
placeholder={element.placeholder || 'Введите текст'}
|
||||
disabled
|
||||
rows={3}
|
||||
className="w-full resize-none"
|
||||
/>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={element.placeholder || '0'}
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
)
|
||||
|
||||
case 'date':
|
||||
return <Input type="date" disabled className="w-full" />
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select disabled>
|
||||
<SelectTrigger className="w-full">
|
||||
<span className="text-gray-500">
|
||||
{element.placeholder || 'Выберите значение'}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
)
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{element.options?.length ? (
|
||||
element.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 rounded-full border border-gray-300" />
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 rounded-full border border-gray-300" />
|
||||
<span className="text-sm text-gray-500">Вариант 1</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 rounded border border-gray-300" />
|
||||
<span className="text-sm">
|
||||
{element.placeholder || element.label || 'Отметить'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'standards':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-md border border-input bg-background p-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{element.placeholder || 'Выберите эталоны'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 opacity-50" />
|
||||
<span className="text-xs text-muted-foreground">Выбрать</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Выбрано эталонов: 0
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'calibration-conditions':
|
||||
return (
|
||||
<div className="space-y-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-1">
|
||||
<Thermometer className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">20°C</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">65%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">101.3кПа</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">220В</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">50Гц</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'button-group':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{element.options?.length ? (
|
||||
element.options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200 hover:border-primary/30 hover:bg-primary/5"
|
||||
>
|
||||
{option.label || `Кнопка ${index + 1}`}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 px-3 py-1.5 text-sm text-muted-foreground">
|
||||
{element.placeholder || 'Добавьте кнопки'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3 text-center text-sm text-gray-500">
|
||||
Выберите тип элемента
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
{element.label && (
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">
|
||||
{element.label}
|
||||
{element.required && <span className="ml-1 text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{renderPreviewInput()}
|
||||
{element.targetCells && element.targetCells.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{element.targetCells.map((target, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700"
|
||||
>
|
||||
{target.sheet}!{target.cell}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
const elementDefinition = formData.type
|
||||
? getElementDefinition(formData.type)
|
||||
: null
|
||||
|
||||
const handleAddOption = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: [...(prev.options || []), { value: '', label: '' }],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleUpdateOption = (
|
||||
index: number,
|
||||
field: 'value' | 'label',
|
||||
value: string
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: prev.options?.map((option, i) =>
|
||||
i === index ? { ...option, [field]: value } : option
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: prev.options?.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
const needsOptions =
|
||||
formData.type === 'select' ||
|
||||
formData.type === 'radio' ||
|
||||
formData.type === 'button-group'
|
||||
|
||||
// Если есть определение элемента в реестре, используем его редактор
|
||||
if (elementDefinition && elementDefinition.Editor) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Форма настроек */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Тип элемента</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType
|
||||
console.log('Element type changed to:', newType)
|
||||
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType }
|
||||
|
||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
||||
if (
|
||||
newType === 'standards' ||
|
||||
newType === 'calibration-conditions'
|
||||
) {
|
||||
console.log(
|
||||
`${newType} type selected, getting element definition`
|
||||
)
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
console.log('Element definition:', elementDefinition)
|
||||
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig =
|
||||
elementDefinition.defaultConfig as any
|
||||
console.log('Default config:', defaultConfig)
|
||||
updates.targetCells =
|
||||
elementDefinition.mapToCells?.(defaultConfig) || []
|
||||
console.log('Generated targetCells:', updates.targetCells)
|
||||
}
|
||||
}
|
||||
|
||||
const newFormData = { ...prev, ...updates }
|
||||
console.log('New form data:', newFormData)
|
||||
return newFormData
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAvailableElementTypes().map(({ type, label, icon }) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Подпись</label>
|
||||
<Input
|
||||
placeholder="Название поля"
|
||||
value={formData.label}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, label: e.target.value }))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Введите значение..."
|
||||
value={formData.placeholder}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
placeholder: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 py-1">
|
||||
<Checkbox
|
||||
id="required"
|
||||
checked={formData.required}
|
||||
onCheckedChange={checked =>
|
||||
setFormData(prev => ({ ...prev, required: !!checked }))
|
||||
}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<CellTargetEditor
|
||||
targets={formData.targetCells || []}
|
||||
onChange={targets =>
|
||||
setFormData(prev => ({ ...prev, targetCells: targets }))
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Специфичный редактор элемента */}
|
||||
<elementDefinition.Editor
|
||||
config={formData as any}
|
||||
onChange={newConfig =>
|
||||
setFormData(prev => ({ ...prev, ...newConfig }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="sticky top-0 flex min-h-[50vh] items-center">
|
||||
<ElementPreview element={formData} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback для старых элементов
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Форма настроек */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Тип элемента</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType
|
||||
console.log('Element type changed to (fallback):', newType)
|
||||
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType }
|
||||
|
||||
// Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию
|
||||
if (
|
||||
newType === 'standards' ||
|
||||
newType === 'calibration-conditions'
|
||||
) {
|
||||
console.log(
|
||||
`${newType} type selected (fallback), getting element definition`
|
||||
)
|
||||
const elementDefinition = getElementDefinition(newType)
|
||||
console.log(
|
||||
'Element definition (fallback):',
|
||||
elementDefinition
|
||||
)
|
||||
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig = elementDefinition.defaultConfig as any
|
||||
console.log('Default config (fallback):', defaultConfig)
|
||||
updates.targetCells =
|
||||
elementDefinition.mapToCells?.(defaultConfig) || []
|
||||
console.log(
|
||||
'Generated targetCells (fallback):',
|
||||
updates.targetCells
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const newFormData = { ...prev, ...updates }
|
||||
console.log('New form data (fallback):', newFormData)
|
||||
return newFormData
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAvailableElementTypes().map(({ type, label, icon }) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Подпись</label>
|
||||
<Input
|
||||
placeholder="Название поля"
|
||||
value={formData.label}
|
||||
onChange={e =>
|
||||
setFormData(prev => ({ ...prev, label: e.target.value }))
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CellTargetEditor
|
||||
targets={formData.targetCells || []}
|
||||
onChange={targets =>
|
||||
setFormData(prev => ({ ...prev, targetCells: targets }))
|
||||
}
|
||||
/>
|
||||
|
||||
{needsOptions && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Варианты</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddOption}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-28 space-y-1.5 overflow-y-auto">
|
||||
{formData.options?.map((option, index) => (
|
||||
<div key={index} className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'value', e.target.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Подпись"
|
||||
value={option.label}
|
||||
onChange={e =>
|
||||
handleUpdateOption(index, 'label', e.target.value)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveOption(index)}
|
||||
className="h-7 w-7 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="sticky top-0 flex min-h-[50vh] items-center">
|
||||
<ElementPreview element={formData} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CanvasSettingsProps {
|
||||
settings: FormLayoutSettings
|
||||
onChange: (settings: FormLayoutSettings) => void
|
||||
}
|
||||
|
||||
const CanvasSettings: React.FC<CanvasSettingsProps> = ({
|
||||
settings,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Размер сетки (px)</label>
|
||||
<Select
|
||||
value={settings.gridSize.toString()}
|
||||
onValueChange={value =>
|
||||
onChange({ ...settings, gridSize: parseInt(value) })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10px</SelectItem>
|
||||
<SelectItem value="20">20px</SelectItem>
|
||||
<SelectItem value="25">25px</SelectItem>
|
||||
<SelectItem value="50">50px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showGrid"
|
||||
checked={settings.showGrid}
|
||||
onCheckedChange={checked =>
|
||||
onChange({ ...settings, showGrid: !!checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor="showGrid" className="text-sm font-medium">
|
||||
Показывать сетку
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ElementConstructorProps {
|
||||
template: Template
|
||||
onElementAdd: (element: TemplateElement) => void
|
||||
// Принимаем частичное обновление элемента, чтобы корректно обновлять только изменённые поля
|
||||
onElementUpdate: (
|
||||
elementId: string,
|
||||
updates: Partial<TemplateElement>
|
||||
) => void
|
||||
onElementDelete: (elementId: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
template,
|
||||
onElementAdd,
|
||||
onElementUpdate,
|
||||
onElementDelete,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const elements = template.elements
|
||||
const layoutSettings = template.layoutSettings || {
|
||||
gridSize: 20,
|
||||
showGrid: true,
|
||||
}
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
const [editingElement, setEditingElement] = useState<TemplateElement | null>(
|
||||
null
|
||||
)
|
||||
const [formData, setFormData] = useState<Partial<TemplateElement>>({
|
||||
type: 'text',
|
||||
name: '',
|
||||
label: '',
|
||||
targetCells: [],
|
||||
placeholder: '',
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
type: 'text',
|
||||
label: '',
|
||||
targetCells: [],
|
||||
placeholder: '',
|
||||
required: false,
|
||||
options: [],
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddElement = () => {
|
||||
console.log('handleAddElement called with formData:', formData)
|
||||
|
||||
if (!formData.label) {
|
||||
console.log('Validation failed: missing label')
|
||||
return
|
||||
}
|
||||
|
||||
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
|
||||
if (
|
||||
formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
formData.type !== 'button-group' &&
|
||||
!formData.targetCells?.length
|
||||
) {
|
||||
console.log(
|
||||
'Validation failed: missing targetCells for element that requires manual targetCells'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем layout для нового элемента
|
||||
const existingLayouts = elements.map(e => e.layout).filter(Boolean)
|
||||
const defaultLayout = generateDefaultLayout(elements.length)
|
||||
|
||||
const layout = findFreePosition(
|
||||
defaultLayout,
|
||||
existingLayouts as any[],
|
||||
layoutSettings.gridSize
|
||||
)
|
||||
|
||||
// Специальная обработка для элементов с автоматической генерацией targetCells
|
||||
let targetCells = formData.targetCells || []
|
||||
if (
|
||||
formData.type === 'standards' ||
|
||||
formData.type === 'calibration-conditions'
|
||||
) {
|
||||
// Генерируем targetCells автоматически, если их нет
|
||||
if (!targetCells.length) {
|
||||
const elementDefinition = getElementDefinition(formData.type)
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig = elementDefinition.defaultConfig as any
|
||||
targetCells = elementDefinition.mapToCells?.(defaultConfig) || []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Для button-group не нужны targetCells, так как это UI элемент
|
||||
if (formData.type === 'button-group') {
|
||||
targetCells = []
|
||||
}
|
||||
|
||||
const newElement: TemplateElement = {
|
||||
id: Date.now().toString(),
|
||||
type: formData.type as ElementType,
|
||||
name: formData.label?.toLowerCase().replace(/\s+/g, '_') || '',
|
||||
label: formData.label,
|
||||
targetCells,
|
||||
placeholder: formData.placeholder,
|
||||
required: formData.required,
|
||||
options: formData.options,
|
||||
order: elements.length,
|
||||
layout,
|
||||
}
|
||||
|
||||
onElementAdd(newElement)
|
||||
resetForm()
|
||||
setIsAddDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleEditElement = (element: TemplateElement) => {
|
||||
setEditingElement(element)
|
||||
setFormData(element)
|
||||
}
|
||||
|
||||
const handleUpdateElement = () => {
|
||||
if (!editingElement || !formData.label) return
|
||||
|
||||
// Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны
|
||||
if (
|
||||
formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
formData.type !== 'button-group' &&
|
||||
!formData.targetCells?.length
|
||||
)
|
||||
return
|
||||
|
||||
const updatedData = {
|
||||
...formData,
|
||||
name:
|
||||
formData.label?.toLowerCase().replace(/\s+/g, '_') ||
|
||||
formData.name ||
|
||||
'',
|
||||
}
|
||||
|
||||
onElementUpdate(editingElement.id, updatedData)
|
||||
setEditingElement(null)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingElement(null)
|
||||
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)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// const reordered = (reorderedElements: TemplateElement[]) => {
|
||||
// onElementsReorder(reorderedElements);
|
||||
// onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
|
||||
// };
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-1 flex-col bg-gray-50">
|
||||
<div className="flex items-center justify-between border-b bg-white p-2">
|
||||
<div className="px-2">
|
||||
<h3 className="text-sm font-medium">Холст</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Перетаскивайте элементы для настройки их расположения
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={disabled}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Добавить элемент
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Добавить новый элемент</DialogTitle>
|
||||
<DialogDescription>
|
||||
Настройте параметры нового элемента формы.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ElementForm formData={formData} setFormData={setFormData} />
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
onClick={() => setIsAddDialogOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddElement}
|
||||
disabled={
|
||||
disabled ||
|
||||
!formData.label ||
|
||||
(formData.type !== 'standards' &&
|
||||
formData.type !== 'calibration-conditions' &&
|
||||
formData.type !== 'button-group' &&
|
||||
!formData.targetCells?.length)
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Добавить элемент
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{editingElement && (
|
||||
<Dialog
|
||||
open={!!editingElement}
|
||||
onOpenChange={() => handleCancelEdit()}
|
||||
>
|
||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Редактировать элемент</DialogTitle>
|
||||
<DialogDescription>
|
||||
Измените параметры элемента формы.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ElementForm formData={formData} setFormData={setFormData} />
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button onClick={handleCancelEdit} variant="outline">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleUpdateElement} disabled={disabled}>
|
||||
Сохранить изменения
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<VisualLayoutEditor
|
||||
elements={elements}
|
||||
layoutSettings={layoutSettings}
|
||||
onElementUpdate={onElementUpdate}
|
||||
onElementDelete={handleDeleteAndSelect}
|
||||
onLayoutSettingsChange={() => {}} // Настройки макета только для чтения
|
||||
onElementEdit={handleEditElement}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/component/TemplateManager/ExcelUploadPanel.tsx
Normal file
64
src/component/TemplateManager/ExcelUploadPanel.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileText, Upload } from 'lucide-react'
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
interface ExcelUploadPanelProps {
|
||||
fileName?: string
|
||||
isLoading: boolean
|
||||
onUploadClick: () => void
|
||||
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export const ExcelUploadPanel: React.FC<ExcelUploadPanelProps> = ({
|
||||
fileName,
|
||||
isLoading,
|
||||
onUploadClick,
|
||||
onFileChange,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={onFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 ${
|
||||
fileName
|
||||
? 'border-emerald-200 bg-emerald-50'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<FileText
|
||||
className={`h-3 w-3 ${fileName ? 'text-emerald-600' : 'text-gray-400'}`}
|
||||
/>
|
||||
<span
|
||||
className={`max-w-32 truncate text-xs font-medium ${
|
||||
fileName ? 'text-emerald-700' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{fileName || 'Файл не выбран'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="h-7 rounded-md px-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Upload className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
src/component/TemplateManager/FormattingToolbar.tsx
Normal file
61
src/component/TemplateManager/FormattingToolbar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Grid,
|
||||
Italic,
|
||||
Palette,
|
||||
Type,
|
||||
Underline,
|
||||
} from 'lucide-react'
|
||||
import React from 'react'
|
||||
|
||||
export const FormattingToolbar: React.FC = () => (
|
||||
<div className="flex items-center gap-0.5 rounded-lg border bg-background p-0.5">
|
||||
<div className="flex items-center">
|
||||
{[Bold, Italic, Underline].map((Icon, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 rounded p-0"
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
|
||||
<div className="flex items-center">
|
||||
{[AlignLeft, AlignCenter, AlignRight].map((Icon, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 rounded p-0"
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-4" />
|
||||
|
||||
<div className="flex items-center">
|
||||
{[Palette, Type, Grid].map((Icon, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 rounded p-0"
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
54
src/component/TemplateManager/HeaderBar.tsx
Normal file
54
src/component/TemplateManager/HeaderBar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Template } from '@/type/template'
|
||||
import { ArrowLeft, FileText, Settings, Wrench } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface HeaderBarProps {
|
||||
template: Template
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const HeaderBar: React.FC<HeaderBarProps> = ({ template, onBack }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<h1 className="text-lg font-semibold">Редактор шаблонов</h1>
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{template.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/templates/${template.id}/elements`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
Элементы интерфейса
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/templates/${template.id}/protocols`)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Создать протокол
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/component/TemplateManager/SpreadsheetViewer.tsx
Normal file
26
src/component/TemplateManager/SpreadsheetViewer.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import DualSpreadsheet from '@/components/DualSpreadsheet/DualSpreadsheet'
|
||||
import { ExcelParseResult } from '@/service/fileApiSevice'
|
||||
import React from 'react'
|
||||
|
||||
interface SpreadsheetViewerProps {
|
||||
data: Record<string, any>
|
||||
mergedCells: ExcelParseResult['mergedCells']
|
||||
dataVersion: number
|
||||
templateId: string | undefined
|
||||
}
|
||||
|
||||
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
|
||||
data,
|
||||
mergedCells,
|
||||
dataVersion,
|
||||
templateId,
|
||||
}) => (
|
||||
<div className="min-h-0 flex-1">
|
||||
<DualSpreadsheet
|
||||
key={dataVersion}
|
||||
templateData={{ L: data }}
|
||||
mergedCells={mergedCells}
|
||||
templateId={templateId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
77
src/component/TemplateManager/TemplateCard.tsx
Normal file
77
src/component/TemplateManager/TemplateCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { FileText, Settings, Wrench } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Template } from '../../type/template'
|
||||
import { Button } from '../ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: Template
|
||||
onDelete: (template: Template) => void
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
const TemplateCard: React.FC<TemplateCardProps> = ({
|
||||
template,
|
||||
onDelete,
|
||||
isSelected = false,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Шаблон считается готовым, когда есть хотя бы один элемент и загружена
|
||||
// Excel-таблица (что подтверждается наличием mergedCells).
|
||||
const isConfigured =
|
||||
template.elements.length > 0 && (template.mergedCells?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<Card className={isSelected ? 'border-2 border-blue-500' : ''}>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base font-medium">{template.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{template.description && (
|
||||
<p className="mb-3 line-clamp-2 text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-3 flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Создан: {new Date(template.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span>
|
||||
Обновлён: {new Date(template.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={!isConfigured}
|
||||
onClick={() => navigate(`/templates/${template.id}/protocols`)}
|
||||
>
|
||||
<FileText className="mr-2 h-3 w-3" />
|
||||
{isConfigured ? 'Создать протокол' : 'Требует настройки'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => navigate(`/templates/${template.id}/edit`)}
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => navigate(`/templates/${template.id}/elements`)}
|
||||
>
|
||||
<Wrench className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateCard
|
||||
137
src/component/TemplateManager/TemplateEditor.tsx
Normal file
137
src/component/TemplateManager/TemplateEditor.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useTemplateContext } from '@/context/TemplateContext'
|
||||
import {
|
||||
getFileData,
|
||||
getLatestFileForTemplate,
|
||||
parseExcelFile,
|
||||
} from '@/service/fileApiSevice'
|
||||
import { Template } from '@/type/template'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ExcelUploadPanel } from './ExcelUploadPanel'
|
||||
import { FormattingToolbar } from './FormattingToolbar'
|
||||
import { HeaderBar } from './HeaderBar'
|
||||
import { SpreadsheetViewer } from './SpreadsheetViewer'
|
||||
|
||||
interface TemplateEditorProps {
|
||||
template: Template
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
template,
|
||||
onBack,
|
||||
}) => {
|
||||
const [editedTemplate, setEditedTemplate] = useState(template)
|
||||
const [excelData, setExcelData] = useState<Record<string, any>>(
|
||||
template.excelData || {}
|
||||
)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [dataVersion, setDataVersion] = useState(0)
|
||||
const [serverFileName, setServerFileName] = useState<string | undefined>()
|
||||
|
||||
// Получаем функцию обновления шаблона из контекста
|
||||
const { updateTemplate } = useTemplateContext()
|
||||
|
||||
// При монтировании пытаемся загрузить последний файл для шаблона
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const loadLatestFile = async () => {
|
||||
try {
|
||||
const latestFile = await getLatestFileForTemplate(template.id)
|
||||
if (!latestFile) return
|
||||
|
||||
// Сохраняем имя файла для отображения
|
||||
if (isMounted) {
|
||||
setServerFileName(latestFile.name)
|
||||
}
|
||||
|
||||
// Если у нас еще нет данных Excel – загружаем их
|
||||
if (
|
||||
Object.keys(excelData).length === 0 &&
|
||||
latestFile &&
|
||||
latestFile.id
|
||||
) {
|
||||
const fileData = await getFileData(latestFile.id)
|
||||
|
||||
if (isMounted && Object.keys(fileData.data).length > 0) {
|
||||
setExcelData(fileData.data)
|
||||
setEditedTemplate(prev => ({
|
||||
...prev,
|
||||
mergedCells: fileData.mergedCells,
|
||||
}))
|
||||
setDataVersion(v => v + 1)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке последнего файла шаблона:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadLatestFile()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { fileId, data, mergedCells } = await parseExcelFile(
|
||||
file,
|
||||
template.id
|
||||
)
|
||||
setExcelData(data)
|
||||
|
||||
const updatedTemplate = {
|
||||
...template,
|
||||
excelFile: file,
|
||||
excelData: data,
|
||||
mergedCells,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
setEditedTemplate(updatedTemplate)
|
||||
|
||||
// Сохраняем шаблон с новым Excel-файлом, чтобы в списке он больше не
|
||||
// помечался как «требует настройки»
|
||||
await updateTemplate({
|
||||
...updatedTemplate,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
setDataVersion(v => v + 1)
|
||||
|
||||
console.log('✅ Файл загружен с ID:', fileId)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Ошибка при загрузке Excel файла на сервер')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<HeaderBar template={editedTemplate} onBack={onBack} />
|
||||
<div className="flex-shrink-0 border-b bg-muted/30 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormattingToolbar />
|
||||
<ExcelUploadPanel
|
||||
fileName={serverFileName || editedTemplate.excelFile?.name}
|
||||
isLoading={isLoading}
|
||||
onUploadClick={() => {}}
|
||||
onFileChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SpreadsheetViewer
|
||||
data={excelData}
|
||||
mergedCells={editedTemplate.mergedCells || []}
|
||||
dataVersion={dataVersion}
|
||||
templateId={editedTemplate.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
390
src/component/TemplateManager/TemplateManager.tsx
Normal file
390
src/component/TemplateManager/TemplateManager.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
import { Circle, CircleCheck, FileText, Plus, Trash2 } from 'lucide-react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTemplateContext } from '../../context/TemplateContext'
|
||||
import { Template } from '../../type/template'
|
||||
import { Button } from '../ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../ui/dialog'
|
||||
import { Input } from '../ui/input'
|
||||
import TemplateCard from './TemplateCard'
|
||||
import { TemplateEditor } from './TemplateEditor'
|
||||
|
||||
interface TemplateManagerProps {
|
||||
onTemplateSelect?: (template: Template) => void
|
||||
templateId?: string
|
||||
}
|
||||
|
||||
export const TemplateManager: React.FC<TemplateManagerProps> = ({
|
||||
templateId,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
templates,
|
||||
isLoading,
|
||||
error,
|
||||
addTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
loadTemplates,
|
||||
} = useTemplateContext()
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||
null
|
||||
)
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isEditMode, setIsEditMode] = useState(false)
|
||||
const [newTemplateName, setNewTemplateName] = useState('')
|
||||
const [newTemplateDescription, setNewTemplateDescription] = useState('')
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(
|
||||
new Set()
|
||||
)
|
||||
const [templatesToDelete, setTemplatesToDelete] = useState<Template[]>([])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
// Автоматически открываем редактирование шаблона, если передан templateId
|
||||
useEffect(() => {
|
||||
if (templateId) {
|
||||
const template = templates.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
setSelectedTemplate(template)
|
||||
setIsEditMode(true)
|
||||
}
|
||||
}
|
||||
}, [templateId, templates])
|
||||
|
||||
const handleCreateTemplate = async () => {
|
||||
if (!newTemplateName.trim()) return
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
await addTemplate({
|
||||
name: newTemplateName.trim(),
|
||||
description: newTemplateDescription.trim(),
|
||||
elements: [],
|
||||
layoutSettings: undefined,
|
||||
})
|
||||
|
||||
// Находим созданный шаблон и открываем его для редактирования
|
||||
// Поскольку addTemplate может занять время, используем небольшую задержку
|
||||
setTimeout(() => {
|
||||
const newTemplate = templates.find(
|
||||
t => t.name === newTemplateName.trim()
|
||||
)
|
||||
if (newTemplate) {
|
||||
setSelectedTemplate(newTemplate)
|
||||
setIsEditMode(true)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
setIsCreateDialogOpen(false)
|
||||
setNewTemplateName('')
|
||||
setNewTemplateDescription('')
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании шаблона:', error)
|
||||
// Здесь можно добавить уведомление пользователю об ошибке
|
||||
alert('Ошибка при создании шаблона. Попробуйте еще раз.')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplateUpdate = async (updatedTemplate: Template) => {
|
||||
try {
|
||||
await updateTemplate(updatedTemplate)
|
||||
setSelectedTemplate(updatedTemplate)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении шаблона:', error)
|
||||
alert('Ошибка при обновлении шаблона. Попробуйте еще раз.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTemplates = () => {
|
||||
templatesToDelete.forEach(template => {
|
||||
deleteTemplate(template.id)
|
||||
})
|
||||
setTemplatesToDelete([])
|
||||
setIsSelectionMode(false)
|
||||
setSelectedTemplates(new Set())
|
||||
}
|
||||
|
||||
const toggleTemplateSelection = (templateId: string) => {
|
||||
const newSelected = new Set(selectedTemplates)
|
||||
if (newSelected.has(templateId)) {
|
||||
newSelected.delete(templateId)
|
||||
} else {
|
||||
newSelected.add(templateId)
|
||||
}
|
||||
setSelectedTemplates(newSelected)
|
||||
}
|
||||
|
||||
const handleSelectionModeToggle = () => {
|
||||
if (isSelectionMode) {
|
||||
setIsSelectionMode(false)
|
||||
setSelectedTemplates(new Set())
|
||||
setTemplatesToDelete([])
|
||||
} else {
|
||||
setIsSelectionMode(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteSelected = () => {
|
||||
const selectedTemplatesList = templates.filter(t =>
|
||||
selectedTemplates.has(t.id)
|
||||
)
|
||||
setTemplatesToDelete(selectedTemplatesList)
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
if (isEditMode && selectedTemplate) {
|
||||
return (
|
||||
<TemplateEditor
|
||||
template={selectedTemplate}
|
||||
onBack={() => {
|
||||
setIsEditMode(false)
|
||||
setSelectedTemplate(null)
|
||||
if (templateId) {
|
||||
navigate('/templates')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Показываем ошибку, если есть
|
||||
if (error && !isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16 text-red-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Ошибка загрузки шаблонов
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-600">{error}</p>
|
||||
<Button onClick={handleRetry}>Попробовать снова</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Шаблоны протоколов</h1>
|
||||
<div className="flex gap-3">
|
||||
{isSelectionMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSelectionModeToggle}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Отменить выбор
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={selectedTemplates.size === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Удалить выбранные ({selectedTemplates.size})
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSelectionModeToggle}
|
||||
disabled={templates.length === 0}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Circle className="h-4 w-4" />
|
||||
Выбрать
|
||||
</Button>
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Создать шаблон
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Создать новый шаблон</DialogTitle>
|
||||
<DialogDescription>
|
||||
Введите основную информацию для нового шаблона протокола
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Название шаблона
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Например: Протокол испытаний"
|
||||
value={newTemplateName}
|
||||
onChange={e => setNewTemplateName(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateTemplate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Описание (необязательно)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Краткое описание назначения шаблона"
|
||||
value={newTemplateDescription}
|
||||
onChange={e =>
|
||||
setNewTemplateDescription(e.target.value)
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateTemplate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateTemplate}
|
||||
disabled={!newTemplateName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? 'Создание...' : 'Создать'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Показываем индикатор загрузки */}
|
||||
{isLoading && (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
|
||||
<p className="text-gray-600">Загрузка шаблонов...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список шаблонов */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<FileText className="mx-auto mb-4 h-16 w-16 text-gray-400" />
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">
|
||||
Нет шаблонов
|
||||
</h3>
|
||||
<p className="mb-4 text-gray-600">
|
||||
Создайте первый шаблон для начала работы
|
||||
</p>
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать шаблон
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map(template => (
|
||||
<div key={template.id} className="relative">
|
||||
{isSelectionMode && (
|
||||
<div className="absolute right-2 top-2 z-10">
|
||||
<button
|
||||
onClick={() => toggleTemplateSelection(template.id)}
|
||||
className="rounded-full bg-white p-1 shadow-md"
|
||||
>
|
||||
{selectedTemplates.has(template.id) ? (
|
||||
<CircleCheck className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<Circle className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<TemplateCard
|
||||
template={template}
|
||||
onDelete={() => deleteTemplate(template.id)}
|
||||
isSelected={selectedTemplates.has(template.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Диалог подтверждения удаления */}
|
||||
{templatesToDelete.length > 0 && (
|
||||
<Dialog
|
||||
open={templatesToDelete.length > 0}
|
||||
onOpenChange={() => setTemplatesToDelete([])}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Подтверждение удаления</DialogTitle>
|
||||
<DialogDescription>
|
||||
Вы действительно хотите удалить выбранные шаблоны? Это действие
|
||||
нельзя отменить.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{templatesToDelete.map(template => (
|
||||
<div key={template.id} className="text-sm text-gray-600">
|
||||
• {template.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTemplatesToDelete([])}
|
||||
>
|
||||
Отменить
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteTemplates}>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
515
src/component/TemplateManager/VisualLayoutEditor.tsx
Normal file
515
src/component/TemplateManager/VisualLayoutEditor.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
import {
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Droplets,
|
||||
Edit,
|
||||
Edit3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Gauge,
|
||||
Grid,
|
||||
Move,
|
||||
Radio,
|
||||
Thermometer,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { Rnd } from 'react-rnd'
|
||||
import {
|
||||
ElementLayout,
|
||||
FormLayoutSettings,
|
||||
TemplateElement,
|
||||
} from '../../type/template'
|
||||
import { Button } from '../ui/button'
|
||||
import { Input } from '../ui/input'
|
||||
import { Textarea } from '../ui/textarea'
|
||||
|
||||
// Минимальные размеры для разных типов элементов.
|
||||
// Если понадобится скорректировать размеры для конкретного элемента,
|
||||
// достаточно изменить эту таблицу.
|
||||
const ELEMENT_MIN_SIZE: Record<string, { width: number; height: number }> = {
|
||||
text: { width: 120, height: 40 },
|
||||
textarea: { width: 160, height: 80 },
|
||||
number: { width: 120, height: 40 },
|
||||
date: { width: 140, height: 40 },
|
||||
select: { width: 140, height: 40 },
|
||||
radio: { width: 160, height: 80 },
|
||||
checkbox: { width: 140, height: 40 },
|
||||
standards: { width: 200, height: 50 },
|
||||
'calibration-conditions': { width: 250, height: 60 },
|
||||
'button-group': { width: 180, height: 50 },
|
||||
}
|
||||
|
||||
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 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]
|
||||
)
|
||||
|
||||
/*
|
||||
* Render a lightweight, disabled preview of the element so that the user can
|
||||
* see how the real control looks directly on the canvas instead of a plain
|
||||
* colored square. Keeping the elements disabled and wrapped with
|
||||
* `pointer-events-none` guarantees that they do not interfere with the drag
|
||||
* behaviour of Rnd and keeps rendering costs minimal.
|
||||
*/
|
||||
const previewControl = useMemo(() => {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={element.placeholder || 'Введите текст'}
|
||||
className="pointer-events-none w-full bg-white"
|
||||
/>
|
||||
)
|
||||
case 'textarea':
|
||||
return (
|
||||
<Textarea
|
||||
readOnly
|
||||
rows={3}
|
||||
placeholder={element.placeholder || 'Введите текст'}
|
||||
className="pointer-events-none w-full resize-none bg-white"
|
||||
/>
|
||||
)
|
||||
case 'number':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
readOnly
|
||||
placeholder={element.placeholder || '0'}
|
||||
className="pointer-events-none w-full bg-white"
|
||||
/>
|
||||
)
|
||||
case 'date':
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
readOnly
|
||||
className="pointer-events-none w-full bg-white"
|
||||
/>
|
||||
)
|
||||
case 'select':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{element.placeholder || 'Выберите значение'}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'radio':
|
||||
return (
|
||||
<div className="pointer-events-none space-y-2">
|
||||
{(element.options?.length
|
||||
? element.options
|
||||
: [{ label: 'Вариант 1' }]
|
||||
).map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded-full border-2 border-gray-300 bg-white" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{opt.label || `Вариант ${idx + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="pointer-events-none flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded border-2 border-gray-300 bg-white" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{element.placeholder || element.label || 'Отметить'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
case 'standards':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{element.placeholder || 'Выберите эталоны'}
|
||||
</span>
|
||||
<Calendar className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'calibration-conditions':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<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-1">
|
||||
<Thermometer className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">20°C</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Droplets className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">65%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Gauge className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">101.3кПа</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">220В</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Radio className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-foreground">50Гц</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 p-0">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'button-group':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(element.options?.length
|
||||
? element.options
|
||||
: [{ label: 'Кнопка 1' }, { label: 'Кнопка 2' }]
|
||||
).map((opt, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-all duration-200"
|
||||
>
|
||||
{opt.label || `Кнопка ${idx + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [element])
|
||||
|
||||
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={ELEMENT_MIN_SIZE[element.type]?.width ?? 120}
|
||||
minHeight={ELEMENT_MIN_SIZE[element.type]?.height ?? 32}
|
||||
bounds="parent"
|
||||
className={`group ${isSelected ? 'z-20 ring-2 ring-blue-500' : 'z-10'}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div
|
||||
className={`h-full cursor-move ${
|
||||
isSelected
|
||||
? 'rounded-md bg-blue-50/10 p-1'
|
||||
: 'rounded-md p-1 hover:bg-gray-50/10'
|
||||
}`}
|
||||
>
|
||||
{/* Label and controls (only visible when selected or hovered) */}
|
||||
<div
|
||||
className={`mb-1 flex items-center justify-between ${
|
||||
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-medium text-gray-600">
|
||||
{element.label || element.name || `${element.type} элемент`}
|
||||
</span>
|
||||
{element.required && (
|
||||
<span className="-mt-1.5 inline-block h-1 w-1 rounded-full bg-red-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Индикатор ячеек */}
|
||||
{element.targetCells && element.targetCells.length > 0 && (
|
||||
<div className="group/cells relative">
|
||||
<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>
|
||||
{element.targetCells.map((cell, i) => (
|
||||
<div key={i} className="font-mono">
|
||||
{cell.sheet}!{cell.cell}
|
||||
{cell.displayName && (
|
||||
<span className="ml-1 text-gray-300">
|
||||
({cell.displayName})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 cursor-pointer hover:bg-blue-100"
|
||||
onClick={handleEditClick}
|
||||
>
|
||||
<Edit className="h-2.5 w-2.5 text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 cursor-pointer hover:bg-red-100"
|
||||
onClick={handleDeleteClick}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real element preview */}
|
||||
{previewControl && (
|
||||
<div className="flex-1 space-y-1">
|
||||
{element.label && (
|
||||
<label className="pointer-events-none block text-sm font-medium text-gray-900">
|
||||
{element.label}
|
||||
</label>
|
||||
)}
|
||||
{previewControl}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional info (only visible on selection to preserve visual clarity & perf) */}
|
||||
{isSelected && (
|
||||
<div className="mt-1 space-y-1 text-xs opacity-75">
|
||||
{/* Здесь можно добавить дополнительную информацию при необходимости */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Rnd>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
||||
elements,
|
||||
layoutSettings,
|
||||
onElementUpdate,
|
||||
onElementDelete,
|
||||
onLayoutSettingsChange,
|
||||
onElementEdit,
|
||||
}) => {
|
||||
console.log('VisualLayoutEditor render with elements:', elements)
|
||||
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="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, #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 h-full flex-col bg-gray-50">
|
||||
<div className="flex shrink-0 items-center justify-between border-b bg-white p-2">
|
||||
<span className="px-2 text-sm text-gray-600">
|
||||
{elements.length} элементов на холсте
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={toggleGrid}>
|
||||
{layoutSettings.showGrid ? (
|
||||
<EyeOff className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{layoutSettings.showGrid ? 'Скрыть сетку' : 'Показать сетку'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex-1 overflow-auto"
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<div
|
||||
className="relative mx-auto my-8 bg-white shadow-lg"
|
||||
style={{ width: canvasSize.width, height: canvasSize.height }}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
{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="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400">
|
||||
<Move className="mx-auto mb-4 h-16 w-16" />
|
||||
<p className="mb-2 text-xl font-medium">Холст пуст</p>
|
||||
<p className="text-sm">Добавьте свой первый элемент</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user