переименования папок

This commit is contained in:
2025-07-21 15:02:10 +03:00
parent d0c79bd334
commit 84f0c9c6aa
72 changed files with 245 additions and 249 deletions

View 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>
)
}

View 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>
)
}

View 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>
)

View 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>
)
}

View 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>
)

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}