архитектура добавления элементов, список эталонов

This commit is contained in:
2025-07-18 07:45:43 +03:00
parent dfb4d377d0
commit 7cf319f25d
36 changed files with 2100 additions and 123 deletions

166
ARCHITECTURE.md Normal file
View 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`. Для полной миграции рекомендуется перевести все элементы на новую архитектуру.

View File

@@ -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",

View File

@@ -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}>

View 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>
),
};

View 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}
/>
),
};

View 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}
/>
),
};

View 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>
),
};

View 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>
),
};

View 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}
/>
),
};

View 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"
/>
),
};

View 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";

View File

@@ -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

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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;
},
};

View File

@@ -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,100 @@ 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 (
<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:', newType);
setFormData(prev => {
const updates: Partial<TemplateElement> = { type: newType };
// Если выбран тип "standards", устанавливаем значения по умолчанию
if (newType === 'standards') {
console.log('Standards type selected, getting element definition');
const elementDefinition = getElementDefinition('standards');
console.log('Element definition:', elementDefinition);
if (elementDefinition && elementDefinition.defaultConfig) {
const defaultConfig = elementDefinition.defaultConfig as any;
console.log('Default config:', defaultConfig);
updates.targetCells = elementDefinition.mapToCells?.(defaultConfig) || [];
console.log('Generated targetCells:', updates.targetCells);
}
}
const newFormData = { ...prev, ...updates };
console.log('New form data:', newFormData);
return newFormData;
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{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 ( return (
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
{/* Форма настроек */} {/* Форма настроек */}
@@ -264,13 +376,38 @@ 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 (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> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{ELEMENT_TYPES.map(({ type, label, icon }) => ( {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>

View File

@@ -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);

View File

@@ -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(() => {

View File

@@ -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 };

View File

@@ -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 }

View 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 }

View 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 }

View File

@@ -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
)); ));

View File

@@ -1,13 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
CellValue, CellValue,
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(() => {

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
CellUtils, CellUtils,
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) {

View 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);
}

View 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];
}

View File

@@ -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

View File

@@ -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,
}; };

View File

@@ -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);
}; };

View File

@@ -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 (

View File

@@ -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}
> >

View File

@@ -7,6 +7,7 @@ export type ElementType =
| 'number' | 'number'
| 'textarea' | 'textarea'
| 'date' | 'date'
| 'standards'
export interface ElementOption { export interface ElementOption {
value: string value: string

View File

@@ -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"