Files
protoc-frontend/src/component/TemplateManager/ElementConstructor.tsx

720 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
getAvailableElementTypes,
getElementDefinition,
} from '@/entitiy/element/model/interface'
import { useToast } from '@/lib/hooks/useToast'
import { generateDefaultLayout } from '@/lib/utils'
import {
CellTarget,
ElementType,
FormLayoutSettings,
Template,
TemplateElement,
} from '@/type/template'
import {
AlertTriangle,
Eye,
HelpCircle,
Plus,
Settings2,
Tag,
Target,
Trash2,
Type,
} from 'lucide-react'
import React, { useState } from 'react'
import { Button } from '../ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'
import { Checkbox } from '../ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '../ui/dialog'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select'
import { Separator } from '../ui/separator'
import { ElementFormulaBar } from './ElementFormulaBar'
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 { warning } = useToast()
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))
}
const handleCellInputChange = (index: number, value: string) => {
// Проверяем на наличие кириллицы
const hasCyrillic = /[а-яё]/i.test(value)
if (hasCyrillic) {
warning('Кириллические символы не разрешены в адресе ячейки', {
title: 'Недопустимый символ',
duration: 3000,
})
return // Не обновляем значение
}
handleUpdateTarget(index, 'cell', value)
}
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Target className="h-4 w-4 text-blue-600" />
<CardTitle className="text-sm">Целевые ячейки</CardTitle>
</div>
<Button
variant="outline"
size="sm"
onClick={handleAddTarget}
className="h-6 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
Добавить
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="max-h-48 space-y-2 overflow-y-auto">
{targets.map((target, index) => (
<div
key={index}
className="flex items-center gap-2 rounded-lg border bg-gray-50/50 p-2"
>
{/* Кнопки выбора листа */}
<div className="flex gap-1">
<Button
variant={target.sheet === 'L' ? 'default' : 'outline'}
size="sm"
onClick={() => handleUpdateTarget(index, 'sheet', 'L')}
className="h-6 w-7 p-0 text-xs"
>
L
</Button>
<Button
variant={target.sheet === 'R' ? 'default' : 'outline'}
size="sm"
onClick={() => handleUpdateTarget(index, 'sheet', 'R')}
className="h-6 w-7 p-0 text-xs"
>
R
</Button>
</div>
{/* Поле ввода ячейки */}
<Input
placeholder="A12"
value={target.cell}
onChange={e => handleCellInputChange(index, e.target.value)}
className="h-6 w-16 px-2 text-xs"
/>
{/* Кнопка удаления */}
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTarget(index)}
className="ml-auto h-6 w-6 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{targets.length === 0 && (
<div className="py-4 text-center">
<Target className="mx-auto mb-1 h-6 w-6 text-gray-300" />
<p className="text-xs text-gray-500">
Добавьте ячейки для связи с элементом
</p>
<p className="mt-1 text-xs text-gray-400">
Например: A1, B5, C10
</p>
</div>
)}
</div>
{targets.length > 0 && (
<div className="mt-2 flex items-center gap-1 text-xs text-gray-500">
<HelpCircle className="h-3 w-3" />
Всего ячеек: {targets.length}
</div>
)}
</CardContent>
</Card>
)
}
// Live Preview компонент
const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({
element,
}) => {
const elementDefinition = element.type
? getElementDefinition(element.type)
: null
return (
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Eye className="h-4 w-4 text-green-600" />
<CardTitle className="text-sm">Предпросмотр элемента</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="min-h-[200px] rounded-lg border border-gray-200 bg-white p-4">
{elementDefinition && elementDefinition.Preview ? (
<elementDefinition.Preview config={element as any} />
) : (
<div className="space-y-3">
{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>
)}
<div className="flex items-center justify-center gap-2 rounded border border-gray-200 bg-gray-50 p-3 text-center text-sm text-gray-500">
<AlertTriangle className="h-4 w-4" />
{element.type
? `Элемент типа "${element.type}" не найден`
: 'Выберите тип элемента'}
</div>
</div>
)}
</div>
</CardContent>
</Card>
)
}
const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
const elementDefinition = formData.type
? getElementDefinition(formData.type)
: null
return (
<div className="grid h-full grid-cols-1 gap-6 lg:grid-cols-2">
{/* Настройки элемента */}
<div className="max-h-[60vh] space-y-4 overflow-y-auto pr-2">
{/* Основные настройки */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-blue-600" />
<CardTitle className="text-sm">Основные настройки</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-medium">
<Type className="h-3 w-3" />
Тип элемента
</Label>
<Select
value={formData.type}
onValueChange={value => {
const newType = value as ElementType
const elementDefinition = getElementDefinition(newType)
setFormData(prev => {
const updates: Partial<TemplateElement> = {
type: newType,
targetCells: [],
}
if (elementDefinition && elementDefinition.defaultConfig) {
Object.assign(updates, elementDefinition.defaultConfig)
}
return { ...prev, ...updates }
})
}}
>
<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-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-medium">
<Tag className="h-3 w-3" />
Подпись
</Label>
<Input
placeholder="Название поля"
value={formData.label}
onChange={e =>
setFormData(prev => ({ ...prev, label: e.target.value }))
}
className="h-9"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm font-medium">
<HelpCircle className="h-3 w-3" />
Подсказка
</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>
</CardContent>
</Card>
{/* Целевые ячейки */}
<CellTargetEditor
targets={formData.targetCells || []}
onChange={targets =>
setFormData(prev => ({ ...prev, targetCells: targets }))
}
/>
{/* Специфичный редактор элемента */}
{elementDefinition && elementDefinition.Editor && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-purple-600" />
<CardTitle className="text-sm">
Дополнительные настройки
</CardTitle>
</div>
</CardHeader>
<CardContent>
<elementDefinition.Editor
config={formData as any}
onChange={newConfig =>
setFormData(prev => ({ ...prev, ...newConfig }))
}
/>
</CardContent>
</Card>
)}
</div>
{/* Предпросмотр */}
<div className="h-fit lg:sticky lg:top-0">
<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
// Пропсы для сохранения
hasUnsavedChanges?: boolean
isSaving?: boolean
lastSaveError?: string | null
onManualSave?: () => Promise<void>
onClearError?: () => void
// Пропсы для настроек макета
onLayoutSettingsChange?: (settings: FormLayoutSettings) => void
}
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
template,
onElementAdd,
onElementUpdate,
onElementDelete,
disabled = false,
hasUnsavedChanges = false,
isSaving = false,
lastSaveError = null,
onManualSave,
onClearError,
onLayoutSettingsChange,
}) => {
const elements = template.elements
const layoutSettings = template.layoutSettings || {
gridSize: 20,
showGrid: true,
}
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingElement, setEditingElement] = useState<TemplateElement | null>(
null
)
// Функция для создания дефолтной конфигурации
const createDefaultFormData = (type: ElementType = 'text') => {
const elementDefinition = getElementDefinition(type)
const baseData = {
type,
name: '',
label: '',
targetCells: [],
placeholder: '',
required: false,
options: [],
}
// Применяем defaultConfig если он есть
if (elementDefinition && elementDefinition.defaultConfig) {
Object.assign(baseData, elementDefinition.defaultConfig)
}
return baseData
}
const [formData, setFormData] = useState<Partial<TemplateElement>>(
createDefaultFormData()
)
const resetForm = () => {
setFormData(createDefaultFormData())
}
const handleAddElement = () => {
if (!formData.label) {
return
}
// Получаем определение элемента
const elementDefinition = getElementDefinition(formData.type as ElementType)
if (!elementDefinition) {
console.error(`Element definition for ${formData.type} not found`)
return
}
// Генерируем layout для нового элемента
const defaultLayout = generateDefaultLayout(elements.length)
// Используем targetCells из формы или генерируем через mapToCells
let targetCells = formData.targetCells || []
if (!targetCells.length) {
targetCells = elementDefinition.mapToCells(formData as any) || []
}
const newElement: TemplateElement = {
...formData, // Копируем все поля из formData, включая специфичные для элемента
id: Date.now().toString(),
type: formData.type as ElementType,
name: formData.label || '',
label: formData.label,
targetCells,
placeholder: formData.placeholder,
required: formData.required,
options: formData.options,
order: elements.length,
layout: defaultLayout,
} as TemplateElement
onElementAdd(newElement)
resetForm()
setIsAddDialogOpen(false)
}
const handleEditElement = (element: TemplateElement) => {
setEditingElement(element)
setFormData(element)
}
const handleUpdateElement = () => {
if (!editingElement || !formData.label) return
// Получаем определение элемента
const elementDefinition = getElementDefinition(
editingElement.type as ElementType
)
if (!elementDefinition) {
console.error(`Element definition for ${editingElement.type} not found`)
return
}
// Используем targetCells из формы или генерируем через mapToCells
let targetCells = formData.targetCells || []
if (!targetCells.length) {
targetCells = elementDefinition.mapToCells(formData as any) || []
}
const updatedData = {
...formData,
name: formData.label || formData.name || '',
targetCells,
}
onElementUpdate(editingElement.id, updatedData)
setEditingElement(null)
resetForm()
}
const handleCancelEdit = () => {
setEditingElement(null)
resetForm()
}
const handleDeleteAndSelect = (elementId: string) => {
onElementDelete(elementId)
setEditingElement(null)
resetForm()
}
const handleElementCopy = (element: Omit<TemplateElement, 'id'>) => {
const newElement: TemplateElement = {
...element,
id: Date.now().toString(),
order: elements.length,
}
onElementAdd(newElement)
}
return (
<div className="flex h-full w-full flex-col bg-background">
<ElementFormulaBar
elementCount={elements.length}
onAddElement={() => setIsAddDialogOpen(true)}
disabled={disabled}
hasUnsavedChanges={hasUnsavedChanges}
isSaving={isSaving}
lastSaveError={lastSaveError}
onManualSave={onManualSave}
onClearError={onClearError}
showGrid={layoutSettings.showGrid}
onToggleGrid={
onLayoutSettingsChange
? () => {
const newSettings = {
...layoutSettings,
showGrid: !layoutSettings.showGrid,
}
onLayoutSettingsChange(newSettings)
}
: undefined
}
/>
<div className="flex-1 overflow-hidden">
<VisualLayoutEditor
elements={elements}
layoutSettings={layoutSettings}
onElementUpdate={onElementUpdate}
onElementDelete={handleDeleteAndSelect}
onLayoutSettingsChange={onLayoutSettingsChange || (() => {})}
onElementEdit={handleEditElement}
onElementAdd={handleElementCopy}
/>
</div>
{/* Диалог добавления элемента */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
<DialogHeader className="pb-4">
<DialogTitle className="flex items-center gap-2 text-lg">
<Plus className="h-5 w-5 text-blue-600" />
Добавить новый элемент
</DialogTitle>
<DialogDescription>
Создайте и настройте новый элемент формы. Вы можете увидеть
предпросмотр справа.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<ElementForm formData={formData} setFormData={setFormData} />
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between pt-2">
<div className="text-sm text-gray-500">
{!formData.label && (
<div className="flex items-center gap-1 text-amber-600">
<AlertTriangle className="h-3 w-3" />
Заполните обязательные поля
</div>
)}
</div>
<div className="flex gap-3">
<Button
onClick={() => setIsAddDialogOpen(false)}
variant="outline"
>
Отмена
</Button>
<Button
onClick={handleAddElement}
disabled={disabled || !formData.label}
className="min-w-[140px]"
>
<Plus className="mr-2 h-4 w-4" />
Добавить элемент
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Диалог редактирования элемента */}
{editingElement && (
<Dialog open={!!editingElement} onOpenChange={() => handleCancelEdit()}>
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
<DialogHeader className="pb-4">
<DialogTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5 text-green-600" />
Редактировать элемент
</DialogTitle>
<DialogDescription>
Измените параметры элемента "
{editingElement.label || editingElement.name}".
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<ElementForm formData={formData} setFormData={setFormData} />
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between pt-2">
<div className="text-sm text-gray-500">
{!formData.label && (
<div className="flex items-center gap-1 text-amber-600">
<AlertTriangle className="h-3 w-3" />
Заполните обязательные поля
</div>
)}
</div>
<div className="flex gap-3">
<Button onClick={handleCancelEdit} variant="outline">
Отмена
</Button>
<Button
onClick={handleUpdateElement}
disabled={disabled || !formData.label}
className="min-w-[160px]"
>
<Settings2 className="mr-2 h-4 w-4" />
Сохранить изменения
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}