Files
protoc-frontend/src/pages/ProtocolCreation/ui/Page/Page.tsx

537 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ArrowLeft, Download, FileText, Grid, Plus, Save, Settings, Wrench } from 'lucide-react';
import { FC, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { StandardsSelector } from '../../../../components/StandardsElement/StandardsSelector';
import { Badge } from '../../../../components/ui/badge';
import { Button } from '../../../../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../../components/ui/card';
import { Checkbox } from '../../../../components/ui/checkbox';
import { Input } from '../../../../components/ui/input';
import { Label } from '../../../../components/ui/label';
import { RadioGroup, RadioGroupItem } from '../../../../components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../../components/ui/select';
import { Textarea } from '../../../../components/ui/textarea';
import { useTemplateContext } from '../../../../contexts/TemplateContext';
import { Template, TemplateElement } from '../../../../types/template';
// Моковые данные для эталонов (такие же как в StandardsSelector)
const mockStandards = [
{
id: "1",
name: "Эталон массы 1 кг",
shortName: "ЭМ-1кг",
type: "Масса",
registryNumber: "ГРСИ 12345-01",
range: "0.5-2 кг",
accuracy: "±0.001 г",
certificateNumber: "СИ-2024-001",
validUntil: "2025-12-31",
},
{
id: "2",
name: "Эталон длины 1 м",
shortName: "ЭД-1м",
type: "Длина",
registryNumber: "ГРСИ 12345-02",
range: "0.5-2 м",
accuracy: "±0.001 мм",
certificateNumber: "СИ-2024-002",
validUntil: "2025-06-30",
},
{
id: "3",
name: "Эталон температуры 20°C",
shortName: "ЭТ-20°C",
type: "Температура",
registryNumber: "ГРСИ 12345-03",
range: "15-25°C",
accuracy: "±0.01°C",
certificateNumber: "СИ-2024-003",
validUntil: "2025-03-15",
},
{
id: "4",
name: "Эталон давления 1 Па",
shortName: "ЭД-1Па",
type: "Давление",
registryNumber: "ГРСИ 12345-04",
range: "0.5-2 Па",
accuracy: "±0.001 Па",
certificateNumber: "СИ-2024-004",
validUntil: "2025-09-20",
},
{
id: "5",
name: "Эталон времени 1 с",
shortName: "ЭВ-1с",
type: "Время",
registryNumber: "ГРСИ 12345-05",
range: "0.5-2 с",
accuracy: "±0.000001 с",
certificateNumber: "СИ-2024-005",
validUntil: "2025-12-01",
},
];
// Функция для получения данных эталона по ID
const getStandardById = (id: string) => {
return mockStandards.find(standard => standard.id === id);
};
// Функция для получения типа эталона в сокращенном виде
const getStandardTypeBadge = (type: string) => {
switch (type) {
case "Манометр грузопоршневой":
return "МГП";
case "Калибратор давления":
return "КД";
default:
return "МО";
}
};
interface ProtocolFormProps {
template: Template;
onSave: (data: Record<string, any>) => void;
onBack: () => void;
}
interface FormElementProps {
element: TemplateElement;
value: any;
onChange: (value: any) => void;
}
const FormElement: FC<FormElementProps> = ({ element, value, onChange }) => {
const [isStandardsDialogOpen, setIsStandardsDialogOpen] = useState(false);
const renderInput = () => {
switch (element.type) {
case 'text':
return (
<Input
placeholder={element.placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
required={element.required}
/>
);
case 'textarea':
return (
<Textarea
placeholder={element.placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
required={element.required}
rows={3}
/>
);
case 'number':
return (
<Input
type="number"
placeholder={element.placeholder}
value={value || ''}
onChange={(e) => onChange(e.target.value ? parseFloat(e.target.value) : '')}
required={element.required}
/>
);
case 'date':
return (
<Input
type="date"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
required={element.required}
/>
);
case 'select':
return (
<Select value={value || ''} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder={element.placeholder || 'Выберите значение'} />
</SelectTrigger>
<SelectContent>
{element.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case 'radio':
return (
<RadioGroup value={value || ''} onValueChange={onChange}>
{element.options?.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${element.id}-${option.value}`} />
<label htmlFor={`${element.id}-${option.value}`} className="text-sm font-medium">
{option.label}
</label>
</div>
))}
</RadioGroup>
);
case 'checkbox':
return (
<div className="flex items-center space-x-2">
<Checkbox
checked={!!value}
onCheckedChange={onChange}
id={element.id}
/>
<label htmlFor={element.id} className="text-sm font-medium">
{element.placeholder || 'Отметить'}
</label>
</div>
);
case 'standards':
const selectedStandards = Array.isArray(value) ? value : [];
const selectedStandardsData = selectedStandards
.map(id => getStandardById(id))
.filter(Boolean);
return (
<div className="space-y-2">
{/* Блок с измерительными эталонами */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium">Эталоны</Label>
<Button
variant="outline"
size="sm"
onClick={() => setIsStandardsDialogOpen(true)}
className="h-7 px-2"
>
<Settings className="w-3 h-3 mr-1" />
Настроить
</Button>
</div>
<div className="space-y-2">
{selectedStandardsData.length > 0 ? (
<div className="space-y-1.5">
{selectedStandardsData.map((standard, index) => (
standard && (
<div
key={standard.id}
className="flex items-center gap-2 p-2 bg-muted/30 rounded-md text-sm"
>
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-medium text-primary">{index + 1}</span>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-xs text-foreground truncate">
{standard.shortName}
</div>
<div className="text-xs text-muted-foreground">
{standard.accuracy} {standard.range}
</div>
</div>
<Badge variant="secondary" className="text-xs shrink-0">
<Wrench className="w-3 h-3 mr-1" />
{getStandardTypeBadge(standard.type)}
</Badge>
</div>
)
))}
</div>
) : (
<div className="text-sm text-muted-foreground bg-muted/30 rounded-md p-3 border border-dashed text-center">
<Settings className="w-4 h-4 mx-auto mb-1 opacity-50" />
Эталоны не выбраны
</div>
)}
</div>
</div>
<StandardsSelector
isOpen={isStandardsDialogOpen}
onClose={() => setIsStandardsDialogOpen(false)}
value={selectedStandards}
onChange={onChange}
registryNumber={element.targetCells?.[0]?.displayName || "ГРСИ 12345"}
/>
</div>
);
default:
return null;
}
};
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-1">
<label className="text-sm font-medium text-gray-900">
{element.label}
</label>
{element.required && <span className="inline-block w-1 h-1 bg-red-500 rounded-full -mt-1.5" />}
</div>
{/* Индикатор ячеек */}
{element.targetCells && element.targetCells.length > 0 && (
<div className="relative group/cells">
<Grid className="w-3 h-3 text-gray-500 opacity-60 hover:opacity-100 cursor-help transition-opacity" />
<div className="absolute top-full right-0 mt-2 p-2 bg-gray-900 text-white text-xs rounded shadow-lg opacity-0 group-hover/cells:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
<div className="font-medium mb-1">Целевые ячейки:</div>
{element.targetCells.map((cell, i) => (
<div key={i} className="font-mono">
{cell.sheet}!{cell.cell}
{cell.displayName && <span className="text-gray-300 ml-1">({cell.displayName})</span>}
</div>
))}
</div>
</div>
)}
</div>
{renderInput()}
</div>
);
};
const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const [formData, setFormData] = useState<Record<string, any>>({});
const handleFieldChange = (elementId: string, value: any) => {
setFormData(prev => ({
...prev,
[elementId]: value
}));
};
const handleSave = () => {
// Ищем элемент с названием протокола (должен иметь специальный тип или название)
const protocolNameElement = template.elements.find(el =>
el.name === 'protocolName' || el.label === 'Название протокола'
);
const protocolName = protocolNameElement ? formData[protocolNameElement.id] : 'Новый протокол';
const data = {
protocolName,
templateId: template.id,
formData,
createdAt: new Date(),
};
onSave(data);
};
const handleExport = () => {
console.log('Экспорт в Excel:', { formData });
alert('Функция экспорта будет реализована в следующих версиях');
};
const canvasSize = useMemo(() => {
const PADDING_Y = 200;
const baseWidth = 1200;
const baseHeight = 800;
if (template.elements.length === 0) {
return { width: baseWidth, height: baseHeight };
}
const contentHeight = Math.max(
0,
...template.elements.map((el) => (el.layout?.y || 0) + (el.layout?.height || 0)),
);
return {
width: baseWidth,
height: Math.max(baseHeight, contentHeight + PADDING_Y),
};
}, [template.elements]);
// Проверяем есть ли кнопка сохранения (есть ли название протокола)
const protocolNameElement = template.elements.find(el =>
el.name === 'protocolName' || el.label === 'Название протокола'
);
const canSave = protocolNameElement ? !!formData[protocolNameElement.id]?.trim() : false;
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* Заголовок */}
<div className="bg-white border-b border-gray-200 p-4 shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={onBack}>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h1 className="text-xl font-semibold text-gray-800">
Создание протокола: {template.name}
</h1>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExport}>
<Download className="h-4 w-4 mr-2" />
Экспорт в Excel
</Button>
<Button onClick={handleSave} disabled={!canSave}>
<Save className="h-4 w-4 mr-2" />
Сохранить протокол
</Button>
</div>
</div>
</div>
{/* Полноэкранная форма */}
<div className="flex-1 overflow-auto">
<div
className="relative mx-auto my-8"
style={{ width: canvasSize.width, height: canvasSize.height }}
>
{template.elements.map((element) => {
const layout = element.layout || { x: 50, y: 50, width: 300, height: 80 };
return (
<div
key={element.id}
className="absolute"
style={{
left: layout.x,
top: layout.y,
width: layout.width,
minHeight: layout.height,
zIndex: layout.zIndex || 1,
}}
>
<FormElement
element={element}
value={formData[element.id]}
onChange={(value) => handleFieldChange(element.id, value)}
/>
</div>
);
})}
{template.elements.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<FileText className="h-16 w-16 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет элементов формы</h3>
<p className="text-gray-600 mb-4">
В этом шаблоне пока не созданы элементы формы
</p>
<Button variant="outline" onClick={() => window.history.back()}>
Настроить шаблон
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export const ProtocolCreation: FC = () => {
const navigate = useNavigate();
const { templates } = useTemplateContext();
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const handleTemplateSelect = (template: Template) => {
setSelectedTemplate(template);
};
const handleProtocolSave = (data: Record<string, any>) => {
console.log('Сохранение протокола:', data);
// Здесь будет логика сохранения протокола
alert(`Протокол "${data.protocolName}" сохранен!`);
navigate('/');
};
const handleBack = () => {
setSelectedTemplate(null);
};
if (selectedTemplate) {
return (
<ProtocolForm
template={selectedTemplate}
onSave={handleProtocolSave}
onBack={handleBack}
/>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Создание протокола</h1>
<p className="text-gray-600 mt-2">Выберите шаблон для создания нового протокола</p>
</div>
<Button
variant="outline"
onClick={() => navigate('/templates')}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Создать шаблон
</Button>
</div>
{templates.length === 0 ? (
<div className="text-center py-12">
<FileText className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет доступных шаблонов</h3>
<p className="text-gray-600 mb-6">Создайте шаблон протокола для начала работы</p>
<Button onClick={() => navigate('/templates')}>
<Plus className="h-4 w-4 mr-2" />
Создать шаблон
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((template) => (
<Card key={template.id} className="hover:shadow-lg transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="text-lg">{template.name}</CardTitle>
{template.description && (
<CardDescription className="mt-1">
{template.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4" />
<span>{template.elements.length} элементов для заполнения</span>
</div>
<div className="text-xs text-gray-500 mt-2">
Создан: {template.createdAt.toLocaleDateString('ru-RU')}
</div>
</div>
<div className="mt-4">
<Button
className="w-full"
onClick={() => handleTemplateSelect(template)}
disabled={!template.excelFile || template.elements.length === 0}
>
{template.excelFile
? template.elements.length > 0
? 'Создать протокол'
: 'Нет элементов формы'
: 'Шаблон не настроен'
}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
};