архитектура добавления элементов, список эталонов
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-label": "^2.1.7",
|
||||||
"@radix-ui/react-radio-group": "^1.3.7",
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-switch": "^1.2.5",
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
"@radix-ui/react-tabs": "^1.1.12",
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
"@radix-ui/react-toast": "^1.2.14",
|
"@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 { 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 App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
// Инициализируем реестр элементов
|
||||||
|
initializeElementRegistry();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Provider store={store}>
|
<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(
|
const FormulaBar = memo(
|
||||||
({
|
({
|
||||||
sheetType,
|
|
||||||
activeSheet,
|
|
||||||
selectedCell,
|
selectedCell,
|
||||||
formulaBarValue,
|
formulaBarValue,
|
||||||
isCalculating,
|
isCalculating,
|
||||||
@@ -181,7 +179,7 @@ const FormulaBar = memo(
|
|||||||
onBlur,
|
onBlur,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
formulaBarRef,
|
formulaBarRef,
|
||||||
}: FormulaBarProps) => {
|
}: Omit<FormulaBarProps, 'sheetType' | 'activeSheet'>) => {
|
||||||
// Теперь FormulaBar всегда отображается, так как он один
|
// Теперь FormulaBar всегда отображается, так как он один
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -958,8 +956,6 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-background">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<FormulaBar
|
<FormulaBar
|
||||||
activeSheet={activeSheet}
|
|
||||||
sheetType={activeSheet} // Передаем activeSheet как текущий sheetType
|
|
||||||
selectedCell={selectedCell}
|
selectedCell={selectedCell}
|
||||||
formulaBarValue={formulaBarValue}
|
formulaBarValue={formulaBarValue}
|
||||||
isCalculating={multiSheetEngine.isCalculating}
|
isCalculating={multiSheetEngine.isCalculating}
|
||||||
@@ -968,7 +964,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
onFocus={handleFormulaBarFocus}
|
onFocus={handleFormulaBarFocus}
|
||||||
onBlur={handleFormulaBarBlur}
|
onBlur={handleFormulaBarBlur}
|
||||||
onKeyDown={handleFormulaBarKeyDown}
|
onKeyDown={handleFormulaBarKeyDown}
|
||||||
formulaBarRef={formulaBarRef}
|
formulaBarRef={(el) => { formulaBarRef.current = el; }}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-w-0 gap-1 overflow-hidden p-1">
|
<div className="flex flex-1 min-w-0 gap-1 overflow-hidden p-1">
|
||||||
<Spreadsheet
|
<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 React, { useState } from 'react';
|
||||||
|
import { getAvailableElementTypes, getElementDefinition } from '../../lib/element-registry';
|
||||||
import { autoArrangeElements, findFreePosition, generateDefaultLayout } from '../../lib/utils';
|
import { autoArrangeElements, findFreePosition, generateDefaultLayout } from '../../lib/utils';
|
||||||
import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
|
import { CellTarget, ElementType, FormLayoutSettings, TemplateElement } from '../../types/template';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
@@ -15,16 +16,6 @@ interface ElementFormProps {
|
|||||||
setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>;
|
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<{
|
const CellTargetEditor: React.FC<{
|
||||||
targets: CellTarget[];
|
targets: CellTarget[];
|
||||||
onChange: (targets: CellTarget[]) => void;
|
onChange: (targets: CellTarget[]) => void;
|
||||||
@@ -116,6 +107,13 @@ const CellTargetEditor: React.FC<{
|
|||||||
|
|
||||||
// Live Preview компонент
|
// Live Preview компонент
|
||||||
const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({ element }) => {
|
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 = () => {
|
const renderPreviewInput = () => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
@@ -194,6 +192,24 @@ const ElementPreview: React.FC<{ element: Partial<TemplateElement> }> = ({ eleme
|
|||||||
</div>
|
</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:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="p-3 border border-gray-200 rounded bg-gray-50 text-center text-sm text-gray-500">
|
<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 ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||||
|
const elementDefinition = formData.type ? getElementDefinition(formData.type) : null;
|
||||||
|
|
||||||
const handleAddOption = () => {
|
const handleAddOption = () => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -256,6 +274,8 @@ const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
|||||||
|
|
||||||
const needsOptions = formData.type === 'select' || formData.type === 'radio';
|
const needsOptions = formData.type === 'select' || formData.type === 'radio';
|
||||||
|
|
||||||
|
// Если есть определение элемента в реестре, используем его редактор
|
||||||
|
if (elementDefinition && elementDefinition.Editor) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<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>
|
<label className="text-sm font-medium">Тип элемента</label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.type}
|
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>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<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}>
|
<SelectItem key={type} value={type}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
@@ -423,7 +560,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
onElementAdd,
|
onElementAdd,
|
||||||
onElementUpdate,
|
onElementUpdate,
|
||||||
onElementDelete,
|
onElementDelete,
|
||||||
onElementsReorder,
|
// onElementsReorder,
|
||||||
onLayoutSettingsChange,
|
onLayoutSettingsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
@@ -452,7 +589,18 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddElement = () => {
|
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 для нового элемента
|
// Генерируем layout для нового элемента
|
||||||
const existingLayouts = elements.map(e => e.layout).filter(Boolean);
|
const existingLayouts = elements.map(e => e.layout).filter(Boolean);
|
||||||
@@ -464,12 +612,25 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
layoutSettings.gridSize
|
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 = {
|
const newElement: TemplateElement = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
type: formData.type as ElementType,
|
type: formData.type as ElementType,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
label: formData.label,
|
label: formData.label,
|
||||||
targetCells: formData.targetCells,
|
targetCells,
|
||||||
placeholder: formData.placeholder,
|
placeholder: formData.placeholder,
|
||||||
required: formData.required,
|
required: formData.required,
|
||||||
options: formData.options,
|
options: formData.options,
|
||||||
@@ -488,7 +649,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateElement = () => {
|
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);
|
onElementUpdate(editingElement.id, formData);
|
||||||
setEditingElement(null);
|
setEditingElement(null);
|
||||||
@@ -517,10 +681,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
resetForm();
|
resetForm();
|
||||||
};
|
};
|
||||||
|
|
||||||
const reordered = (reorderedElements: TemplateElement[]) => {
|
// const reordered = (reorderedElements: TemplateElement[]) => {
|
||||||
onElementsReorder(reorderedElements);
|
// onElementsReorder(reorderedElements);
|
||||||
onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
|
// onLayoutSettingsChange({ ...layoutSettings, showGrid: true });
|
||||||
};
|
// };
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -552,7 +716,10 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
<Button onClick={() => setIsAddDialogOpen(false)} variant="outline">
|
<Button onClick={() => setIsAddDialogOpen(false)} variant="outline">
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</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" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Добавить элемент
|
Добавить элемент
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ interface TemplateManagerProps {
|
|||||||
onTemplateSelect?: (template: Template) => void;
|
onTemplateSelect?: (template: Template) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateManager: React.FC<TemplateManagerProps> = ({ onTemplateSelect }) => {
|
export const TemplateManager: React.FC<TemplateManagerProps> = () => {
|
||||||
const { templates, addTemplate, updateTemplate, deleteTemplate } = useTemplateContext();
|
const { templates, addTemplate, updateTemplate } = useTemplateContext();
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
CheckSquare,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Edit,
|
Edit,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FileText,
|
|
||||||
Hash,
|
|
||||||
Move,
|
Move,
|
||||||
Trash2,
|
Trash2
|
||||||
Type
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Rnd } from 'react-rnd';
|
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 { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Textarea } from '../ui/textarea';
|
import { Textarea } from '../ui/textarea';
|
||||||
@@ -37,25 +33,25 @@ interface DraggableElementProps {
|
|||||||
gridSize: number;
|
gridSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
// const ELEMENT_ICONS: Record<ElementType, React.ReactNode> = {
|
||||||
text: <Type className="h-4 w-4" />,
|
// text: <Type className="h-4 w-4" />,
|
||||||
textarea: <FileText className="h-4 w-4" />,
|
// textarea: <FileText className="h-4 w-4" />,
|
||||||
number: <Hash className="h-4 w-4" />,
|
// number: <Hash className="h-4 w-4" />,
|
||||||
date: <Calendar className="h-4 w-4" />,
|
// date: <Calendar className="h-4 w-4" />,
|
||||||
select: <ChevronDown className="h-4 w-4" />,
|
// select: <ChevronDown className="h-4 w-4" />,
|
||||||
radio: <CheckSquare className="h-4 w-4" />,
|
// radio: <CheckSquare className="h-4 w-4" />,
|
||||||
checkbox: <CheckSquare className="h-4 w-4" />,
|
// checkbox: <CheckSquare className="h-4 w-4" />,
|
||||||
};
|
// };
|
||||||
|
|
||||||
const ELEMENT_COLORS: Record<ElementType, string> = {
|
// const ELEMENT_COLORS: Record<ElementType, string> = {
|
||||||
text: 'bg-blue-100 border-blue-300 text-blue-800',
|
// text: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||||
textarea: 'bg-green-100 border-green-300 text-green-800',
|
// textarea: 'bg-green-100 border-green-300 text-green-800',
|
||||||
number: 'bg-purple-100 border-purple-300 text-purple-800',
|
// number: 'bg-purple-100 border-purple-300 text-purple-800',
|
||||||
date: 'bg-orange-100 border-orange-300 text-orange-800',
|
// date: 'bg-orange-100 border-orange-300 text-orange-800',
|
||||||
select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
// select: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||||
radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
// radio: 'bg-pink-100 border-pink-300 text-pink-800',
|
||||||
checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
// checkbox: 'bg-teal-100 border-teal-300 text-teal-800',
|
||||||
};
|
// };
|
||||||
|
|
||||||
const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
||||||
element,
|
element,
|
||||||
@@ -130,7 +126,7 @@ const DraggableElement: React.FC<DraggableElementProps> = React.memo(({
|
|||||||
{(element.options?.length ? element.options : [{ label: 'Вариант 1' }]).map((opt, idx) => (
|
{(element.options?.length ? element.options : [{ label: 'Вариант 1' }]).map((opt, idx) => (
|
||||||
<div key={idx} className="flex items-center gap-2">
|
<div key={idx} className="flex items-center gap-2">
|
||||||
<div className="w-4 h-4 border-2 border-gray-300 rounded-full bg-white" />
|
<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>
|
||||||
))}
|
))}
|
||||||
</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>
|
<span className="text-sm text-gray-900">{element.placeholder || element.label || 'Отметить'}</span>
|
||||||
</div>
|
</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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -239,6 +246,7 @@ export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
|||||||
onLayoutSettingsChange,
|
onLayoutSettingsChange,
|
||||||
onElementEdit,
|
onElementEdit,
|
||||||
}) => {
|
}) => {
|
||||||
|
console.log('VisualLayoutEditor render with elements:', elements);
|
||||||
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
|
const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canvasSize = useMemo(() => {
|
const canvasSize = useMemo(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "../../lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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",
|
"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:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
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: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
export interface BadgeProps
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
@@ -32,7 +30,7 @@ export interface BadgeProps
|
|||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@@ -29,10 +30,10 @@ const CardHeader = React.forwardRef<
|
|||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h3
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
@@ -44,10 +45,10 @@ const CardTitle = React.forwardRef<
|
|||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -76,3 +77,4 @@ const CardFooter = React.forwardRef<
|
|||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
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();
|
const protocolNameElement = createProtocolNameElement();
|
||||||
templateWithProtocolName = {
|
templateWithProtocolName = {
|
||||||
...template,
|
...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) => {
|
const updateTemplate = (updatedTemplate: Template) => {
|
||||||
|
console.log('updateTemplate called with:', updatedTemplate);
|
||||||
|
console.log('Elements count:', updatedTemplate.elements.length);
|
||||||
|
|
||||||
const migratedTemplate = migrateTemplate(updatedTemplate);
|
const migratedTemplate = migrateTemplate(updatedTemplate);
|
||||||
|
console.log('Migrated template:', migratedTemplate);
|
||||||
|
console.log('Migrated elements count:', migratedTemplate.elements.length);
|
||||||
|
|
||||||
setTemplates(prev => prev.map(t =>
|
setTemplates(prev => prev.map(t =>
|
||||||
t.id === migratedTemplate.id ? migratedTemplate : t
|
t.id === migratedTemplate.id ? migratedTemplate : t
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {
|
|||||||
Workbook,
|
Workbook,
|
||||||
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
||||||
|
|
||||||
interface CellData {
|
// interface CellData {
|
||||||
value: string
|
// value: string
|
||||||
isSelected: boolean
|
// isSelected: boolean
|
||||||
}
|
// }
|
||||||
|
|
||||||
interface SheetConfig {
|
interface SheetConfig {
|
||||||
name: string
|
name: string
|
||||||
@@ -75,40 +75,40 @@ export function useMultiSheetEngine({
|
|||||||
}, [initialData, safeRecalc, sheets]) // Восстанавливаем зависимости
|
}, [initialData, safeRecalc, sheets]) // Восстанавливаем зависимости
|
||||||
|
|
||||||
// Синхронизация всех листов из движка
|
// Синхронизация всех листов из движка
|
||||||
const syncAllSheetsFromEngine = useCallback(() => {
|
// const syncAllSheetsFromEngine = useCallback(() => {
|
||||||
if (!workbookRef.current || isRecalculatingRef.current) return
|
// if (!workbookRef.current || isRecalculatingRef.current) return
|
||||||
|
|
||||||
const newSheetCells: Record<string, CellData[][]> = {}
|
// const newSheetCells: Record<string, CellData[][]> = {}
|
||||||
|
|
||||||
sheets.forEach((sheetConfig) => {
|
// sheets.forEach((sheetConfig) => {
|
||||||
const sheet = workbookRef.current!.getSheet(sheetConfig.name)
|
// const sheet = workbookRef.current!.getSheet(sheetConfig.name)
|
||||||
if (!sheet) return
|
// if (!sheet) return
|
||||||
|
|
||||||
const cells = Array(sheetConfig.rows)
|
// const cells = Array(sheetConfig.rows)
|
||||||
.fill(null)
|
// .fill(null)
|
||||||
.map(() =>
|
// .map(() =>
|
||||||
Array(sheetConfig.cols)
|
// Array(sheetConfig.cols)
|
||||||
.fill(null)
|
// .fill(null)
|
||||||
.map(() => ({ value: '', isSelected: false })),
|
// .map(() => ({ value: '', isSelected: false })),
|
||||||
)
|
// )
|
||||||
|
|
||||||
// Получаем все ячейки из движка
|
// // Получаем все ячейки из движка
|
||||||
for (let row = 0; row < sheetConfig.rows; row++) {
|
// for (let row = 0; row < sheetConfig.rows; row++) {
|
||||||
for (let col = 0; col < sheetConfig.cols; col++) {
|
// for (let col = 0; col < sheetConfig.cols; col++) {
|
||||||
const cellName = columnToLabel(col) + (row + 1)
|
// const cellName = columnToLabel(col) + (row + 1)
|
||||||
const cell = sheet.getCell(cellName)
|
// const cell = sheet.getCell(cellName)
|
||||||
|
|
||||||
if (cell.raw !== null && cell.raw !== undefined) {
|
// if (cell.raw !== null && cell.raw !== undefined) {
|
||||||
cells[row][col].value = String(cell.raw)
|
// cells[row][col] = String(cell.raw)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
newSheetCells[sheetConfig.name] = cells
|
// newSheetCells[sheetConfig.name] = cells
|
||||||
})
|
// })
|
||||||
|
|
||||||
// setSheetCells(newSheetCells) // Удалено, так как sheetCells убрано
|
// // setSheetCells(newSheetCells) // Удалено, так как sheetCells убрано
|
||||||
}, [sheets])
|
// }, [sheets])
|
||||||
|
|
||||||
// Отложенный пересчет с дебаунсом
|
// Отложенный пересчет с дебаунсом
|
||||||
const debouncedRecalc = useCallback(() => {
|
const debouncedRecalc = useCallback(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
CellValue,
|
CellValue,
|
||||||
Workbook,
|
Workbook,
|
||||||
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
} from '../lib/spreadsheet-engine/spreadsheet-engine'
|
||||||
import { addCustomFunctions } from '../lib/spreadsheet-example'
|
// import { addCustomFunctions } from '../lib/spreadsheet-example'
|
||||||
|
|
||||||
interface CellData {
|
interface CellData {
|
||||||
value: string
|
value: string
|
||||||
@@ -41,7 +41,7 @@ export function useSpreadsheetEngine({
|
|||||||
if (!workbookRef.current) {
|
if (!workbookRef.current) {
|
||||||
workbookRef.current = new Workbook()
|
workbookRef.current = new Workbook()
|
||||||
workbookRef.current.addSheet(sheetName)
|
workbookRef.current.addSheet(sheetName)
|
||||||
addCustomFunctions(workbookRef.current)
|
// addCustomFunctions(workbookRef.current)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем начальные данные если они есть
|
// Загружаем начальные данные если они есть
|
||||||
@@ -236,7 +236,7 @@ export function useSpreadsheetEngine({
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(jsonData)
|
const data = JSON.parse(jsonData)
|
||||||
workbookRef.current.fromJSON(data)
|
workbookRef.current.fromJSON(data)
|
||||||
addCustomFunctions(workbookRef.current)
|
// addCustomFunctions(workbookRef.current)
|
||||||
workbookRef.current.recalc()
|
workbookRef.current.recalc()
|
||||||
syncFromEngine()
|
syncFromEngine()
|
||||||
} catch (error) {
|
} 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
|
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 {
|
export function getDefaultLayoutSettings(): FormLayoutSettings {
|
||||||
return {
|
return {
|
||||||
canvasWidth: 1200,
|
|
||||||
canvasHeight: 800,
|
|
||||||
gridSize: 20,
|
gridSize: 20,
|
||||||
showGrid: true,
|
showGrid: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export const ElementsCreation: FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleElementAdd = (element: TemplateElement) => {
|
const handleElementAdd = (element: TemplateElement) => {
|
||||||
if (!selectedTemplate) return;
|
console.log('handleElementAdd called with element:', element);
|
||||||
|
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
console.log('No selected template');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updatedTemplate = {
|
const updatedTemplate = {
|
||||||
...selectedTemplate,
|
...selectedTemplate,
|
||||||
@@ -25,6 +30,9 @@ export const ElementsCreation: FC = () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('Updated template:', updatedTemplate);
|
||||||
|
console.log('Elements count:', updatedTemplate.elements.length);
|
||||||
|
|
||||||
updateTemplate(updatedTemplate);
|
updateTemplate(updatedTemplate);
|
||||||
setSelectedTemplate(updatedTemplate);
|
setSelectedTemplate(updatedTemplate);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { DualSpreadsheet } from "../../../../components";
|
import { DualSpreadsheet } from "../../../../components";
|
||||||
import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data";
|
// import { exampleCalculationsData, exampleTemplateData } from "../../../../lib/template-data";
|
||||||
|
|
||||||
const Home: FC = () => {
|
const Home: FC = () => {
|
||||||
const initialData = {
|
const initialData = {
|
||||||
Report: exampleTemplateData,
|
Report: {},
|
||||||
Calculations: exampleCalculationsData
|
Calculations: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 { FC, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { StandardsSelector } from '../../../../components/StandardsElement/StandardsSelector';
|
||||||
import { Badge } from '../../../../components/ui/badge';
|
import { Badge } from '../../../../components/ui/badge';
|
||||||
import { Button } from '../../../../components/ui/button';
|
import { Button } from '../../../../components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
|
||||||
import { Checkbox } from '../../../../components/ui/checkbox';
|
import { Checkbox } from '../../../../components/ui/checkbox';
|
||||||
import { Input } from '../../../../components/ui/input';
|
import { Input } from '../../../../components/ui/input';
|
||||||
|
import { Label } from '../../../../components/ui/label';
|
||||||
import { RadioGroup, RadioGroupItem } from '../../../../components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '../../../../components/ui/radio-group';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../../components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../../components/ui/select';
|
||||||
import { Textarea } from '../../../../components/ui/textarea';
|
import { Textarea } from '../../../../components/ui/textarea';
|
||||||
import { useTemplateContext } from '../../../../contexts/TemplateContext';
|
import { useTemplateContext } from '../../../../contexts/TemplateContext';
|
||||||
import { Template, TemplateElement } from '../../../../types/template';
|
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 {
|
interface ProtocolFormProps {
|
||||||
template: Template;
|
template: Template;
|
||||||
onSave: (data: Record<string, any>) => void;
|
onSave: (data: Record<string, any>) => void;
|
||||||
@@ -25,6 +103,8 @@ interface FormElementProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
||||||
|
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false);
|
||||||
|
|
||||||
const renderInput = () => {
|
const renderInput = () => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
@@ -113,6 +193,76 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
|||||||
</div>
|
</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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -125,7 +275,7 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
|||||||
{element.label}
|
{element.label}
|
||||||
</label>
|
</label>
|
||||||
{element.required && (
|
{element.required && (
|
||||||
<Badge variant="required" className="text-xs">
|
<Badge variant="destructive" className="text-xs">
|
||||||
!
|
!
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -136,7 +286,7 @@ const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
|
|||||||
{element.targetCells.map((target, index) => (
|
{element.targetCells.map((target, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={index}
|
||||||
variant="excel"
|
variant="secondary"
|
||||||
className="text-xs font-mono"
|
className="text-xs font-mono"
|
||||||
title={target.displayName}
|
title={target.displayName}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type ElementType =
|
|||||||
| 'number'
|
| 'number'
|
||||||
| 'textarea'
|
| 'textarea'
|
||||||
| 'date'
|
| 'date'
|
||||||
|
| 'standards'
|
||||||
|
|
||||||
export interface ElementOption {
|
export interface ElementOption {
|
||||||
value: string
|
value: string
|
||||||
|
|||||||
@@ -550,6 +550,13 @@
|
|||||||
aria-hidden "^1.2.4"
|
aria-hidden "^1.2.4"
|
||||||
react-remove-scroll "^2.6.3"
|
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":
|
"@radix-ui/react-slot@1.2.3":
|
||||||
version "1.2.3"
|
version "1.2.3"
|
||||||
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
|
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user