From 021368f173c5eb9e6a08efad2681533307d9fad3 Mon Sep 17 00:00:00 2001 From: tlartem Date: Fri, 18 Jul 2025 09:11:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=8D=D0=BB?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 166 ---- .../BasicElements/ButtonGroupElement.tsx | 237 ++++++ .../CalibrationConditionsElement.tsx | 642 +++++++++++++++ src/components/BasicElements/index.ts | 16 +- .../StandardsElement/StandardsDialog.tsx | 169 ++-- .../StandardsElement/definition.tsx | 105 +-- .../TemplateManager/ElementConstructor.tsx | 135 +++- .../TemplateManager/VisualLayoutEditor.tsx | 729 +++++++++++------- src/lib/element-registry-init.ts | 44 +- src/pages/ProtocolCreation/ui/Page/Page.tsx | 608 +++++++++------ src/types/template.ts | 2 + 11 files changed, 1964 insertions(+), 889 deletions(-) delete mode 100644 ARCHITECTURE.md create mode 100644 src/components/BasicElements/ButtonGroupElement.tsx create mode 100644 src/components/BasicElements/CalibrationConditionsElement.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 797402c..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,166 +0,0 @@ -# Архитектура системы элементов - -## Обзор - -Система была переработана для обеспечения универсальности и расширяемости. Теперь все элементы регистрируются в едином реестре, что позволяет легко добавлять новые типы элементов без изменения основного кода. - -## Структура - -### 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/src/components/BasicElements/ButtonGroupElement.tsx b/src/components/BasicElements/ButtonGroupElement.tsx new file mode 100644 index 0000000..d41cfea --- /dev/null +++ b/src/components/BasicElements/ButtonGroupElement.tsx @@ -0,0 +1,237 @@ +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ElementDefinition } from '@/lib/element-registry' +import { ElementOption } from '@/types/template' +import { Plus, Square, Trash2 } from 'lucide-react' + +interface ButtonGroupConfig { + placeholder?: string + required?: boolean + options: ElementOption[] + targetCells: any[] + layout?: 'horizontal' | 'vertical' + buttonStyle?: 'default' | 'outline' | 'ghost' +} + +export const buttonGroupDefinition: ElementDefinition< + ButtonGroupConfig, + string +> = { + type: 'button-group', + label: 'Группа кнопок', + icon: , + defaultConfig: { + placeholder: '', + required: false, + options: [], + targetCells: [], + layout: 'horizontal', + buttonStyle: 'default', + }, + 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) => ( + + )) + ) : ( +
+ {config.placeholder || 'Добавьте кнопки'} +
+ )} +
+
+
+ ), + Render: ({ config, value, onChange }) => ( +
+
+ {config.options.map((option, index) => ( + + ))} +
+ {config.required && !value && ( +

+ Это поле обязательно для заполнения +

+ )} +
+ ), +} diff --git a/src/components/BasicElements/CalibrationConditionsElement.tsx b/src/components/BasicElements/CalibrationConditionsElement.tsx new file mode 100644 index 0000000..62bebcb --- /dev/null +++ b/src/components/BasicElements/CalibrationConditionsElement.tsx @@ -0,0 +1,642 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ElementDefinition } from '@/lib/element-registry' +import { + AlertCircle, + Check, + Droplets, + Edit3, + Gauge, + Radio, + Thermometer, + X, + Zap, +} from 'lucide-react' +import React, { useEffect, useState } from 'react' + +interface CalibrationConditions { + temperature: string + humidity: string + pressure: string + voltage: string + frequency: string + lastUpdated: string + updatedBy?: string +} + +interface ValidationResult { + isValid: boolean + message?: string +} + +interface CalibrationCondInfoProps { + conditions: CalibrationConditions + isOutdated: boolean + onUpdate: (conditions: CalibrationConditions) => Promise +} + +// Валидационные правила +const validateField = (field: string, value: string): ValidationResult => { + const numValue = parseFloat(value) + + if (!value.trim()) { + return { isValid: false, message: 'Поле обязательно' } + } + + if (isNaN(numValue)) { + return { isValid: false, message: 'Введите число' } + } + + switch (field) { + case 'temperature': + if (numValue < -40 || numValue > 85) { + return { isValid: false, message: 'Диапазон: -40...+85°C' } + } + break + case 'humidity': + if (numValue < 0 || numValue > 100) { + return { isValid: false, message: 'Диапазон: 0...100%' } + } + break + case 'pressure': + if (numValue < 80 || numValue > 120) { + return { isValid: false, message: 'Диапазон: 80...120 кПа' } + } + break + case 'voltage': + if (numValue < 180 || numValue > 250) { + return { isValid: false, message: 'Диапазон: 180...250 В' } + } + break + case 'frequency': + if (numValue < 45 || numValue > 65) { + return { isValid: false, message: 'Диапазон: 45...65 Гц' } + } + break + } + + return { isValid: true } +} + +const formatLastUpdated = (dateString: string) => { + const date = new Date(dateString) + const today = new Date() + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + + const weekdayRu = date.toLocaleString('ru-RU', { weekday: 'long' }) + const monthShortRu = date + .toLocaleString('ru-RU', { month: 'short' }) + .replace(/\./, '') + .replace(/^./, (s) => s.toUpperCase()) + const day = date.getDate() + const time: string = date.toLocaleString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + }) + + if (date.toDateString() === today.toDateString()) { + return `Сегодня в ${time}` + } else if (date.toDateString() === yesterday.toDateString()) { + return `Вчера в ${time}` + } else { + return `${weekdayRu.replace(/^./, (s) => + s.toUpperCase(), + )} ${day} ${monthShortRu} ${time}` + } +} + +function CalibrationCondInfo({ + conditions, + isOutdated, + onUpdate, +}: CalibrationCondInfoProps) { + const [showModal, setShowModal] = useState(false) + const [tempConditions, setTempConditions] = + useState(conditions) + const [validationErrors, setValidationErrors] = useState< + Record + >({}) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + setTempConditions(conditions) + }, [conditions]) + + const validateForm = () => { + const errors: Record = {} + const fields = [ + 'temperature', + 'humidity', + 'pressure', + 'voltage', + 'frequency', + ] + + fields.forEach((field) => { + const validation = validateField( + field, + tempConditions[field as keyof CalibrationConditions] as string, + ) + if (!validation.isValid && validation.message) { + errors[field] = validation.message + } + }) + + setValidationErrors(errors) + return Object.keys(errors).length === 0 + } + + const handleFieldChange = (field: string, value: string) => { + setTempConditions((prev) => ({ ...prev, [field]: value })) + + // Валидация в реальном времени + const validation = validateField(field, value) + setValidationErrors((prev) => ({ + ...prev, + [field]: validation.isValid ? '' : validation.message || '', + })) + } + + const handleSave = async () => { + if (!validateForm()) return + + setIsLoading(true) + const newConditions = { + ...tempConditions, + lastUpdated: new Date().toISOString(), + } + + const success = await onUpdate(newConditions) + setIsLoading(false) + + if (success) { + setShowModal(false) + } else { + alert('Не удалось сохранить условия поверки') + } + } + + const openModal = () => { + setTempConditions(conditions) + setValidationErrors({}) + setShowModal(true) + // Устанавливаем фокус на первое поле через небольшую задержку + setTimeout(() => { + const firstInput = document.getElementById( + 'modal-temperature', + ) as HTMLInputElement + if (firstInput) { + firstInput.focus() + firstInput.select() + } + }, 100) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + e.key === 'Enter' && + !Object.keys(validationErrors).some((key) => validationErrors[key]) + ) { + handleSave() + } + if (e.key === 'Escape') { + setShowModal(false) + } + } + + const fieldConfigs = [ + { + key: 'temperature', + label: 'Температура', + icon: Thermometer, + unit: '°C', + placeholder: '20', + }, + { + key: 'humidity', + label: 'Влажность', + icon: Droplets, + unit: '%', + placeholder: '65', + }, + { + key: 'pressure', + label: 'Давление', + icon: Gauge, + unit: 'кПа', + placeholder: '101.3', + }, + { + key: 'voltage', + label: 'Напряжение', + icon: Zap, + unit: 'В', + placeholder: '220', + }, + { + key: 'frequency', + label: 'Частота', + icon: Radio, + unit: 'Гц', + placeholder: '50', + }, + ] + + return ( + <> + {/* Компактное отображение */} +
+
+ {fieldConfigs.map(({ key, icon: Icon, unit }) => ( +
+ + + {conditions[key as keyof CalibrationConditions]} + {unit} + +
+ ))} +
+ + {isOutdated && ( +
+ )} +
+ + {/* Модальное окно */} + {showModal && ( +
+
+ + + Условия поверки + + + +
+ {fieldConfigs.map( + ({ key, label, icon: Icon, unit, placeholder }) => { + const hasError = validationErrors[key] + const isValid = + tempConditions[key as keyof CalibrationConditions] && + !hasError + + return ( +
+ +
+
+ + handleFieldChange(key, e.target.value) + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSave() + } + }} + className={`text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${ + hasError + ? 'border-destructive focus-visible:ring-destructive' + : isValid + ? 'border-green-500 focus-visible:ring-green-500' + : '' + }`} + placeholder={placeholder} + type="number" + step="0.1" + /> +
+ + {unit} + + {isValid && ( + + )} + {hasError && ( +
+ +
+ {hasError} +
+
+ )} +
+
+
+
+ ) + }, + )} +
+ +
+
+ + Обновлено: {formatLastUpdated(conditions.lastUpdated)} + +
+ {isOutdated && ( + + Устарели + + )} +
+ +
+ + +
+
+
+
+
+ )} + + ) +} + +interface CalibrationConditionsConfig { + targetCells: any[] + defaultConditions?: CalibrationConditions +} + +export const calibrationConditionsDefinition: ElementDefinition< + CalibrationConditionsConfig, + CalibrationConditions +> = { + type: 'calibration-conditions', + label: 'Условия калибровки', + icon: , + defaultConfig: { + targetCells: [], + defaultConditions: { + temperature: '20', + humidity: '65', + pressure: '101.3', + voltage: '220', + frequency: '50', + lastUpdated: new Date().toISOString(), + }, + }, + mapToCells: () => { + // Генерируем ячейки для каждого параметра калибровки + const cells = [ + { sheet: 'L', cell: 'B2', displayName: 'Температура' }, + { sheet: 'L', cell: 'B3', displayName: 'Влажность' }, + { sheet: 'L', cell: 'B4', displayName: 'Давление' }, + { sheet: 'L', cell: 'B5', displayName: 'Напряжение' }, + { sheet: 'L', cell: 'B6', displayName: 'Частота' }, + { sheet: 'L', cell: 'B7', displayName: 'Дата обновления' }, + ] + return cells + }, + Editor: ({ config, onChange }) => ( +
+
+

+ Элемент "Условия калибровки" позволяет вводить и отслеживать параметры + окружающей среды при проведении измерений. Поддерживает валидацию + значений и отслеживание времени обновления. +

+
+
+ +
+
+ + + onChange({ + ...config, + defaultConditions: { + ...config.defaultConditions, + temperature: e.target.value, + humidity: config.defaultConditions?.humidity || '65', + pressure: config.defaultConditions?.pressure || '101.3', + voltage: config.defaultConditions?.voltage || '220', + frequency: config.defaultConditions?.frequency || '50', + lastUpdated: + config.defaultConditions?.lastUpdated || + new Date().toISOString(), + }, + }) + } + placeholder="20" + /> +
+
+ + + onChange({ + ...config, + defaultConditions: { + ...config.defaultConditions, + temperature: config.defaultConditions?.temperature || '20', + humidity: e.target.value, + pressure: config.defaultConditions?.pressure || '101.3', + voltage: config.defaultConditions?.voltage || '220', + frequency: config.defaultConditions?.frequency || '50', + lastUpdated: + config.defaultConditions?.lastUpdated || + new Date().toISOString(), + }, + }) + } + placeholder="65" + /> +
+
+ + + onChange({ + ...config, + defaultConditions: { + ...config.defaultConditions, + temperature: config.defaultConditions?.temperature || '20', + humidity: config.defaultConditions?.humidity || '65', + pressure: e.target.value, + voltage: config.defaultConditions?.voltage || '220', + frequency: config.defaultConditions?.frequency || '50', + lastUpdated: + config.defaultConditions?.lastUpdated || + new Date().toISOString(), + }, + }) + } + placeholder="101.3" + /> +
+
+ + + onChange({ + ...config, + defaultConditions: { + ...config.defaultConditions, + temperature: config.defaultConditions?.temperature || '20', + humidity: config.defaultConditions?.humidity || '65', + pressure: config.defaultConditions?.pressure || '101.3', + voltage: e.target.value, + frequency: config.defaultConditions?.frequency || '50', + lastUpdated: + config.defaultConditions?.lastUpdated || + new Date().toISOString(), + }, + }) + } + placeholder="220" + /> +
+
+ + + onChange({ + ...config, + defaultConditions: { + ...config.defaultConditions, + temperature: config.defaultConditions?.temperature || '20', + humidity: config.defaultConditions?.humidity || '65', + pressure: config.defaultConditions?.pressure || '101.3', + voltage: config.defaultConditions?.voltage || '220', + frequency: e.target.value, + lastUpdated: + config.defaultConditions?.lastUpdated || + new Date().toISOString(), + }, + }) + } + placeholder="50" + /> +
+
+
+
+ ), + Preview: ({ config }) => ( +
+
Предпросмотр
+
+ true} + /> +
+
+ ), + Render: ({ config, value, onChange }) => { + const conditions = value || + config.defaultConditions || { + temperature: '20', + humidity: '65', + pressure: '101.3', + voltage: '220', + frequency: '50', + lastUpdated: new Date().toISOString(), + } + + // Проверяем, устарели ли условия (больше 24 часов) + const lastUpdated = new Date(conditions.lastUpdated) + const now = new Date() + const isOutdated = + now.getTime() - lastUpdated.getTime() > 24 * 60 * 60 * 1000 + + return ( + { + onChange?.(newConditions) + return true + }} + /> + ) + }, +} diff --git a/src/components/BasicElements/index.ts b/src/components/BasicElements/index.ts index 6bafa2a..a0d54fe 100644 --- a/src/components/BasicElements/index.ts +++ b/src/components/BasicElements/index.ts @@ -1,7 +1,9 @@ -export { checkboxDefinition } from "./CheckboxElement"; -export { dateDefinition } from "./DateElement"; -export { numberDefinition } from "./NumberElement"; -export { radioDefinition } from "./RadioElement"; -export { selectDefinition } from "./SelectElement"; -export { textareaDefinition } from "./TextareaElement"; -export { textDefinition } from "./TextElement"; +export { buttonGroupDefinition } from './ButtonGroupElement' +export { calibrationConditionsDefinition } from './CalibrationConditionsElement' +export { checkboxDefinition } from './CheckboxElement' +export { dateDefinition } from './DateElement' +export { numberDefinition } from './NumberElement' +export { radioDefinition } from './RadioElement' +export { selectDefinition } from './SelectElement' +export { textareaDefinition } from './TextareaElement' +export { textDefinition } from './TextElement' diff --git a/src/components/StandardsElement/StandardsDialog.tsx b/src/components/StandardsElement/StandardsDialog.tsx index 2e22ea6..15750e2 100644 --- a/src/components/StandardsElement/StandardsDialog.tsx +++ b/src/components/StandardsElement/StandardsDialog.tsx @@ -1,35 +1,35 @@ -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Calendar, Check, FileText, Settings, X } from "lucide-react"; -import { useState } from "react"; +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' +import { Calendar, Check, FileText, Settings } from 'lucide-react' +import { useState } from 'react' interface MeasurementStandard { - id: string; - name: string; - shortName: string; - type: string; - registryNumber: string; - range: string; - accuracy: string; - certificateNumber: string; - validUntil: string; + id: string + name: string + shortName: string + type: string + registryNumber: string + range: string + accuracy: string + certificateNumber: string + validUntil: string } interface StandardsConfig { - registryId: string; - selectedStandards: string[]; - lastUpdated: string; + registryId: string + selectedStandards: string[] + lastUpdated: string } interface StandardsDialogProps { - isOpen: boolean; - onClose: () => void; - standards: MeasurementStandard[]; - config: StandardsConfig; - onSave: (config: StandardsConfig) => void; - registryNumber: string; + isOpen: boolean + onClose: () => void + standards: MeasurementStandard[] + config: StandardsConfig + onSave: (config: StandardsConfig) => void + registryNumber: string } export function StandardsDialog({ @@ -38,129 +38,136 @@ export function StandardsDialog({ standards, config, onSave, - registryNumber + registryNumber, }: StandardsDialogProps) { - const [selectedStandards, setSelectedStandards] = useState(config.selectedStandards); + const [selectedStandards, setSelectedStandards] = useState( + config.selectedStandards, + ) - if (!isOpen) return null; + if (!isOpen) return null const toggleStandard = (standardId: string) => { - setSelectedStandards(prev => { + setSelectedStandards((prev) => { if (prev.includes(standardId)) { - return prev.filter(id => id !== standardId); + return prev.filter((id) => id !== standardId) } else if (prev.length < 7) { - return [...prev, standardId]; + return [...prev, standardId] } - return prev; - }); - }; + return prev + }) + } const handleSave = () => { onSave({ ...config, selectedStandards, - lastUpdated: new Date().toISOString() - }); - onClose(); - }; + lastUpdated: new Date().toISOString(), + }) + onClose() + } const isExpiringSoon = (validUntil: string) => { - const expiryDate = new Date(validUntil); - const now = new Date(); - const monthsUntilExpiry = (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30); - return monthsUntilExpiry <= 3; - }; + const expiryDate = new Date(validUntil) + const now = new Date() + const monthsUntilExpiry = + (expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24 * 30) + return monthsUntilExpiry <= 3 + } return ( -
- +
+
- - + + Измерительные эталоны -

+

ГРСИ: {registryNumber} • Выбрано: {selectedStandards.length}/7

-
- + -
+
{standards.map((standard) => { - const isSelected = selectedStandards.includes(standard.id); - const isExpiring = isExpiringSoon(standard.validUntil); - + const isSelected = selectedStandards.includes(standard.id) + const isExpiring = isExpiringSoon(standard.validUntil) + return (
toggleStandard(standard.id)} - className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${ + className={`cursor-pointer rounded-lg border p-3 transition-all duration-200 ${ isSelected - ? "border-primary bg-primary/10 shadow-sm" - : "border-border hover:border-primary/50 hover:bg-primary/5" + ? 'border-primary bg-primary/10 shadow-sm' + : 'border-border hover:border-primary/50 hover:bg-primary/5' }`} >
-
- {isSelected && } +
+ {isSelected && ( + + )}
- -
+ +
-
+
{standard.shortName}
-
+
{standard.name}
- -
+ +
{standard.accuracy} {isExpiring && ( - + Истекает )}
- +
- + {standard.registryNumber}
Диапазон: {standard.range}
- Действует до: {new Date(standard.validUntil).toLocaleDateString('ru-RU')} + Действует до:{' '} + {new Date(standard.validUntil).toLocaleDateString( + 'ru-RU', + )}
- ); + ) })}
- + - +
Максимум 7 эталонов. Выбрано: {selectedStandards.length} @@ -169,13 +176,11 @@ export function StandardsDialog({ - +
- ); -} \ No newline at end of file + ) +} diff --git a/src/components/StandardsElement/definition.tsx b/src/components/StandardsElement/definition.tsx index 3bca851..6d84bee 100644 --- a/src/components/StandardsElement/definition.tsx +++ b/src/components/StandardsElement/definition.tsx @@ -1,56 +1,57 @@ -import { ElementDefinition } from "@/lib/element-registry"; -import { parseCellAddress } from "@/lib/utils"; -import { CellTarget } from "@/types/template"; -import { Calendar } from "lucide-react"; -import { StandardsEditor } from "./StandardsEditor"; -import { StandardsPreview } from "./StandardsPreview"; +import { ElementDefinition } from '@/lib/element-registry' +import { parseCellAddress } from '@/lib/utils' +import { CellTarget } from '@/types/template' +import { Weight } from 'lucide-react' +import { StandardsEditor } from './StandardsEditor' +import { StandardsPreview } from './StandardsPreview' interface StandardsConfig { - registryNumber: string; // ГРСИ - sheet: string; // Лист Excel - startCell: string; // Первая ячейка (например "A10") - maxItems: number; // Обычно 7 - targetCells: CellTarget[]; + registryNumber: string // ГРСИ + sheet: string // Лист Excel + startCell: string // Первая ячейка (например "A10") + maxItems: number // Обычно 7 + targetCells: CellTarget[] } -export const standardsDefinition: ElementDefinition = { - type: "standards", - label: "Измерительные эталоны", - icon: , - defaultConfig: { - registryNumber: "", - sheet: "Report", - startCell: "A1", - maxItems: 7, - targetCells: [], - }, - Editor: StandardsEditor, - Preview: StandardsPreview, - Render: () => { - // Здесь нужно создать обертку для StandardsDialog - // Пока возвращаем null, так как StandardsDialog требует дополнительные пропсы - return null; - }, - 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; - }, -}; \ No newline at end of file +export const standardsDefinition: ElementDefinition = + { + type: 'standards', + label: 'Измерительные эталоны', + icon: , + defaultConfig: { + registryNumber: '', + sheet: 'Report', + startCell: 'A1', + maxItems: 7, + targetCells: [], + }, + Editor: StandardsEditor, + Preview: StandardsPreview, + Render: () => { + // Здесь нужно создать обертку для StandardsDialog + // Пока возвращаем null, так как StandardsDialog требует дополнительные пропсы + return null + }, + 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 + }, + } diff --git a/src/components/TemplateManager/ElementConstructor.tsx b/src/components/TemplateManager/ElementConstructor.tsx index d0de120..a50e755 100644 --- a/src/components/TemplateManager/ElementConstructor.tsx +++ b/src/components/TemplateManager/ElementConstructor.tsx @@ -1,4 +1,16 @@ -import { Calendar, LayoutGrid, Plus, Settings, Trash2 } from 'lucide-react' +import { + Calendar, + Droplets, + Edit3, + Gauge, + LayoutGrid, + Plus, + Radio, + Settings, + Thermometer, + Trash2, + Zap, +} from 'lucide-react' import React, { useState } from 'react' import { getAvailableElementTypes, @@ -251,6 +263,61 @@ const ElementPreview: React.FC<{ element: Partial }> = ({
) + case 'calibration-conditions': + return ( +
+
+
+
+ + 20°C +
+
+ + 65% +
+
+ + 101.3кПа +
+
+ + 220В +
+
+ + 50Гц +
+
+ +
+
+ ) + + case 'button-group': + return ( +
+
+ {element.options?.length ? ( + element.options.map((option, index) => ( + + )) + ) : ( +
+ {element.placeholder || 'Добавьте кнопки'} +
+ )} +
+
+ ) + default: return (
@@ -321,7 +388,10 @@ const ElementForm: React.FC = ({ formData, setFormData }) => { })) } - const needsOptions = formData.type === 'select' || formData.type === 'radio' + const needsOptions = + formData.type === 'select' || + formData.type === 'radio' || + formData.type === 'button-group' // Если есть определение элемента в реестре, используем его редактор if (elementDefinition && elementDefinition.Editor) { @@ -340,12 +410,15 @@ const ElementForm: React.FC = ({ formData, setFormData }) => { setFormData((prev) => { const updates: Partial = { type: newType } - // Если выбран тип "standards", устанавливаем значения по умолчанию - if (newType === 'standards') { + // Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию + if ( + newType === 'standards' || + newType === 'calibration-conditions' + ) { console.log( - 'Standards type selected, getting element definition', + `${newType} type selected, getting element definition`, ) - const elementDefinition = getElementDefinition('standards') + const elementDefinition = getElementDefinition(newType) console.log('Element definition:', elementDefinition) if (elementDefinition && elementDefinition.defaultConfig) { @@ -444,12 +517,15 @@ const ElementForm: React.FC = ({ formData, setFormData }) => { setFormData((prev) => { const updates: Partial = { type: newType } - // Если выбран тип "standards", устанавливаем значения по умолчанию - if (newType === 'standards') { + // Если выбран тип "standards" или "calibration-conditions", устанавливаем значения по умолчанию + if ( + newType === 'standards' || + newType === 'calibration-conditions' + ) { console.log( - 'Standards type selected (fallback), getting element definition', + `${newType} type selected (fallback), getting element definition`, ) - const elementDefinition = getElementDefinition('standards') + const elementDefinition = getElementDefinition(newType) console.log( 'Element definition (fallback):', elementDefinition, @@ -696,10 +772,15 @@ export const ElementConstructor: React.FC = ({ return } - // Для элемента типа "standards" не требуем targetCells, так как они генерируются автоматически - if (formData.type !== 'standards' && !formData.targetCells?.length) { + // Для элементов типа "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 non-standards element', + 'Validation failed: missing targetCells for element that requires manual targetCells', ) return } @@ -714,12 +795,15 @@ export const ElementConstructor: React.FC = ({ layoutSettings.gridSize, ) - // Специальная обработка для эталонов + // Специальная обработка для элементов с автоматической генерацией targetCells let targetCells = formData.targetCells || [] - if (formData.type === 'standards') { - // Для эталонов генерируем targetCells автоматически, если их нет + if ( + formData.type === 'standards' || + formData.type === 'calibration-conditions' + ) { + // Генерируем targetCells автоматически, если их нет if (!targetCells.length) { - const elementDefinition = getElementDefinition('standards') + const elementDefinition = getElementDefinition(formData.type) if (elementDefinition && elementDefinition.defaultConfig) { const defaultConfig = elementDefinition.defaultConfig as any targetCells = elementDefinition.mapToCells?.(defaultConfig) || [] @@ -727,6 +811,11 @@ export const ElementConstructor: React.FC = ({ } } + // Для button-group не нужны targetCells, так как это UI элемент + if (formData.type === 'button-group') { + targetCells = [] + } + const newElement: TemplateElement = { id: Date.now().toString(), type: formData.type as ElementType, @@ -753,8 +842,14 @@ export const ElementConstructor: React.FC = ({ const handleUpdateElement = () => { if (!editingElement || !formData.name || !formData.label) return - // Для элемента типа "standards" не требуем targetCells, так как они генерируются автоматически - if (formData.type !== 'standards' && !formData.targetCells?.length) return + // Для элементов типа "standards", "calibration-conditions" и "button-group" не требуем targetCells, так как они генерируются автоматически или не нужны + if ( + formData.type !== 'standards' && + formData.type !== 'calibration-conditions' && + formData.type !== 'button-group' && + !formData.targetCells?.length + ) + return onElementUpdate(editingElement.id, formData) setEditingElement(null) @@ -828,6 +923,8 @@ export const ElementConstructor: React.FC = ({ !formData.name || !formData.label || (formData.type !== 'standards' && + formData.type !== 'calibration-conditions' && + formData.type !== 'button-group' && !formData.targetCells?.length) } > diff --git a/src/components/TemplateManager/VisualLayoutEditor.tsx b/src/components/TemplateManager/VisualLayoutEditor.tsx index 17aa987..0812262 100644 --- a/src/components/TemplateManager/VisualLayoutEditor.tsx +++ b/src/components/TemplateManager/VisualLayoutEditor.tsx @@ -1,37 +1,50 @@ import { - Calendar, - ChevronDown, - Edit, - Eye, - EyeOff, - Grid, - Move, - Trash2 -} from 'lucide-react'; -import React, { useCallback, useMemo, useState } from 'react'; -import { Rnd } from 'react-rnd'; -import { ElementLayout, FormLayoutSettings, TemplateElement } from '../../types/template'; -import { Button } from '../ui/button'; -import { Input } from '../ui/input'; -import { Textarea } from '../ui/textarea'; + 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 '../../types/template' +import { Button } from '../ui/button' +import { Input } from '../ui/input' +import { Textarea } from '../ui/textarea' interface VisualLayoutEditorProps { - elements: TemplateElement[]; - layoutSettings: FormLayoutSettings; - onElementUpdate: (elementId: string, updates: Partial) => void; - onElementDelete: (elementId: string) => void; - onLayoutSettingsChange: (settings: FormLayoutSettings) => void; - onElementEdit?: (element: TemplateElement) => void; + elements: TemplateElement[] + layoutSettings: FormLayoutSettings + onElementUpdate: ( + elementId: string, + updates: Partial, + ) => 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; + element: TemplateElement + isSelected: boolean + onSelect: () => void + onUpdate: (layout: ElementLayout) => void + onDelete: () => void + onEdit?: () => void + gridSize: number } // const ELEMENT_ICONS: Record = { @@ -54,44 +67,61 @@ interface DraggableElementProps { // checkbox: 'bg-teal-100 border-teal-300 text-teal-800', // }; -const DraggableElement: React.FC = React.memo(({ - element, - isSelected, - onSelect, - onUpdate, - onDelete, - onEdit, - gridSize, -}) => { - const layout = element.layout || { x: 50, y: 50, width: 300, height: 80, zIndex: 1 }; +const DraggableElement: React.FC = 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 }) => { + 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 }); + onUpdate({ ...layout, x: d.x, y: d.y }) } - }, [layout, onUpdate]); + }, + [layout, onUpdate], + ) - const handleResizeStop = useCallback((_e: any, _dir: any, ref: HTMLElement, _delta: any, pos: { x: number; y: number }) => { + 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]); + ...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 handleDeleteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onDelete() + }, + [onDelete], + ) - const handleEditClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - onEdit?.(); - }, [onEdit]); + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + onEdit?.() + }, + [onEdit], + ) /* * Render a lightweight, disabled preview of the element so that the user can @@ -101,256 +131,371 @@ const DraggableElement: React.FC = React.memo(({ * behaviour of Rnd and keeps rendering costs minimal. */ const previewControl = useMemo(() => { - switch (element.type) { - case 'text': - return ; - case 'textarea': - return