fix: Исправление загрузки документов и SQL запросов
- Исправлена потеря документов при обновлении черновика (SQL объединяет вместо перезаписи) - Исправлено определение типа документа (приоритет field_label над field_name) - Исправлены дубликаты в documents_meta и documents_uploaded - Добавлена передача group_index с фронтенда для правильного field_name - Исправлены все документы в таблице clpr_claim_documents с правильными field_name - Обновлены SQL запросы: claimsave и claimsave_final для нового флоу - Добавлена поддержка multi-file upload для одного документа - Исправлены дубликаты в списке загруженных документов на фронтенде Файлы: - SQL: SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql, SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql - n8n: N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js (поддержка group_index) - Backend: documents.py (передача group_index в n8n) - Frontend: StepWizardPlan.tsx (передача group_index, исправление дубликатов) - Скрипты: fix_claim_documents_field_names.py, fix_documents_meta_duplicates.py Результат: документы больше не теряются, имеют правильные типы и field_name
This commit is contained in:
@@ -74,6 +74,16 @@ export default function StepDescription({
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
description_length: safeDescription.length,
|
||||
description_preview: safeDescription.substring(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -81,14 +91,29 @@ export default function StepDescription({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
||||
problem_description: safeDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('📝 Ответ сервера:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ Ошибка отправки описания:', {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
throw new Error(`Ошибка API: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ Описание успешно отправлено:', responseData);
|
||||
|
||||
message.success('Описание отправлено, подбираем рекомендации...');
|
||||
updateFormData({
|
||||
problemDescription: safeDescription,
|
||||
|
||||
725
frontend/src/components/form/StepDocumentsNew.tsx
Normal file
725
frontend/src/components/form/StepDocumentsNew.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
/**
|
||||
* StepDocumentsNew.tsx
|
||||
*
|
||||
* Поэкранная загрузка документов.
|
||||
* Один документ на экран с возможностью пропуска.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Upload,
|
||||
Progress,
|
||||
Alert,
|
||||
Typography,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
Result
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
FileTextOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
InboxOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
// === Типы ===
|
||||
export interface DocumentConfig {
|
||||
type: string; // Идентификатор: contract, payment, correspondence
|
||||
name: string; // Название: "Договор или оферта"
|
||||
critical: boolean; // Обязательный документ?
|
||||
hints?: string; // Подсказка: "Скриншот или PDF договора"
|
||||
accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png']
|
||||
}
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
updateFormData: (data: any) => void;
|
||||
documents: DocumentConfig[];
|
||||
currentIndex: number;
|
||||
onDocumentUploaded: (docType: string, fileData: any) => void;
|
||||
onDocumentSkipped: (docType: string) => void;
|
||||
onAllDocumentsComplete: () => void;
|
||||
onPrev: () => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
// === Компонент ===
|
||||
export default function StepDocumentsNew({
|
||||
formData,
|
||||
updateFormData,
|
||||
documents,
|
||||
currentIndex,
|
||||
onDocumentUploaded,
|
||||
onDocumentSkipped,
|
||||
onAllDocumentsComplete,
|
||||
onPrev,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
// Текущий документ
|
||||
const currentDoc = documents[currentIndex];
|
||||
const isLastDocument = currentIndex === documents.length - 1;
|
||||
const totalDocs = documents.length;
|
||||
|
||||
// Сбрасываем файлы при смене документа
|
||||
useEffect(() => {
|
||||
setFileList([]);
|
||||
setUploadProgress(0);
|
||||
}, [currentIndex]);
|
||||
|
||||
// === Handlers ===
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('Выберите файл для загрузки');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileList[0];
|
||||
if (!file.originFileObj) {
|
||||
message.error('Ошибка: файл не найден');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
});
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id || '');
|
||||
formDataToSend.append('session_id', formData.session_id || '');
|
||||
formDataToSend.append('document_type', currentDoc.type);
|
||||
formDataToSend.append('file', file.originFileObj, file.name);
|
||||
|
||||
// Симуляция прогресса (реальный прогресс будет через XHR)
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90));
|
||||
}, 200);
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
});
|
||||
|
||||
message.success(`${currentDoc.name} загружен!`);
|
||||
|
||||
// Сохраняем в formData
|
||||
const uploadedDocs = formData.documents_uploaded || [];
|
||||
uploadedDocs.push({
|
||||
type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
file_name: file.name,
|
||||
ocr_status: 'processing',
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
documents_uploaded: uploadedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentUploaded(currentDoc.type, result);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Upload error:', error);
|
||||
message.error('Ошибка загрузки файла. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, {
|
||||
error: String(error),
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (currentDoc.critical) {
|
||||
// Показываем предупреждение, но всё равно разрешаем пропустить
|
||||
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`);
|
||||
}
|
||||
|
||||
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
was_critical: currentDoc.critical,
|
||||
});
|
||||
|
||||
// Сохраняем в список пропущенных
|
||||
const skippedDocs = formData.documents_skipped || [];
|
||||
if (!skippedDocs.includes(currentDoc.type)) {
|
||||
skippedDocs.push(currentDoc.type);
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
documents_skipped: skippedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentSkipped(currentDoc.type);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
}, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
// === Upload Props ===
|
||||
const uploadProps: UploadProps = {
|
||||
fileList,
|
||||
onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл
|
||||
beforeUpload: () => false, // Не загружаем автоматически
|
||||
maxCount: 1,
|
||||
accept: currentDoc?.accept
|
||||
? currentDoc.accept.map(ext => `.${ext}`).join(',')
|
||||
: '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx',
|
||||
disabled: uploading,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (!currentDoc) {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Все документы обработаны"
|
||||
subTitle="Переходим к формированию заявления..."
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
<Card>
|
||||
{/* === Прогресс === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">
|
||||
Документ {currentIndex + 1} из {totalDocs}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{Math.round((currentIndex / totalDocs) * 100)}% завершено
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((currentIndex / totalDocs) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileTextOutlined style={{ color: '#595959' }} />
|
||||
{currentDoc.name}
|
||||
{currentDoc.critical && (
|
||||
<ExclamationCircleOutlined
|
||||
style={{ color: '#fa8c16', fontSize: 20 }}
|
||||
title="Важный документ"
|
||||
/>
|
||||
)}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Алерт для критичных документов === */}
|
||||
{currentDoc.critical && (
|
||||
<Alert
|
||||
message="Важный документ"
|
||||
description="Этот документ значительно повысит шансы на успешное рассмотрение заявки. Если документа нет — можно пропустить, но мы рекомендуем загрузить."
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Загрузка файла === */}
|
||||
<Dragger {...uploadProps} style={{ marginBottom: 24 }}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 48, color: '#595959' }} spin />
|
||||
) : (
|
||||
<InboxOutlined style={{ fontSize: 48, color: '#595959' }} />
|
||||
)}
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading
|
||||
? 'Загружаем документ...'
|
||||
: 'Перетащите файл сюда или нажмите для выбора'
|
||||
}
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ)
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* === Прогресс загрузки === */}
|
||||
{uploading && (
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status="active"
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Кнопки === */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={onPrev}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
Пропустить
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
loading={uploading}
|
||||
disabled={fileList.length === 0}
|
||||
size="large"
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
{isLastDocument ? 'Загрузить и продолжить' : 'Загрузить'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* === Уже загруженные документы === */}
|
||||
{formData.documents_uploaded && formData.documents_uploaded.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<Text strong>Загруженные документы:</Text>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
|
||||
{formData.documents_uploaded.map((doc: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
|
||||
{documents.find(d => d.type === doc.type)?.name || doc.type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
* StepDocumentsNew.tsx
|
||||
*
|
||||
* Поэкранная загрузка документов.
|
||||
* Один документ на экран с возможностью пропуска.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Upload,
|
||||
Progress,
|
||||
Alert,
|
||||
Typography,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
Result
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
FileTextOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
InboxOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
// === Типы ===
|
||||
export interface DocumentConfig {
|
||||
type: string; // Идентификатор: contract, payment, correspondence
|
||||
name: string; // Название: "Договор или оферта"
|
||||
critical: boolean; // Обязательный документ?
|
||||
hints?: string; // Подсказка: "Скриншот или PDF договора"
|
||||
accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png']
|
||||
}
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
updateFormData: (data: any) => void;
|
||||
documents: DocumentConfig[];
|
||||
currentIndex: number;
|
||||
onDocumentUploaded: (docType: string, fileData: any) => void;
|
||||
onDocumentSkipped: (docType: string) => void;
|
||||
onAllDocumentsComplete: () => void;
|
||||
onPrev: () => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
// === Компонент ===
|
||||
export default function StepDocumentsNew({
|
||||
formData,
|
||||
updateFormData,
|
||||
documents,
|
||||
currentIndex,
|
||||
onDocumentUploaded,
|
||||
onDocumentSkipped,
|
||||
onAllDocumentsComplete,
|
||||
onPrev,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
// Текущий документ
|
||||
const currentDoc = documents[currentIndex];
|
||||
const isLastDocument = currentIndex === documents.length - 1;
|
||||
const totalDocs = documents.length;
|
||||
|
||||
// Сбрасываем файлы при смене документа
|
||||
useEffect(() => {
|
||||
setFileList([]);
|
||||
setUploadProgress(0);
|
||||
}, [currentIndex]);
|
||||
|
||||
// === Handlers ===
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('Выберите файл для загрузки');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileList[0];
|
||||
if (!file.originFileObj) {
|
||||
message.error('Ошибка: файл не найден');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
});
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id || '');
|
||||
formDataToSend.append('session_id', formData.session_id || '');
|
||||
formDataToSend.append('document_type', currentDoc.type);
|
||||
formDataToSend.append('file', file.originFileObj, file.name);
|
||||
|
||||
// Симуляция прогресса (реальный прогресс будет через XHR)
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90));
|
||||
}, 200);
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
});
|
||||
|
||||
message.success(`${currentDoc.name} загружен!`);
|
||||
|
||||
// Сохраняем в formData
|
||||
const uploadedDocs = formData.documents_uploaded || [];
|
||||
uploadedDocs.push({
|
||||
type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
file_name: file.name,
|
||||
ocr_status: 'processing',
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
documents_uploaded: uploadedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentUploaded(currentDoc.type, result);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Upload error:', error);
|
||||
message.error('Ошибка загрузки файла. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, {
|
||||
error: String(error),
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (currentDoc.critical) {
|
||||
// Показываем предупреждение, но всё равно разрешаем пропустить
|
||||
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`);
|
||||
}
|
||||
|
||||
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
was_critical: currentDoc.critical,
|
||||
});
|
||||
|
||||
// Сохраняем в список пропущенных
|
||||
const skippedDocs = formData.documents_skipped || [];
|
||||
if (!skippedDocs.includes(currentDoc.type)) {
|
||||
skippedDocs.push(currentDoc.type);
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
documents_skipped: skippedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentSkipped(currentDoc.type);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
}, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
// === Upload Props ===
|
||||
const uploadProps: UploadProps = {
|
||||
fileList,
|
||||
onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл
|
||||
beforeUpload: () => false, // Не загружаем автоматически
|
||||
maxCount: 1,
|
||||
accept: currentDoc?.accept
|
||||
? currentDoc.accept.map(ext => `.${ext}`).join(',')
|
||||
: '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx',
|
||||
disabled: uploading,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (!currentDoc) {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Все документы обработаны"
|
||||
subTitle="Переходим к формированию заявления..."
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
<Card>
|
||||
{/* === Прогресс === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">
|
||||
Документ {currentIndex + 1} из {totalDocs}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{Math.round((currentIndex / totalDocs) * 100)}% завершено
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((currentIndex / totalDocs) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileTextOutlined style={{ color: '#595959' }} />
|
||||
{currentDoc.name}
|
||||
{currentDoc.critical && (
|
||||
<ExclamationCircleOutlined
|
||||
style={{ color: '#fa8c16', fontSize: 20 }}
|
||||
title="Важный документ"
|
||||
/>
|
||||
)}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Алерт для критичных документов === */}
|
||||
{currentDoc.critical && (
|
||||
<Alert
|
||||
message="Важный документ"
|
||||
description="Этот документ значительно повысит шансы на успешное рассмотрение заявки. Если документа нет — можно пропустить, но мы рекомендуем загрузить."
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Загрузка файла === */}
|
||||
<Dragger {...uploadProps} style={{ marginBottom: 24 }}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 48, color: '#595959' }} spin />
|
||||
) : (
|
||||
<InboxOutlined style={{ fontSize: 48, color: '#595959' }} />
|
||||
)}
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading
|
||||
? 'Загружаем документ...'
|
||||
: 'Перетащите файл сюда или нажмите для выбора'
|
||||
}
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ)
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* === Прогресс загрузки === */}
|
||||
{uploading && (
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status="active"
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Кнопки === */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={onPrev}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
Пропустить
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
loading={uploading}
|
||||
disabled={fileList.length === 0}
|
||||
size="large"
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
{isLastDocument ? 'Загрузить и продолжить' : 'Загрузить'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* === Уже загруженные документы === */}
|
||||
{formData.documents_uploaded && formData.documents_uploaded.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<Text strong>Загруженные документы:</Text>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
|
||||
{formData.documents_uploaded.map((doc: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
|
||||
{documents.find(d => d.type === doc.type)?.name || doc.type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,37 @@
|
||||
/**
|
||||
* StepDraftSelection.tsx
|
||||
*
|
||||
* Выбор черновика с поддержкой разных статусов:
|
||||
* - draft_new: только описание
|
||||
* - draft_docs_progress: часть документов загружена
|
||||
* - draft_docs_complete: все документы, ждём заявление
|
||||
* - draft_claim_ready: заявление готово
|
||||
* - awaiting_sms: ждёт SMS подтверждения
|
||||
* - legacy: старый формат (без documents_required)
|
||||
*
|
||||
* @version 2.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag } from 'antd';
|
||||
import { FileTextOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
// Форматирование даты без date-fns (если библиотека не установлена)
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
UploadOutlined,
|
||||
FileSearchOutlined,
|
||||
MobileOutlined,
|
||||
ExclamationCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
@@ -16,35 +46,129 @@ const formatDate = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
// Относительное время
|
||||
const getRelativeTime = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'только что';
|
||||
if (diffMins < 60) return `${diffMins} мин. назад`;
|
||||
if (diffHours < 24) return `${diffHours} ч. назад`;
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
return formatDate(dateStr);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
claim_id: string;
|
||||
session_token: string;
|
||||
status_code: string;
|
||||
channel: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
problem_description?: string;
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
// Новые поля для нового флоу
|
||||
documents_total?: number;
|
||||
documents_uploaded?: number;
|
||||
documents_skipped?: number;
|
||||
wizard_ready?: boolean;
|
||||
claim_ready?: boolean;
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
}
|
||||
|
||||
interface Props {
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
unified_id?: string; // ✅ Добавляем unified_id
|
||||
unified_id?: string;
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
|
||||
}
|
||||
|
||||
// === Конфиг статусов ===
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
description: string;
|
||||
action: string;
|
||||
}> = {
|
||||
draft: {
|
||||
color: 'default',
|
||||
icon: <FileTextOutlined />,
|
||||
label: 'Черновик',
|
||||
description: 'Начато заполнение',
|
||||
action: 'Продолжить',
|
||||
},
|
||||
draft_new: {
|
||||
color: 'blue',
|
||||
icon: <FileTextOutlined />,
|
||||
label: 'Новый',
|
||||
description: 'Только описание проблемы',
|
||||
action: 'Загрузить документы',
|
||||
},
|
||||
draft_docs_progress: {
|
||||
color: 'processing',
|
||||
icon: <UploadOutlined />,
|
||||
label: 'Загрузка документов',
|
||||
description: 'Часть документов загружена',
|
||||
action: 'Продолжить загрузку',
|
||||
},
|
||||
draft_docs_complete: {
|
||||
color: 'orange',
|
||||
icon: <LoadingOutlined />,
|
||||
label: 'Обработка',
|
||||
description: 'Формируется заявление...',
|
||||
action: 'Ожидайте',
|
||||
},
|
||||
draft_claim_ready: {
|
||||
color: 'green',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Готово к отправке',
|
||||
description: 'Заявление готово',
|
||||
action: 'Просмотреть и отправить',
|
||||
},
|
||||
awaiting_sms: {
|
||||
color: 'volcano',
|
||||
icon: <MobileOutlined />,
|
||||
label: 'Ожидает подтверждения',
|
||||
description: 'Введите SMS код',
|
||||
action: 'Подтвердить',
|
||||
},
|
||||
in_work: {
|
||||
color: 'cyan',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: 'В работе',
|
||||
description: 'Заявка на рассмотрении',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
legacy: {
|
||||
color: 'warning',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
label: 'Устаревший формат',
|
||||
description: 'Требуется обновление',
|
||||
action: 'Начать заново',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StepDraftSelection({
|
||||
phone,
|
||||
session_id,
|
||||
unified_id, // ✅ Добавляем unified_id
|
||||
unified_id,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
onRestartDraft,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -54,7 +178,7 @@ export default function StepDraftSelection({
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
// ✅ Приоритет: unified_id > phone > session_id
|
||||
|
||||
if (unified_id) {
|
||||
params.append('unified_id', unified_id);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
|
||||
@@ -76,8 +200,22 @@ export default function StepDraftSelection({
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔍 StepDraftSelection: ответ API:', data);
|
||||
console.log('🔍 StepDraftSelection: количество черновиков:', data.count);
|
||||
setDrafts(data.drafts || []);
|
||||
|
||||
// Определяем legacy черновики (без documents_required в payload)
|
||||
const processedDrafts = (data.drafts || []).map((draft: Draft) => {
|
||||
// Legacy только если:
|
||||
// 1. Статус 'draft' (старый формат) ИЛИ
|
||||
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
|
||||
// И есть wizard_plan (старый формат)
|
||||
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
|
||||
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
|
||||
return {
|
||||
...draft,
|
||||
is_legacy: isLegacy,
|
||||
};
|
||||
});
|
||||
|
||||
setDrafts(processedDrafts);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновиков:', error);
|
||||
message.error('Не удалось загрузить список черновиков');
|
||||
@@ -88,7 +226,7 @@ export default function StepDraftSelection({
|
||||
|
||||
useEffect(() => {
|
||||
loadDrafts();
|
||||
}, [phone, session_id, unified_id]); // ✅ Добавляем unified_id в зависимости
|
||||
}, [phone, session_id, unified_id]);
|
||||
|
||||
const handleDelete = async (claimId: string) => {
|
||||
try {
|
||||
@@ -111,14 +249,56 @@ export default function StepDraftSelection({
|
||||
}
|
||||
};
|
||||
|
||||
// Получение конфига статуса
|
||||
const getStatusConfig = (draft: Draft) => {
|
||||
if (draft.is_legacy) {
|
||||
return STATUS_CONFIG.legacy;
|
||||
}
|
||||
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
|
||||
};
|
||||
|
||||
const getProgressInfo = (draft: Draft) => {
|
||||
const parts: string[] = [];
|
||||
if (draft.problem_description) parts.push('Описание');
|
||||
if (draft.wizard_plan) parts.push('План вопросов');
|
||||
if (draft.wizard_answers) parts.push('Ответы');
|
||||
if (draft.has_documents) parts.push('Документы');
|
||||
return parts.length > 0 ? parts.join(', ') : 'Начато';
|
||||
// Прогресс документов
|
||||
const getDocsProgress = (draft: Draft) => {
|
||||
if (!draft.documents_total) return null;
|
||||
const uploaded = draft.documents_uploaded || 0;
|
||||
const skipped = draft.documents_skipped || 0;
|
||||
const total = draft.documents_total;
|
||||
const percent = Math.round(((uploaded + skipped) / total) * 100);
|
||||
return { uploaded, skipped, total, percent };
|
||||
};
|
||||
|
||||
// Обработка клика на черновик
|
||||
const handleDraftAction = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
|
||||
if (draft.is_legacy && onRestartDraft) {
|
||||
// Legacy черновик - предлагаем начать заново с тем же описанием
|
||||
onRestartDraft(draftId, draft.problem_description || '');
|
||||
} else if (draft.status_code === 'draft_docs_complete') {
|
||||
// Всё ещё обрабатывается - показываем сообщение
|
||||
message.info('Заявление формируется. Пожалуйста, подождите.');
|
||||
} else {
|
||||
// Обычный переход
|
||||
onSelectDraft(draftId);
|
||||
}
|
||||
};
|
||||
|
||||
// Кнопка действия
|
||||
const getActionButton = (draft: Draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const isProcessing = draft.status_code === 'draft_docs_complete';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={isProcessing ? 'default' : 'primary'}
|
||||
onClick={() => handleDraftAction(draft)}
|
||||
icon={config.icon}
|
||||
disabled={isProcessing}
|
||||
loading={isProcessing}
|
||||
>
|
||||
{config.action}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -133,10 +313,10 @@ export default function StepDraftSelection({
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
||||
📋 Ваши черновики заявок
|
||||
📋 Ваши заявки
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||
Выберите черновик, чтобы продолжить заполнение, или создайте новую заявку.
|
||||
Выберите заявку для продолжения или создайте новую.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +326,7 @@ export default function StepDraftSelection({
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<Empty
|
||||
description="У вас нет незавершенных черновиков"
|
||||
description="У вас нет незавершенных заявок"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
|
||||
@@ -157,89 +337,146 @@ export default function StepDraftSelection({
|
||||
<>
|
||||
<List
|
||||
dataSource={drafts}
|
||||
renderItem={(draft) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
background: '#fff',
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="continue"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
console.log('🔍 Выбран черновик:', draft.claim_id, 'id:', draft.id);
|
||||
// Используем id (UUID) если claim_id отсутствует
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
console.log('🔍 Загружаем черновик с ID:', draftId);
|
||||
onSelectDraft(draftId);
|
||||
}}
|
||||
icon={<FileTextOutlined />}
|
||||
>
|
||||
Продолжить
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить черновик?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id!)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === draft.claim_id}
|
||||
disabled={deletingId === draft.claim_id}
|
||||
renderItem={(draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const docsProgress = getDocsProgress(draft);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
actions={[
|
||||
getActionButton(draft),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>Черновик</Text>
|
||||
<Tag color="default">Черновик</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Обновлен: {formatDate(draft.updated_at)}
|
||||
</Text>
|
||||
{draft.problem_description && (
|
||||
<Text
|
||||
ellipsis={{ tooltip: draft.problem_description }}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{draft.problem_description}
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
color: draft.is_legacy ? '#faad14' : '#595959',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{config.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Описание проблемы */}
|
||||
{draft.problem_description && (
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
title={draft.problem_description}
|
||||
>
|
||||
{draft.problem_description.length > 60
|
||||
? draft.problem_description.substring(0, 60) + '...'
|
||||
: draft.problem_description
|
||||
}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Время обновления */}
|
||||
<Space size="small">
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Tooltip title={formatDate(draft.updated_at)}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{getRelativeTime(draft.updated_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{/* Legacy предупреждение */}
|
||||
{draft.is_legacy && (
|
||||
<Alert
|
||||
message="Черновик в старом формате. Нажмите 'Начать заново'."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Прогресс документов */}
|
||||
{docsProgress && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
|
||||
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
|
||||
</Text>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#52c41a"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Старые теги прогресса (для обратной совместимости) */}
|
||||
{!docsProgress && !draft.is_legacy && (
|
||||
<Space size="small" wrap>
|
||||
<Tag color={draft.problem_description ? 'green' : 'default'}>
|
||||
{draft.problem_description ? '✓ Описание' : 'Описание'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
)}
|
||||
<Space size="small">
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_answers ? 'green' : 'default'}>
|
||||
{draft.wizard_answers ? '✓ Ответы' : 'Ответы'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Прогресс: {getProgressInfo(draft)}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
@@ -271,4 +508,3 @@ export default function StepDraftSelection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
679
frontend/src/components/form/StepWaitingClaim.tsx
Normal file
679
frontend/src/components/form/StepWaitingClaim.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
/**
|
||||
* StepWaitingClaim.tsx
|
||||
*
|
||||
* Экран ожидания формирования заявления.
|
||||
* Показывает прогресс: OCR → Анализ → Формирование заявления.
|
||||
* Подписывается на SSE для получения claim_ready.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, Typography, Progress, Space, Button, Spin, Result, Steps } from 'antd';
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
FileSearchOutlined,
|
||||
RobotOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
claimId?: string;
|
||||
documentsCount: number;
|
||||
onClaimReady: (claimData: any) => void;
|
||||
onTimeout: () => void;
|
||||
onError: (error: string) => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
type ProcessingStep = 'ocr' | 'analysis' | 'generation' | 'ready';
|
||||
|
||||
interface ProcessingState {
|
||||
currentStep: ProcessingStep;
|
||||
ocrCompleted: number;
|
||||
ocrTotal: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function StepWaitingClaim({
|
||||
sessionId,
|
||||
claimId,
|
||||
documentsCount,
|
||||
onClaimReady,
|
||||
onTimeout,
|
||||
onError,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [state, setState] = useState<ProcessingState>({
|
||||
currentStep: 'ocr',
|
||||
ocrCompleted: 0,
|
||||
ocrTotal: documentsCount,
|
||||
message: 'Распознаём документы...',
|
||||
});
|
||||
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Таймер для отображения времени
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// SSE подписка
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('Отсутствует session_id');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔌 StepWaitingClaim: подписываемся на SSE', { sessionId, claimId });
|
||||
|
||||
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
addDebugEvent?.('waiting', 'info', '🔌 Подписка на SSE для ожидания заявления', {
|
||||
session_id: sessionId,
|
||||
claim_id: claimId,
|
||||
});
|
||||
|
||||
// Таймаут 5 минут
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
console.warn('⏰ Timeout ожидания заявления');
|
||||
setError('Превышено время ожидания. Попробуйте обновить страницу.');
|
||||
addDebugEvent?.('waiting', 'warning', '⏰ Таймаут ожидания заявления');
|
||||
eventSource.close();
|
||||
onTimeout();
|
||||
}, 300000); // 5 минут
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE соединение открыто (waiting)');
|
||||
addDebugEvent?.('waiting', 'info', '✅ SSE соединение открыто');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📥 SSE event (waiting):', data);
|
||||
|
||||
const eventType = data.event_type || data.type;
|
||||
|
||||
// OCR документа завершён
|
||||
if (eventType === 'document_ocr_completed') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
ocrCompleted: prev.ocrCompleted + 1,
|
||||
message: `Распознано ${prev.ocrCompleted + 1} из ${prev.ocrTotal} документов`,
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', `📄 OCR завершён: ${data.document_type}`);
|
||||
}
|
||||
|
||||
// Все документы распознаны, начинаем анализ
|
||||
if (eventType === 'ocr_all_completed' || eventType === 'analysis_started') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'analysis',
|
||||
message: 'Анализируем данные...',
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', '🔍 Начат анализ данных');
|
||||
}
|
||||
|
||||
// Генерация заявления
|
||||
if (eventType === 'claim_generation_started') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'generation',
|
||||
message: 'Формируем заявление...',
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', '📝 Начато формирование заявления');
|
||||
}
|
||||
|
||||
// Заявление готово!
|
||||
if (eventType === 'claim_ready' || eventType === 'claim_plan_ready') {
|
||||
console.log('🎉 Заявление готово!', data);
|
||||
|
||||
// Очищаем таймаут
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'ready',
|
||||
message: 'Заявление готово!',
|
||||
}));
|
||||
|
||||
addDebugEvent?.('waiting', 'success', '✅ Заявление готово');
|
||||
|
||||
// Закрываем SSE
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Callback с данными
|
||||
setTimeout(() => {
|
||||
onClaimReady(data.data || data.claim_data || data);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Ошибка
|
||||
if (eventType === 'claim_error' || data.status === 'error') {
|
||||
setError(data.message || 'Произошла ошибка при формировании заявления');
|
||||
addDebugEvent?.('waiting', 'error', `❌ Ошибка: ${data.message}`);
|
||||
eventSource.close();
|
||||
onError(data.message);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка парсинга SSE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('❌ SSE error (waiting):', err);
|
||||
// Не показываем ошибку сразу — SSE может переподключиться
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, claimId, onClaimReady, onTimeout, onError, addDebugEvent]);
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Вычисляем процент прогресса
|
||||
const getProgress = (): number => {
|
||||
switch (state.currentStep) {
|
||||
case 'ocr':
|
||||
// OCR: 0-50%
|
||||
return state.ocrTotal > 0
|
||||
? Math.round((state.ocrCompleted / state.ocrTotal) * 50)
|
||||
: 25;
|
||||
case 'analysis':
|
||||
return 60;
|
||||
case 'generation':
|
||||
return 85;
|
||||
case 'ready':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Индекс текущего шага для Steps
|
||||
const getStepIndex = (): number => {
|
||||
switch (state.currentStep) {
|
||||
case 'ocr': return 0;
|
||||
case 'analysis': return 1;
|
||||
case 'generation': return 2;
|
||||
case 'ready': return 3;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Ошибка"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Обновить страницу
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.currentStep === 'ready') {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Заявление готово!"
|
||||
subTitle="Переходим к просмотру..."
|
||||
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<Card style={{ textAlign: 'center' }}>
|
||||
{/* === Иллюстрация === */}
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
alt="AI работает"
|
||||
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<Title level={3}>{state.message}</Title>
|
||||
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
Наш AI-ассистент обрабатывает ваши документы и формирует заявление.
|
||||
Это займёт 1-2 минуты.
|
||||
</Paragraph>
|
||||
|
||||
{/* === Прогресс === */}
|
||||
<Progress
|
||||
percent={getProgress()}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* === Шаги обработки === */}
|
||||
<Steps
|
||||
current={getStepIndex()}
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Step
|
||||
title="OCR"
|
||||
description={state.ocrTotal > 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''}
|
||||
icon={state.currentStep === 'ocr' ? <LoadingOutlined /> : <FileSearchOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Анализ"
|
||||
icon={state.currentStep === 'analysis' ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Заявление"
|
||||
icon={state.currentStep === 'generation' ? <LoadingOutlined /> : <FileTextOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Готово"
|
||||
icon={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Steps>
|
||||
|
||||
{/* === Таймер === */}
|
||||
<Space>
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Text type="secondary">
|
||||
Время ожидания: {formatTime(elapsedTime)}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{/* === Подсказка === */}
|
||||
<Paragraph type="secondary" style={{ marginTop: 16, fontSize: 12 }}>
|
||||
Не закрывайте эту страницу. Обработка происходит на сервере.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
* StepWaitingClaim.tsx
|
||||
*
|
||||
* Экран ожидания формирования заявления.
|
||||
* Показывает прогресс: OCR → Анализ → Формирование заявления.
|
||||
* Подписывается на SSE для получения claim_ready.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, Typography, Progress, Space, Button, Spin, Result, Steps } from 'antd';
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
FileSearchOutlined,
|
||||
RobotOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
claimId?: string;
|
||||
documentsCount: number;
|
||||
onClaimReady: (claimData: any) => void;
|
||||
onTimeout: () => void;
|
||||
onError: (error: string) => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
type ProcessingStep = 'ocr' | 'analysis' | 'generation' | 'ready';
|
||||
|
||||
interface ProcessingState {
|
||||
currentStep: ProcessingStep;
|
||||
ocrCompleted: number;
|
||||
ocrTotal: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function StepWaitingClaim({
|
||||
sessionId,
|
||||
claimId,
|
||||
documentsCount,
|
||||
onClaimReady,
|
||||
onTimeout,
|
||||
onError,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [state, setState] = useState<ProcessingState>({
|
||||
currentStep: 'ocr',
|
||||
ocrCompleted: 0,
|
||||
ocrTotal: documentsCount,
|
||||
message: 'Распознаём документы...',
|
||||
});
|
||||
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Таймер для отображения времени
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// SSE подписка
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('Отсутствует session_id');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔌 StepWaitingClaim: подписываемся на SSE', { sessionId, claimId });
|
||||
|
||||
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
addDebugEvent?.('waiting', 'info', '🔌 Подписка на SSE для ожидания заявления', {
|
||||
session_id: sessionId,
|
||||
claim_id: claimId,
|
||||
});
|
||||
|
||||
// Таймаут 5 минут
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
console.warn('⏰ Timeout ожидания заявления');
|
||||
setError('Превышено время ожидания. Попробуйте обновить страницу.');
|
||||
addDebugEvent?.('waiting', 'warning', '⏰ Таймаут ожидания заявления');
|
||||
eventSource.close();
|
||||
onTimeout();
|
||||
}, 300000); // 5 минут
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE соединение открыто (waiting)');
|
||||
addDebugEvent?.('waiting', 'info', '✅ SSE соединение открыто');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📥 SSE event (waiting):', data);
|
||||
|
||||
const eventType = data.event_type || data.type;
|
||||
|
||||
// OCR документа завершён
|
||||
if (eventType === 'document_ocr_completed') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
ocrCompleted: prev.ocrCompleted + 1,
|
||||
message: `Распознано ${prev.ocrCompleted + 1} из ${prev.ocrTotal} документов`,
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', `📄 OCR завершён: ${data.document_type}`);
|
||||
}
|
||||
|
||||
// Все документы распознаны, начинаем анализ
|
||||
if (eventType === 'ocr_all_completed' || eventType === 'analysis_started') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'analysis',
|
||||
message: 'Анализируем данные...',
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', '🔍 Начат анализ данных');
|
||||
}
|
||||
|
||||
// Генерация заявления
|
||||
if (eventType === 'claim_generation_started') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'generation',
|
||||
message: 'Формируем заявление...',
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', '📝 Начато формирование заявления');
|
||||
}
|
||||
|
||||
// Заявление готово!
|
||||
if (eventType === 'claim_ready' || eventType === 'claim_plan_ready') {
|
||||
console.log('🎉 Заявление готово!', data);
|
||||
|
||||
// Очищаем таймаут
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'ready',
|
||||
message: 'Заявление готово!',
|
||||
}));
|
||||
|
||||
addDebugEvent?.('waiting', 'success', '✅ Заявление готово');
|
||||
|
||||
// Закрываем SSE
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Callback с данными
|
||||
setTimeout(() => {
|
||||
onClaimReady(data.data || data.claim_data || data);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Ошибка
|
||||
if (eventType === 'claim_error' || data.status === 'error') {
|
||||
setError(data.message || 'Произошла ошибка при формировании заявления');
|
||||
addDebugEvent?.('waiting', 'error', `❌ Ошибка: ${data.message}`);
|
||||
eventSource.close();
|
||||
onError(data.message);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка парсинга SSE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('❌ SSE error (waiting):', err);
|
||||
// Не показываем ошибку сразу — SSE может переподключиться
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, claimId, onClaimReady, onTimeout, onError, addDebugEvent]);
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Вычисляем процент прогресса
|
||||
const getProgress = (): number => {
|
||||
switch (state.currentStep) {
|
||||
case 'ocr':
|
||||
// OCR: 0-50%
|
||||
return state.ocrTotal > 0
|
||||
? Math.round((state.ocrCompleted / state.ocrTotal) * 50)
|
||||
: 25;
|
||||
case 'analysis':
|
||||
return 60;
|
||||
case 'generation':
|
||||
return 85;
|
||||
case 'ready':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Индекс текущего шага для Steps
|
||||
const getStepIndex = (): number => {
|
||||
switch (state.currentStep) {
|
||||
case 'ocr': return 0;
|
||||
case 'analysis': return 1;
|
||||
case 'generation': return 2;
|
||||
case 'ready': return 3;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Ошибка"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Обновить страницу
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.currentStep === 'ready') {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Заявление готово!"
|
||||
subTitle="Переходим к просмотру..."
|
||||
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<Card style={{ textAlign: 'center' }}>
|
||||
{/* === Иллюстрация === */}
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
alt="AI работает"
|
||||
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<Title level={3}>{state.message}</Title>
|
||||
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
Наш AI-ассистент обрабатывает ваши документы и формирует заявление.
|
||||
Это займёт 1-2 минуты.
|
||||
</Paragraph>
|
||||
|
||||
{/* === Прогресс === */}
|
||||
<Progress
|
||||
percent={getProgress()}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* === Шаги обработки === */}
|
||||
<Steps
|
||||
current={getStepIndex()}
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Step
|
||||
title="OCR"
|
||||
description={state.ocrTotal > 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''}
|
||||
icon={state.currentStep === 'ocr' ? <LoadingOutlined /> : <FileSearchOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Анализ"
|
||||
icon={state.currentStep === 'analysis' ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Заявление"
|
||||
icon={state.currentStep === 'generation' ? <LoadingOutlined /> : <FileTextOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Готово"
|
||||
icon={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Steps>
|
||||
|
||||
{/* === Таймер === */}
|
||||
<Space>
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Text type="secondary">
|
||||
Время ожидания: {formatTime(elapsedTime)}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{/* === Подсказка === */}
|
||||
<Paragraph type="secondary" style={{ marginTop: 16, fontSize: 12 }}>
|
||||
Не закрывайте эту страницу. Обработка происходит на сервере.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
@@ -239,17 +239,27 @@ export default function StepWizardPlan({
|
||||
? docList[0].id
|
||||
: docId;
|
||||
|
||||
handleDocumentBlocksChange(docId, (blocks) => [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docId),
|
||||
fieldName: docId,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: docLabel,
|
||||
files: [],
|
||||
},
|
||||
]);
|
||||
handleDocumentBlocksChange(docId, (blocks) => {
|
||||
// ✅ Автогенерация уникального описания:
|
||||
// - Первый блок: пустое (будет использоваться docLabel)
|
||||
// - Второй и далее: "docLabel #N"
|
||||
const blockNumber = blocks.length + 1;
|
||||
const autoDescription = blockNumber > 1
|
||||
? `${docLabel || docId} #${blockNumber}`
|
||||
: '';
|
||||
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docId),
|
||||
fieldName: docId,
|
||||
description: autoDescription,
|
||||
category: category,
|
||||
docLabel: docLabel,
|
||||
files: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
const updateDocumentBlock = (
|
||||
@@ -328,53 +338,61 @@ export default function StepWizardPlan({
|
||||
setProgressState({ done, total });
|
||||
}, [formValues, questions]);
|
||||
|
||||
// Автоматически создаём блоки для обязательных документов при ответе "Да"
|
||||
// Автоматически создаём блоки для ВСЕХ документов из плана при загрузке
|
||||
// Используем ref чтобы отслеживать какие блоки уже созданы
|
||||
const createdDocBlocksRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!plan || !formValues) return;
|
||||
if (!plan || !documents || documents.length === 0) return;
|
||||
|
||||
questions.forEach((question) => {
|
||||
const visible = evaluateCondition(question.ask_if, formValues);
|
||||
if (!visible) return;
|
||||
documents.forEach((doc) => {
|
||||
const docKey = doc.id || doc.name || `doc_unknown`;
|
||||
|
||||
const questionValue = formValues?.[question.name];
|
||||
if (!isAffirmative(questionValue)) return;
|
||||
// Не создаём блок, если уже создавали
|
||||
if (createdDocBlocksRef.current.has(docKey)) return;
|
||||
|
||||
const questionDocs = documentGroups[question.name] || [];
|
||||
questionDocs.forEach((doc) => {
|
||||
if (!doc.required) return;
|
||||
|
||||
const docKey = doc.id || doc.name || `doc_${question.name}`;
|
||||
|
||||
// Не создаём блок, если документ пропущен
|
||||
if (skippedDocuments.has(docKey)) return;
|
||||
|
||||
const existingBlocks = questionFileBlocks[docKey] || [];
|
||||
|
||||
// Если блока ещё нет, создаём его автоматически
|
||||
if (existingBlocks.length === 0) {
|
||||
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
||||
handleDocumentBlocksChange(docKey, (blocks) => [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docKey),
|
||||
fieldName: docKey,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: doc.name,
|
||||
files: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
// Не создаём блок, если документ пропущен
|
||||
if (skippedDocuments.has(docKey)) return;
|
||||
|
||||
// Помечаем как созданный
|
||||
createdDocBlocksRef.current.add(docKey);
|
||||
|
||||
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
||||
handleDocumentBlocksChange(docKey, (blocks) => {
|
||||
// Проверяем ещё раз внутри callback
|
||||
if (blocks.length > 0) return blocks;
|
||||
return [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docKey),
|
||||
fieldName: docKey,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: doc.name,
|
||||
files: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
}, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]);
|
||||
}, [plan, documents, handleDocumentBlocksChange, skippedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWaiting || !formData.session_id || plan) {
|
||||
console.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
|
||||
isWaiting,
|
||||
hasSessionId: !!formData.session_id,
|
||||
hasPlan: !!plan,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = formData.session_id;
|
||||
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
|
||||
session_id: sessionId,
|
||||
sse_url: `/events/${sessionId}`,
|
||||
redis_channel: `ocr_events:${sessionId}`,
|
||||
});
|
||||
|
||||
const source = new EventSource(`/events/${sessionId}`);
|
||||
eventSourceRef.current = source;
|
||||
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
|
||||
@@ -441,6 +459,43 @@ export default function StepWizardPlan({
|
||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||
});
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
||||
if (eventType === 'documents_list_ready') {
|
||||
const documentsRequired = payload.documents_required || [];
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: documentsRequired.length,
|
||||
documents: documentsRequired.map((d: any) => d.name),
|
||||
});
|
||||
|
||||
console.log('📋 documents_list_ready:', {
|
||||
claim_id: payload.claim_id,
|
||||
documents_required: documentsRequired,
|
||||
});
|
||||
|
||||
// Сохраняем в formData для нового флоу
|
||||
updateFormData({
|
||||
documents_required: documentsRequired,
|
||||
claim_id: payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready', // Новый статус
|
||||
});
|
||||
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||
|
||||
// TODO: onNext() для перехода к StepDocumentsNew
|
||||
return;
|
||||
}
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
|
||||
@@ -695,6 +750,17 @@ export default function StepWizardPlan({
|
||||
return `upload_${group.index}`;
|
||||
};
|
||||
|
||||
// ✅ Подсчитываем дубликаты labels для автоматической нумерации
|
||||
const labelCounts: Record<string, number> = {};
|
||||
const labelIndexes: Record<string, number> = {};
|
||||
|
||||
// Первый проход - считаем сколько раз встречается каждый label
|
||||
groups.forEach((group) => {
|
||||
const block = group.block;
|
||||
const baseLabel = (block.description?.trim()) || block.docLabel || block.fieldName || guessFieldName(group);
|
||||
labelCounts[baseLabel] = (labelCounts[baseLabel] || 0) + 1;
|
||||
});
|
||||
|
||||
groups.forEach((group) => {
|
||||
const i = group.index;
|
||||
const block = group.block;
|
||||
@@ -713,10 +779,29 @@ export default function StepWizardPlan({
|
||||
);
|
||||
|
||||
// ✅ Добавляем реальное название поля (label) для использования в n8n
|
||||
// Приоритет: description (если заполнено) > docLabel > fieldLabel
|
||||
const baseLabel = (block.description?.trim()) || block.docLabel || fieldLabel;
|
||||
|
||||
// ✅ Автоматическая нумерация для дубликатов
|
||||
let finalFieldLabel = baseLabel;
|
||||
if (labelCounts[baseLabel] > 1) {
|
||||
labelIndexes[baseLabel] = (labelIndexes[baseLabel] || 0) + 1;
|
||||
finalFieldLabel = `${baseLabel} #${labelIndexes[baseLabel]}`;
|
||||
}
|
||||
|
||||
formPayload.append(
|
||||
`uploads_field_labels[${i}]`,
|
||||
block.docLabel || block.description || fieldLabel
|
||||
finalFieldLabel
|
||||
);
|
||||
|
||||
// 🔍 Логируем отправляемые метаданные документов
|
||||
console.log(`📁 Группа ${i}:`, {
|
||||
field_name: fieldLabel,
|
||||
field_label: finalFieldLabel,
|
||||
description: block.description,
|
||||
docLabel: block.docLabel,
|
||||
filesCount: block.files.length,
|
||||
});
|
||||
|
||||
// Файлы: uploads[i][j]
|
||||
block.files.forEach((file, j) => {
|
||||
@@ -919,23 +1004,19 @@ export default function StepWizardPlan({
|
||||
const accept = docList.flatMap((doc) => doc.accept || []);
|
||||
const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png']));
|
||||
|
||||
// Если документ предопределён (конкретный тип, не общий), не показываем лишние поля
|
||||
// Предопределённые документы: contract, payment, payment_confirmation и их вариации
|
||||
// Документ предопределён если у него есть id и он НЕ общий (не содержит _exist)
|
||||
// Для предустановленных документов НЕ показываем поле описания и кнопку "Удалить"
|
||||
const doc = docList[0];
|
||||
const isPredefinedDoc = docList.length === 1 && doc && doc.id &&
|
||||
!doc.id.includes('_exist') &&
|
||||
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
|
||||
doc.id.includes('contract') || doc.id.includes('payment') || doc.id.includes('receipt') ||
|
||||
doc.id.includes('cheque') || doc.id.includes('чек'));
|
||||
const singleDocName = isPredefinedDoc ? doc.name : null;
|
||||
const isPredefinedDoc = docList.length === 1 && doc && doc.id && !doc.id.includes('_exist');
|
||||
const singleDocName = doc?.name || docLabel;
|
||||
const isRequired = docList.some(doc => doc.required);
|
||||
const isSkipped = skippedDocuments.has(docId);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{/* Чекбокс "Пропустить" для обязательных документов */}
|
||||
{isRequired && (
|
||||
<div style={{ marginBottom: 8, padding: 8, background: '#f8f9fa', borderRadius: 8 }}>
|
||||
{/* Если документ пропущен - показываем только сообщение */}
|
||||
{isSkipped && (
|
||||
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, border: '1px solid #ffd591' }}>
|
||||
<Checkbox
|
||||
checked={isSkipped}
|
||||
onChange={(e) => {
|
||||
@@ -949,7 +1030,7 @@ export default function StepWizardPlan({
|
||||
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
||||
}}
|
||||
>
|
||||
У меня нет этого документа
|
||||
<Text type="warning">У меня нет документа: {docLabel}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
@@ -965,7 +1046,9 @@ export default function StepWizardPlan({
|
||||
}}
|
||||
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
|
||||
extra={
|
||||
currentBlocks.length > 1 && (
|
||||
// Кнопка "Удалить" только если это дополнительный блок (idx > 0)
|
||||
// Первый блок предустановленного документа удалять нельзя
|
||||
(currentBlocks.length > 1 && idx > 0) && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
@@ -978,11 +1061,11 @@ export default function StepWizardPlan({
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{/* Поле описания только для необязательных/кастомных документов */}
|
||||
{/* Для обязательных документов (contract, payment) описание не требуется */}
|
||||
{!isPredefinedDoc && !isRequired && (
|
||||
{/* Поле описания показываем только для дополнительных блоков (idx > 0)
|
||||
или для общих документов (docs_exist) */}
|
||||
{(idx > 0 || !isPredefinedDoc) && (
|
||||
<Input
|
||||
placeholder="Описание документов (например: договор от 12.05, платёжка №123)"
|
||||
placeholder="Уточните тип документа (например: Претензия от 12.05)"
|
||||
value={block.description}
|
||||
onChange={(e) =>
|
||||
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
||||
@@ -1023,6 +1106,24 @@ export default function StepWizardPlan({
|
||||
Допустимые форматы: {uniqueAccept.join(', ')}. До 5 файлов, максимум 20 МБ каждый.
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* Чекбокс "Нет документа" под загрузкой - только для обязательных и только в первом блоке */}
|
||||
{isRequired && idx === 0 && block.files.length === 0 && (
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
const newSkipped = new Set(skippedDocuments);
|
||||
newSkipped.add(docId);
|
||||
setSkippedDocuments(newSkipped);
|
||||
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
||||
}
|
||||
}}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<Text type="secondary">У меня нет этого документа</Text>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
@@ -1170,6 +1271,17 @@ export default function StepWizardPlan({
|
||||
// Если в плане визарда есть документы, не показываем поля про загрузку (text/textarea/file)
|
||||
const questionLabelLower = (question.label || '').toLowerCase();
|
||||
const questionNameLower = (question.name || '').toLowerCase();
|
||||
|
||||
// Скрываем вопрос docs_exist (чекбоксы "какие документы есть") если есть документы
|
||||
// Загрузка документов реализована через отдельные блоки под информационной карточкой
|
||||
const isDocsExistQuestion = questionNameLower === 'docs_exist' ||
|
||||
questionNameLower === 'correspondence_exist' ||
|
||||
questionNameLower.includes('docs_exist');
|
||||
if (isDocsExistQuestion && documents.length > 0) {
|
||||
console.log(`🚫 Question ${question.name} hidden: docs_exist with documents`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDocumentUploadQuestion =
|
||||
(question.input_type === 'text' ||
|
||||
question.input_type === 'textarea' ||
|
||||
@@ -1256,11 +1368,164 @@ export default function StepWizardPlan({
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
|
||||
const documentsRequired = formData.documents_required || [];
|
||||
const hasNewFlowDocs = documentsRequired.length > 0;
|
||||
|
||||
// 🔍 ОТЛАДКА: Логируем состояние для диагностики
|
||||
console.log('🔍 StepWizardPlan - определение флоу:', {
|
||||
documentsRequiredCount: documentsRequired.length,
|
||||
documentsRequired: documentsRequired,
|
||||
hasNewFlowDocs,
|
||||
hasPlan: !!plan,
|
||||
isWaiting,
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
|
||||
// Состояние для поэкранной загрузки документов (новый флоу)
|
||||
const [currentDocIndex, setCurrentDocIndex] = useState(formData.current_doc_index || 0);
|
||||
// Убираем дубликаты при инициализации
|
||||
const initialUploadedDocs = formData.documents_uploaded?.map((d: any) => d.type || d.id) || [];
|
||||
const [uploadedDocs, setUploadedDocs] = useState<string[]>(Array.from(new Set(initialUploadedDocs)));
|
||||
const [skippedDocs, setSkippedDocs] = useState<string[]>(formData.documents_skipped || []);
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
|
||||
|
||||
// Текущий документ для загрузки
|
||||
const currentDoc = documentsRequired[currentDocIndex];
|
||||
const isLastDoc = currentDocIndex >= documentsRequired.length - 1;
|
||||
const allDocsProcessed = currentDocIndex >= documentsRequired.length;
|
||||
|
||||
// Обработчик выбора файлов (НЕ отправляем сразу, только сохраняем)
|
||||
const handleFilesChange = (fileList: any[]) => {
|
||||
console.log('📁 handleFilesChange:', fileList.length, 'файлов', fileList.map(f => f.name));
|
||||
setCurrentUploadedFiles(fileList);
|
||||
if (fileList.length > 0) {
|
||||
setDocChoice('upload');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик "Продолжить" — отправляем файл или пропускаем
|
||||
const handleDocContinue = async () => {
|
||||
if (!currentDoc) return;
|
||||
|
||||
// Если выбрано "Нет документа" — пропускаем
|
||||
if (docChoice === 'none') {
|
||||
if (currentDoc.required) {
|
||||
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки. Постарайтесь найти его позже.`);
|
||||
}
|
||||
|
||||
const newSkipped = [...skippedDocs, currentDoc.id];
|
||||
setSkippedDocs(newSkipped);
|
||||
|
||||
updateFormData({
|
||||
documents_skipped: newSkipped,
|
||||
current_doc_index: currentDocIndex + 1,
|
||||
});
|
||||
|
||||
// Переход к следующему (сброс состояния в useEffect)
|
||||
setCurrentDocIndex(prev => prev + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если выбрано "Загрузить" — отправляем все файлы ОДНИМ запросом
|
||||
if (docChoice === 'upload' && currentUploadedFiles.length > 0) {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
console.log('📤 Загружаем все файлы одним запросом:', {
|
||||
totalFiles: currentUploadedFiles.length,
|
||||
files: currentUploadedFiles.map(f => ({ name: f.name, uid: f.uid, size: f.size }))
|
||||
});
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id || '');
|
||||
formDataToSend.append('session_id', formData.session_id || '');
|
||||
formDataToSend.append('unified_id', formData.unified_id || '');
|
||||
formDataToSend.append('contact_id', formData.contact_id || '');
|
||||
formDataToSend.append('phone', formData.phone || '');
|
||||
formDataToSend.append('document_type', currentDoc.id);
|
||||
formDataToSend.append('document_name', currentDoc.name || currentDoc.id);
|
||||
formDataToSend.append('document_description', currentDoc.hints || '');
|
||||
formDataToSend.append('group_index', String(currentDocIndex)); // ✅ Передаём индекс документа для правильного field_name
|
||||
|
||||
// Добавляем все файлы в один запрос
|
||||
currentUploadedFiles.forEach((file) => {
|
||||
formDataToSend.append('files', file.originFileObj, file.name);
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload-multiple', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Ошибка загрузки файлов');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('✅ Все файлы загружены:', result);
|
||||
|
||||
// Обновляем состояние
|
||||
const uploadedDocsData = [...(formData.documents_uploaded || [])];
|
||||
|
||||
// Добавляем информацию о каждом загруженном файле
|
||||
result.file_ids.forEach((fileId: string, i: number) => {
|
||||
uploadedDocsData.push({
|
||||
type: currentDoc.id,
|
||||
file_id: fileId,
|
||||
filename: currentUploadedFiles[i]?.name || `file_${i}`,
|
||||
ocr_status: 'processing',
|
||||
});
|
||||
});
|
||||
|
||||
message.success(`${currentDoc.name}: загружено ${result.files_count} файл(ов)!`);
|
||||
|
||||
// Убираем дубликаты при добавлении
|
||||
const newUploaded = uploadedDocs.includes(currentDoc.id)
|
||||
? uploadedDocs
|
||||
: [...uploadedDocs, currentDoc.id];
|
||||
setUploadedDocs(newUploaded);
|
||||
|
||||
updateFormData({
|
||||
documents_uploaded: uploadedDocsData,
|
||||
current_doc_index: currentDocIndex + 1,
|
||||
});
|
||||
|
||||
// Переход к следующему (сброс состояния в useEffect)
|
||||
setCurrentDocIndex(prev => prev + 1);
|
||||
|
||||
} catch (error: any) {
|
||||
message.error(`Ошибка загрузки: ${error.message}`);
|
||||
console.error('Upload error:', error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Можно ли нажать "Продолжить"
|
||||
const canContinue = docChoice === 'none' || (docChoice === 'upload' && currentUploadedFiles.length > 0);
|
||||
|
||||
// Сброс состояния при переходе к следующему документу
|
||||
useEffect(() => {
|
||||
setDocChoice('upload');
|
||||
setCurrentUploadedFiles([]);
|
||||
}, [currentDocIndex]);
|
||||
|
||||
// Все документы загружены — переход к ожиданию заявления
|
||||
const handleAllDocsComplete = () => {
|
||||
message.loading('Формируем заявление...', 0);
|
||||
// TODO: Переход к StepWaitingClaim или показ loader
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
{plan && (
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
@@ -1274,7 +1539,143 @@ export default function StepWizardPlan({
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{isWaiting && (
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && currentDoc && (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
{/* Прогресс */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
|
||||
<Text type="secondary">{Math.round((currentDocIndex / documentsRequired.length) * 100)}% завершено</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((currentDocIndex / documentsRequired.length) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Заголовок документа */}
|
||||
<Title level={4} style={{ marginBottom: 8 }}>
|
||||
📄 {currentDoc.name}
|
||||
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* Радио-кнопки выбора */}
|
||||
<Radio.Group
|
||||
value={docChoice}
|
||||
onChange={(e) => {
|
||||
setDocChoice(e.target.value);
|
||||
if (e.target.value === 'none') {
|
||||
setCurrentUploadedFiles([]);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: 16, display: 'block' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 16 }}>
|
||||
📎 Загрузить документ
|
||||
</Radio>
|
||||
<Radio value="none" style={{ fontSize: 16 }}>
|
||||
❌ У меня нет этого документа
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger
|
||||
multiple={true}
|
||||
beforeUpload={() => false}
|
||||
fileList={currentUploadedFiles}
|
||||
onChange={({ fileList }) => handleFilesChange(fileList)}
|
||||
onRemove={(file) => {
|
||||
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
|
||||
return true;
|
||||
}}
|
||||
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
|
||||
disabled={submitting}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Перетащите файлы или нажмите для выбора
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
📌 Можно загрузить несколько файлов (все страницы документа)
|
||||
<br />
|
||||
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
{/* Предупреждение если "нет документа" для важного */}
|
||||
{docChoice === 'none' && currentDoc.required && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Text type="warning">
|
||||
⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
disabled={!canContinue || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{submitting ? 'Загружаем...' : 'Продолжить →'}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* Уже загруженные */}
|
||||
{uploadedDocs.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
|
||||
<Text strong>✅ Загружено:</Text>
|
||||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
{/* Убираем дубликаты и используем уникальные ключи */}
|
||||
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
|
||||
const doc = documentsRequired.find((d: any) => d.id === docId);
|
||||
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Все документы загружены */}
|
||||
{hasNewFlowDocs && allDocsProcessed && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Title level={4}>✅ Все документы загружены!</Title>
|
||||
<Paragraph type="secondary">
|
||||
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
|
||||
</Paragraph>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
||||
{!hasNewFlowDocs && isWaiting && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
@@ -1306,7 +1707,8 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isWaiting && plan && (
|
||||
{/* СТАРЫЙ ФЛОУ: Визард готов */}
|
||||
{!hasNewFlowDocs && !isWaiting && plan && (
|
||||
<div>
|
||||
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
|
||||
@@ -1316,41 +1718,60 @@ export default function StepWizardPlan({
|
||||
</Paragraph>
|
||||
|
||||
{documents.length > 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
title="Документы, которые понадобятся"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{documents.map((doc: any) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>{doc.name}</Text>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{doc.hints}
|
||||
</Paragraph>
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
title="Документы, которые понадобятся"
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{documents.map((doc: any) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text strong>{doc.name}</Text>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{doc.hints}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Tag color={doc.required ? 'volcano' : 'geekblue'}>
|
||||
{doc.required ? 'Обязательно' : 'Опционально'}
|
||||
</Tag>
|
||||
</div>
|
||||
<Tag color={doc.required ? 'volcano' : 'geekblue'}>
|
||||
{doc.required ? 'Обязательно' : 'Опционально'}
|
||||
</Tag>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Блоки загрузки для каждого документа из плана */}
|
||||
<div style={{ marginTop: 16, marginBottom: 24 }}>
|
||||
<Text strong style={{ fontSize: 16, marginBottom: 16, display: 'block' }}>
|
||||
Загрузите документы
|
||||
</Text>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{documents.map((doc: any) => {
|
||||
const docKey = doc.id || doc.name || `doc_${Math.random()}`;
|
||||
return (
|
||||
<div key={docKey}>
|
||||
{renderDocumentBlocks(docKey, [doc])}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderQuestions()}
|
||||
@@ -1360,6 +1781,3 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,18 @@ import './ClaimForm.css';
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
/**
|
||||
* Генерация UUID v4
|
||||
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
function generateUUIDv4(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
// Шаг 1: Phone
|
||||
phone?: string;
|
||||
@@ -633,12 +645,33 @@ export default function ClaimForm() {
|
||||
console.log('🔄 Загрузка черновика: session_id из черновика:', claim.session_token);
|
||||
console.log('🔄 Загрузка черновика: текущий sessionIdRef.current:', sessionIdRef.current);
|
||||
console.log('🔄 Загрузка черновика: текущий formData.session_id:', formData.session_id);
|
||||
const actualSessionId = sessionIdRef.current || formData.session_id;
|
||||
|
||||
// ✅ При загрузке черновика используем session_id из черновика (для продолжения работы с той же жалобой)
|
||||
// Если session_id из черновика есть - используем его, иначе текущий
|
||||
const actualSessionId = claim.session_token || sessionIdRef.current || formData.session_id;
|
||||
console.log('🔄 Загрузка черновика: ИСПОЛЬЗУЕМ session_id:', actualSessionId);
|
||||
|
||||
// ✅ Обновляем sessionIdRef на сессию из черновика (если есть)
|
||||
if (claim.session_token && claim.session_token !== sessionIdRef.current) {
|
||||
sessionIdRef.current = claim.session_token;
|
||||
console.log('🔄 Обновляем sessionIdRef на сессию из черновика:', claim.session_token);
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Извлекаем documents_required из payload
|
||||
const documentsRequired = body.documents_required || payload.documents_required || [];
|
||||
const documentsUploaded = body.documents_uploaded || payload.documents_uploaded || [];
|
||||
const documentsSkipped = body.documents_skipped || payload.documents_skipped || [];
|
||||
const currentDocIndex = body.current_doc_index ?? payload.current_doc_index ?? 0;
|
||||
|
||||
console.log('📋 Загрузка черновика - documents_required:', documentsRequired.length, 'шт.');
|
||||
console.log('📋 Загрузка черновика - body.documents_required:', body.documents_required);
|
||||
console.log('📋 Загрузка черновика - payload.documents_required:', payload.documents_required);
|
||||
console.log('📋 Загрузка черновика - status_code:', claim.status_code);
|
||||
console.log('📋 Загрузка черновика - все ключи payload:', Object.keys(payload));
|
||||
|
||||
updateFormData({
|
||||
claim_id: finalClaimId, // ✅ Используем извлечённый claim_id
|
||||
session_id: actualSessionId, // ✅ Используем ТЕКУЩИЙ session_id, а не старый из черновика
|
||||
session_id: actualSessionId, // ✅ Используем session_id из черновика (если есть) или текущий
|
||||
phone: body.phone || payload.phone || formData.phone,
|
||||
email: body.email || payload.email || formData.email,
|
||||
problemDescription: problemDescription || formData.problemDescription,
|
||||
@@ -661,6 +694,11 @@ export default function ClaimForm() {
|
||||
contact_id: body.contact_id || payload.contact_id || formData.contact_id,
|
||||
project_id: body.project_id || payload.project_id || formData.project_id,
|
||||
unified_id: formData.unified_id, // ✅ Сохраняем unified_id
|
||||
// ✅ НОВЫЙ ФЛОУ: Документы
|
||||
documents_required: documentsRequired,
|
||||
documents_uploaded: documentsUploaded,
|
||||
documents_skipped: documentsSkipped,
|
||||
current_doc_index: currentDocIndex,
|
||||
});
|
||||
|
||||
setSelectedDraftId(finalClaimId);
|
||||
@@ -703,11 +741,16 @@ export default function ClaimForm() {
|
||||
|
||||
let targetStep = 1; // По умолчанию - описание (шаг 1)
|
||||
|
||||
if (wizardPlan) {
|
||||
// ✅ Если есть wizard_plan - переходим к визарду (шаг 2)
|
||||
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
|
||||
if (documentsRequired.length > 0) {
|
||||
targetStep = 2;
|
||||
console.log('✅ Переходим к StepWizardPlan (шаг 2) - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
|
||||
console.log('✅ documents_required:', documentsRequired.length, 'документов');
|
||||
} else if (wizardPlan) {
|
||||
// ✅ СТАРЫЙ ФЛОУ: Если есть wizard_plan - переходим к визарду (шаг 2)
|
||||
// Пользователь уже описывал проблему, и есть план вопросов
|
||||
targetStep = 2;
|
||||
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть wizard_plan');
|
||||
console.log('✅ Переходим к StepWizardPlan (шаг 2) - СТАРЫЙ ФЛОУ: есть wizard_plan');
|
||||
console.log('✅ answers в черновике:', answers ? 'есть (показываем заполненную форму)' : 'нет (показываем пустую форму)');
|
||||
} else if (problemDescription) {
|
||||
// Если есть описание, но нет плана - переходим к визарду (шаг 2), чтобы получить план
|
||||
@@ -793,12 +836,27 @@ export default function ClaimForm() {
|
||||
console.log('🆕 Текущий currentStep:', currentStep);
|
||||
console.log('🆕 isPhoneVerified:', isPhoneVerified);
|
||||
|
||||
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
||||
const newSessionId = 'sess_' + generateUUIDv4();
|
||||
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
|
||||
console.log('🆕 Старая сессия:', sessionIdRef.current);
|
||||
|
||||
// ✅ Обновляем sessionIdRef на новую сессию
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
||||
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
||||
|
||||
setShowDraftSelection(false);
|
||||
setSelectedDraftId(null);
|
||||
setHasDrafts(false); // ✅ Сбрасываем флаг наличия черновиков
|
||||
|
||||
// Очищаем данные формы, кроме телефона и session_id
|
||||
// ✅ Очищаем данные формы и устанавливаем НОВЫЙ session_id
|
||||
// unified_id, phone, contact_id остаются прежними - авторизация сохранена!
|
||||
updateFormData({
|
||||
session_id: newSessionId, // ✅ Новая сессия для новой жалобы
|
||||
claim_id: undefined,
|
||||
problemDescription: undefined,
|
||||
wizardPlan: undefined,
|
||||
@@ -809,6 +867,7 @@ export default function ClaimForm() {
|
||||
wizardUploads: undefined,
|
||||
wizardSkippedDocuments: undefined,
|
||||
eventType: undefined,
|
||||
// ✅ unified_id, phone, contact_id НЕ очищаем - авторизация сохраняется!
|
||||
});
|
||||
|
||||
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
||||
@@ -819,7 +878,7 @@ export default function ClaimForm() {
|
||||
// Шаг 1 - Description (сюда переходим)
|
||||
// Шаг 2 - WizardPlan
|
||||
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
|
||||
}, [updateFormData, currentStep, isPhoneVerified]);
|
||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user