feat: Пошаговая загрузка документов с модалкой на Step 2
🎯 Изменения: - Документы загружаются по очереди (один за другим) - После загрузки каждого документа открывается модалка с крутилкой - SSE слушает конкретный event_type: {file_type}_processed - Модалка показывает результат распознавания с извлечёнными данными - Кнопка 'Продолжить' → переход к следующему документу - Опциональные документы можно пропустить - После обработки всех обязательных → 'Далее на Step 3' 📊 UX флоу: 1. Выбор типа события → показываются нужные документы 2. Документ 1: Выбрать файл → Загрузить → Модалка → Результат → Продолжить 3. Документ 2: Выбрать файл → Загрузить → Модалка → Результат → Продолжить 4. Документ 3 (опц): Загрузить ИЛИ Пропустить 5. Все обязательные обработаны → Далее на Step 3 🔑 Каждый документ получает свой уникальный event_type: - frontend отправляет file_type - n8n возвращает event_type = {file_type}_processed - frontend слушает этот конкретный event_type через SSE
This commit is contained in:
@@ -622,6 +622,168 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* 🔧 Технические кнопки для разработки */}
|
||||
<div style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
border: '2px dashed #999'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
||||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
// Пропускаем валидацию, заполняем минимальные данные
|
||||
const devData = {
|
||||
voucher: 'E1000-123456789',
|
||||
claim_id: `CLM-DEV-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
|
||||
};
|
||||
updateFormData(devData);
|
||||
onNext();
|
||||
}}
|
||||
size="small"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Далее → (Step 2) [пропустить]
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* Прогресс обработки */}
|
||||
{uploading && uploadProgress && (
|
||||
<Alert
|
||||
message={uploadProgress}
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPolicyNotFound(false);
|
||||
setFileList([]);
|
||||
}}
|
||||
size="large"
|
||||
disabled={uploading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmitWithScan}
|
||||
loading={uploading}
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{uploading ? 'Обрабатываем...' : 'Продолжить со сканом'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!policyNotFound && (
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#f0f9ff', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||
💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно ожидания OCR результата */}
|
||||
<Modal
|
||||
open={ocrModalVisible}
|
||||
closable={ocrModalContent !== 'loading'}
|
||||
maskClosable={false}
|
||||
footer={ocrModalContent === 'loading' ? null :
|
||||
ocrModalContent?.success ? [
|
||||
// ✅ Полис распознан - кнопка "Продолжить"
|
||||
<Button key="next" type="primary" onClick={() => {
|
||||
setOcrModalVisible(false);
|
||||
onNext(); // Переход на следующий шаг
|
||||
}}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
] : [
|
||||
// ❌ Полис не распознан - кнопка "Загрузить другой файл"
|
||||
<Button key="retry" type="primary" onClick={() => {
|
||||
setOcrModalVisible(false);
|
||||
setFileList([]); // Очищаем список файлов
|
||||
setPolicyNotFound(true); // Показываем форму загрузки снова
|
||||
}}>
|
||||
Загрузить другой файл
|
||||
</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 }}>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><strong>Номер полиса:</strong> {ocrModalContent.data?.policy_number || 'н/д'}</p>
|
||||
<p><strong>Владелец:</strong> {ocrModalContent.data?.policyholder_full_name || 'н/д'}</p>
|
||||
{ocrModalContent.data?.insured_persons?.length > 0 && (
|
||||
<>
|
||||
<p><strong>Застрахованные лица:</strong></p>
|
||||
<ul>
|
||||
{ocrModalContent.data.insured_persons.map((person: any, i: number) => (
|
||||
<li key={i}>{person.full_name} (ДР: {person.birth_date || 'н/д'})</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{ocrModalContent.data?.policy_period && (
|
||||
<p><strong>Период:</strong> {ocrModalContent.data.policy_period.insured_from} - {ocrModalContent.data.policy_period.insured_to}</p>
|
||||
)}
|
||||
<p style={{ marginTop: 16 }}><strong>Полный ответ AI:</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: '#fff3f3', padding: 12, borderRadius: 4, fontSize: 12, maxHeight: 400, overflow: 'auto' }}>
|
||||
{JSON.stringify(ocrModalContent.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* 🔧 Технические кнопки для разработки */}
|
||||
<div style={{
|
||||
marginTop: 24,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Form, Button, Select, Upload, message, Spin, Alert, Card } from 'antd';
|
||||
import { UploadOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
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';
|
||||
@@ -172,125 +172,188 @@ const DOCUMENT_CONFIGS: Record<string, any[]> = {
|
||||
export default function Step2Details({ formData, updateFormData, onNext, onPrev, addDebugEvent }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [eventType, setEventType] = useState(formData.eventType || '');
|
||||
const [documentFiles, setDocumentFiles] = useState<Record<string, UploadFile[]>>({});
|
||||
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 [uploadProgress, setUploadProgress] = useState('');
|
||||
const [waitingForOcr, setWaitingForOcr] = useState(false);
|
||||
const [ocrResults, setOcrResults] = useState<any>(null);
|
||||
const [ocrModalVisible, setOcrModalVisible] = useState(false);
|
||||
const [ocrModalContent, setOcrModalContent] = useState<any>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const handleEventTypeChange = (value: string) => {
|
||||
setEventType(value);
|
||||
setDocumentFiles({}); // Очищаем загруженные файлы при смене типа
|
||||
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]);
|
||||
|
||||
const handleUploadChange = (field: string, { fileList: newFileList }: any) => {
|
||||
setDocumentFiles(prev => ({
|
||||
...prev,
|
||||
[field]: newFileList
|
||||
}));
|
||||
// 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 handleNext = async () => {
|
||||
const handleUploadAndProcess = async () => {
|
||||
if (!currentFile || !currentDocConfig) {
|
||||
message.error('Выберите файл');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// Проверяем что все обязательные документы загружены
|
||||
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;
|
||||
}
|
||||
|
||||
// Загружаем все документы в S3 через n8n
|
||||
setUploading(true);
|
||||
setUploadProgress('📤 Загружаем документы...');
|
||||
|
||||
setOcrModalVisible(true);
|
||||
setOcrModalContent('loading');
|
||||
|
||||
const claimId = formData.claim_id;
|
||||
const uploadedFiles: any[] = [];
|
||||
|
||||
for (const docConfig of currentDocuments) {
|
||||
const files = documentFiles[docConfig.field] || [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (!file.originFileObj) continue;
|
||||
|
||||
setUploadProgress(`📡 Загружаем: ${docConfig.name} (${i + 1}/${files.length})...`);
|
||||
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append('claim_id', claimId);
|
||||
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,
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
message.error('Заполните все обязательные поля');
|
||||
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', sessionStorage.getItem('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);
|
||||
setUploadProgress('');
|
||||
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}
|
||||
@@ -316,108 +379,216 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{/* Показываем документы только после выбора типа события */}
|
||||
{/* Прогресс обработки документов */}
|
||||
{eventType && currentDocuments.length > 0 && (
|
||||
<Card
|
||||
title="📋 Загрузите документы для обработки"
|
||||
style={{ marginTop: 24 }}
|
||||
headStyle={{ background: '#f0f9ff', borderBottom: '2px solid #91d5ff' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#e6f7ff', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#0050b3' }}>
|
||||
💡 Просто загрузите документы — наш AI автоматически распознает все данные
|
||||
(номера рейсов, даты, время, причины задержек)
|
||||
</p>
|
||||
<Card style={{ marginBottom: 24, background: '#f0f9ff', borderColor: '#91d5ff' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong>Прогресс обработки документов:</strong>
|
||||
</div>
|
||||
|
||||
{currentDocuments.map((doc, index) => (
|
||||
<div key={doc.field} style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 500 }}>
|
||||
{doc.required ? '✅' : 'ℹ️'} {doc.name}
|
||||
{doc.required && <span style={{ color: '#ff4d4f', marginLeft: 4 }}>*</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
💡 {doc.description}
|
||||
</div>
|
||||
|
||||
<Upload
|
||||
listType="picture"
|
||||
fileList={documentFiles[doc.field] || []}
|
||||
onChange={(info) => 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,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
size="large"
|
||||
block
|
||||
disabled={(documentFiles[doc.field] || []).length >= doc.maxFiles}
|
||||
>
|
||||
Загрузить файл{doc.maxFiles > 1 ? 'ы' : ''} (до {doc.maxFiles} шт, макс 15MB)
|
||||
</Button>
|
||||
</Upload>
|
||||
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||
Загружено: {(documentFiles[doc.field] || []).length}/{doc.maxFiles} файл(ов)
|
||||
</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: '#52c41a' }}>
|
||||
<CheckCircleOutlined /> {doc.name} - ✅ Обработан
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Прогресс обработки */}
|
||||
{uploading && uploadProgress && (
|
||||
<Alert
|
||||
message={uploadProgress}
|
||||
type="info"
|
||||
showIcon
|
||||
icon={<Spin indicator={<LoadingOutlined style={{ fontSize: 16 }} spin />} />}
|
||||
style={{ marginBottom: 16, marginTop: 16 }}
|
||||
/>
|
||||
{/* Текущий документ для загрузки */}
|
||||
{eventType && currentDocConfig && (
|
||||
<Card
|
||||
title={`📋 Шаг ${currentDocumentIndex + 1}/${currentDocuments.length}: ${currentDocConfig.name}`}
|
||||
style={{ marginTop: 24 }}
|
||||
headStyle={{ background: currentDocConfig.required ? '#fff7e6' : '#f0f9ff', borderBottom: '2px solid #ffa940' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#e6f7ff', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#0050b3' }}>
|
||||
💡 {currentDocConfig.description}
|
||||
</p>
|
||||
{currentDocConfig.required && (
|
||||
<p style={{ margin: '8px 0 0 0', fontSize: 12, color: '#d46b08' }}>
|
||||
⚠️ Этот документ обязательный
|
||||
</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: '#f6ffed', borderColor: '#b7eb8f' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
||||
<h3 style={{ color: '#52c41a' }}>✅ Все документы обработаны!</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: '#f6ffed',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #b7eb8f',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<p style={{ margin: '0 0 8px 0', color: '#52c41a', 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: '#fff3f3',
|
||||
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={handleNext}
|
||||
loading={uploading}
|
||||
onClick={handleFinishStep}
|
||||
disabled={!allRequiredProcessed || uploading}
|
||||
style={{ flex: 1 }}
|
||||
size="large"
|
||||
>
|
||||
{uploading ? 'Обрабатываем...' : 'Далее'}
|
||||
{allRequiredProcessed ? 'Далее →' : `Осталось документов: ${totalRequired - processedRequired}`}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
@@ -444,9 +615,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
// Пропускаем валидацию, заполняем минимальные данные
|
||||
const devData = {
|
||||
eventType: 'delay_flight',
|
||||
processedDocuments: {
|
||||
boarding_or_ticket: { flight_number: 'DEV123', date: '2025-10-28' },
|
||||
delay_confirmation: { delay_duration: '4h' }
|
||||
}
|
||||
};
|
||||
updateFormData(devData);
|
||||
onNext();
|
||||
|
||||
@@ -379,3 +379,63 @@ export default function Step3Payment({
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
borderRadius: 8,
|
||||
border: '2px dashed #999'
|
||||
}}>
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
||||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={onPrev}
|
||||
size="small"
|
||||
>
|
||||
← Назад (Step 2)
|
||||
</Button>
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
// Пропускаем валидацию телефона
|
||||
setIsPhoneVerified(true);
|
||||
const devData = {
|
||||
fullName: 'Тест Тестов',
|
||||
email: 'test@test.ru',
|
||||
phone: '+79991234567',
|
||||
paymentMethod: 'sbp',
|
||||
bankName: 'sberbank',
|
||||
};
|
||||
updateFormData(devData);
|
||||
message.success('DEV: Телефон автоматически подтверждён');
|
||||
}}
|
||||
size="small"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
✅ Автоподтверждение телефона [dev]
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
// Автоматически отправляем заявку
|
||||
setIsPhoneVerified(true);
|
||||
const devData = {
|
||||
fullName: 'Тест Тестов',
|
||||
email: 'test@test.ru',
|
||||
phone: '+79991234567',
|
||||
paymentMethod: 'sbp',
|
||||
bankName: 'sberbank',
|
||||
};
|
||||
updateFormData(devData);
|
||||
onSubmit();
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
🚀 Отправить [пропустить]
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user