Files
aiform_dev/frontend/src/components/form/Step2Details.tsx

600 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { Form, Button, Select, Upload, message, Spin, Card, Modal, Progress } from 'antd';
import { UploadOutlined, LoadingOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useState, useEffect, useRef } from 'react';
import type { UploadFile } from 'antd/es/upload/interface';
import dayjs from 'dayjs';
const { Option } = Select;
interface Props {
formData: any;
updateFormData: (data: any) => void;
onNext: () => void;
onPrev: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
// Типы страховых случаев
const EVENT_TYPES = [
{ value: 'delay_flight', label: 'Задержка авиарейса (более 3 часов)' },
{ value: 'cancel_flight', 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<string, any[]> = {
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 [eventType, setEventType] = useState(formData.eventType || '');
const [currentDocumentIndex, setCurrentDocumentIndex] = useState(0);
const [processedDocuments, setProcessedDocuments] = useState<Record<string, any>>({});
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [ocrModalVisible, setOcrModalVisible] = useState(false);
const [ocrModalContent, setOcrModalContent] = useState<any>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const handleEventTypeChange = (value: string) => {
setEventType(value);
setCurrentDocumentIndex(0);
setProcessedDocuments({});
setCurrentFile(null);
form.setFieldValue('eventType', value);
};
// Получаем конфигурацию документов для выбранного типа события
const currentDocuments = eventType ? DOCUMENT_CONFIGS[eventType] || [] : [];
const currentDocConfig = currentDocuments[currentDocumentIndex];
// Проверяем все ли обязательные документы обработаны
const allRequiredProcessed = currentDocuments
.filter(doc => doc.required)
.every(doc => processedDocuments[doc.field]);
// SSE подключение для получения результатов OCR
useEffect(() => {
const claimId = formData.claim_id;
if (!claimId || !uploading || !currentDocConfig) {
return;
}
console.log('🔌 SSE: Открываю соединение для', currentDocConfig.file_type);
const eventSource = new EventSource(`/events/${claimId}`);
eventSourceRef.current = eventSource;
const expectedEventType = `${currentDocConfig.file_type}_processed`;
console.log('👀 Ожидаю event_type:', expectedEventType);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📨 SSE event received:', data);
if (data.event_type === expectedEventType) {
console.log('✅ Получил результат для документа:', currentDocConfig.name);
// Сохраняем результат
setProcessedDocuments(prev => ({
...prev,
[currentDocConfig.field]: data.data?.output || data.data
}));
// Показываем результат в модалке
setOcrModalContent({
success: data.status === 'completed',
data: data.data?.output || data.data,
message: data.message,
documentName: currentDocConfig.name
});
setUploading(false);
eventSource.close();
addDebugEvent?.('ocr', 'success', `${currentDocConfig.name} обработан`, {
file_type: currentDocConfig.file_type,
data: data.data?.output || data.data
});
}
} catch (error) {
console.error('SSE parse error:', error);
}
};
eventSource.onerror = (error) => {
console.error('❌ SSE connection error:', error);
setOcrModalContent((prev) => {
if (prev && prev !== 'loading') {
console.log('✅ SSE закрыто после получения результата');
return prev;
}
return { success: false, data: null, message: 'Ошибка подключения к серверу' };
});
setUploading(false);
eventSource.close();
};
eventSource.onopen = () => {
console.log('✅ SSE: Соединение открыто');
};
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [formData.claim_id, uploading, currentDocConfig]);
const handleFileSelect = (file: File) => {
setCurrentFile(file);
return false; // Предотвращаем автозагрузку
};
const handleUploadAndProcess = async () => {
if (!currentFile || !currentDocConfig) {
message.error('Выберите файл');
return;
}
try {
setUploading(true);
setOcrModalVisible(true);
setOcrModalContent('loading');
const claimId = formData.claim_id;
addDebugEvent?.('upload', 'pending', `📤 Загружаю: ${currentDocConfig.name}`, {
file_type: currentDocConfig.file_type,
filename: currentFile.name
});
const uploadFormData = new FormData();
uploadFormData.append('claim_id', claimId);
uploadFormData.append('file_type', currentDocConfig.file_type);
uploadFormData.append('filename', currentFile.name);
uploadFormData.append('voucher', formData.voucher || '');
uploadFormData.append('session_id', formData.session_id || 'unknown');
uploadFormData.append('upload_timestamp', new Date().toISOString());
uploadFormData.append('file', currentFile);
const uploadResponse = await fetch('https://n8n.clientright.pro/webhook/7e2abc64-eaca-4671-86e4-12786700fe95', {
method: 'POST',
body: uploadFormData,
});
const uploadResult = await uploadResponse.json();
console.log('📤 Файл загружен, ждём OCR результат...');
addDebugEvent?.('upload', 'success', `✅ Файл загружен, обрабатывается...`, {
file_type: currentDocConfig.file_type
});
// SSE обработчик получит результат и обновит состояние
} catch (error: any) {
console.error('Ошибка загрузки:', error);
message.error('Ошибка загрузки файла');
setUploading(false);
setOcrModalVisible(false);
addDebugEvent?.('upload', 'error', `❌ Ошибка загрузки: ${error.message}`);
}
};
const handleContinueToNextDocument = () => {
setOcrModalVisible(false);
setCurrentFile(null);
setCurrentDocumentIndex(prev => prev + 1);
};
const handleSkipOptionalDocument = () => {
setCurrentFile(null);
setCurrentDocumentIndex(prev => prev + 1);
};
const handleFinishStep = () => {
updateFormData({
eventType,
processedDocuments
});
onNext();
};
// Прогресс загрузки
const totalRequired = currentDocuments.filter(d => d.required).length;
const processedRequired = currentDocuments.filter(d => d.required && processedDocuments[d.field]).length;
const progressPercent = totalRequired > 0 ? Math.round((processedRequired / totalRequired) * 100) : 0;
return (
<Form
form={form}
layout="vertical"
initialValues={formData}
style={{ marginTop: 24 }}
>
<Form.Item
label="Выберите тип события"
name="eventType"
rules={[{ required: true, message: 'Выберите тип события' }]}
>
<Select
placeholder="Выберите тип события"
size="large"
onChange={handleEventTypeChange}
>
{EVENT_TYPES.map(type => (
<Option key={type.value} value={type.value}>
{type.label}
</Option>
))}
</Select>
</Form.Item>
{/* Прогресс обработки документов */}
{eventType && currentDocuments.length > 0 && (
<Card style={{ marginBottom: 24, background: '#fafafa', borderColor: '#d9d9d9' }}>
<div style={{ marginBottom: 12 }}>
<strong>Прогресс обработки документов:</strong>
</div>
<Progress
percent={progressPercent}
status={allRequiredProcessed ? 'success' : 'active'}
format={() => `${processedRequired}/${totalRequired} обязательных`}
/>
{/* Список обработанных документов */}
{Object.keys(processedDocuments).length > 0 && (
<div style={{ marginTop: 16 }}>
{currentDocuments.map(doc =>
processedDocuments[doc.field] ? (
<div key={doc.field} style={{ marginBottom: 8, color: '#595959' }}>
<CheckCircleOutlined /> {doc.name} - Обработан
</div>
) : null
)}
</div>
)}
</Card>
)}
{/* Текущий документ для загрузки */}
{eventType && currentDocConfig && (
<Card
title={`📋 Шаг ${currentDocumentIndex + 1}/${currentDocuments.length}: ${currentDocConfig.name}`}
style={{ marginTop: 24 }}
headStyle={{ background: currentDocConfig.required ? '#fafafa' : '#fafafa', borderBottom: '2px solid #d9d9d9' }}
>
<div style={{ marginBottom: 16, padding: 12, background: '#fafafa', borderRadius: 8 }}>
<p style={{ margin: 0, fontSize: 13, color: '#000000' }}>
💡 {currentDocConfig.description}
</p>
{currentDocConfig.required && (
<p style={{ margin: '8px 0 0 0', fontSize: 12, color: '#595959' }}>
Этот документ обязательный
</p>
)}
</div>
<Upload
beforeUpload={handleFileSelect}
accept="image/*,.pdf,.heic,.heif,.webp"
maxCount={1}
showUploadList={true}
fileList={currentFile ? [{
uid: '-1',
name: currentFile.name,
status: 'done',
url: URL.createObjectURL(currentFile),
}] : []}
onRemove={() => setCurrentFile(null)}
>
<Button
icon={<UploadOutlined />}
size="large"
block
disabled={!!currentFile}
>
Выбрать файл (JPG, PNG, PDF, HEIC, макс 15MB)
</Button>
</Upload>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<Button
type="primary"
size="large"
onClick={handleUploadAndProcess}
disabled={!currentFile || uploading}
loading={uploading}
style={{ flex: 1 }}
>
{uploading ? 'Обрабатываем...' : 'Загрузить и обработать'}
</Button>
{!currentDocConfig.required && (
<Button
size="large"
onClick={handleSkipOptionalDocument}
disabled={uploading}
>
Пропустить
</Button>
)}
</div>
</Card>
)}
{/* Если все документы обработаны или текущий индекс вышел за пределы */}
{eventType && currentDocumentIndex >= currentDocuments.length && (
<Card
style={{ marginTop: 24, background: '#fafafa', borderColor: '#d9d9d9' }}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<CheckCircleOutlined style={{ fontSize: 48, color: '#595959', marginBottom: 16 }} />
<h3 style={{ color: '#000000' }}> Все документы обработаны!</h3>
<p style={{ color: '#666' }}>
Обработано обязательных документов: {processedRequired}/{totalRequired}
</p>
</div>
</Card>
)}
{/* Модальное окно обработки OCR */}
<Modal
open={ocrModalVisible}
closable={ocrModalContent !== 'loading'}
maskClosable={false}
footer={ocrModalContent === 'loading' ? null :
ocrModalContent?.success ? [
<Button key="next" type="primary" onClick={handleContinueToNextDocument}>
{currentDocumentIndex < currentDocuments.length - 1 ? 'Продолжить к следующему документу →' : 'Завершить загрузку документов'}
</Button>
] : [
<Button key="retry" type="primary" onClick={() => {
setOcrModalVisible(false);
setUploading(false);
}}>
Попробовать другой файл
</Button>
]
}
width={700}
centered
>
{ocrModalContent === 'loading' ? (
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 48 }} spin />} />
<h3 style={{ marginTop: 24, marginBottom: 12 }}> Обрабатываем документ</h3>
<p style={{ color: '#666', marginBottom: 8 }}>📤 Загрузка в облако...</p>
<p style={{ color: '#666', marginBottom: 8 }}>📝 OCR распознавание текста...</p>
<p style={{ color: '#666', marginBottom: 8 }}>🤖 AI анализ документа...</p>
<p style={{ color: '#666' }}> Извлечение данных...</p>
<p style={{ color: '#999', fontSize: 12, marginTop: 20 }}>
Это может занять 20-30 секунд. Пожалуйста, подождите...
</p>
</div>
) : ocrModalContent ? (
<div>
<h3 style={{ marginBottom: 16 }}>
{ocrModalContent.success ? '✅ Результат обработки' : '❌ Ошибка обработки'}
</h3>
{ocrModalContent.success ? (
<div>
<p style={{ marginBottom: 16, fontSize: 15, fontWeight: 500 }}>
📋 {ocrModalContent.documentName}
</p>
<div style={{
padding: 16,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #d9d9d9',
marginBottom: 16
}}>
<p style={{ margin: '0 0 8px 0', color: '#000000', fontWeight: 500 }}>
Документ успешно распознан
</p>
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
Данные извлечены и сохранены
</p>
</div>
<p style={{ marginTop: 16 }}><strong>Извлечённые данные:</strong></p>
<pre style={{
background: '#f5f5f5',
padding: 12,
borderRadius: 4,
fontSize: 12,
maxHeight: 400,
overflow: 'auto'
}}>
{JSON.stringify(ocrModalContent.data, null, 2)}
</pre>
</div>
) : (
<div>
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
<p style={{ marginTop: 16 }}><strong>Детали:</strong></p>
<pre style={{
background: '#fafafa',
padding: 12,
borderRadius: 4,
fontSize: 12,
maxHeight: 400,
overflow: 'auto'
}}>
{JSON.stringify(ocrModalContent.data, null, 2)}
</pre>
</div>
)}
</div>
) : null}
</Modal>
{/* Кнопки навигации */}
<Form.Item>
<div style={{ display: 'flex', gap: 8, marginTop: 32 }}>
<Button onClick={onPrev} size="large" disabled={uploading}>Назад</Button>
<Button
type="primary"
onClick={handleFinishStep}
disabled={!allRequiredProcessed || uploading}
style={{ flex: 1 }}
size="large"
>
{allRequiredProcessed ? 'Далее →' : `Осталось документов: ${totalRequired - processedRequired}`}
</Button>
</div>
</Form.Item>
{/* DEV MODE секция удалена для продакшена */}
</Form>
);
}