600 lines
22 KiB
TypeScript
600 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|