/
FlysAt
/
DataFoundry
Обзор
Документация
Войти
/
FlysAt
/
DataFoundry
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
frontend/src/components/modals/CreateOrderModal.tsx
1 104 строки
51 KB
FlysAt8
hints/fix numbox
27 май 2026, 14:40
27 май 2026, 14:40
30c8e53
Код
Авторство
О чём код?
// src/components/modals/CreateOrderModal.tsx import React, { useState, useEffect } from 'react'; import { X, ChevronLeft, ChevronRight, Upload, Calculator, FileText, Image } from 'lucide-react'; import { useToast } from '../../contexts/ToastContext'; import { useAuth } from '../../contexts/AuthContext'; import { ordersAPI } from '../../api/orders'; import type { Order } from '../../types'; interface CreateOrderModalProps { isOpen: boolean; onClose: () => void; onSuccess?: (order: Order) => void; } // Типы для формы interface TaskFormData { // Основные поля для бэкенда title: string; description: string; operation_type: 'annotation' | 'collection' | 'upload'; deadline: string; deadlineTime: string; data_volume: string; budget: number; operation_tags: string[]; quality_requirement: number; dataset_link: string | null; license_name: 'CC BY 4.0' | 'ODC-BY' | null; visibility: 'public' | 'request' | 'private'; // Дополнительные поля для UI taskType: 'annotation' | 'collection' | 'upload'; useTemplate: boolean; scienceField: string; selectedTemplate: string; dataType: 'text' | 'image' | 'audio' | 'video'; budgetPerHour: number; estimatedHours: number; totalBudget: number; annotation_types: string[]; datasetFile: File | null; // изменено с files: File[] на один файл } // Научные области для шаблонов const scienceFields = [ { id: 'biology', label: 'Биология', icon: '🧬' }, { id: 'medicine', label: 'Медицина', icon: '🏥' }, { id: 'nlp', label: 'NLP', icon: '📝' }, { id: 'computer-vision', label: 'Компьютерное зрение', icon: '👁️' }, { id: 'physics', label: 'Физика', icon: '⚛️' }, { id: 'chemistry', label: 'Химия', icon: '🧪' }, ]; // Шаблоны по областям const templates: Record<string, Array<{ id: string; label: string; description: string; preset?: Partial<TaskFormData> }>> = { biology: [ { id: 'microscopy-segmentation', label: 'Сегментация микроскопии', description: 'Разметка клеток и структур на микроскопических изображениях', preset: { dataType: 'image', quality_requirement: 90, estimatedHours: 100 } }, { id: 'cell-classification', label: 'Классификация клеток', description: 'Определение типов клеток на изображениях', preset: { dataType: 'image', quality_requirement: 85, estimatedHours: 80 } }, ], medicine: [ { id: 'mri-segmentation', label: 'Сегментация МРТ', description: 'Разметка областей на МРТ снимках', preset: { dataType: 'image', quality_requirement: 95, estimatedHours: 150 } }, { id: 'xray-annotation', label: 'Аннотация рентгенов', description: 'Выделение патологий на рентгеновских снимках', preset: { dataType: 'image', quality_requirement: 92, estimatedHours: 120 } }, ], nlp: [ { id: 'ner-articles', label: 'NER статей', description: 'Выделение именованных сущностей в научных статьях', preset: { dataType: 'text', quality_requirement: 88, estimatedHours: 60 } }, { id: 'sentiment-analysis', label: 'Анализ тональности', description: 'Разметка эмоциональной окраски текстов', preset: { dataType: 'text', quality_requirement: 85, estimatedHours: 50 } }, ], 'computer-vision': [ { id: 'object-detection', label: 'Детекция объектов', description: 'Выделение объектов на изображениях', preset: { dataType: 'image', quality_requirement: 90, estimatedHours: 120 } }, { id: 'image-classification', label: 'Классификация изображений', description: 'Категоризация изображений по классам', preset: { dataType: 'image', quality_requirement: 92, estimatedHours: 80 } }, ], physics: [ { id: 'spectrum-classification', label: 'Классификация спектров', description: 'Разметка спектральных данных', preset: { dataType: 'image', quality_requirement: 94, estimatedHours: 90 } }, ], chemistry: [ { id: 'molecule-structure', label: 'Структура молекул', description: 'Разметка химических структур', preset: { dataType: 'image', quality_requirement: 96, estimatedHours: 110 } }, ], }; // Научный пресет const sciencePreset = { title: 'Научный исследовательский проект', description: 'Сбор и разметка данных для научного исследования с высокими требованиями к точности', quality_requirement: 95, dataType: 'image' as const, budgetPerHour: 250, estimatedHours: 200, }; // Начальное состояние формы const initialFormData: TaskFormData = { title: '', description: '', operation_type: 'annotation', deadline: '', deadlineTime: '12:00', data_volume: '', budget: 0, operation_tags: [], quality_requirement: 85, dataset_link: null, license_name: 'CC BY 4.0', visibility: 'public', annotation_types: [] as string[], taskType: 'annotation', useTemplate: false, scienceField: '', selectedTemplate: '', dataType: 'image', budgetPerHour: 150, estimatedHours: 40, totalBudget: 6000, datasetFile: null, }; const steps = [ { number: 1, title: 'Тип заказа' }, { number: 2, title: 'Шаблон' }, { number: 3, title: 'Параметры' }, { number: 4, title: 'Данные' }, { number: 5, title: 'Подтверждение' }, ]; export const CreateOrderModal: React.FC<CreateOrderModalProps> = ({ isOpen, onClose, onSuccess }) => { const [step, setStep] = useState(1); const [formData, setFormData] = useState<TaskFormData>(initialFormData); const [dragActive, setDragActive] = useState(false); const [loading, setLoading] = useState(false); const { showToast } = useToast(); const { token } = useAuth(); const [annotationsInput, setAnnotationsInput] = useState(''); const resetForm = () => { setFormData(initialFormData); setAnnotationsInput(''); setStep(1); setDragActive(false); }; useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; resetForm(); } return () => { document.body.style.overflow = 'auto'; }; }, [isOpen]); // Пересчет общей стоимости useEffect(() => { const total = formData.budgetPerHour * formData.estimatedHours; setFormData(prev => ({ ...prev, totalBudget: total, budget: total })); }, [formData.budgetPerHour, formData.estimatedHours]); useEffect(() => { if (formData.dataType === 'text') { const fixedAnnotations = 'Да,Нет,Возможно'; if (annotationsInput !== fixedAnnotations) { setAnnotationsInput(fixedAnnotations); } } else if (formData.dataType === 'image' && annotationsInput === 'Да,Нет,Возможно') { // Если переключаемся с текста на изображения, очищаем аннотации setAnnotationsInput(''); } }, [formData.dataType]); const applySciencePreset = () => { setFormData(prev => ({ ...prev, ...sciencePreset, useTemplate: true, scienceField: 'biology', selectedTemplate: 'microscopy-segmentation', })); }; const applyTemplate = (templateId: string) => { const template = templates[formData.scienceField]?.find(t => t.id === templateId); if (template?.preset) { setFormData(prev => ({ ...prev, ...template.preset, selectedTemplate: templateId, })); } }; const handleNext = () => { if (step === 1 && !formData.taskType) { showToast('Пожалуйста, выберите тип заказа'); return; } if (step === 2 && formData.useTemplate && !formData.selectedTemplate) { showToast('Пожалуйста, выберите шаблон'); return; } if (step === 3 && (!formData.title || !formData.description || !formData.deadline)) { showToast('Пожалуйста, заполните все обязательные поля'); return; } if (step === 4 && formData.taskType === 'annotation' && !formData.datasetFile) { showToast('Для разметки данных необходимо загрузить ZIP-архив'); return; } setStep(step + 1); }; const handleBack = () => { setStep(step - 1); }; const handleSubmit = async () => { if (!token) { showToast('Необходимо войти в систему'); return; } // Валидация if (!formData.title?.trim()) { showToast('Введите название заказа'); return; } if (!formData.description?.trim()) { showToast('Введите описание заказа'); return; } if (!formData.deadline) { showToast('Укажите дедлайн'); return; } if (!formData.totalBudget || formData.totalBudget <= 0) { showToast('Укажите бюджет (больше 0)'); return; } // Аннотации - опционально const annotationTypes = annotationsInput .split(',') .map(s => s.trim()) .filter(s => s.length > 0); if (annotationTypes.length === 0) { console.log('[CreateOrder] No annotation types provided'); } // Проверка датасета if (formData.taskType === 'annotation' && !formData.datasetFile) { showToast('Для разметки данных необходимо загрузить ZIP-архив'); return; } setLoading(true); try { // Конвертируем operation_type из фронтенда в бэкенд let operationType: 'collection' | 'labeling'; if (formData.operation_type === 'annotation') { operationType = 'labeling'; } else if (formData.operation_type === 'upload') { operationType = 'collection'; } else { operationType = formData.operation_type as 'collection' | 'labeling'; } const operationTags = [...formData.operation_tags.filter(tag => tag.trim())]; if (formData.dataType === 'text') { operationTags.push('text'); } else if (formData.dataType === 'image') { operationTags.push('image'); } // Формируем данные для отправки на бэкенд const orderData = { title: formData.title.trim(), description: formData.description.trim() || null, operation_type: operationType, deadline: new Date(formData.deadline).toISOString(), data_volume: formData.dataType, budget: formData.totalBudget, operation_tags: operationTags, description_tags: [], quality_requirement: formData.quality_requirement, dataset_link: null, // будет заполнен после загрузки датасета license_name: formData.license_name, visibility: formData.visibility, }; console.log('Creating order:', orderData); // 1. Создаем заказ const newOrder = await ordersAPI.create(orderData, token); // 2. Отправляем типы аннотаций (если есть) if (annotationTypes.length > 0) { console.log('Sending annotation types:', annotationTypes); await ordersAPI.addAnnotationTypes(newOrder.id, annotationTypes, token); } // 3. Загружаем датасет (только для разметки) if (formData.taskType === 'annotation' && formData.datasetFile) { console.log('Uploading dataset for order:', newOrder.id); await ordersAPI.uploadDataset(newOrder.id, formData.datasetFile, token); } else { console.log('No dataset to upload (collection task)'); } showToast('Заказ успешно создан! Датасет загружается и будет проверен'); onSuccess?.(newOrder); onClose(); } catch (error: any) { console.error('Failed to create order:', error); showToast(error.message || 'Ошибка создания заказа'); } finally { setLoading(false); } }; const handleClose = () => { if (!loading) { resetForm(); onClose(); } }; const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true); } else if (e.type === 'dragleave') { setDragActive(false); } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setDragActive(false); const files = Array.from(e.dataTransfer.files); if (files.length > 0) { const file = files[0]; // Проверяем, что это ZIP-архив if (!file.name.toLowerCase().endsWith('.zip')) { showToast('Пожалуйста, загрузите ZIP-архив'); return; } setFormData(prev => ({ ...prev, datasetFile: file })); } }; const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files && e.target.files[0]) { const file = e.target.files[0]; if (!file.name.toLowerCase().endsWith('.zip')) { showToast('Пожалуйста, загрузите ZIP-архив'); return; } setFormData(prev => ({ ...prev, datasetFile: file })); } }; const calculateTimeToFill = () => { const baseDays = Math.ceil(formData.estimatedHours / 10); return `~${baseDays} дней`; }; if (!isOpen) return null; return ( <div className="fixed inset-0 z-50"> <div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={handleClose}></div> <div className="absolute inset-0 flex items-start justify-center p-4 min-h-screen overflow-y-auto"> <div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-4xl my-8 relative animate-slide-up"> <button onClick={handleClose} disabled={loading} className="absolute top-4 right-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors z-10 disabled:opacity-50" > <X className="w-5 h-5" /> </button> <div className="p-8"> {/* Прогресс-бар */} <div className="mb-8"> <div className="flex justify-between mb-2"> {steps.map((s) => ( <div key={s.number} className="flex flex-col items-center"> <div className={`w-8 h-8 rounded-full flex items-center justify-center ${ step >= s.number ? 'bg-primary-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-500' }`}> {s.number} </div> <span className="text-xs mt-1 text-gray-500 hidden sm:block"> {s.title} </span> </div> ))} </div> <div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden"> <div className="h-full bg-gradient-to-r from-primary-600 to-secondary-600 transition-all duration-300" style={{ width: `${(step / steps.length) * 100}%` }} ></div> </div> </div> {/* Шаг 1: Тип заказа */} {step === 1 && ( <div> <h2 className="text-2xl font-bold mb-2 text-gray-900 dark:text-white"> Выберите тип заказа </h2> <p className="text-gray-600 dark:text-gray-400 mb-6"> Что вы хотите сделать? </p> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <button onClick={() => setFormData({...formData, taskType: 'annotation', operation_type: 'annotation'})} className={`p-6 rounded-2xl border-2 text-center transition-all ${ formData.taskType === 'annotation' ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-primary-300' }`} > <div className="text-4xl mb-3">✍️</div> <h3 className="text-lg font-bold mb-2">Разметка данных</h3> <p className="text-sm text-gray-600 dark:text-gray-400"> Нужно разметить существующие данные </p> </button> <button onClick={() => setFormData({...formData, taskType: 'collection', operation_type: 'collection'})} className={`p-6 rounded-2xl border-2 text-center transition-all ${ formData.taskType === 'collection' ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-primary-300' }`} > <div className="text-4xl mb-3">📊</div> <h3 className="text-lg font-bold mb-2">Сбор новых данных</h3> <p className="text-sm text-gray-600 dark:text-gray-400"> Нужно собрать данные с нуля </p> </button> {/* <button onClick={() => setFormData({...formData, taskType: 'upload', operation_type: 'upload'})} className={`p-6 rounded-2xl border-2 text-center transition-all ${ formData.taskType === 'upload' ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-primary-300' }`} > <div className="text-4xl mb-3">📦</div> <h3 className="text-lg font-bold mb-2">Загрузка готового</h3> <p className="text-sm text-gray-600 dark:text-gray-400"> Уже есть готовый датасет </p> </button> */} </div> </div> )} {/* Шаг 2: Шаблон */} {step === 2 && ( <div> <h2 className="text-2xl font-bold mb-2 text-gray-900 dark:text-white"> Выберите способ создания </h2> <div className="flex gap-4 mb-6"> <button onClick={() => { setFormData({ ...formData, useTemplate: false, scienceField: '', selectedTemplate: '', title: '', description: '', dataType: 'image', quality_requirement: 85, budgetPerHour: 150, estimatedHours: 40, }); }} className={`flex-1 py-3 rounded-xl border-2 transition-all ${ !formData.useTemplate ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700' }`} > Создать с нуля </button> <button onClick={() => { setFormData({ ...formData, useTemplate: true, }); }} className={`flex-1 py-3 rounded-xl border-2 transition-all ${ formData.useTemplate ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700' }`} > Использовать шаблон </button> </div> {formData.useTemplate && ( <> <div className="mb-6"> <label className="block text-sm font-medium mb-2">Область науки</label> <div className="grid grid-cols-2 md:grid-cols-3 gap-3"> {scienceFields.map((field) => ( <button key={field.id} onClick={() => setFormData({...formData, scienceField: field.id, selectedTemplate: ''})} className={`p-3 rounded-xl border-2 transition-all ${ formData.scienceField === field.id ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700' }`} > <span className="text-2xl mr-2">{field.icon}</span> {field.label} </button> ))} </div> </div> {formData.scienceField && ( <div> <div className="flex justify-between items-center mb-4"> <label className="block text-sm font-medium">Выберите шаблон</label> <button onClick={applySciencePreset} className="px-3 py-1 text-sm bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded-lg" > 🎓 Научный проект </button> </div> <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> {templates[formData.scienceField]?.map((template) => ( <button key={template.id} onClick={() => applyTemplate(template.id)} className={`p-4 rounded-xl border-2 text-left transition-all ${ formData.selectedTemplate === template.id ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700' }`} > <h4 className="font-bold mb-1">{template.label}</h4> <p className="text-sm text-gray-600 dark:text-gray-400"> {template.description} </p> </button> ))} </div> </div> )} </> )} </div> )} {/* Шаг 3: Параметры */} {step === 3 && ( <div> <h2 className="text-2xl font-bold mb-2 text-gray-900 dark:text-white"> Настройка параметров </h2> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="space-y-4"> <div> <label className="block text-sm font-medium mb-1">Название заказа *</label> <input type="text" value={formData.title} onChange={(e) => setFormData({...formData, title: e.target.value})} className="w-full px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700" placeholder="Введите название" /> </div> <div> <label className="block text-sm font-medium mb-1">Описание *</label> <textarea rows={4} value={formData.description} onChange={(e) => setFormData({...formData, description: e.target.value})} className="w-full px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700" placeholder="Опишите заказ" /> </div> <div> <label className="block text-sm font-medium mb-1">Тип данных</label> <div className="grid grid-cols-2 gap-2"> {[ { id: 'text', icon: FileText, label: 'Текст' }, { id: 'image', icon: Image, label: 'Изображения' }, // { id: 'audio', icon: Mic, label: 'Аудио' }, // { id: 'video', icon: Video, label: 'Видео' }, ].map((type) => ( <button key={type.id} onClick={() => setFormData({...formData, dataType: type.id as any})} className={`p-3 rounded-xl border-2 transition-all flex flex-col items-center gap-1 ${ formData.dataType === type.id ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-primary-300' }`} > <type.icon className="w-5 h-5" /> <span className="text-sm">{type.label}</span> </button> ))} </div> </div> <div> <label className="block text-sm font-medium mb-1"> Требования к качеству: {formData.quality_requirement}% </label> <input type="range" min="50" max="100" value={formData.quality_requirement} onChange={(e) => setFormData({...formData, quality_requirement: parseInt(e.target.value)})} className="w-full" /> <p className="text-xs text-gray-500 mt-1">Целевой % согласия аннотаторов</p> </div> </div> <div className="space-y-4"> <div> <label className="block text-sm font-medium mb-1"> Типы аннотаций (через запятую) {formData.dataType === 'text' && ( <span className="text-blue-500 text-xs ml-2">(фиксированные)</span> )} </label> <input type="text" value={annotationsInput} onChange={(e) => { if (formData.dataType === 'text') return; setAnnotationsInput(e.target.value); }} placeholder={formData.dataType === 'text' ? 'Фиксированные аннотации: Да, Нет, Возможно' : 'Введите типы аннотаций'} className={`w-full px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 transition-all ${ formData.dataType === 'text' ? 'bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed' : 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white' }`} readOnly={formData.dataType === 'text'} /> {formData.dataType === 'text' ? ( <p className="text-xs text-blue-500 mt-1"> Для текстовых данных используются фиксированные аннотации: Да, Нет, Возможно </p> ) : ( <p className="text-xs text-gray-500 mt-1"> Например: кошка, собака, птица </p> )} {/* Превью списка аннотаций */} {annotationsInput.trim() && ( <div className="mt-2 flex flex-wrap gap-2"> {annotationsInput.split(',').map((tag, idx) => { const trimmed = tag.trim(); if (!trimmed) return null; return ( <span key={idx} className="px-2 py-1 rounded-lg text-xs font-medium bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300" > {trimmed} </span> ); })} </div> )} </div> <div> <label className="block text-sm font-medium mb-1">Дедлайн *</label> <input type="date" value={formData.deadline} onChange={(e) => setFormData({...formData, deadline: e.target.value})} className="w-full px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700" /> <input type="time" value={formData.deadlineTime} onChange={(e) => setFormData({...formData, deadlineTime: e.target.value})} className="w-full mt-2 px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700" /> </div> <div> <label className="block text-sm font-medium mb-1"> Бюджет </label> <div className="grid grid-cols-2 gap-2"> <div> <input type="number" value={formData.budgetPerHour} onChange={(e) => setFormData({...formData, budgetPerHour: parseInt(e.target.value)})} className="w-full px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700" placeholder="₽/час" /> <p className="text-xs text-gray-500 mt-1">от 150 ₽/час</p> </div> <div> <input type="number" value={formData.estimatedHours} onChange={(e) => setFormData({...formData, estimatedHours: parseInt(e.target.value)})} className="w-full px-4 py-2 rounded-xl border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700" placeholder="Часов" /> <p className="text-xs text-gray-500 mt-1">Часов работы</p> </div> </div> </div> {/* Калькулятор */} <div className="bg-gradient-to-r from-primary-50 to-secondary-50 dark:from-primary-900/20 dark:to-secondary-900/20 rounded-xl p-4"> <div className="flex items-center gap-2 mb-2"> <Calculator className="w-5 h-5 text-primary-600" /> <h3 className="font-bold">Калькулятор стоимости</h3> </div> <div className="space-y-1 text-sm"> <div className="flex justify-between"> <span>Ставка:</span> <span>{formData.budgetPerHour} ₽/час</span> </div> <div className="flex justify-between"> <span>Оценка времени:</span> <span>{formData.estimatedHours} часов</span> </div> <div className="flex justify-between font-bold text-lg mt-2 pt-2 border-t"> <span>Итого:</span> <span className="text-primary-600">{formData.totalBudget.toLocaleString()} ₽</span> </div> </div> </div> </div> </div> </div> )} {/* Шаг 4: Загрузка датасета */} {step === 4 && ( <div> <h2 className="text-2xl font-bold mb-2 text-gray-900 dark:text-white"> {formData.taskType === 'annotation' ? 'Загрузка датасета' : 'Данные заказа'} </h2> <p className="text-gray-600 dark:text-gray-400 mb-6"> {formData.taskType === 'annotation' ? 'Загрузите ZIP-архив с данными для разметки' : 'Для сбора данных датасет не требуется. Исполнитель соберёт данные с нуля по вашим требованиям.'} </p> {formData.taskType === 'annotation' && ( <div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800"> <div className="flex items-start gap-3"> <div className="text-blue-500 text-xl">ℹ️</div> <div className="flex-1"> <h4 className="font-semibold text-blue-800 dark:text-blue-300 mb-1"> Что должно быть в ZIP-архиве? </h4> {formData.dataType === 'image' ? ( <div className="text-sm text-blue-700 dark:text-blue-300 space-y-1"> <p>• Файлы для разметки: <strong>PNG, JPEG</strong></p> <p className="text-xs mt-1 text-blue-600 dark:text-blue-400"> Пример: images.zip → внутри изображения </p> </div> ) : ( <div className="text-sm text-blue-700 dark:text-blue-300 space-y-1"> <p>• Файл с условиями: <strong>DOCX, TXT, PDF</strong></p> <p>• Файл для разметки: <strong>CSV</strong></p> <p>• Добавляете в архив только эти два файла</p> <p className="text-xs mt-1 text-blue-600 dark:text-blue-400"> Пример: texts.zip → внутри файл с условиями и CSV для разметки </p> </div> )} </div> </div> </div> )} <div className="space-y-6"> {/* Разный контент в зависимости от типа заказа */} {formData.taskType === 'annotation' ? ( <> {/* Drag & Drop область для разметки */} <div onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop} className={`border-2 border-dashed rounded-2xl p-8 text-center transition-all ${ dragActive ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600' }`} > <Upload className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <p className="text-gray-600 dark:text-gray-400 mb-2"> Перетащите ZIP-архив сюда или </p> <label className="cursor-pointer"> <span className="px-4 py-2 bg-primary-600 text-white rounded-lg inline-block"> выберите файл </span> <input type="file" accept=".zip" onChange={handleFileSelect} className="hidden" /> </label> <p className="text-xs text-gray-500 mt-2"> Только ZIP-архивы. Максимальный размер: 500MB </p> </div> {/* Превью файла для разметки */} {formData.datasetFile && ( <div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4"> <div className="flex items-center justify-between"> <div className="flex items-center gap-3"> <div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg"> <FileText className="w-5 h-5 text-primary-600" /> </div> <div> <p className="font-medium">{formData.datasetFile.name}</p> <p className="text-sm text-gray-500"> {(formData.datasetFile.size / (1024 * 1024)).toFixed(2)} MB </p> </div> </div> <button onClick={() => setFormData({...formData, datasetFile: null})} className="text-red-500 hover:text-red-700" > <X className="w-5 h-5" /> </button> </div> </div> )} </> ) : ( <div className="bg-blue-50 dark:bg-blue-900/20 rounded-2xl p-8 text-center"> <div className="text-5xl mb-4">📊</div> <h3 className="text-xl font-bold mb-2 text-gray-900 dark:text-white"> Сбор данных с нуля </h3> <p className="text-gray-600 dark:text-gray-400 mb-3"> Исполнитель соберёт данные в соответствии с вашими требованиями. </p> <p className="text-sm text-gray-500 dark:text-gray-400"> Вы сможете обсудить детали сбора в чате после создания заказа. </p> </div> )} {/* Лицензия и видимость (показываем для обоих типов, но для сбора данные поля не влияют на загрузку) */} <div className="border-t pt-6"> <h3 className="font-bold mb-4"> {formData.taskType === 'annotation' ? 'Настройки датасета' : 'Настройки заказа'} </h3> <div className="space-y-4"> <div> <label className="block text-sm font-medium mb-2">Лицензия</label> <div className="flex gap-4"> {(['CC BY 4.0', 'ODC-BY'] as const).map((license) => ( <label key={license} className="flex items-center gap-2"> <input type="radio" checked={formData.license_name === license} onChange={() => setFormData({...formData, license_name: license})} className="w-4 h-4" /> <span>{license}</span> </label> ))} </div> </div> <div> <label className="block text-sm font-medium mb-2">Видимость заказа</label> <div className="flex gap-4"> {[ { id: 'public', label: 'Публичный' }, { id: 'request', label: 'По запросу' }, { id: 'private', label: 'Приватный' }, ].map((opt) => ( <label key={opt.id} className="flex items-center gap-2"> <input type="radio" checked={formData.visibility === opt.id} onChange={() => setFormData({...formData, visibility: opt.id as any})} className="w-4 h-4" /> <span>{opt.label}</span> </label> ))} </div> <p className="text-xs text-gray-500 mt-1"> {formData.visibility === 'public' && 'Виден всем исполнителям'} {formData.visibility === 'request' && 'Только по вашему приглашению'} {formData.visibility === 'private' && 'Только для выбранных исполнителей'} </p> </div> </div> </div> </div> </div> )} {/* Шаг 5: Подтверждение */} {step === 5 && ( <div> <h2 className="text-2xl font-bold mb-2 text-gray-900 dark:text-white"> Подтверждение заказа </h2> <div className="bg-gray-50 dark:bg-gray-700 rounded-2xl p-6 mb-6"> <div className="grid grid-cols-2 gap-4"> <div> <p className="text-sm text-gray-500">Название</p> <p className="font-medium">{formData.title || '—'}</p> </div> <div> <p className="text-sm text-gray-500">Тип заказа</p> <p className="font-medium"> {formData.taskType === 'annotation' && 'Разметка данных'} {formData.taskType === 'collection' && 'Сбор данных'} {formData.taskType === 'upload' && 'Загрузка готового'} </p> </div> <div> <p className="text-sm text-gray-500">Тип данных</p> <p className="font-medium"> {formData.dataType === 'text' && 'Текст'} {formData.dataType === 'image' && 'Изображение'} {/* {formData.dataType === 'audio' && 'Аудио'} {formData.dataType === 'video' && 'Видео'} */} </p> </div> <div> <p className="text-sm text-gray-500">Типы аннотаций</p> <div className="flex flex-wrap gap-2 mt-1"> {annotationsInput.split(',').map((tag, idx) => { const trimmed = tag.trim(); if (!trimmed) return null; return ( <span key={idx} className="px-2 py-1 rounded-lg text-xs font-medium bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300" > {trimmed} </span> ); })} </div> </div> <div> <p className="text-sm text-gray-500">Качество</p> <p className="font-medium">{formData.quality_requirement}%</p> </div> <div> <p className="text-sm text-gray-500">Дедлайн</p> <p className="font-medium"> {formData.deadline && new Date(formData.deadline).toLocaleDateString('ru-RU')} {formData.deadlineTime && ` в ${formData.deadlineTime}`} </p> </div> <div> <p className="text-sm text-gray-500">Бюджет</p> <p className="font-bold text-primary-600 text-lg"> {formData.totalBudget.toLocaleString()} ₽ </p> </div> <div> <p className="text-sm text-gray-500">Лицензия</p> <p className="font-medium">{formData.license_name}</p> </div> <div> <p className="text-sm text-gray-500">Видимость</p> <p className="font-medium"> {formData.visibility === 'public' && 'Публичный'} {formData.visibility === 'request' && 'По запросу'} {formData.visibility === 'private' && 'Приватный'} </p> </div> </div> <div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600"> <div className="flex justify-between items-center"> <span className="text-sm text-gray-500">Прогноз Time-to-Fill</span> <span className="font-bold text-green-600">{calculateTimeToFill()}</span> </div> </div> </div> <p className="text-sm text-gray-500 text-center"> После создания заказа система автоматически начнет поиск исполнителей </p> </div> )} {/* Кнопки навигации */} <div className="flex justify-between mt-8 pt-4 border-t border-gray-200 dark:border-gray-700"> {step > 1 && ( <button onClick={handleBack} disabled={loading} className="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-xl flex items-center gap-2 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50" > <ChevronLeft className="w-4 h-4" /> Назад </button> )} <div className="flex-1"></div> {step < steps.length ? ( <button onClick={handleNext} disabled={loading} className="px-6 py-2 bg-primary-600 text-white rounded-xl flex items-center gap-2 hover:bg-primary-700 transition-colors disabled:opacity-50" > Далее <ChevronRight className="w-4 h-4" /> </button> ) : ( <button onClick={handleSubmit} disabled={loading} className="px-6 py-2 bg-gradient-to-r from-primary-600 to-secondary-600 text-white rounded-xl font-bold hover:from-primary-700 hover:to-secondary-700 transition-all disabled:opacity-50 flex items-center gap-2" > {loading ? ( <> <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> Создание... </> ) : ( 'Создать заказ' )} </button> )} </div> </div> </div> </div> </div> ); };