From 7cf319f25dc2c5156b877a53440f4cfb793a49f1 Mon Sep 17 00:00:00 2001 From: tlartem Date: Fri, 18 Jul 2025 07:45:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2,=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 166 +++++++++++++ package.json | 1 + src/app/main.tsx | 10 +- .../BasicElements/CheckboxElement.tsx | 70 ++++++ src/components/BasicElements/DateElement.tsx | 53 ++++ .../BasicElements/NumberElement.tsx | 65 +++++ src/components/BasicElements/RadioElement.tsx | 140 +++++++++++ .../BasicElements/SelectElement.tsx | 131 ++++++++++ src/components/BasicElements/TextElement.tsx | 63 +++++ .../BasicElements/TextareaElement.tsx | 67 +++++ src/components/BasicElements/index.ts | 7 + .../DualSpreadsheet/DualSpreadsheet.tsx | 8 +- .../StandardsElement/StandardsDialog.tsx | 181 ++++++++++++++ .../StandardsElement/StandardsEditor.tsx | 219 +++++++++++++++++ .../StandardsElement/StandardsPreview.tsx | 64 +++++ .../StandardsElement/StandardsSelector.tsx | 228 ++++++++++++++++++ .../StandardsElement/definition.tsx | 56 +++++ .../TemplateManager/ElementConstructor.tsx | 211 ++++++++++++++-- .../TemplateManager/TemplateManager.tsx | 4 +- .../TemplateManager/VisualLayoutEditor.tsx | 58 +++-- src/components/ui/badge.tsx | 14 +- src/components/ui/card.tsx | 16 +- src/components/ui/label.tsx | 24 ++ src/components/ui/separator.tsx | 29 +++ src/contexts/TemplateContext.tsx | 8 +- src/hooks/useMultiSheetEngine.ts | 66 ++--- src/hooks/useSpreadsheetEngine.ts | 12 +- src/lib/element-registry-init.ts | 26 ++ src/lib/element-registry.ts | 42 ++++ .../spreadsheet-engine/spreadsheet-engine.ts | 2 +- src/lib/utils.ts | 2 - src/pages/ElementsCreation/ui/Page/Page.tsx | 10 +- src/pages/Home/ui/Page/Page.tsx | 6 +- src/pages/ProtocolCreation/ui/Page/Page.tsx | 156 +++++++++++- src/types/template.ts | 1 + yarn.lock | 7 + 36 files changed, 2100 insertions(+), 123 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 src/components/BasicElements/CheckboxElement.tsx create mode 100644 src/components/BasicElements/DateElement.tsx create mode 100644 src/components/BasicElements/NumberElement.tsx create mode 100644 src/components/BasicElements/RadioElement.tsx create mode 100644 src/components/BasicElements/SelectElement.tsx create mode 100644 src/components/BasicElements/TextElement.tsx create mode 100644 src/components/BasicElements/TextareaElement.tsx create mode 100644 src/components/BasicElements/index.ts create mode 100644 src/components/StandardsElement/StandardsDialog.tsx create mode 100644 src/components/StandardsElement/StandardsEditor.tsx create mode 100644 src/components/StandardsElement/StandardsPreview.tsx create mode 100644 src/components/StandardsElement/StandardsSelector.tsx create mode 100644 src/components/StandardsElement/definition.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/lib/element-registry-init.ts create mode 100644 src/lib/element-registry.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..797402c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,166 @@ +# Архитектура системы элементов + +## Обзор + +Система была переработана для обеспечения универсальности и расширяемости. Теперь все элементы регистрируются в едином реестре, что позволяет легко добавлять новые типы элементов без изменения основного кода. + +## Структура + +### 1. Реестр элементов (`src/lib/element-registry.ts`) + +Центральный реестр, который содержит все определения элементов: + +```typescript +export interface ElementDefinition { + type: ElementType; // Уникальный тип элемента + label: string; // Человекочитаемое имя + icon: ReactNode; // Иконка для UI + + Editor: React.FC<{ config: Config; onChange(c: Config): void }>; + Preview: React.FC<{ config: Config }>; + Render: React.FC<{ config: Config; value: Value; onChange?(v: Value): void }>; + + defaultConfig: Config; // Конфигурация по умолчанию + mapToCells?(config: Config): CellTarget[]; // Маппинг на ячейки Excel +} +``` + +### 2. Базовые элементы (`src/components/BasicElements/`) + +Стандартные элементы формы: +- `TextElement.tsx` - текстовое поле +- `SelectElement.tsx` - выпадающий список +- `NumberElement.tsx` - числовое поле +- `DateElement.tsx` - поле даты +- `TextareaElement.tsx` - многострочный текст +- `CheckboxElement.tsx` - чекбокс +- `RadioElement.tsx` - радиокнопки + +### 3. Специальные элементы (`src/components/StandardsElement/`) + +Сложные элементы с собственной логикой: +- `StandardsDialog.tsx` - диалог выбора эталонов +- `StandardsEditor.tsx` - редактор настроек +- `StandardsPreview.tsx` - превью элемента +- `definition.tsx` - определение элемента + +## Как добавить новый элемент + +### 1. Создать компоненты + +```typescript +// MyElement/MyElementEditor.tsx +export const MyElementEditor: React.FC<{ config: MyConfig; onChange: (config: MyConfig) => void }> = ({ config, onChange }) => { + // Логика редактирования +}; + +// MyElement/MyElementPreview.tsx +export const MyElementPreview: React.FC<{ config: MyConfig }> = ({ config }) => { + // Логика превью +}; + +// MyElement/MyElementRender.tsx +export const MyElementRender: React.FC<{ config: MyConfig; value: string; onChange?: (value: string) => void }> = ({ config, value, onChange }) => { + // Логика рендеринга +}; +``` + +### 2. Создать определение + +```typescript +// MyElement/definition.tsx +export const myElementDefinition: ElementDefinition = { + type: "my_element", + label: "Мой элемент", + icon: , + defaultConfig: { + // конфигурация по умолчанию + }, + Editor: MyElementEditor, + Preview: MyElementPreview, + Render: MyElementRender, + mapToCells: (config) => { + // логика маппинга на ячейки Excel + return []; + }, +}; +``` + +### 3. Зарегистрировать в реестре + +```typescript +// src/lib/element-registry-init.ts +import { myElementDefinition } from "@/components/MyElement/definition"; + +export function initializeElementRegistry() { + // ... существующие элементы + registerElement("my_element", myElementDefinition); +} +``` + +### 4. Добавить тип в types/template.ts + +```typescript +export type ElementType = + | 'text' | 'select' | 'radio' | 'checkbox' | 'number' + | 'textarea' | 'date' | 'standards' + | 'my_element'; // <-- новый тип +``` + +## Особенности элемента "Измерительные эталоны" + +### Конфигурация + +```typescript +interface StandardsConfig { + registryNumber: string; // ГРСИ + sheet: string; // Лист Excel + startCell: string; // Первая ячейка (например "A10") + maxItems: number; // Обычно 7 + targetCells: CellTarget[]; +} +``` + +### Логика маппинга + +Элемент автоматически генерирует ячейки для записи выбранных эталонов: + +```typescript +mapToCells: (cfg) => { + const res: CellTarget[] = []; + const parsed = parseCellAddress(cfg.startCell); + + if (!parsed) return res; + + const { column, row } = parsed; + + // Генерируем ячейки вертикально (A1, A2, A3...) + for (let i = 0; i < cfg.maxItems; i++) { + res.push({ + sheet: cfg.sheet, + cell: `${column}${row + i}`, + displayName: `Эталон ${i + 1}`, + }); + } + + return res; +} +``` + +### Ограничения + +- Максимум 7 эталонов (настраивается) +- Автоматическое заполнение только выбранных позиций +- Оставшиеся ячейки остаются пустыми + +## Преимущества новой архитектуры + +1. **Расширяемость**: Добавление новых элементов требует минимальных изменений +2. **Универсальность**: Единый интерфейс для всех элементов +3. **Типобезопасность**: TypeScript обеспечивает корректность типов +4. **Модульность**: Каждый элемент самодостаточен +5. **Переиспользование**: Компоненты можно использовать в разных контекстах + +## Миграция + +Старые элементы продолжают работать через fallback-логику в `ElementConstructor.tsx`. Для полной миграции рекомендуется перевести все элементы на новую архитектуру. \ No newline at end of file diff --git a/package.json b/package.json index e443741..f36e987 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-toast": "^1.2.14", diff --git a/src/app/main.tsx b/src/app/main.tsx index 08489f3..76ad4c9 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,11 +1,15 @@ -import ReactDOM from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; -import { Provider } from "react-redux"; import { store } from "@/app/store"; +import ReactDOM from "react-dom/client"; +import { Provider } from "react-redux"; +import { BrowserRouter } from "react-router-dom"; +import { initializeElementRegistry } from '../lib/element-registry-init'; import App from "./App.tsx"; import "./index.css"; +// Инициализируем реестр элементов +initializeElementRegistry(); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/components/BasicElements/CheckboxElement.tsx b/src/components/BasicElements/CheckboxElement.tsx new file mode 100644 index 0000000..483b2cf --- /dev/null +++ b/src/components/BasicElements/CheckboxElement.tsx @@ -0,0 +1,70 @@ +import { Input } from "@/components/ui/input"; +import { ElementDefinition } from "@/lib/element-registry"; +import { CheckSquare } from "lucide-react"; + +interface CheckboxConfig { + placeholder?: string; + required?: boolean; + targetCells: any[]; +} + +export const checkboxDefinition: ElementDefinition = { + type: "checkbox", + label: "Чекбокс", + icon: , + defaultConfig: { + placeholder: "", + required: false, + targetCells: [], + }, + Editor: ({ config, onChange }) => ( +
+
+ + onChange({ ...config, placeholder: e.target.value })} + /> +
+
+ onChange({ ...config, required: e.target.checked })} + /> + +
+
+ ), + Preview: ({ config }) => ( +
+
Предпросмотр
+
+
+
+ + {config.placeholder || "Отметить"} + +
+
+
+ ), + Render: ({ config, value, onChange }) => ( +
+ onChange?.(e.target.checked)} + required={config.required} + className="w-4 h-4" + /> + + {config.placeholder || "Отметить"} + +
+ ), +}; \ No newline at end of file diff --git a/src/components/BasicElements/DateElement.tsx b/src/components/BasicElements/DateElement.tsx new file mode 100644 index 0000000..e64e5a0 --- /dev/null +++ b/src/components/BasicElements/DateElement.tsx @@ -0,0 +1,53 @@ +import { Input } from "@/components/ui/input"; +import { ElementDefinition } from "@/lib/element-registry"; +import { Calendar } from "lucide-react"; + +interface DateConfig { + required?: boolean; + targetCells: any[]; +} + +export const dateDefinition: ElementDefinition = { + type: "date", + label: "Дата", + icon: , + defaultConfig: { + required: false, + targetCells: [], + }, + Editor: ({ config, onChange }) => ( +
+
+ onChange({ ...config, required: e.target.checked })} + /> + +
+
+ ), + Preview: () => ( +
+
Предпросмотр
+
+ +
+
+ ), + Render: ({ config, value, onChange }) => ( + onChange?.(e.target.value)} + required={config.required} + /> + ), +}; \ No newline at end of file diff --git a/src/components/BasicElements/NumberElement.tsx b/src/components/BasicElements/NumberElement.tsx new file mode 100644 index 0000000..5b419d9 --- /dev/null +++ b/src/components/BasicElements/NumberElement.tsx @@ -0,0 +1,65 @@ +import { Input } from "@/components/ui/input"; +import { ElementDefinition } from "@/lib/element-registry"; +import { Hash } from "lucide-react"; + +interface NumberConfig { + placeholder?: string; + required?: boolean; + targetCells: any[]; +} + +export const numberDefinition: ElementDefinition = { + type: "number", + label: "Число", + icon: , + defaultConfig: { + placeholder: "", + required: false, + targetCells: [], + }, + Editor: ({ config, onChange }) => ( +
+
+ + onChange({ ...config, placeholder: e.target.value })} + /> +
+
+ onChange({ ...config, required: e.target.checked })} + /> + +
+
+ ), + Preview: ({ config }) => ( +
+
Предпросмотр
+
+ +
+
+ ), + Render: ({ config, value, onChange }) => ( + onChange?.(parseFloat(e.target.value) || 0)} + required={config.required} + /> + ), +}; \ No newline at end of file diff --git a/src/components/BasicElements/RadioElement.tsx b/src/components/BasicElements/RadioElement.tsx new file mode 100644 index 0000000..a50893f --- /dev/null +++ b/src/components/BasicElements/RadioElement.tsx @@ -0,0 +1,140 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ElementDefinition } from "@/lib/element-registry"; +import { ElementOption } from "@/types/template"; +import { CheckSquare, Plus, Trash2 } from "lucide-react"; + +interface RadioConfig { + placeholder?: string; + required?: boolean; + options: ElementOption[]; + targetCells: any[]; +} + +export const radioDefinition: ElementDefinition = { + type: "radio", + label: "Радиокнопки", + icon: , + defaultConfig: { + placeholder: "", + required: false, + options: [], + targetCells: [], + }, + Editor: ({ config, onChange }) => { + const handleAddOption = () => { + onChange({ + ...config, + options: [...config.options, { value: '', label: '' }], + }); + }; + + const handleUpdateOption = (index: number, field: 'value' | 'label', value: string) => { + onChange({ + ...config, + options: config.options.map((option, i) => + i === index ? { ...option, [field]: value } : option + ), + }); + }; + + const handleRemoveOption = (index: number) => { + onChange({ + ...config, + options: config.options.filter((_, i) => i !== index), + }); + }; + + return ( +
+
+ + onChange({ ...config, placeholder: e.target.value })} + /> +
+ +
+ onChange({ ...config, required: e.target.checked })} + /> + +
+ +
+
+ + +
+
+ {config.options.map((option, index) => ( +
+ handleUpdateOption(index, 'value', e.target.value)} + /> + handleUpdateOption(index, 'label', e.target.value)} + /> + +
+ ))} +
+
+
+ ); + }, + Preview: ({ config }) => ( +
+
Предпросмотр
+
+
+ {config.options.length > 0 ? config.options.map((option, index) => ( +
+
+ {option.label} +
+ )) : ( +
+
+ Вариант 1 +
+ )} +
+
+
+ ), + Render: ({ config, value, onChange }) => ( +
+ {config.options.map((option, index) => ( +
+ onChange?.(e.target.value)} + required={config.required} + className="w-4 h-4" + /> + {option.label} +
+ ))} +
+ ), +}; \ No newline at end of file diff --git a/src/components/BasicElements/SelectElement.tsx b/src/components/BasicElements/SelectElement.tsx new file mode 100644 index 0000000..26a7157 --- /dev/null +++ b/src/components/BasicElements/SelectElement.tsx @@ -0,0 +1,131 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ElementDefinition } from "@/lib/element-registry"; +import { ElementOption } from "@/types/template"; +import { ChevronDown, Plus, Trash2 } from "lucide-react"; + +interface SelectConfig { + placeholder?: string; + required?: boolean; + options: ElementOption[]; + targetCells: any[]; +} + +export const selectDefinition: ElementDefinition = { + type: "select", + label: "Выпадающий список", + icon: , + defaultConfig: { + placeholder: "", + required: false, + options: [], + targetCells: [], + }, + Editor: ({ config, onChange }) => { + const handleAddOption = () => { + onChange({ + ...config, + options: [...config.options, { value: '', label: '' }], + }); + }; + + const handleUpdateOption = (index: number, field: 'value' | 'label', value: string) => { + onChange({ + ...config, + options: config.options.map((option, i) => + i === index ? { ...option, [field]: value } : option + ), + }); + }; + + const handleRemoveOption = (index: number) => { + onChange({ + ...config, + options: config.options.filter((_, i) => i !== index), + }); + }; + + return ( +
+
+ + onChange({ ...config, placeholder: e.target.value })} + /> +
+ +
+ onChange({ ...config, required: e.target.checked })} + /> + +
+ +
+
+ + +
+
+ {config.options.map((option, index) => ( +
+ handleUpdateOption(index, 'value', e.target.value)} + /> + handleUpdateOption(index, 'label', e.target.value)} + /> + +
+ ))} +
+
+
+ ); + }, + Preview: ({ config }) => ( +
+
Предпросмотр
+
+ +
+
+ ), + Render: ({ config, value, onChange }) => ( + + ), +}; \ No newline at end of file diff --git a/src/components/BasicElements/TextElement.tsx b/src/components/BasicElements/TextElement.tsx new file mode 100644 index 0000000..c30fb6c --- /dev/null +++ b/src/components/BasicElements/TextElement.tsx @@ -0,0 +1,63 @@ +import { Input } from "@/components/ui/input"; +import { ElementDefinition } from "@/lib/element-registry"; +import { Type } from "lucide-react"; + +interface TextConfig { + placeholder?: string; + required?: boolean; + targetCells: any[]; +} + +export const textDefinition: ElementDefinition = { + type: "text", + label: "Текстовое поле", + icon: , + defaultConfig: { + placeholder: "", + required: false, + targetCells: [], + }, + Editor: ({ config, onChange }) => ( +
+
+ + onChange({ ...config, placeholder: e.target.value })} + /> +
+
+ onChange({ ...config, required: e.target.checked })} + /> + +
+
+ ), + Preview: ({ config }) => ( +
+
Предпросмотр
+
+ +
+
+ ), + Render: ({ config, value, onChange }) => ( + onChange?.(e.target.value)} + required={config.required} + /> + ), +}; \ No newline at end of file diff --git a/src/components/BasicElements/TextareaElement.tsx b/src/components/BasicElements/TextareaElement.tsx new file mode 100644 index 0000000..b496dad --- /dev/null +++ b/src/components/BasicElements/TextareaElement.tsx @@ -0,0 +1,67 @@ +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ElementDefinition } from "@/lib/element-registry"; +import { FileText } from "lucide-react"; + +interface TextareaConfig { + placeholder?: string; + required?: boolean; + targetCells: any[]; +} + +export const textareaDefinition: ElementDefinition = { + type: "textarea", + label: "Многострочный текст", + icon: , + defaultConfig: { + placeholder: "", + required: false, + targetCells: [], + }, + Editor: ({ config, onChange }) => ( +
+
+ + onChange({ ...config, placeholder: e.target.value })} + /> +
+
+ onChange({ ...config, required: e.target.checked })} + /> + +
+
+ ), + Preview: ({ config }) => ( +
+
Предпросмотр
+
+