From b77303fa9706eeff2e4a17ddc1f15bb2c2ff1f66 Mon Sep 17 00:00:00 2001 From: tlartem Date: Wed, 16 Jul 2025 15:00:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B1=D0=B5=D1=82=D0=B0=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 + .../TemplateManager/ElementConstructor.tsx | 722 +++++++++++++++--- src/contexts/TemplateContext.tsx | 23 +- src/lib/utils.ts | 68 +- src/pages/ElementsCreation/ui/Page/Page.tsx | 79 +- src/pages/ProtocolCreation/ui/Page/Page.tsx | 408 +++++++++- src/types/template.ts | 22 +- yarn.lock | 31 + 8 files changed, 1226 insertions(+), 130 deletions(-) diff --git a/package.json b/package.json index 658b00f..8a2025e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "format": "prettier --write ." }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", diff --git a/src/components/TemplateManager/ElementConstructor.tsx b/src/components/TemplateManager/ElementConstructor.tsx index 5845dff..a50a8ca 100644 --- a/src/components/TemplateManager/ElementConstructor.tsx +++ b/src/components/TemplateManager/ElementConstructor.tsx @@ -1,11 +1,15 @@ -import { Calendar, CheckSquare, ChevronDown, Edit, FileText, Hash, Plus, Trash2, Type } from 'lucide-react'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Calendar, CheckSquare, ChevronDown, Edit, FileText, GripVertical, Hash, LayoutGrid, Plus, Trash2, Type } from 'lucide-react'; import React, { useState } from 'react'; -import { ElementType, TemplateElement } from '../../types/template'; +import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template'; import { Button } from '../ui/button'; -import { Card, CardContent, CardHeader } from '../ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Checkbox } from '../ui/checkbox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Input } from '../ui/input'; +import { RadioGroup, RadioGroupItem } from '../ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; interface ElementFormProps { @@ -13,6 +17,12 @@ interface ElementFormProps { setFormData: React.Dispatch>>; } +interface SortableElementProps { + element: TemplateElement; + onEdit: (element: TemplateElement) => void; + onDelete: (elementId: string) => void; +} + const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.ReactNode }> = [ { type: 'text', label: 'Текстовое поле', icon: }, { type: 'textarea', label: 'Многострочный текст', icon: }, @@ -23,6 +33,179 @@ const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.React { type: 'date', label: 'Дата', icon: }, ]; +const SortableElement: React.FC = ({ element, onEdit, onDelete }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: element.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( + + +
+
+ +
+
+ {ELEMENT_TYPES.find(t => t.type === element.type)?.icon} +
+

{element.label}

+

{element.name}

+
+
+
+
+
+ {element.targetCells?.length || 0} ячеек +
+ + +
+
+ +
+
+ Тип: {ELEMENT_TYPES.find(t => t.type === element.type)?.label} +
+
+ Ячейки: +
+ {element.targetCells?.map((target, index) => ( + + {target.sheet}!{target.cell} + {target.displayName && ` (${target.displayName})`} + + )) || ( + Ячейки не настроены + )} +
+
+ {element.category && ( +
+ Категория: {element.category} +
+ )} +
+
+
+ ); +}; + +const CellTargetEditor: React.FC<{ + targets: CellTarget[]; + onChange: (targets: CellTarget[]) => void; +}> = ({ targets, onChange }) => { + const handleAddTarget = () => { + onChange([...targets, { sheet: 'L', cell: '', displayName: '' }]); + }; + + 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 ( +
+
+ + +
+
+ {targets.map((target, index) => ( +
+
+
+ + +
+
+ + handleUpdateTarget(index, 'cell', e.target.value)} + className="h-8" + /> +
+
+ + handleUpdateTarget(index, 'displayName', e.target.value)} + className="h-8" + /> +
+
+ +
+ ))} + {targets.length === 0 && ( +

+ Добавьте ячейки для связи с элементом +

+ )} +
+
+ ); +}; + const ElementForm: React.FC = ({ formData, setFormData }) => { const handleAddOption = () => { setFormData(prev => ({ @@ -48,7 +231,7 @@ const ElementForm: React.FC = ({ formData, setFormData }) => { const needsOptions = formData.type === 'select' || formData.type === 'radio'; return ( -
+
@@ -73,12 +256,20 @@ const ElementForm: React.FC = ({ formData, setFormData }) => {
- - setFormData(prev => ({ ...prev, targetCell: e.target.value }))} - /> + +
@@ -102,6 +293,20 @@ const ElementForm: React.FC = ({ formData, setFormData }) => {
+
+ + setFormData(prev => ({ ...prev, category: e.target.value }))} + /> +
+ + setFormData(prev => ({ ...prev, targetCells: targets }))} + /> +
= ({ formData, setFormData }) => { ); }; +interface LayoutSettingsProps { + settings: FormLayoutSettings; + onChange: (settings: FormLayoutSettings) => void; +} + +const LayoutSettings: React.FC = ({ settings, onChange }) => { + return ( +
+
+ + +
+ +
+ + onChange({ ...settings, grouping: value as FormLayoutSettings['grouping'] })} + > +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + +
+ +
+ onChange({ ...settings, showLabels: !!checked })} + /> + +
+ +
+ onChange({ ...settings, enableDragDrop: !!checked })} + /> + +
+
+ ); +}; + +interface FormPreviewProps { + elements: TemplateElement[]; + layoutSettings: FormLayoutSettings; +} + +const FormPreview: React.FC = ({ elements, layoutSettings }) => { + // Группировка элементов для предварительного просмотра + const groupedElements = () => { + const sortedElements = [...elements].sort((a, b) => (a.order || 0) - (b.order || 0)); + + if (layoutSettings.grouping === 'by-category') { + const groups: Record = {}; + sortedElements.forEach(element => { + const category = element.category || 'Без категории'; + if (!groups[category]) groups[category] = []; + groups[category].push(element); + }); + return Object.entries(groups); + } + + if (layoutSettings.grouping === 'by-type') { + const groups: Record = {}; + sortedElements.forEach(element => { + const type = element.type; + if (!groups[type]) groups[type] = []; + groups[type].push(element); + }); + return Object.entries(groups); + } + + return [['Все элементы', sortedElements]]; + }; + + const getSpacingClass = () => { + switch (layoutSettings.elementSpacing) { + case 'compact': + return 'gap-3'; + case 'spacious': + return 'gap-8'; + default: + return 'gap-6'; + } + }; + + const getGridCols = () => { + switch (layoutSettings.columns) { + case 1: + return 'grid-cols-1'; + case 3: + return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'; + case 4: + return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'; + default: + return 'grid-cols-1 md:grid-cols-2'; + } + }; + + const typeLabels: Record = { + text: 'Текстовые поля', + textarea: 'Многострочный текст', + number: 'Числовые поля', + date: 'Даты', + select: 'Выпадающие списки', + radio: 'Переключатели', + checkbox: 'Флажки', + }; + + const renderPreviewElement = (element: TemplateElement) => { + const getWidthClass = () => { + switch (element.width) { + case 'half': + return 'col-span-1'; + case 'full': + return 'col-span-full'; + default: + return 'col-span-1'; + } + }; + + return ( +
+ {layoutSettings.showLabels && ( + + )} +
+ {element.type === 'text' && 'Текстовое поле'} + {element.type === 'textarea' && 'Многострочный текст'} + {element.type === 'number' && 'Числовое поле'} + {element.type === 'date' && 'Выбор даты'} + {element.type === 'select' && `Выпадающий список (${element.options?.length || 0} вариантов)`} + {element.type === 'radio' && `Радиокнопки (${element.options?.length || 0} вариантов)`} + {element.type === 'checkbox' && 'Флажок'} +
+ {element.targetCells && element.targetCells.length > 0 && ( +
+ {element.targetCells.map((target, index) => ( + + {target.sheet}!{target.cell} + + ))} +
+ )} +
+ ); + }; + + return ( +
+ {elements.length === 0 ? ( +
+

Добавьте элементы для предварительного просмотра

+
+ ) : ( + groupedElements().map(([groupName, groupElements]) => ( + + + + {layoutSettings.grouping === 'by-type' + ? typeLabels[groupName as ElementType] || groupName + : groupName + } + + {layoutSettings.grouping !== 'none' && ( +

+ {groupElements.length} элемент{groupElements.length > 1 ? 'ов' : ''} +

+ )} +
+ +
+ {groupElements.map(renderPreviewElement)} +
+
+
+ )) + )} +
+ ); +}; + interface ElementConstructorProps { elements: TemplateElement[]; + layoutSettings: FormLayoutSettings; onElementAdd: (element: TemplateElement) => void; onElementUpdate: (elementId: string, updates: Partial) => void; onElementDelete: (elementId: string) => void; + onElementsReorder: (elements: TemplateElement[]) => void; + onLayoutSettingsChange: (settings: FormLayoutSettings) => void; } export const ElementConstructor: React.FC = ({ elements, + layoutSettings, onElementAdd, onElementUpdate, onElementDelete, + onElementsReorder, + onLayoutSettingsChange, }) => { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isLayoutDialogOpen, setIsLayoutDialogOpen] = useState(false); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [editingElement, setEditingElement] = useState(null); const [formData, setFormData] = useState>({ type: 'text', name: '', label: '', - targetCell: '', + targetCells: [], placeholder: '', required: false, options: [], + width: 'auto', + category: '', }); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + const resetForm = () => { setFormData({ type: 'text', name: '', label: '', - targetCell: '', + targetCells: [], placeholder: '', required: false, options: [], + width: 'auto', + category: '', }); }; const handleAddElement = () => { - if (!formData.name || !formData.label || !formData.targetCell) return; + if (!formData.name || !formData.label || !formData.targetCells?.length) return; const newElement: TemplateElement = { id: Date.now().toString(), type: formData.type as ElementType, name: formData.name, label: formData.label, - targetCell: formData.targetCell, + targetCells: formData.targetCells, placeholder: formData.placeholder, required: formData.required, options: formData.options, + order: elements.length, + category: formData.category, + width: formData.width, }; onElementAdd(newElement); @@ -218,7 +681,7 @@ export const ElementConstructor: React.FC = ({ }; const handleUpdateElement = () => { - if (!editingElement || !formData.name || !formData.label || !formData.targetCell) return; + if (!editingElement || !formData.name || !formData.label || !formData.targetCells?.length) return; onElementUpdate(editingElement.id, formData); setEditingElement(null); @@ -230,112 +693,157 @@ export const ElementConstructor: React.FC = ({ resetForm(); }; + const handleDragEnd = (event: any) => { + const { active, over } = event; + + if (active.id !== over.id) { + const oldIndex = elements.findIndex((item) => item.id === active.id); + const newIndex = elements.findIndex((item) => item.id === over.id); + + const reorderedElements = arrayMove(elements, oldIndex, newIndex); + onElementsReorder(reorderedElements); + } + }; + + // Сортировка элементов по порядку + const sortedElements = [...elements].sort((a, b) => (a.order || 0) - (b.order || 0)); + return ( -
-
-

Элементы формы

- - - - - - - Добавить элемент - - Создайте новый элемент формы для связи с ячейкой Excel - - - -
- - -
-
-
+ + + + Предпросмотр формы создания протокола + + Так будет выглядеть форма для пользователей при создании протокола + + + +
+ +
+
+ + + + + + + + + Настройки отображения элементов + + Настройте внешний вид формы создания протокола + + + +
+ +
+
+
+ + + + + + + + Добавить элемент + + Создайте новый элемент формы с привязкой к ячейкам Excel + + + +
+ + +
+
+
+
-
+
{elements.length === 0 ? ( -
-
- +
+
+
-

Нет элементов интерфейса

-

- Создайте элементы формы для связи с ячейками Excel +

Нет элементов интерфейса

+

+ Создайте элементы формы для связи с ячейками Excel. Элементы будут отображаться + пользователям при создании протокола.

) : ( - elements.map(element => ( - - -
- {ELEMENT_TYPES.find(t => t.type === element.type)?.icon} -

{element.label}

-
-
- !isOpen && handleCancelEdit()} - > - - - - - - Редактировать элемент - - Измените настройки элемента формы - - - -
- - -
-
-
- - -
-
- -
-

- Тип: {ELEMENT_TYPES.find(t => t.type === element.type)?.label} -

-

- Имя: {element.name} -

-

- Ячейка: {element.targetCell} -

-
-
-
- )) + + e.id)} strategy={verticalListSortingStrategy}> +
+ {sortedElements.map(element => ( + + ))} +
+
+
)}
+ + {/* Dialog для редактирования элемента */} + !isOpen && handleCancelEdit()} + > + + + Редактировать элемент + + Измените настройки элемента формы + + + +
+ + +
+
+
); }; \ No newline at end of file diff --git a/src/contexts/TemplateContext.tsx b/src/contexts/TemplateContext.tsx index a9a56cf..9bf0f97 100644 --- a/src/contexts/TemplateContext.tsx +++ b/src/contexts/TemplateContext.tsx @@ -1,4 +1,5 @@ import React, { createContext, ReactNode, useContext, useState } from 'react'; +import { getDefaultLayoutSettings, migrateTemplateElement } from '../lib/utils'; import { Template } from '../types/template'; interface TemplateContextType { @@ -23,16 +24,27 @@ interface TemplateProviderProps { children: ReactNode; } +// Функция миграции шаблона +function migrateTemplate(template: any): Template { + return { + ...template, + elements: template.elements?.map(migrateTemplateElement) || [], + layoutSettings: template.layoutSettings || getDefaultLayoutSettings(), + }; +} + export const TemplateProvider: React.FC = ({ children }) => { const [templates, setTemplates] = useState([]); const addTemplate = (template: Template) => { - setTemplates(prev => [...prev, template]); + const migratedTemplate = migrateTemplate(template); + setTemplates(prev => [...prev, migratedTemplate]); }; const updateTemplate = (updatedTemplate: Template) => { + const migratedTemplate = migrateTemplate(updatedTemplate); setTemplates(prev => prev.map(t => - t.id === updatedTemplate.id ? updatedTemplate : t + t.id === migratedTemplate.id ? migratedTemplate : t )); }; @@ -41,16 +53,17 @@ export const TemplateProvider: React.FC = ({ children }) }; const getTemplate = (templateId: string) => { - return templates.find(t => t.id === templateId); + const template = templates.find(t => t.id === templateId); + return template ? migrateTemplate(template) : undefined; }; return ( {children} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f7654e8..94379d6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,70 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; +import { CellTarget, FormLayoutSettings, TemplateElement } from "../types/template"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) +} + +// Миграция элементов из старого формата в новый +export function migrateTemplateElement(element: any): TemplateElement { + // Если элемент уже в новом формате + if (element.targetCells && Array.isArray(element.targetCells)) { + return element as TemplateElement; + } + + // Миграция из старого формата + const targetCells: CellTarget[] = []; + + if (element.targetCell) { + // Парсим старый формат targetCell (например "A1", "B5") + const cellMatch = element.targetCell.match(/^([A-Z]+)(\d+)$/); + if (cellMatch) { + targetCells.push({ + sheet: 'Report', // По умолчанию используем Report + cell: element.targetCell, + displayName: `Ячейка ${element.targetCell}`, + }); + } + } + + return { + ...element, + targetCells, + order: element.order || 0, + category: element.category || '', + width: element.width || 'auto', + } as TemplateElement; +} + +// Получение настроек отображения по умолчанию +export function getDefaultLayoutSettings(): FormLayoutSettings { + return { + columns: 2, + grouping: 'none', + elementSpacing: 'normal', + showLabels: true, + enableDragDrop: true, + }; +} + +// Валидация адреса ячейки +export function validateCellAddress(cell: string): boolean { + return /^[A-Z]+\d+$/.test(cell); +} + +// Парсинг адреса ячейки в компоненты +export function parseCellAddress(cell: string): { column: string; row: number } | null { + const match = cell.match(/^([A-Z]+)(\d+)$/); + if (!match) return null; + + return { + column: match[1], + row: parseInt(match[2], 10), + }; +} + +// Генерация уникального ID для элемента +export function generateElementId(): string { + return `element_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } \ No newline at end of file diff --git a/src/pages/ElementsCreation/ui/Page/Page.tsx b/src/pages/ElementsCreation/ui/Page/Page.tsx index f725329..5538574 100644 --- a/src/pages/ElementsCreation/ui/Page/Page.tsx +++ b/src/pages/ElementsCreation/ui/Page/Page.tsx @@ -5,13 +5,12 @@ import { ElementConstructor } from '../../../../components/TemplateManager/Eleme import { Button } from '../../../../components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card'; import { useTemplateContext } from '../../../../contexts/TemplateContext'; -import { Template, TemplateElement } from '../../../../types/template'; +import { FormLayoutSettings, Template, TemplateElement } from '../../../../types/template'; export const ElementsCreation: FC = () => { const navigate = useNavigate(); const { templates, updateTemplate } = useTemplateContext(); const [selectedTemplate, setSelectedTemplate] = useState