архитектура добавления элементов, список эталонов
This commit is contained in:
166
ARCHITECTURE.md
Normal file
166
ARCHITECTURE.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Архитектура системы элементов
|
||||
|
||||
## Обзор
|
||||
|
||||
Система была переработана для обеспечения универсальности и расширяемости. Теперь все элементы регистрируются в едином реестре, что позволяет легко добавлять новые типы элементов без изменения основного кода.
|
||||
|
||||
## Структура
|
||||
|
||||
### 1. Реестр элементов (`src/lib/element-registry.ts`)
|
||||
|
||||
Центральный реестр, который содержит все определения элементов:
|
||||
|
||||
```typescript
|
||||
export interface ElementDefinition<Config = any, Value = any> {
|
||||
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<MyConfig, string> = {
|
||||
type: "my_element",
|
||||
label: "Мой элемент",
|
||||
icon: <MyIcon className="h-4 w-4" />,
|
||||
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`. Для полной миграции рекомендуется перевести все элементы на новую архитектуру.
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
|
||||
70
src/components/BasicElements/CheckboxElement.tsx
Normal file
70
src/components/BasicElements/CheckboxElement.tsx
Normal file
@@ -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<CheckboxConfig, boolean> = {
|
||||
type: "checkbox",
|
||||
label: "Чекбокс",
|
||||
icon: <CheckSquare className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
placeholder: "",
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
Editor: ({ config, onChange }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Отметить"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border border-gray-300 rounded" />
|
||||
<span className="text-sm">
|
||||
{config.placeholder || "Отметить"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value || false}
|
||||
onChange={(e) => onChange?.(e.target.checked)}
|
||||
required={config.required}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{config.placeholder || "Отметить"}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
53
src/components/BasicElements/DateElement.tsx
Normal file
53
src/components/BasicElements/DateElement.tsx
Normal file
@@ -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<DateConfig, string> = {
|
||||
type: "date",
|
||||
label: "Дата",
|
||||
icon: <Calendar className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
Editor: ({ config, onChange }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Preview: () => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<Input
|
||||
type="date"
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<Input
|
||||
type="date"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
required={config.required}
|
||||
/>
|
||||
),
|
||||
};
|
||||
65
src/components/BasicElements/NumberElement.tsx
Normal file
65
src/components/BasicElements/NumberElement.tsx
Normal file
@@ -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<NumberConfig, number> = {
|
||||
type: "number",
|
||||
label: "Число",
|
||||
icon: <Hash className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
placeholder: "",
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
Editor: ({ config, onChange }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="0"
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={config.placeholder || "0"}
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={config.placeholder || "0"}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(parseFloat(e.target.value) || 0)}
|
||||
required={config.required}
|
||||
/>
|
||||
),
|
||||
};
|
||||
140
src/components/BasicElements/RadioElement.tsx
Normal file
140
src/components/BasicElements/RadioElement.tsx
Normal file
@@ -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<RadioConfig, string> = {
|
||||
type: "radio",
|
||||
label: "Радиокнопки",
|
||||
icon: <CheckSquare className="h-4 w-4" />,
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Выберите вариант..."
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Варианты</label>
|
||||
<Button variant="outline" size="sm" onClick={handleAddOption}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{config.options.map((option, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={(e) => handleUpdateOption(index, 'value', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Подпись"
|
||||
value={option.label}
|
||||
onChange={(e) => handleUpdateOption(index, 'label', e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={() => handleRemoveOption(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<div className="space-y-2">
|
||||
{config.options.length > 0 ? config.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border border-gray-300 rounded-full" />
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border border-gray-300 rounded-full" />
|
||||
<span className="text-sm text-gray-500">Вариант 1</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<div className="space-y-2">
|
||||
{config.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="radio-group"
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
required={config.required}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
131
src/components/BasicElements/SelectElement.tsx
Normal file
131
src/components/BasicElements/SelectElement.tsx
Normal file
@@ -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<SelectConfig, string> = {
|
||||
type: "select",
|
||||
label: "Выпадающий список",
|
||||
icon: <ChevronDown className="h-4 w-4" />,
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Выберите значение..."
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Варианты</label>
|
||||
<Button variant="outline" size="sm" onClick={handleAddOption}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{config.options.map((option, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Значение"
|
||||
value={option.value}
|
||||
onChange={(e) => handleUpdateOption(index, 'value', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Подпись"
|
||||
value={option.label}
|
||||
onChange={(e) => handleUpdateOption(index, 'label', e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" size="icon" onClick={() => handleRemoveOption(index)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<Select disabled>
|
||||
<SelectTrigger className="w-full">
|
||||
<span className="text-gray-500">
|
||||
{config.placeholder || "Выберите значение"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={config.placeholder || "Выберите значение"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.options.map((option, index) => (
|
||||
<SelectItem key={index} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
),
|
||||
};
|
||||
63
src/components/BasicElements/TextElement.tsx
Normal file
63
src/components/BasicElements/TextElement.tsx
Normal file
@@ -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<TextConfig, string> = {
|
||||
type: "text",
|
||||
label: "Текстовое поле",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
placeholder: "",
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
Editor: ({ config, onChange }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Введите текст..."
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<Input
|
||||
placeholder={config.placeholder || "Введите текст"}
|
||||
disabled
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<Input
|
||||
placeholder={config.placeholder || "Введите текст"}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
required={config.required}
|
||||
/>
|
||||
),
|
||||
};
|
||||
67
src/components/BasicElements/TextareaElement.tsx
Normal file
67
src/components/BasicElements/TextareaElement.tsx
Normal file
@@ -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<TextareaConfig, string> = {
|
||||
type: "textarea",
|
||||
label: "Многострочный текст",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
defaultConfig: {
|
||||
placeholder: "",
|
||||
required: false,
|
||||
targetCells: [],
|
||||
},
|
||||
Editor: ({ config, onChange }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Введите текст..."
|
||||
value={config.placeholder || ""}
|
||||
onChange={(e) => onChange({ ...config, placeholder: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="required"
|
||||
checked={config.required}
|
||||
onChange={(e) => onChange({ ...config, required: e.target.checked })}
|
||||
/>
|
||||
<label htmlFor="required" className="text-sm font-medium">
|
||||
Обязательное поле
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Preview: ({ config }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<Textarea
|
||||
placeholder={config.placeholder || "Введите текст"}
|
||||
disabled
|
||||
rows={3}
|
||||
className="w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
Render: ({ config, value, onChange }) => (
|
||||
<Textarea
|
||||
placeholder={config.placeholder || "Введите текст"}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
required={config.required}
|
||||
rows={3}
|
||||
className="w-full resize-none"
|
||||
/>
|
||||
),
|
||||
};
|
||||
7
src/components/BasicElements/index.ts
Normal file
7
src/components/BasicElements/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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";
|
||||
@@ -170,8 +170,6 @@ interface FormulaBarProps {
|
||||
|
||||
const FormulaBar = memo(
|
||||
({
|
||||
sheetType,
|
||||
activeSheet,
|
||||
selectedCell,
|
||||
formulaBarValue,
|
||||
isCalculating,
|
||||
@@ -181,7 +179,7 @@ const FormulaBar = memo(
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
formulaBarRef,
|
||||
}: FormulaBarProps) => {
|
||||
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
|
||||
// Теперь FormulaBar всегда отображается, так как он один
|
||||
|
||||
return (
|
||||
@@ -958,8 +956,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
<FormulaBar
|
||||
activeSheet={activeSheet}
|
||||
sheetType={activeSheet} // Передаем activeSheet как текущий sheetType
|
||||
selectedCell={selectedCell}
|
||||
formulaBarValue={formulaBarValue}
|
||||
isCalculating={multiSheetEngine.isCalculating}
|
||||
@@ -968,7 +964,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
||||
onFocus={handleFormulaBarFocus}
|
||||
onBlur={handleFormulaBarBlur}
|
||||
onKeyDown={handleFormulaBarKeyDown}
|
||||
formulaBarRef={formulaBarRef}
|
||||
formulaBarRef={(el) => { formulaBarRef.current = el; }}
|
||||
/>
|
||||
<div className="flex flex-1 min-w-0 gap-1 overflow-hidden p-1">
|
||||
<Spreadsheet
|
||||
|
||||
181
src/components/StandardsElement/StandardsDialog.tsx
Normal file
181
src/components/StandardsElement/StandardsDialog.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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";
|
||||
|
||||
interface MeasurementStandard {
|
||||
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;
|
||||
}
|
||||
|
||||
interface StandardsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
standards: MeasurementStandard[];
|
||||
config: StandardsConfig;
|
||||
onSave: (config: StandardsConfig) => void;
|
||||
registryNumber: string;
|
||||
}
|
||||
|
||||
export function StandardsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
standards,
|
||||
config,
|
||||
onSave,
|
||||
registryNumber
|
||||
}: StandardsDialogProps) {
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>(config.selectedStandards);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => {
|
||||
if (prev.includes(standardId)) {
|
||||
return prev.filter(id => id !== standardId);
|
||||
} else if (prev.length < 7) {
|
||||
return [...prev, standardId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...config,
|
||||
selectedStandards,
|
||||
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;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Измерительные эталоны
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
ГРСИ: {registryNumber} • Выбрано: {selectedStandards.length}/7
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
|
||||
{standards.map((standard) => {
|
||||
const isSelected = selectedStandards.includes(standard.id);
|
||||
const isExpiring = isExpiringSoon(standard.validUntil);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={standard.id}
|
||||
onClick={() => toggleStandard(standard.id)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-sm"
|
||||
: "border-border hover:border-primary/50 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-border"
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm text-foreground leading-tight">
|
||||
{standard.shortName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{standard.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{standard.accuracy}
|
||||
</Badge>
|
||||
{isExpiring && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
Истекает
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="w-3 h-3" />
|
||||
{standard.registryNumber}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Диапазон: {standard.range}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Действует до: {new Date(standard.validUntil).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
219
src/components/StandardsElement/StandardsEditor.tsx
Normal file
219
src/components/StandardsElement/StandardsEditor.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { CellTarget } from "@/types/template";
|
||||
import { Calendar, Settings, Trash2 } from "lucide-react";
|
||||
|
||||
interface StandardsConfig {
|
||||
registryNumber: string; // ГРСИ
|
||||
sheet: string; // Лист Excel
|
||||
startCell: string; // Первая ячейка (например "A10")
|
||||
maxItems: number; // Обычно 7
|
||||
targetCells: CellTarget[];
|
||||
}
|
||||
|
||||
interface StandardsEditorProps {
|
||||
config: StandardsConfig;
|
||||
onChange: (config: StandardsConfig) => void;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Целевые ячейки</label>
|
||||
<Button variant="outline" size="sm" onClick={handleAddTarget}>
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
Добавить ячейку
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||
{targets.map((target, index) => (
|
||||
<div key={index} className="p-3 border rounded-lg bg-gray-50">
|
||||
<div className="grid grid-cols-3 gap-2 mb-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600">Лист</label>
|
||||
<Select
|
||||
value={target.sheet}
|
||||
onValueChange={(value) => handleUpdateTarget(index, 'sheet', value)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="L">L (Левый)</SelectItem>
|
||||
<SelectItem value="R">R (Правый)</SelectItem>
|
||||
<SelectItem value="Report">Report</SelectItem>
|
||||
<SelectItem value="Calculations">Calculations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600">Ячейка</label>
|
||||
<Input
|
||||
placeholder="A1, B5..."
|
||||
value={target.cell}
|
||||
onChange={(e) => handleUpdateTarget(index, 'cell', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-600">Название</label>
|
||||
<Input
|
||||
placeholder="Описание"
|
||||
value={target.displayName || ''}
|
||||
onChange={(e) => handleUpdateTarget(index, 'displayName', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveTarget(index)}
|
||||
className="h-6 text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-1" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{targets.length === 0 && (
|
||||
<p className="text-sm text-gray-500 text-center py-4">
|
||||
Добавьте ячейки для связи с элементом
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const StandardsEditor: React.FC<StandardsEditorProps> = ({ config, onChange }) => {
|
||||
// Обеспечиваем значения по умолчанию
|
||||
const safeConfig = {
|
||||
registryNumber: config.registryNumber || '',
|
||||
sheet: config.sheet || 'Report',
|
||||
startCell: config.startCell || 'A1',
|
||||
maxItems: config.maxItems || 7,
|
||||
targetCells: config.targetCells || [],
|
||||
};
|
||||
|
||||
const updateConfig = (updates: Partial<StandardsConfig>) => {
|
||||
onChange({ ...safeConfig, ...updates });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Настройки эталонов
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Настройте параметры для выбора измерительных эталонов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Номер ГРСИ</label>
|
||||
<Input
|
||||
placeholder="Введите номер ГРСИ"
|
||||
value={safeConfig.registryNumber}
|
||||
onChange={(e) => updateConfig({ registryNumber: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Максимум эталонов</label>
|
||||
<Select
|
||||
value={safeConfig.maxItems.toString()}
|
||||
onValueChange={(value) => updateConfig({ maxItems: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3 эталона</SelectItem>
|
||||
<SelectItem value="5">5 эталонов</SelectItem>
|
||||
<SelectItem value="7">7 эталонов</SelectItem>
|
||||
<SelectItem value="10">10 эталонов</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Лист Excel</label>
|
||||
<Select
|
||||
value={safeConfig.sheet}
|
||||
onValueChange={(value) => updateConfig({ sheet: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="L">L (Левый)</SelectItem>
|
||||
<SelectItem value="R">R (Правый)</SelectItem>
|
||||
<SelectItem value="Report">Report</SelectItem>
|
||||
<SelectItem value="Calculations">Calculations</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Начальная ячейка</label>
|
||||
<Input
|
||||
placeholder="A1, B5..."
|
||||
value={safeConfig.startCell}
|
||||
onChange={(e) => updateConfig({ startCell: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Целевые ячейки</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Ячейки для записи выбранных эталонов (автоматически генерируются)
|
||||
</p>
|
||||
<CellTargetEditor
|
||||
targets={safeConfig.targetCells}
|
||||
onChange={(targets) => updateConfig({ targetCells: targets })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="h-4 w-4 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-blue-800">Автоматическое заполнение</div>
|
||||
<div className="text-blue-600 text-xs mt-1">
|
||||
При выборе эталонов данные будут записаны в указанные ячейки.
|
||||
Если выбрано меньше эталонов, чем максимальное количество,
|
||||
оставшиеся ячейки останутся пустыми.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
64
src/components/StandardsElement/StandardsPreview.tsx
Normal file
64
src/components/StandardsElement/StandardsPreview.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Settings } from "lucide-react";
|
||||
|
||||
interface StandardsConfig {
|
||||
registryNumber: string;
|
||||
sheet: string;
|
||||
startCell: string;
|
||||
maxItems: number;
|
||||
targetCells: any[];
|
||||
}
|
||||
|
||||
interface StandardsPreviewProps {
|
||||
config: StandardsConfig;
|
||||
}
|
||||
|
||||
export const StandardsPreview: React.FC<StandardsPreviewProps> = ({ config }) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-gray-700">Предпросмотр</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg bg-white">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm font-medium">Измерительные эталоны</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-gray-500">
|
||||
ГРСИ: {config.registryNumber || "Не указан"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Максимум: {config.maxItems} эталонов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled className="text-xs">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
Выбрать эталоны
|
||||
</Button>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
0/{config.maxItems} выбрано
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{config.targetCells && config.targetCells.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{config.targetCells.map((target, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded"
|
||||
title={target.displayName}
|
||||
>
|
||||
{target.sheet}!{target.cell}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
228
src/components/StandardsElement/StandardsSelector.tsx
Normal file
228
src/components/StandardsElement/StandardsSelector.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Calendar, Check, FileText, Settings, X } 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;
|
||||
}
|
||||
|
||||
interface StandardsSelectorProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
registryNumber?: string;
|
||||
}
|
||||
|
||||
// Моковые данные для эталонов
|
||||
const mockStandards: MeasurementStandard[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Эталон массы 1 кг",
|
||||
shortName: "ЭМ-1кг",
|
||||
type: "Масса",
|
||||
registryNumber: "ГРСИ 12345-01",
|
||||
range: "0.5-2 кг",
|
||||
accuracy: "±0.001 г",
|
||||
certificateNumber: "СИ-2024-001",
|
||||
validUntil: "2025-12-31",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Эталон длины 1 м",
|
||||
shortName: "ЭД-1м",
|
||||
type: "Длина",
|
||||
registryNumber: "ГРСИ 12345-02",
|
||||
range: "0.5-2 м",
|
||||
accuracy: "±0.001 мм",
|
||||
certificateNumber: "СИ-2024-002",
|
||||
validUntil: "2025-06-30",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Эталон температуры 20°C",
|
||||
shortName: "ЭТ-20°C",
|
||||
type: "Температура",
|
||||
registryNumber: "ГРСИ 12345-03",
|
||||
range: "15-25°C",
|
||||
accuracy: "±0.01°C",
|
||||
certificateNumber: "СИ-2024-003",
|
||||
validUntil: "2025-03-15",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Эталон давления 1 Па",
|
||||
shortName: "ЭД-1Па",
|
||||
type: "Давление",
|
||||
registryNumber: "ГРСИ 12345-04",
|
||||
range: "0.5-2 Па",
|
||||
accuracy: "±0.001 Па",
|
||||
certificateNumber: "СИ-2024-004",
|
||||
validUntil: "2025-09-20",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Эталон времени 1 с",
|
||||
shortName: "ЭВ-1с",
|
||||
type: "Время",
|
||||
registryNumber: "ГРСИ 12345-05",
|
||||
range: "0.5-2 с",
|
||||
accuracy: "±0.000001 с",
|
||||
certificateNumber: "СИ-2024-005",
|
||||
validUntil: "2025-12-01",
|
||||
},
|
||||
];
|
||||
|
||||
export function StandardsSelector({
|
||||
isOpen,
|
||||
onClose,
|
||||
value = [],
|
||||
onChange,
|
||||
registryNumber = "ГРСИ 12345"
|
||||
}: StandardsSelectorProps) {
|
||||
const [selectedStandards, setSelectedStandards] = useState<string[]>(value);
|
||||
|
||||
const toggleStandard = (standardId: string) => {
|
||||
setSelectedStandards(prev => {
|
||||
if (prev.includes(standardId)) {
|
||||
return prev.filter(id => id !== standardId);
|
||||
} else if (prev.length < 7) {
|
||||
return [...prev, standardId];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(selectedStandards);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedStandards(value); // Сбрасываем к исходному значению
|
||||
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;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-lg flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Измерительные эталоны
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
ГРСИ: {registryNumber} • Выбрано: {selectedStandards.length}/7
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-96 overflow-y-auto">
|
||||
{mockStandards.map((standard) => {
|
||||
const isSelected = selectedStandards.includes(standard.id);
|
||||
const isExpiring = isExpiringSoon(standard.validUntil);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={standard.id}
|
||||
onClick={() => toggleStandard(standard.id)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-sm"
|
||||
: "border-border hover:border-primary/50 hover:bg-primary/5"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-1 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||
isSelected
|
||||
? "border-primary bg-primary"
|
||||
: "border-border"
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-primary-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm text-foreground leading-tight">
|
||||
{standard.shortName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{standard.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{standard.accuracy}
|
||||
</Badge>
|
||||
{isExpiring && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
Истекает
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FileText className="w-3 h-3" />
|
||||
{standard.registryNumber}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Диапазон: {standard.range}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Действует до: {new Date(standard.validUntil).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Максимум 7 эталонов. Выбрано: {selectedStandards.length}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
56
src/components/StandardsElement/definition.tsx
Normal file
56
src/components/StandardsElement/definition.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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";
|
||||
|
||||
interface StandardsConfig {
|
||||
registryNumber: string; // ГРСИ
|
||||
sheet: string; // Лист Excel
|
||||
startCell: string; // Первая ячейка (например "A10")
|
||||
maxItems: number; // Обычно 7
|
||||
targetCells: CellTarget[];
|
||||
}
|
||||
|
||||
export const standardsDefinition: ElementDefinition<StandardsConfig, string[]> = {
|
||||
type: "standards",
|
||||
label: "Измерительные эталоны",
|
||||
icon: <Calendar className="h-4 w-4" />,
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Calendar, CheckSquare, ChevronDown, FileText, Hash, LayoutGrid, Plus, Settings, Trash2, Type } from 'lucide-react';
|
||||
import { Calendar, LayoutGrid, Plus, Settings, Trash2 } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { getAvailableElementTypes, getElementDefinition } from '../../lib/element-registry';
|
||||
import { autoArrangeElements, findFreePosition, generateDefaultLayout } from '../../lib/utils';
|
||||
import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -15,16 +16,6 @@ interface ElementFormProps {
|
||||
setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>;
|
||||
}
|
||||
|
||||
const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.ReactNode }> = [
|
||||
{ type: 'text', label: 'Текстовое поле', icon: <Type className="h-4 w-4" /> },
|
||||
{ type: 'textarea', label: 'Многострочный текст', icon: <FileText className="h-4 w-4" /> },
|
||||
{ type: 'number', label: 'Число', icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: 'select', label: 'Выпадающий список', icon: <ChevronDown className="h-4 w-4" /> },
|
||||
{ type: 'radio', label: 'Радиокнопки', icon: <CheckSquare className="h-4 w-4" /> },
|
||||
{ type: 'checkbox', label: 'Чекбокс', icon: <CheckSquare className="h-4 w-4" /> },
|
||||
{ type: 'date', label: 'Дата', icon: <Calendar className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
const CellTargetEditor: React.FC<{
|
||||
targets: CellTarget[];
|
||||
onChange: (targets: CellTarget[]) => void;
|
||||
@@ -116,6 +107,13 @@ const CellTargetEditor: React.FC<{
|
||||
|
||||
// Live Preview компонент
|
||||
const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({ element }) => {
|
||||
const elementDefinition = element.type ? getElementDefinition(element.type) : null;
|
||||
|
||||
if (elementDefinition && elementDefinition.Preview) {
|
||||
return <elementDefinition.Preview config={element as any} />;
|
||||
}
|
||||
|
||||
// Fallback для старых элементов
|
||||
const renderPreviewInput = () => {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
@@ -194,6 +192,24 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({ eleme
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'standards':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-3 border border-input rounded-md bg-background">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{element.placeholder || 'Выберите эталоны'}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 opacity-50" />
|
||||
<span className="text-xs text-muted-foreground">Выбрать</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Выбрано эталонов: 0
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-3 border border-gray-200 rounded bg-gray-50 text-center text-sm text-gray-500">
|
||||
@@ -233,6 +249,8 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({ eleme
|
||||
};
|
||||
|
||||
const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
const elementDefinition = formData.type ? getElementDefinition(formData.type) : null;
|
||||
|
||||
const handleAddOption = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -256,6 +274,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
|
||||
const needsOptions = formData.type === 'select' || formData.type === 'radio';
|
||||
|
||||
// Если есть определение элемента в реестре, используем его редактор
|
||||
if (elementDefinition && elementDefinition.Editor) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Форма настроек */}
|
||||
@@ -264,13 +284,130 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||
<label className="text-sm font-medium">Тип элемента</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={value => setFormData(prev => ({ ...prev, type: value as ElementType }))}
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType;
|
||||
console.log('Element type changed to:', newType);
|
||||
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType };
|
||||
|
||||
// Если выбран тип "standards", устанавливаем значения по умолчанию
|
||||
if (newType === 'standards') {
|
||||
console.log('Standards type selected, getting element definition');
|
||||
const elementDefinition = getElementDefinition('standards');
|
||||
console.log('Element definition:', elementDefinition);
|
||||
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig = elementDefinition.defaultConfig as any;
|
||||
console.log('Default config:', defaultConfig);
|
||||
updates.targetCells = elementDefinition.mapToCells?.(defaultConfig) || [];
|
||||
console.log('Generated targetCells:', updates.targetCells);
|
||||
}
|
||||
}
|
||||
|
||||
const newFormData = { ...prev, ...updates };
|
||||
console.log('New form data:', newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ELEMENT_TYPES.map(({ type, label, icon }) => (
|
||||
{getAvailableElementTypes().map(({ type, label, icon }) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Имя поля</label>
|
||||
<Input
|
||||
placeholder="field_name"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Подпись</label>
|
||||
<Input
|
||||
placeholder="Название поля"
|
||||
value={formData.label}
|
||||
onChange={e => setFormData(prev => ({ ...prev, label: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CellTargetEditor
|
||||
targets={formData.targetCells || []}
|
||||
onChange={(targets) => setFormData(prev => ({ ...prev, targetCells: targets }))}
|
||||
/>
|
||||
|
||||
{/* Специфичный редактор элемента */}
|
||||
<elementDefinition.Editor
|
||||
config={formData as any}
|
||||
onChange={(newConfig) => setFormData(prev => ({ ...prev, ...newConfig }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Live Preview */}
|
||||
<div className="sticky top-0">
|
||||
<ElementPreview element={formData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback для старых элементов
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Форма настроек */}
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Тип элемента</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={value => {
|
||||
const newType = value as ElementType;
|
||||
console.log('Element type changed to (fallback):', newType);
|
||||
|
||||
setFormData(prev => {
|
||||
const updates: Partial<TemplateElement> = { type: newType };
|
||||
|
||||
// Если выбран тип "standards", устанавливаем значения по умолчанию
|
||||
if (newType === 'standards') {
|
||||
console.log('Standards type selected (fallback), getting element definition');
|
||||
const elementDefinition = getElementDefinition('standards');
|
||||
console.log('Element definition (fallback):', elementDefinition);
|
||||
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig = elementDefinition.defaultConfig as any;
|
||||
console.log('Default config (fallback):', defaultConfig);
|
||||
updates.targetCells = elementDefinition.mapToCells?.(defaultConfig) || [];
|
||||
console.log('Generated targetCells (fallback):', updates.targetCells);
|
||||
}
|
||||
}
|
||||
|
||||
const newFormData = { ...prev, ...updates };
|
||||
console.log('New form data (fallback):', newFormData);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{getAvailableElementTypes().map(({ type, label, icon }) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
@@ -423,7 +560,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
onElementAdd,
|
||||
onElementUpdate,
|
||||
onElementDelete,
|
||||
onElementsReorder,
|
||||
// onElementsReorder,
|
||||
onLayoutSettingsChange,
|
||||
}) => {
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
@@ -452,7 +589,18 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
};
|
||||
|
||||
const handleAddElement = () => {
|
||||
if (!formData.name || !formData.label || !formData.targetCells?.length) return;
|
||||
console.log('handleAddElement called with formData:', formData);
|
||||
|
||||
if (!formData.name || !formData.label) {
|
||||
console.log('Validation failed: missing name or label');
|
||||
return;
|
||||
}
|
||||
|
||||
// Для элемента типа "standards" не требуем targetCells, так как они генерируются автоматически
|
||||
if (formData.type !== 'standards' && !formData.targetCells?.length) {
|
||||
console.log('Validation failed: missing targetCells for non-standards element');
|
||||
return;
|
||||
}
|
||||
|
||||
// Генерируем layout для нового элемента
|
||||
const existingLayouts = elements.map(e => e.layout).filter(Boolean);
|
||||
@@ -464,12 +612,25 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
layoutSettings.gridSize
|
||||
);
|
||||
|
||||
// Специальная обработка для эталонов
|
||||
let targetCells = formData.targetCells || [];
|
||||
if (formData.type === 'standards') {
|
||||
// Для эталонов генерируем targetCells автоматически, если их нет
|
||||
if (!targetCells.length) {
|
||||
const elementDefinition = getElementDefinition('standards');
|
||||
if (elementDefinition && elementDefinition.defaultConfig) {
|
||||
const defaultConfig = elementDefinition.defaultConfig as any;
|
||||
targetCells = elementDefinition.mapToCells?.(defaultConfig) || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newElement: TemplateElement = {
|
||||
id: Date.now().toString(),
|
||||
type: formData.type as ElementType,
|
||||
name: formData.name,
|
||||
label: formData.label,
|
||||
targetCells: formData.targetCells,
|
||||
targetCells,
|
||||
placeholder: formData.placeholder,
|
||||
required: formData.required,
|
||||
options: formData.options,
|
||||
@@ -488,7 +649,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
};
|
||||
|
||||
const handleUpdateElement = () => {
|
||||
if (!editingElement || !formData.name || !formData.label || !formData.targetCells?.length) return;
|
||||
if (!editingElement || !formData.name || !formData.label) return;
|
||||
|
||||
// Для элемента типа "standards" не требуем targetCells, так как они генерируются автоматически
|
||||
if (formData.type !== 'standards' && !formData.targetCells?.length) return;
|
||||
|
||||
onElementUpdate(editingElement.id, formData);
|
||||
setEditingElement(null);
|
||||
@@ -517,10 +681,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const reordered = (reorderedElements: TemplateElement[]) => {
|
||||
onElementsReorder(reorderedElements);
|
||||
onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
|
||||
};
|
||||
// const reordered = (reorderedElements: TemplateElement[]) => {
|
||||
// onElementsReorder(reorderedElements);
|
||||
// onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
|
||||
// };
|
||||
|
||||
|
||||
return (
|
||||
@@ -552,7 +716,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||
<Button onClick={() => setIsAddDialogOpen(false)} variant="outline">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleAddElement}>
|
||||
<Button
|
||||
onClick={handleAddElement}
|
||||
disabled={!formData.name || !formData.label || (formData.type !== 'standards' && !formData.targetCells?.length)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Добавить элемент
|
||||
</Button>
|
||||
|
||||
@@ -12,8 +12,8 @@ interface TemplateManagerProps {
|
||||
onTemplateSelect?: (template: Template) => void;
|
||||
}
|
||||
|
||||
export const TemplateManager: React.FC<TemplateManagerProps> = ({ onTemplateSelect }) => {
|
||||
const { templates, addTemplate, updateTemplate, deleteTemplate } = useTemplateContext();
|
||||
export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||
const { templates, addTemplate, updateTemplate } = useTemplateContext();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
Edit,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Hash,
|
||||
Move,
|
||||
Trash2,
|
||||
Type
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Rnd } from 'react-rnd';
|
||||
import { ElementLayout, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
|
||||
import { ElementLayout, FormLayoutSettings, TemplateElement } from '../../types/template';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
@@ -37,25 +33,25 @@ interface DraggableElementProps {
|
||||
gridSize: number;
|
||||
}
|
||||
|
||||
const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
||||
text: <Type className="h-4 w-4" />,
|
||||
textarea: <FileText className="h-4 w-4" />,
|
||||
number: <Hash className="h-4 w-4" />,
|
||||
date: <Calendar className="h-4 w-4" />,
|
||||
select: <ChevronDown className="h-4 w-4" />,
|
||||
radio: <CheckSquare className="h-4 w-4" />,
|
||||
checkbox: <CheckSquare className="h-4 w-4" />,
|
||||
};
|
||||
// const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
||||
// text: <Type className="h-4 w-4" />,
|
||||
// textarea: <FileText className="h-4 w-4" />,
|
||||
// number: <Hash className="h-4 w-4" />,
|
||||
// date: <Calendar className="h-4 w-4" />,
|
||||
// select: <ChevronDown className="h-4 w-4" />,
|
||||
// radio: <CheckSquare className="h-4 w-4" />,
|
||||
// checkbox: <CheckSquare className="h-4 w-4" />,
|
||||
// };
|
||||
|
||||
const ELEMENT_COLORS: Record<ElementType, string> = {
|
||||
text: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||
textarea: 'bg-green-100 border-green-300 text-green-800',
|
||||
number: 'bg-purple-100 border-purple-300 text-purple-800',
|
||||
date: 'bg-orange-100 border-orange-300 text-orange-800',
|
||||
select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||
radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
||||
checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
||||
};
|
||||
// const ELEMENT_COLORS: Record<ElementType, string> = {
|
||||
// text: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||
// textarea: 'bg-green-100 border-green-300 text-green-800',
|
||||
// number: 'bg-purple-100 border-purple-300 text-purple-800',
|
||||
// date: 'bg-orange-100 border-orange-300 text-orange-800',
|
||||
// select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||
// radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
||||
// checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
||||
// };
|
||||
|
||||
const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
||||
element,
|
||||
@@ -130,7 +126,7 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
||||
{(element.options?.length ? element.options : [{ label: 'Вариант 1' }]).map((opt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 rounded-full bg-white" />
|
||||
<span className="text-sm text-gray-900">{opt.label || opt.value || `Вариант ${idx + 1}`}</span>
|
||||
<span className="text-sm text-gray-900">{opt.label || `Вариант ${idx + 1}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -142,6 +138,17 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
||||
<span className="text-sm text-gray-900">{element.placeholder || element.label || 'Отметить'}</span>
|
||||
</div>
|
||||
);
|
||||
case 'standards':
|
||||
return (
|
||||
<div className="pointer-events-none w-full">
|
||||
<div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-white px-3 py-2 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{element.placeholder || 'Выберите эталоны'}
|
||||
</span>
|
||||
<Calendar className="h-4 w-4 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -239,6 +246,7 @@ export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
||||
onLayoutSettingsChange,
|
||||
onElementEdit,
|
||||
}) => {
|
||||
console.log('VisualLayoutEditor render with elements:', elements);
|
||||
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
|
||||
|
||||
const canvasSize = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
@@ -15,15 +15,13 @@ const badgeVariants = cva(
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
excel: "border-transparent bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
required: "border-transparent bg-red-100 text-red-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
@@ -32,7 +30,7 @@ export interface BadgeProps
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@@ -29,10 +30,10 @@ const CardHeader = React.forwardRef<
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
@@ -44,10 +45,10 @@ const CardTitle = React.forwardRef<
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
@@ -76,3 +77,4 @@ const CardFooter = React.forwardRef<
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
|
||||
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -70,7 +70,7 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({ children })
|
||||
const protocolNameElement = createProtocolNameElement();
|
||||
templateWithProtocolName = {
|
||||
...template,
|
||||
elements: [protocolNameElement, ...template.elements.map(el => ({ ...el, order: el.order + 1 }))]
|
||||
elements: [protocolNameElement, ...template.elements.map(el => ({ ...el, order: (el.order || 0) + 1 }))]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,7 +79,13 @@ export const TemplateProvider: React.FC<TemplateProviderProps> = ({ children })
|
||||
};
|
||||
|
||||
const updateTemplate = (updatedTemplate: Template) => {
|
||||
console.log('updateTemplate called with:', updatedTemplate);
|
||||
console.log('Elements count:', updatedTemplate.elements.length);
|
||||
|
||||
const migratedTemplate = migrateTemplate(updatedTemplate);
|
||||
console.log('Migrated template:', migratedTemplate);
|
||||
console.log('Migrated elements count:', migratedTemplate.elements.length);
|
||||
|
||||
setTemplates(prev => prev.map(t =>
|
||||
t.id === migratedTemplate.id ? migratedTemplate : t
|
||||
));
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
Workbook,
|
||||
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
||||
|
||||
interface CellData {
|
||||
value: string
|
||||
isSelected: boolean
|
||||
}
|
||||
// interface CellData {
|
||||
// value: string
|
||||
// isSelected: boolean
|
||||
// }
|
||||
|
||||
interface SheetConfig {
|
||||
name: string
|
||||
@@ -75,40 +75,40 @@ export function useMultiSheetEngine({
|
||||
}, [initialData, safeRecalc, sheets]) // Восстанавливаем зависимости
|
||||
|
||||
// Синхронизация всех листов из движка
|
||||
const syncAllSheetsFromEngine = useCallback(() => {
|
||||
if (!workbookRef.current || isRecalculatingRef.current) return
|
||||
// const syncAllSheetsFromEngine = useCallback(() => {
|
||||
// if (!workbookRef.current || isRecalculatingRef.current) return
|
||||
|
||||
const newSheetCells: Record<string, CellData[][]> = {}
|
||||
// const newSheetCells: Record<string, CellData[][]> = {}
|
||||
|
||||
sheets.forEach((sheetConfig) => {
|
||||
const sheet = workbookRef.current!.getSheet(sheetConfig.name)
|
||||
if (!sheet) return
|
||||
// sheets.forEach((sheetConfig) => {
|
||||
// const sheet = workbookRef.current!.getSheet(sheetConfig.name)
|
||||
// if (!sheet) return
|
||||
|
||||
const cells = Array(sheetConfig.rows)
|
||||
.fill(null)
|
||||
.map(() =>
|
||||
Array(sheetConfig.cols)
|
||||
.fill(null)
|
||||
.map(() => ({ value: '', isSelected: false })),
|
||||
)
|
||||
// const cells = Array(sheetConfig.rows)
|
||||
// .fill(null)
|
||||
// .map(() =>
|
||||
// Array(sheetConfig.cols)
|
||||
// .fill(null)
|
||||
// .map(() => ({ value: '', isSelected: false })),
|
||||
// )
|
||||
|
||||
// Получаем все ячейки из движка
|
||||
for (let row = 0; row < sheetConfig.rows; row++) {
|
||||
for (let col = 0; col < sheetConfig.cols; col++) {
|
||||
const cellName = columnToLabel(col) + (row + 1)
|
||||
const cell = sheet.getCell(cellName)
|
||||
// // Получаем все ячейки из движка
|
||||
// for (let row = 0; row < sheetConfig.rows; row++) {
|
||||
// for (let col = 0; col < sheetConfig.cols; col++) {
|
||||
// const cellName = columnToLabel(col) + (row + 1)
|
||||
// const cell = sheet.getCell(cellName)
|
||||
|
||||
if (cell.raw !== null && cell.raw !== undefined) {
|
||||
cells[row][col].value = String(cell.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (cell.raw !== null && cell.raw !== undefined) {
|
||||
// cells[row][col] = String(cell.raw)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
newSheetCells[sheetConfig.name] = cells
|
||||
})
|
||||
// newSheetCells[sheetConfig.name] = cells
|
||||
// })
|
||||
|
||||
// setSheetCells(newSheetCells) // Удалено, так как sheetCells убрано
|
||||
}, [sheets])
|
||||
// // setSheetCells(newSheetCells) // Удалено, так как sheetCells убрано
|
||||
// }, [sheets])
|
||||
|
||||
// Отложенный пересчет с дебаунсом
|
||||
const debouncedRecalc = useCallback(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CellValue,
|
||||
Workbook,
|
||||
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
||||
import { addCustomFunctions } from '../lib/spreadsheet-example'
|
||||
// import { addCustomFunctions } from '../lib/spreadsheet-example'
|
||||
|
||||
interface CellData {
|
||||
value: string
|
||||
@@ -41,7 +41,7 @@ export function useSpreadsheetEngine({
|
||||
if (!workbookRef.current) {
|
||||
workbookRef.current = new Workbook()
|
||||
workbookRef.current.addSheet(sheetName)
|
||||
addCustomFunctions(workbookRef.current)
|
||||
// addCustomFunctions(workbookRef.current)
|
||||
}
|
||||
|
||||
// Загружаем начальные данные если они есть
|
||||
@@ -236,7 +236,7 @@ export function useSpreadsheetEngine({
|
||||
try {
|
||||
const data = JSON.parse(jsonData)
|
||||
workbookRef.current.fromJSON(data)
|
||||
addCustomFunctions(workbookRef.current)
|
||||
// addCustomFunctions(workbookRef.current)
|
||||
workbookRef.current.recalc()
|
||||
syncFromEngine()
|
||||
} catch (error) {
|
||||
|
||||
26
src/lib/element-registry-init.ts
Normal file
26
src/lib/element-registry-init.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
checkboxDefinition,
|
||||
dateDefinition,
|
||||
numberDefinition,
|
||||
radioDefinition,
|
||||
selectDefinition,
|
||||
textareaDefinition,
|
||||
textDefinition
|
||||
} from "@/components/BasicElements";
|
||||
import { standardsDefinition } from "@/components/StandardsElement/definition";
|
||||
import { registerElement } from "./element-registry";
|
||||
|
||||
// Регистрация всех элементов
|
||||
export function initializeElementRegistry() {
|
||||
// Базовые элементы
|
||||
registerElement("text", textDefinition);
|
||||
registerElement("select", selectDefinition);
|
||||
registerElement("number", numberDefinition);
|
||||
registerElement("date", dateDefinition);
|
||||
registerElement("textarea", textareaDefinition);
|
||||
registerElement("checkbox", checkboxDefinition);
|
||||
registerElement("radio", radioDefinition);
|
||||
|
||||
// Специальные элементы
|
||||
registerElement("standards", standardsDefinition);
|
||||
}
|
||||
42
src/lib/element-registry.ts
Normal file
42
src/lib/element-registry.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ReactNode } from "react";
|
||||
import { CellTarget, ElementType } from "../types/template";
|
||||
|
||||
export interface ElementDefinition<Config = any, Value = any> {
|
||||
/* служебные */
|
||||
type: ElementType; // "text" | "select" | ...
|
||||
label: string; // Человекочитаемое имя
|
||||
icon: ReactNode;
|
||||
|
||||
/* React-компоненты */
|
||||
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[]; // куда пишем данные
|
||||
}
|
||||
|
||||
export const elementRegistry: Record<ElementType, ElementDefinition> = {} as any;
|
||||
|
||||
// Функция для регистрации элемента
|
||||
export function registerElement<T extends ElementType>(
|
||||
type: T,
|
||||
definition: ElementDefinition
|
||||
) {
|
||||
elementRegistry[type] = definition;
|
||||
}
|
||||
|
||||
// Получить все доступные типы элементов
|
||||
export function getAvailableElementTypes(): Array<{ type: ElementType; label: string; icon: ReactNode }> {
|
||||
return Object.values(elementRegistry).map(({ type, label, icon }) => ({
|
||||
type,
|
||||
label,
|
||||
icon,
|
||||
}));
|
||||
}
|
||||
|
||||
// Получить определение элемента по типу
|
||||
export function getElementDefinition(type: ElementType): ElementDefinition | undefined {
|
||||
return elementRegistry[type];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_FUNCTIONS, VOLATILE_FUNCTIONS } from './functions'
|
||||
import { DEFAULT_FUNCTIONS, ExcelFunction, VOLATILE_FUNCTIONS } from './functions'
|
||||
|
||||
// Регулярные выражения для ссылок
|
||||
const QUALIFIED_REF_RE = /\b[A-Za-z0-9_]+![A-Z]+[0-9]+\b/g // SheetName!A1
|
||||
|
||||
@@ -39,8 +39,6 @@ export function migrateTemplateElement(element: any): TemplateElement {
|
||||
// Получение настроек отображения по умолчанию
|
||||
export function getDefaultLayoutSettings(): FormLayoutSettings {
|
||||
return {
|
||||
canvasWidth: 1200,
|
||||
canvasHeight: 800,
|
||||
gridSize: 20,
|
||||
showGrid: true,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,12 @@ export const ElementsCreation: FC = () => {
|
||||
};
|
||||
|
||||
const handleElementAdd = (element: TemplateElement) => {
|
||||
if (!selectedTemplate) return;
|
||||
console.log('handleElementAdd called with element:', element);
|
||||
|
||||
if (!selectedTemplate) {
|
||||
console.log('No selected template');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTemplate = {
|
||||
...selectedTemplate,
|
||||
@@ -25,6 +30,9 @@ export const ElementsCreation: FC = () => {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
console.log('Updated template:', updatedTemplate);
|
||||
console.log('Elements count:', updatedTemplate.elements.length);
|
||||
|
||||
updateTemplate(updatedTemplate);
|
||||
setSelectedTemplate(updatedTemplate);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FC } from "react";
|
||||
import { DualSpreadsheet } from "../../../../components";
|
||||
import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data";
|
||||
// import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data";
|
||||
|
||||
const Home: FC = () => {
|
||||
const initialData = {
|
||||
Report: exampleTemplateData,
|
||||
Calculations: exampleCalculationsData
|
||||
Report: {},
|
||||
Calculations: {}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,95 @@
|
||||
import { ArrowLeft, Download, FileText, Plus, Save } from 'lucide-react';
|
||||
import { ArrowLeft, Download, FileText, Plus, Save, Settings, Wrench } from 'lucide-react';
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { StandardsSelector } from '../../../../components/StandardsElement/StandardsSelector';
|
||||
import { Badge } from '../../../../components/ui/badge';
|
||||
import { Button } from '../../../../components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
|
||||
import { Checkbox } from '../../../../components/ui/checkbox';
|
||||
import { Input } from '../../../../components/ui/input';
|
||||
import { Label } from '../../../../components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '../../../../components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../../components/ui/select';
|
||||
import { Textarea } from '../../../../components/ui/textarea';
|
||||
import { useTemplateContext } from '../../../../contexts/TemplateContext';
|
||||
import { Template, TemplateElement } from '../../../../types/template';
|
||||
|
||||
// Моковые данные для эталонов (такие же как в StandardsSelector)
|
||||
const mockStandards = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Эталон массы 1 кг",
|
||||
shortName: "ЭМ-1кг",
|
||||
type: "Масса",
|
||||
registryNumber: "ГРСИ 12345-01",
|
||||
range: "0.5-2 кг",
|
||||
accuracy: "±0.001 г",
|
||||
certificateNumber: "СИ-2024-001",
|
||||
validUntil: "2025-12-31",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Эталон длины 1 м",
|
||||
shortName: "ЭД-1м",
|
||||
type: "Длина",
|
||||
registryNumber: "ГРСИ 12345-02",
|
||||
range: "0.5-2 м",
|
||||
accuracy: "±0.001 мм",
|
||||
certificateNumber: "СИ-2024-002",
|
||||
validUntil: "2025-06-30",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Эталон температуры 20°C",
|
||||
shortName: "ЭТ-20°C",
|
||||
type: "Температура",
|
||||
registryNumber: "ГРСИ 12345-03",
|
||||
range: "15-25°C",
|
||||
accuracy: "±0.01°C",
|
||||
certificateNumber: "СИ-2024-003",
|
||||
validUntil: "2025-03-15",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Эталон давления 1 Па",
|
||||
shortName: "ЭД-1Па",
|
||||
type: "Давление",
|
||||
registryNumber: "ГРСИ 12345-04",
|
||||
range: "0.5-2 Па",
|
||||
accuracy: "±0.001 Па",
|
||||
certificateNumber: "СИ-2024-004",
|
||||
validUntil: "2025-09-20",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Эталон времени 1 с",
|
||||
shortName: "ЭВ-1с",
|
||||
type: "Время",
|
||||
registryNumber: "ГРСИ 12345-05",
|
||||
range: "0.5-2 с",
|
||||
accuracy: "±0.000001 с",
|
||||
certificateNumber: "СИ-2024-005",
|
||||
validUntil: "2025-12-01",
|
||||
},
|
||||
];
|
||||
|
||||
// Функция для получения данных эталона по ID
|
||||
const getStandardById = (id: string) => {
|
||||
return mockStandards.find(standard => standard.id === id);
|
||||
};
|
||||
|
||||
// Функция для получения типа эталона в сокращенном виде
|
||||
const getStandardTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case "Манометр грузопоршневой":
|
||||
return "МГП";
|
||||
case "Калибратор давления":
|
||||
return "КД";
|
||||
default:
|
||||
return "МО";
|
||||
}
|
||||
};
|
||||
|
||||
interface ProtocolFormProps {
|
||||
template: Template;
|
||||
onSave: (data: Record<string, any>) => void;
|
||||
@@ -25,6 +103,8 @@ interface FormElementProps {
|
||||
}
|
||||
|
||||
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
||||
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false);
|
||||
|
||||
const renderInput = () => {
|
||||
switch (element.type) {
|
||||
case 'text':
|
||||
@@ -113,6 +193,76 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'standards':
|
||||
const selectedStandards = Array.isArray(value) ? value : [];
|
||||
const selectedStandardsData = selectedStandards
|
||||
.map(id => getStandardById(id))
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Блок с измерительными эталонами */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm font-medium">Эталоны</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsStandardsDialogOpen(true)}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
<Settings className="w-3 h-3 mr-1" />
|
||||
Настроить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{selectedStandardsData.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{selectedStandardsData.map((standard, index) => (
|
||||
standard && (
|
||||
<div
|
||||
key={standard.id}
|
||||
className="flex items-center gap-2 p-2 bg-muted/30 rounded-md text-sm"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs font-medium text-primary">{index + 1}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-xs text-foreground truncate">
|
||||
{standard.shortName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{standard.accuracy} • {standard.range}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
<Wrench className="w-3 h-3 mr-1" />
|
||||
{getStandardTypeBadge(standard.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground bg-muted/30 rounded-md p-3 border border-dashed text-center">
|
||||
<Settings className="w-4 h-4 mx-auto mb-1 opacity-50" />
|
||||
Эталоны не выбраны
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StandardsSelector
|
||||
isOpen={isStandardsDialogOpen}
|
||||
onClose={() => setIsStandardsDialogOpen(false)}
|
||||
value={selectedStandards}
|
||||
onChange={onChange}
|
||||
registryNumber={element.targetCells?.[0]?.displayName || "ГРСИ 12345"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -125,7 +275,7 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
||||
{element.label}
|
||||
</label>
|
||||
{element.required && (
|
||||
<Badge variant="required" className="text-xs">
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
!
|
||||
</Badge>
|
||||
)}
|
||||
@@ -136,7 +286,7 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
||||
{element.targetCells.map((target, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="excel"
|
||||
variant="secondary"
|
||||
className="text-xs font-mono"
|
||||
title={target.displayName}
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ElementType =
|
||||
| 'number'
|
||||
| 'textarea'
|
||||
| 'date'
|
||||
| 'standards'
|
||||
|
||||
export interface ElementOption {
|
||||
value: string
|
||||
|
||||
@@ -550,6 +550,13 @@
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-separator@^1.1.7":
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
|
||||
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
|
||||
dependencies:
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
|
||||
"@radix-ui/react-slot@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
|
||||
|
||||
Reference in New Issue
Block a user