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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user