diff --git a/frontend/src/components/form/Step2Details.tsx b/frontend/src/components/form/Step2Details.tsx index 76632fd..26f8c10 100644 --- a/frontend/src/components/form/Step2Details.tsx +++ b/frontend/src/components/form/Step2Details.tsx @@ -1,6 +1,6 @@ -import { Form, Input, Button, Select, DatePicker, Upload, message, Spin, Alert } from 'antd'; -import { UploadOutlined, LoadingOutlined } from '@ant-design/icons'; -import { useState } from 'react'; +import { Form, Button, Select, Upload, message, Spin, Alert, Card } from 'antd'; +import { UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; +import { useState, useEffect, useRef } from 'react'; import type { UploadFile } from 'antd/es/upload/interface'; import dayjs from 'dayjs'; @@ -14,105 +14,276 @@ interface Props { addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; } -// Типы страховых случаев из erv_ticket +// Типы страховых случаев const EVENT_TYPES = [ { value: 'delay_flight', label: 'Задержка авиарейса (более 3 часов)' }, { value: 'cancel_flight', label: 'Отмена авиарейса' }, - { value: 'miss_connection', label: 'Пропуск (задержка прибытия) стыковочного рейса (авиа/жд/паром и тд)' }, + { value: 'miss_connection', label: 'Пропуск (задержка прибытия) стыковочного рейса' }, { value: 'emergency_landing', label: 'Посадка воздушного судна на запасной аэродром' }, { value: 'delay_train', label: 'Задержка отправки поезда' }, { value: 'cancel_train', label: 'Отмена поезда' }, { value: 'delay_ferry', label: 'Задержка/отмена отправки парома/круизного судна' }, ]; +// Конфигурация документов для каждого типа события с уникальными file_type +const DOCUMENT_CONFIGS: Record = { + delay_flight: [ + { + name: "Посадочный талон или Билет", + field: "boarding_or_ticket", + file_type: "flight_delay_boarding_or_ticket", + required: true, + maxFiles: 1, + description: "Boarding pass или ticket/booking confirmation" + }, + { + name: "Подтверждение задержки", + field: "delay_confirmation", + file_type: "flight_delay_confirmation", + required: true, + maxFiles: 3, + description: "Справка от АК, email/SMS, или фото табло" + } + ], + + cancel_flight: [ + { + name: "Билет", + field: "ticket", + file_type: "flight_cancel_ticket", + required: true, + maxFiles: 1, + description: "Ticket/booking confirmation" + }, + { + name: "Уведомление об отмене", + field: "cancellation_notice", + file_type: "flight_cancel_notice", + required: true, + maxFiles: 3, + description: "Email, SMS или скриншот из приложения АК" + } + ], + + miss_connection: [ + { + name: "Посадочный талон рейса ПРИБЫТИЯ", + field: "arrival_boarding", + file_type: "connection_arrival_boarding", + required: true, + maxFiles: 1, + description: "Boarding pass рейса, который задержался" + }, + { + name: "Посадочный талон ИЛИ Билет рейса ОТПРАВЛЕНИЯ", + field: "departure_boarding_or_ticket", + file_type: "connection_departure_boarding_or_ticket", + required: true, + maxFiles: 1, + description: "Boarding pass (если успели) ИЛИ билет (если не успели)" + }, + { + name: "Доказательство задержки (опционально)", + field: "delay_proof", + file_type: "connection_delay_proof", + required: false, + maxFiles: 5, + description: "Справка, фото табло, email/SMS" + } + ], + + delay_train: [ + { + name: "Билет на поезд", + field: "train_ticket", + file_type: "train_ticket", + required: true, + maxFiles: 1, + description: "Билет РЖД или другого перевозчика" + }, + { + name: "Подтверждение задержки", + field: "delay_proof", + file_type: "train_delay_proof", + required: true, + maxFiles: 3, + description: "Справка от РЖД, фото табло, скриншот приложения" + } + ], + + cancel_train: [ + { + name: "Билет на поезд", + field: "train_ticket", + file_type: "train_ticket", + required: true, + maxFiles: 1, + description: "Билет РЖД или другого перевозчика" + }, + { + name: "Подтверждение отмены", + field: "cancel_proof", + file_type: "train_cancel_proof", + required: true, + maxFiles: 3, + description: "Справка от РЖД, фото табло, скриншот приложения" + } + ], + + delay_ferry: [ + { + name: "Билет на паром/круиз", + field: "ferry_ticket", + file_type: "ferry_ticket", + required: true, + maxFiles: 1, + description: "Билет или booking confirmation" + }, + { + name: "Подтверждение задержки/отмены", + field: "delay_proof", + file_type: "ferry_delay_proof", + required: true, + maxFiles: 3, + description: "Справка от перевозчика, фото расписания, email/SMS" + } + ], + + emergency_landing: [ + { + name: "Посадочный талон или Билет", + field: "boarding_or_ticket", + file_type: "emergency_boarding_or_ticket", + required: true, + maxFiles: 1, + description: "Boarding pass или ticket" + }, + { + name: "Подтверждение посадки на запасной аэродром", + field: "emergency_proof", + file_type: "emergency_landing_proof", + required: true, + maxFiles: 3, + description: "Справка от АК, email/SMS, документы" + } + ] +}; + export default function Step2Details({ formData, updateFormData, onNext, onPrev, addDebugEvent }: Props) { const [form] = Form.useForm(); - const [fileList, setFileList] = useState([]); + const [eventType, setEventType] = useState(formData.eventType || ''); + const [documentFiles, setDocumentFiles] = useState>({}); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(''); + const [waitingForOcr, setWaitingForOcr] = useState(false); + const [ocrResults, setOcrResults] = useState(null); + const eventSourceRef = useRef(null); + + const handleEventTypeChange = (value: string) => { + setEventType(value); + setDocumentFiles({}); // Очищаем загруженные файлы при смене типа + form.setFieldValue('eventType', value); + }; + + // Получаем конфигурацию документов для выбранного типа события + const currentDocuments = eventType ? DOCUMENT_CONFIGS[eventType] || [] : []; + + const handleUploadChange = (field: string, { fileList: newFileList }: any) => { + setDocumentFiles(prev => ({ + ...prev, + [field]: newFileList + })); + }; const handleNext = async () => { try { const values = await form.validateFields(); - // Если есть файлы - загружаем - if (fileList.length > 0) { - setUploading(true); - setUploadProgress('📤 Подготавливаем документы...'); - - addDebugEvent?.('upload', 'pending', `📤 Загружаю ${fileList.length} документ(ов) в S3 через n8n...`, { - count: fileList.length - }); + // Проверяем что все обязательные документы загружены + const missingDocs = currentDocuments.filter(doc => + doc.required && (!documentFiles[doc.field] || documentFiles[doc.field].length === 0) + ); + + if (missingDocs.length > 0) { + message.error(`Загрузите обязательные документы: ${missingDocs.map(d => d.name).join(', ')}`); + return; + } - // Используем claim_id из formData (уже сгенерирован в Step1) - const claimId = formData.claim_id; + // Загружаем все документы в S3 через n8n + setUploading(true); + setUploadProgress('📤 Загружаем документы...'); + + const claimId = formData.claim_id; + const uploadedFiles: any[] = []; - // Загружаем каждый документ через n8n вебхук - const uploadedFiles = []; + for (const docConfig of currentDocuments) { + const files = documentFiles[docConfig.field] || []; - for (let i = 0; i < fileList.length; i++) { - const file = fileList[i]; + for (let i = 0; i < files.length; i++) { + const file = files[i]; if (!file.originFileObj) continue; - setUploadProgress(`📡 Загружаем документ ${i + 1} из ${fileList.length}: ${file.name}...`); + setUploadProgress(`📡 Загружаем: ${docConfig.name} (${i + 1}/${files.length})...`); const uploadFormData = new FormData(); uploadFormData.append('claim_id', claimId); - uploadFormData.append('file_type', `document_${i + 1}`); // document_1, document_2, etc + uploadFormData.append('file_type', docConfig.file_type); // 🔑 Уникальный file_type для n8n uploadFormData.append('filename', file.name); uploadFormData.append('voucher', formData.voucher || ''); uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown'); uploadFormData.append('upload_timestamp', new Date().toISOString()); uploadFormData.append('file', file.originFileObj); + addDebugEvent?.('upload', 'pending', `📤 Загружаю документ: ${docConfig.name} (${docConfig.file_type})`, { + file_type: docConfig.file_type, + filename: file.name + }); + const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', { method: 'POST', body: uploadFormData, }); - setUploadProgress(`🔍 Обрабатываем документ ${i + 1} из ${fileList.length}...`); const uploadResult = await uploadResponse.json(); - const resultData = Array.isArray(uploadResult) ? uploadResult[0] : uploadResult; + if (resultData?.success) { uploadedFiles.push({ filename: file.name, + file_type: docConfig.file_type, + field: docConfig.field, success: true }); + + addDebugEvent?.('upload', 'success', `✅ Документ загружен: ${docConfig.name}`, { + file_type: docConfig.file_type, + filename: file.name + }); } } + } - const uploadResult = { - success: uploadedFiles.length > 0, - uploaded_count: uploadedFiles.length, - total_count: fileList.length, - files: uploadedFiles - }; - - if (uploadResult.success) { - addDebugEvent?.('upload', 'success', `✅ Документы загружены через n8n: ${uploadResult.uploaded_count}/${uploadResult.total_count}`, { - files: uploadResult.files, - claim_id: claimId - }); - - updateFormData({ - ...values, - uploadedFiles: uploadResult.files - }); - } else { - message.error('Ошибка загрузки документов'); - setUploading(false); - setUploadProgress(''); - return; - } + if (uploadedFiles.length > 0) { + setUploadProgress('🤖 AI анализирует документы...'); + + updateFormData({ + ...values, + uploadedDocuments: uploadedFiles + }); + // TODO: Здесь будет ожидание SSE события с результатами OCR/AI + // Пока просто переходим дальше + setUploadProgress(''); + setUploading(false); + + message.success(`Загружено документов: ${uploadedFiles.length}. Переходим дальше...`); + onNext(); + } else { + message.error('Не удалось загрузить документы'); setUploading(false); setUploadProgress(''); - } else { - updateFormData(values); } - onNext(); } catch (error) { message.error('Заполните все обязательные поля'); setUploading(false); @@ -120,21 +291,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, } }; - const handleUploadChange = ({ fileList: newFileList }: any) => { - setFileList(newFileList); - }; - - const [eventType, setEventType] = useState(formData.eventType || ''); - - const handleEventTypeChange = (value: string) => { - setEventType(value); - form.setFieldValue('eventType', value); - }; - - // Проверяем нужны ли дополнительные поля для стыковочного рейса - const showConnectionFields = eventType === 'miss_connection'; - const showCancelFlightDocs = eventType === 'cancel_flight'; - return (
- - current && current > dayjs().endOf('day')} - /> - - - {/* Для стыковочного рейса - номер рейса прибытия */} - {showConnectionFields && ( - 0 && ( + - - - )} +
+

+ 💡 Просто загрузите документы — наш AI автоматически распознает все данные + (номера рейсов, даты, время, причины задержек) +

+
- {showConnectionFields && ( - - current && current > dayjs().endOf('day')} - /> - - )} - - {/* Для стыковочного рейса - номер рейса отправления */} - {showConnectionFields && ( - - - - )} - - {showConnectionFields && ( - - current && current > dayjs().endOf('day')} - /> - - )} - - {/* Для обычных рейсов */} - {!showConnectionFields && ( - - - - )} - - {/* Дополнительные документы для отмены рейса */} - {showCancelFlightDocs && ( - - { - const isLt15M = file.size / 1024 / 1024 < 15; - if (!isLt15M) { - message.error(`${file.name}: файл больше 15MB`); - return Upload.LIST_IGNORE; - } + {currentDocuments.map((doc, index) => ( +
+
+ + {doc.required ? '✅' : 'ℹ️'} {doc.name} + {doc.required && *} + +
- const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; - const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; - - if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) { - message.error(`${file.name}: неподдерживаемый формат`); - return Upload.LIST_IGNORE; - } - - return false; - }} - accept="image/*,.pdf,.heic,.heif,.webp" - multiple - maxCount={5} - > - - - - )} +
+ 💡 {doc.description} +
- - { - const isLt15M = file.size / 1024 / 1024 < 15; - if (!isLt15M) { - message.error(`${file.name}: файл больше 15MB`); - return Upload.LIST_IGNORE; - } - - if (fileList.length >= 10) { - message.error('Максимум 10 файлов'); - return Upload.LIST_IGNORE; - } - - const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; - const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; - - if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) { - message.error(`${file.name}: неподдерживаемый формат`); - return Upload.LIST_IGNORE; - } - - return false; - }} - accept="image/*,.pdf,.heic,.heif,.webp" - multiple - maxCount={10} - showUploadList={{ - showPreviewIcon: true, - showRemoveIcon: true, - }} - > - - -
- Загружено: {fileList.length}/10 файлов -
-
+ handleUploadChange(doc.field, info)} + beforeUpload={(file) => { + const isLt15M = file.size / 1024 / 1024 < 15; + if (!isLt15M) { + message.error(`${file.name}: файл больше 15MB`); + return Upload.LIST_IGNORE; + } + + const currentFiles = documentFiles[doc.field] || []; + if (currentFiles.length >= doc.maxFiles) { + message.error(`Максимум ${doc.maxFiles} файл(ов) для этого документа`); + return Upload.LIST_IGNORE; + } + + const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']; + const validExtensions = /\.(jpg|jpeg|png|pdf|heic|heif|webp)$/i; + + if (!validTypes.includes(file.type) && !validExtensions.test(file.name)) { + message.error(`${file.name}: неподдерживаемый формат`); + return Upload.LIST_IGNORE; + } + + return false; + }} + accept="image/*,.pdf,.heic,.heif,.webp" + multiple={doc.maxFiles > 1} + maxCount={doc.maxFiles} + showUploadList={{ + showPreviewIcon: true, + showRemoveIcon: true, + }} + > + + + +
+ Загружено: {(documentFiles[doc.field] || []).length}/{doc.maxFiles} файл(ов) +
+
+ ))} + + )} {/* Прогресс обработки */} {uploading && uploadProgress && ( @@ -384,8 +447,6 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev, // Пропускаем валидацию, заполняем минимальные данные const devData = { eventType: 'delay_flight', - incidentDate: dayjs(), - transportNumber: 'TEST123', }; updateFormData(devData); onNext();