Files
aiform_dev/frontend/src/components/form/StepWizardPlan.tsx
AI Assistant 02689e65db 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
2025-11-26 19:54:51 +03:00

1784 lines
73 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

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

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { 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, InboxOutlined } from '@ant-design/icons';
import AiWorkingIllustration from '../../assets/ai-working.svg';
import type { UploadFile } from 'antd/es/upload/interface';
const { Paragraph, Title, Text } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
const { Option } = Select;
interface WizardQuestion {
order: number;
name: string;
label: string;
control: string;
input_type: string;
required: boolean;
priority?: number;
rationale?: string;
ask_if?: {
field: string;
op: '==' | '!=' | '>' | '<' | '>=' | '<=';
value: any;
} | null;
options?: { label: string; value: string }[];
}
interface WizardDocument {
id: string;
name: string;
required: boolean;
priority?: number;
hints?: string;
accept?: string[];
}
interface FileBlock {
id: string;
fieldName: string;
description: string;
category?: string;
files: UploadFile[];
required?: boolean;
docLabel?: string;
}
interface Props {
formData: any;
updateFormData: (data: any) => void;
onNext: () => void;
onPrev: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record<string, any>) => {
if (!condition) return true;
const left = values?.[condition.field];
const right = condition.value;
// Приводим к строкам для более надёжного сравнения (Radio.Group может возвращать строки)
const leftStr = left != null ? String(left) : null;
const rightStr = right != null ? String(right) : null;
switch (condition.op) {
case '==':
return leftStr === rightStr;
case '!=':
return leftStr !== rightStr;
case '>':
return left > right;
case '<':
return left < right;
case '>=':
return left >= right;
case '<=':
return left <= right;
default:
return true;
}
};
const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
if (!prefill || prefill.length === 0) return {};
return prefill.reduce<Record<string, any>>((acc, item) => {
if (item.name) {
acc[item.name] = item.value;
}
return acc;
}, {});
};
const YES_VALUES = ['да', 'yes', 'true', '1'];
const isAffirmative = (value: any) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
return YES_VALUES.includes(value.toLowerCase());
}
return false;
};
const generateBlockId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
export default function StepWizardPlan({
formData,
updateFormData,
onNext,
onPrev,
addDebugEvent,
}: Props) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
const [form] = Form.useForm();
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
);
const [questionFileBlocks, setQuestionFileBlocks] = useState<Record<string, FileBlock[]>>(
formData.wizardUploads?.documents || {}
);
const [customFileBlocks, setCustomFileBlocks] = useState<FileBlock[]>(
formData.wizardUploads?.custom || []
);
const [skippedDocuments, setSkippedDocuments] = useState<Set<string>>(
new Set(formData.wizardSkippedDocuments || [])
);
const [submitting, setSubmitting] = useState(false);
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
done: 0,
total: 0,
});
const formValues = Form.useWatch([], form);
const progressPercent = useMemo(() => {
if (!progressState.total) return 0;
return Math.round((progressState.done / progressState.total) * 100);
}, [progressState]);
useEffect(() => {
debugLoggerRef.current = addDebugEvent;
}, [addDebugEvent]);
// ✅ Автосохранение прогресса заполнения (debounce 3 секунды)
useEffect(() => {
if (!formData.claim_id || !formValues) return;
const timeoutId = setTimeout(() => {
const answers = form.getFieldsValue(true);
// Сохраняем только если есть хоть какие-то ответы
const hasAnswers = Object.keys(answers).some(key => answers[key] !== undefined && answers[key] !== '');
if (hasAnswers) {
console.log('💾 Автосохранение прогресса:', { claim_id: formData.claim_id, answersCount: Object.keys(answers).length });
// Обновляем formData с текущими ответами
updateFormData({
wizardAnswers: answers,
wizardUploads: {
documents: questionFileBlocks,
custom: customFileBlocks,
},
wizardSkippedDocuments: Array.from(skippedDocuments),
});
addDebugEvent?.('wizard', 'info', '💾 Автосохранение прогресса');
}
}, 3000); // 3 секунды debounce
return () => clearTimeout(timeoutId);
}, [formValues, formData.claim_id]); // Зависимость от formValues, но БЕЗ questionFileBlocks/customFileBlocks/skippedDocuments (они обновляются отдельно)
const questions: WizardQuestion[] = useMemo(() => plan?.questions || [], [plan]);
const documents: WizardDocument[] = plan?.documents || [];
const documentGroups = useMemo(() => {
const groups: Record<string, WizardDocument[]> = {};
documents.forEach((doc) => {
const id = doc.id?.toLowerCase() || '';
let key = 'docs_exist';
if (id.includes('correspondence') || id.includes('chat')) {
key = 'correspondence_exist';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(doc);
});
return groups;
}, [documents]);
const documentCategoryOptions = useMemo(() => {
const map = new Map<string, string>();
documents.forEach((doc) => {
const key = doc.id || doc.name || '';
const label = doc.name || doc.id || '';
if (key) {
map.set(key, label);
}
});
map.set('other', 'Другое');
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
}, [documents]);
const customCategoryOptions = useMemo(() => documentCategoryOptions, [documentCategoryOptions]);
const handleDocumentBlocksChange = useCallback(
(docId: string, updater: (blocks: FileBlock[]) => FileBlock[]) => {
setQuestionFileBlocks((prev) => {
const nextDocs = { ...prev };
const currentBlocks = nextDocs[docId] || [];
const updated = updater(currentBlocks);
nextDocs[docId] = updated;
return nextDocs;
});
},
[]
);
const handleCustomBlocksChange = useCallback(
(updater: (blocks: FileBlock[]) => FileBlock[]) => {
setCustomFileBlocks((prev) => {
const updated = updater(prev);
return updated;
});
},
[]
);
const addDocumentBlock = (docId: string, docLabel?: string, docList?: WizardDocument[]) => {
// Для предопределённых документов используем их ID как категорию
const category = docList && docList.length === 1 && docList[0].id && !docList[0].id.includes('_exist')
? docList[0].id
: docId;
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 = (
docId: string,
blockId: string,
patch: Partial<Omit<FileBlock, 'id' | 'fieldName'>>
) => {
handleDocumentBlocksChange(docId, (blocks) =>
blocks.map((block) => (block.id === blockId ? { ...block, ...patch } : block))
);
};
const removeDocumentBlock = (docId: string, blockId: string) => {
handleDocumentBlocksChange(docId, (blocks) => blocks.filter((block) => block.id !== blockId));
};
const addCustomBlock = () => {
handleCustomBlocksChange((blocks) => [
...blocks,
{
id: generateBlockId('custom'),
fieldName: 'custom',
description: '',
category: undefined,
files: [],
},
]);
};
const updateCustomBlock = (blockId: string, patch: Partial<FileBlock>) => {
handleCustomBlocksChange((blocks) =>
blocks.map((block) => (block.id === blockId ? { ...block, ...patch } : block))
);
};
const removeCustomBlock = (blockId: string) => {
handleCustomBlocksChange((blocks) => blocks.filter((block) => block.id !== blockId));
};
useEffect(() => {
if (plan) {
const existingAnswers = formData.wizardAnswers || {};
const initialValues = { ...prefillMap, ...existingAnswers };
form.setFieldsValue(initialValues);
}
}, [plan, prefillMap, formData.wizardAnswers, form]);
useEffect(() => {
if (!questions.length) {
setProgressState({ done: 0, total: 0 });
return;
}
const values = formValues || {};
let total = 0;
let done = 0;
questions.forEach((question) => {
const visible = evaluateCondition(question.ask_if, values);
if (question.required && visible) {
total += 1;
const value = values?.[question.name];
let filled = false;
if (Array.isArray(value)) {
filled = value.length > 0;
} else if (typeof value === 'boolean') {
filled = value;
} else {
filled = value !== undefined && value !== null && String(value).trim().length > 0;
}
if (filled) {
done += 1;
}
}
});
setProgressState({ done, total });
}, [formValues, questions]);
// Автоматически создаём блоки для ВСЕХ документов из плана при загрузке
// Используем ref чтобы отслеживать какие блоки уже созданы
const createdDocBlocksRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!plan || !documents || documents.length === 0) return;
documents.forEach((doc) => {
const docKey = doc.id || doc.name || `doc_unknown`;
// Не создаём блок, если уже создавали
if (createdDocBlocksRef.current.has(docKey)) return;
// Не создаём блок, если документ пропущен
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: [],
},
];
});
});
}, [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 });
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
timeoutRef.current = setTimeout(() => {
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, 120000); // 2 минуты для RAG обработки
source.onopen = () => {
setConnectionError(null);
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { session_id: sessionId });
};
source.onerror = (error) => {
console.error('❌ Wizard SSE error:', error);
setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.');
source.close();
eventSourceRef.current = null;
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { session_id: sessionId });
};
const extractWizardPayload = (incoming: any): any => {
if (!incoming || typeof incoming !== 'object') return null;
if (incoming.wizard_plan) return incoming;
const candidates = [
incoming.data,
incoming.redis_value,
incoming.event,
incoming.payload,
];
for (const candidate of candidates) {
if (!candidate) continue;
const unwrapped = extractWizardPayload(candidate);
if (unwrapped) return unwrapped;
}
return null;
};
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
const eventType =
payload.event_type ||
payload.type ||
payload?.event?.event_type ||
payload?.data?.event_type ||
payload?.redis_value?.event_type;
// Логируем все события для отладки
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
session_id: sessionId,
event_type: eventType,
has_wizard_plan: Boolean(extractWizardPayload(payload)),
payload_keys: Object.keys(payload),
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);
if (eventType?.includes('wizard') || hasWizardPlan) {
const wizardPlan = wizardPayload?.wizard_plan;
const answersPrefill = wizardPayload?.answers_prefill;
const coverageReport = wizardPayload?.coverage_report;
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
session_id: sessionId,
questions: wizardPlan?.questions?.length || 0,
});
const prefill = buildPrefillMap(answersPrefill);
setPlan(wizardPlan);
setPrefillMap(prefill);
setIsWaiting(false);
setConnectionError(null);
updateFormData({
wizardPlan: wizardPlan,
wizardPrefill: prefill,
wizardPrefillArray: answersPrefill,
wizardCoverageReport: coverageReport,
wizardPlanStatus: 'ready',
});
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
source.close();
eventSourceRef.current = null;
}
} catch (err) {
console.error('❌ Ошибка разбора события wizard:', err);
}
};
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [isWaiting, formData.session_id, plan, updateFormData]);
const handleRefreshPlan = () => {
if (!formData.session_id) {
message.error('Не найден session_id для подписки на события.');
return;
}
setIsWaiting(true);
setPlan(null);
setConnectionError(null);
updateFormData({
wizardPlan: null,
wizardPlanStatus: 'pending',
});
};
const validateUploads = (values: Record<string, any>) => {
// Проверяем каждый документ по его ID
for (const doc of documents) {
// Находим вопрос, к которому привязан документ
const questionName = Object.keys(documentGroups).find(key =>
documentGroups[key].some(d => d.id === doc.id)
);
if (!questionName) continue;
const answer = values?.[questionName];
if (!isAffirmative(answer)) continue;
// Блоки теперь хранятся по doc.id, а не по questionName
const docKey = doc.id || doc.name || `doc_${questionName}`;
const blocks = questionFileBlocks[docKey] || [];
// Проверяем, есть ли файлы для обязательного документа (если он не пропущен)
if (doc.required) {
if (skippedDocuments.has(docKey)) {
continue; // Пропускаем валидацию для пропущенных документов
}
const hasFiles = blocks.some((block) => block.files.length > 0);
if (!hasFiles) {
return `Добавьте файлы для документа "${doc.name}" или отметьте, что документа нет`;
}
}
// Проверяем описание только для необязательных документов И только если документ не предопределённый
// Предопределённые документы (contract, payment, payment_confirmation, receipt, cheque) не требуют описания
const docIdLower = (doc.id || '').toLowerCase();
const docNameLower = (doc.name || '').toLowerCase();
const isPredefinedDoc = doc.id && !doc.id.includes('_exist') &&
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
docIdLower.includes('contract') || docIdLower.includes('payment') ||
docIdLower.includes('receipt') || docIdLower.includes('cheque') ||
docNameLower.includes('договор') || docNameLower.includes('чек') ||
docNameLower.includes('оплат') || docNameLower.includes('платеж'));
// Для обязательных документов описание не требуется
// Для предопределённых документов описание не требуется
if (!doc.required && !isPredefinedDoc) {
const missingDescription = blocks.some(
(block) => block.files.length > 0 && !block.description?.trim()
);
if (missingDescription) {
return `Заполните описание для документа "${doc.name}"`;
}
}
}
const customMissingDescription = customFileBlocks.some(
(block) => block.files.length > 0 && !block.description?.trim()
);
if (customMissingDescription) {
return 'Заполните описание для дополнительных документов';
}
return null;
};
const handleFinish = async (values: Record<string, any>) => {
const uploadError = validateUploads(values);
if (uploadError) {
message.error(uploadError);
return;
}
// Сохраняем в общий стейт
updateFormData({
wizardPlan: plan,
wizardAnswers: values,
wizardPlanStatus: 'answered',
wizardUploads: {
documents: questionFileBlocks,
custom: customFileBlocks,
},
wizardSkippedDocuments: Array.from(skippedDocuments),
});
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
answers: values,
});
// Дёргаем вебхук через backend сразу после заполнения визарда (multipart/form-data)
try {
setSubmitting(true);
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
session_id: formData.session_id,
});
const formPayload = new FormData();
formPayload.append('stage', 'wizard');
formPayload.append('form_id', 'ticket_form');
if (formData.session_id) formPayload.append('session_id', formData.session_id);
if (formData.clientIp) formPayload.append('client_ip', formData.clientIp);
if (formData.smsCode) formPayload.append('sms_code', formData.smsCode);
// Добавляем unified_id и claim_id (если есть)
if (formData.unified_id) formPayload.append('unified_id', formData.unified_id);
if (formData.claim_id) formPayload.append('claim_id', formData.claim_id);
if (formData.contact_id) formPayload.append('contact_id', String(formData.contact_id));
if (formData.project_id) formPayload.append('project_id', String(formData.project_id));
if (typeof formData.is_new_contact !== 'undefined') {
formPayload.append('is_new_contact', String(formData.is_new_contact));
}
if (typeof formData.is_new_project !== 'undefined') {
formPayload.append('is_new_project', String(formData.is_new_project));
}
if (formData.phone) formPayload.append('phone', formData.phone);
if (formData.email) formPayload.append('email', formData.email);
if (formData.eventType) formPayload.append('event_type', formData.eventType);
// JSON-поля
formPayload.append('wizard_plan', JSON.stringify(plan || {}));
formPayload.append('wizard_answers', JSON.stringify(values || {}));
formPayload.append('wizard_skipped_documents', JSON.stringify(Array.from(skippedDocuments)));
// --- Группируем блоки в uploads[i][j] + uploads_descriptions[i] + uploads_field_names[i]
type UploadGroup = {
index: number;
question?: string;
block: FileBlock;
kind: 'question' | 'custom';
};
const groups: UploadGroup[] = [];
let groupIndex = 0;
// Собираем все блоки документов (теперь они хранятся по doc.id)
// Сначала ищем блоки, которые привязаны к вопросам через documentGroups
const allDocKeys = new Set<string>();
Object.values(documentGroups).forEach(docs => {
docs.forEach(doc => {
const docKey = doc.id || doc.name;
if (docKey && questionFileBlocks[docKey]) {
allDocKeys.add(docKey);
}
});
});
// Также добавляем блоки по старым ключам (для обратной совместимости)
Object.keys(questionFileBlocks).forEach(key => {
if (!allDocKeys.has(key) && (key.includes('_exist') || key.startsWith('doc_'))) {
allDocKeys.add(key);
}
});
Array.from(allDocKeys).forEach((docKey) => {
const blocks = questionFileBlocks[docKey] || [];
blocks.forEach((block) => {
groups.push({
index: groupIndex++,
question: docKey, // Используем docKey как идентификатор
block,
kind: 'question',
});
});
});
// Затем кастомные блоки
customFileBlocks.forEach((block) => {
groups.push({
index: groupIndex++,
question: 'custom',
block,
kind: 'custom',
});
});
const guessFieldName = (group: UploadGroup): string => {
const cat = (group.block.category || group.question || '').toLowerCase();
// Определяем имя поля на основе категории (которая теперь равна doc.id)
if (cat.includes('contract') || cat === 'contract' || cat === 'договор') {
return 'upload_contract';
}
if (cat.includes('payment') || cat.includes('cheque') || cat.includes('receipt') ||
cat.includes('подтверждение') || cat === 'payment_proof') {
return 'upload_payment';
}
if (cat.includes('correspondence') || cat.includes('chat') || cat.includes('переписка')) {
return 'upload_correspondence';
}
// Если категория похожа на ID документа, используем её
if (cat && !cat.includes('_exist')) {
return `upload_${cat.replace(/[^a-z0-9_]/g, '_')}`;
}
// Fallback на индекс
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;
// Описание группы
formPayload.append(
`uploads_descriptions[${i}]`,
block.description || ''
);
// Имя "поля" группы (используем docLabel если есть, иначе guessFieldName)
const fieldLabel = block.docLabel || block.fieldName || guessFieldName(group);
formPayload.append(
`uploads_field_names[${i}]`,
fieldLabel
);
// ✅ Добавляем реальное название поля (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}]`,
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) => {
const origin: any = (file as any).originFileObj;
if (!origin) return;
formPayload.append(`uploads[${i}][${j}]`, origin, origin.name);
});
});
// Логируем ключевые поля перед отправкой
console.log('📤 Отправка в n8n:', {
session_id: formData.session_id,
unified_id: formData.unified_id,
claim_id: formData.claim_id,
contact_id: formData.contact_id,
phone: formData.phone,
});
const response = await fetch('/api/v1/claims/wizard', {
method: 'POST',
body: formPayload,
});
const text = await response.text();
let parsed: any = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
if (!response.ok) {
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
status: response.status,
body: text,
});
return;
}
addDebugEvent?.('wizard', 'success', '✅ Визард отправлен в n8n', {
response: parsed ?? text,
});
message.success('Мы изучаем ваш вопрос и документы.');
// Подписываемся на канал claim:plan для получения данных заявления
if (formData.session_id) {
subscribeToClaimPlan(formData.session_id);
} else {
console.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
onNext();
}
} catch (error) {
message.error('Ошибка соединения при отправке визарда.');
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
error: String(error),
});
onNext();
} finally {
setSubmitting(false);
}
};
// Функция подписки на канал claim:plan
const subscribeToClaimPlan = useCallback((sessionToken: string) => {
console.log('📡 Подписка на канал claim:plan для session:', sessionToken);
// Закрываем предыдущее соединение, если есть
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Создаём новое SSE соединение
const eventSource = new EventSource(`/api/v1/claim-plan/${sessionToken}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('✅ Подключено к каналу claim:plan');
addDebugEvent?.('claim-plan', 'info', '📡 Ожидание данных заявления...');
message.loading('Ожидание данных заявления...', 0);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📥 Получены данные из claim:plan:', data);
if (data.event_type === 'claim_plan_ready' && data.status === 'ready') {
// Данные заявления получены!
message.destroy(); // Убираем loading сообщение
message.success('Данные заявления готовы!');
// Сохраняем данные заявления в formData
updateFormData({
claimPlanData: data.data, // Данные от n8n
showClaimConfirmation: true, // Флаг для показа формы подтверждения
});
// Закрываем SSE соединение
eventSource.close();
eventSourceRef.current = null;
// Переходим к следующему шагу (форма подтверждения)
onNext();
} else if (data.event_type === 'claim_plan_error' || data.status === 'error') {
message.destroy();
message.error(data.message || 'Ошибка получения данных заявления');
eventSource.close();
eventSourceRef.current = null;
onNext(); // Переходим дальше даже при ошибке
} else if (data.event_type === 'claim_plan_timeout' || data.status === 'timeout') {
message.destroy();
message.warning('Превышено время ожидания. Попробуйте обновить страницу.');
eventSource.close();
eventSourceRef.current = null;
onNext();
}
} catch (error) {
console.error('❌ Ошибка парсинга данных из claim:plan:', error);
message.destroy();
message.error('Ошибка обработки данных заявления');
}
};
eventSource.onerror = (error) => {
console.error('❌ Ошибка SSE соединения claim:plan:', error);
message.destroy();
message.error('Ошибка подключения к серверу');
eventSource.close();
eventSourceRef.current = null;
onNext(); // Переходим дальше даже при ошибке
};
// Таймаут на 5 минут
timeoutRef.current = setTimeout(() => {
console.warn('⏰ Таймаут ожидания данных заявления');
message.destroy();
message.warning('Превышено время ожидания данных заявления');
eventSource.close();
eventSourceRef.current = null;
onNext();
}, 300000); // 5 минут
}, [addDebugEvent, updateFormData, onNext]);
const renderQuestionField = (question: WizardQuestion) => {
// Обработка по input_type для более точного определения типа поля
if (question.input_type === 'multi_choice' || question.control === 'input[type="checkbox"]') {
return (
<Checkbox.Group>
<Space direction="vertical">
{question.options?.map((option) => (
<Checkbox key={option.value} value={option.value}>
{option.label}
</Checkbox>
))}
</Space>
</Checkbox.Group>
);
}
switch (question.control) {
case 'textarea':
case 'input[type="textarea"]':
return (
<TextArea
rows={4}
placeholder="Ответ"
autoSize={{ minRows: 3, maxRows: 6 }}
/>
);
case 'input[type="date"]':
return (
<Input
type="date"
size="large"
placeholder="Выберите дату"
/>
);
case 'input[type="radio"]':
return (
<Radio.Group>
<Space direction="vertical">
{question.options?.map((option) => (
<Radio key={option.value} value={option.value}>
{option.label}
</Radio>
))}
</Space>
</Radio.Group>
);
default:
return <Input size="large" placeholder="Ответ" />;
}
};
const renderDocumentBlocks = (docId: string, docList: WizardDocument[]) => {
const currentBlocks = questionFileBlocks[docId] || [];
const docLabel = docList.map((doc) => doc.name).join(', ');
const accept = docList.flatMap((doc) => doc.accept || []);
const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png']));
// Документ предопределён если у него есть id и он НЕ общий (не содержит _exist)
// Для предустановленных документов НЕ показываем поле описания и кнопку "Удалить"
const doc = docList[0];
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%' }}>
{/* Если документ пропущен - показываем только сообщение */}
{isSkipped && (
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, border: '1px solid #ffd591' }}>
<Checkbox
checked={isSkipped}
onChange={(e) => {
const newSkipped = new Set(skippedDocuments);
if (e.target.checked) {
newSkipped.add(docId);
} else {
newSkipped.delete(docId);
}
setSkippedDocuments(newSkipped);
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
}}
>
<Text type="warning">У меня нет документа: {docLabel}</Text>
</Checkbox>
</div>
)}
{!isSkipped && currentBlocks.map((block, idx) => (
<Card
key={block.id}
size="small"
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fff',
}}
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
extra={
// Кнопка "Удалить" только если это дополнительный блок (idx > 0)
// Первый блок предустановленного документа удалять нельзя
(currentBlocks.length > 1 && idx > 0) && (
<Button
type="link"
danger
size="small"
onClick={() => removeDocumentBlock(docId, block.id)}
>
Удалить
</Button>
)
}
>
<Space direction="vertical" style={{ width: '100%' }}>
{/* Поле описания показываем только для дополнительных блоков (idx > 0)
или для общих документов (docs_exist) */}
{(idx > 0 || !isPredefinedDoc) && (
<Input
placeholder="Уточните тип документа (например: Претензия от 12.05)"
value={block.description}
onChange={(e) =>
updateDocumentBlock(docId, block.id, { description: e.target.value })
}
/>
)}
{/* Выпадашка категорий только для общих вопросов (docs_exist, correspondence_exist) */}
{!isPredefinedDoc && (
<Select
value={block.category || docId}
onChange={(value) => updateDocumentBlock(docId, block.id, { category: value })}
placeholder="Категория блока"
>
{documentCategoryOptions.map((option) => (
<Option key={`${docId}-${option.value}`} value={option.value}>
{option.label}
</Option>
))}
</Select>
)}
<Dragger
multiple
beforeUpload={() => false}
fileList={block.files}
onChange={({ fileList }) =>
updateDocumentBlock(docId, block.id, { files: fileList })
}
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
style={{ background: '#fafafa' }}
>
<p className="ant-upload-drag-icon">
<LoadingOutlined style={{ color: '#595959' }} />
</p>
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
<p className="ant-upload-hint">
Допустимые форматы: {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>
))}
{/* Кнопка "Добавить" только если документ не пропущен */}
{!isSkipped && (!isPredefinedDoc || currentBlocks.length === 0) && (
<Button
icon={<PlusOutlined />}
onClick={() => addDocumentBlock(docId, docLabel, docList)}
style={{ width: '100%' }}
>
{isPredefinedDoc && currentBlocks.length === 0
? `Загрузить ${singleDocName || docLabel}`
: `Добавить документы (${docLabel})`}
</Button>
)}
</Space>
);
};
const renderCustomUploads = () => (
<Card
size="small"
style={{ marginTop: 24, borderRadius: 8, border: '1px solid #d9d9d9' }}
title="Документы"
extra={
<Button type="link" onClick={addCustomBlock} icon={<PlusOutlined />}>
Добавить блок
</Button>
}
>
{customFileBlocks.length === 0 && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Можно добавить произвольные группы документов например, переписку, дополнительные акты
или фото.
</Paragraph>
)}
<Space direction="vertical" style={{ width: '100%' }}>
{customFileBlocks.map((block, idx) => (
<Card
key={block.id}
size="small"
type="inner"
title={`Группа #${idx + 1}`}
extra={
<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>
Удалить
</Button>
}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Select
value={block.category}
placeholder="Категория"
onChange={(value) => updateCustomBlock(block.id, { category: value })}
allowClear
>
{customCategoryOptions.map((option) => (
<Option key={`custom-${option.value}`} value={option.value}>
{option.label}
</Option>
))}
</Select>
<TextArea
placeholder="Описание (например: переписка в WhatsApp с менеджером)"
autoSize={{ minRows: 2, maxRows: 4 }}
value={block.description}
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
/>
<Dragger
multiple
beforeUpload={() => false}
fileList={block.files}
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
>
<p className="ant-upload-drag-icon">
<LoadingOutlined style={{ color: '#595959' }} />
</p>
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
<p className="ant-upload-hint">Максимум 10 файлов, до 20 МБ каждый.</p>
</Dragger>
</Space>
</Card>
))}
{customFileBlocks.length > 0 && (
<Button onClick={addCustomBlock} icon={<PlusOutlined />}>
Добавить ещё документы
</Button>
)}
</Space>
</Card>
);
const renderQuestions = () => {
console.log('🔍 StepWizardPlan renderQuestions:', {
questionsCount: questions.length,
documentsCount: documents.length,
questions: questions.map(q => ({ name: q.name, label: q.label, input_type: q.input_type, required: q.required }))
});
return (
<>
<Card
size="small"
style={{ marginBottom: 16, borderRadius: 8, border: '1px solid #d9d9d9' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text strong>Прогресс заполнения</Text>
<Text type="secondary">
{progressState.done}/{progressState.total} обязательных ответов
</Text>
</div>
<Progress percent={progressPercent} showInfo={false} />
</Space>
</Card>
<Form
form={form}
layout="vertical"
onFinish={handleFinish}
initialValues={{ ...prefillMap, ...formData.wizardAnswers }}
>
{questions.map((question) => {
// Для условных полей используем shouldUpdate для отслеживания изменений
const hasCondition = !!question.ask_if;
return (
<Form.Item
key={question.name}
shouldUpdate={hasCondition ? (prev, curr) => {
// Обновляем только если изменилось значение поля, от которого зависит вопрос
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
} : true} // ✅ Для безусловных полей shouldUpdate=true, чтобы render function работала
>
{() => {
const values = form.getFieldsValue(true);
if (!evaluateCondition(question.ask_if, values)) {
console.log(`⏭️ Question ${question.name} skipped: condition not met`, question.ask_if, values);
return null;
}
const questionDocs = documentGroups[question.name] || [];
const questionValue = values?.[question.name];
// Скрываем вопросы, которые связаны с загрузкой документов
// Если в плане визарда есть документы, не показываем поля про загрузку (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' ||
question.input_type === 'file') &&
(questionLabelLower.includes('загрузите') ||
questionLabelLower.includes('фото') ||
questionLabelLower.includes('сканы') ||
questionLabelLower.includes('документ') ||
questionLabelLower.includes('договор') ||
questionLabelLower.includes('чек') ||
questionLabelLower.includes('платеж') ||
questionLabelLower.includes('копии') ||
questionLabelLower.includes('переписк') ||
questionNameLower.includes('upload') ||
questionNameLower.includes('document'));
// Если это вопрос про загрузку документов И в плане есть документы, не показываем поле
// (даже если вопрос не связан с documentGroups)
// Загрузка файлов уже реализована через блоки документов (documents)
if (isDocumentUploadQuestion && documents.length > 0) {
console.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
return null;
}
console.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
return (
<>
<Form.Item
label={question.label}
name={question.name}
rules={[
{
required: question.required,
message: 'Поле обязательно для заполнения',
},
]}
>
{renderQuestionField(question)}
</Form.Item>
{questionDocs.length > 0 && isAffirmative(questionValue) && (
<div style={{ marginBottom: 24 }}>
<Text strong>Загрузите документы:</Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 16 }}>
{questionDocs.map((doc) => {
// Используем doc.id как ключ для отдельного хранения блоков каждого документа
const docKey = doc.id || doc.name || `doc_${question.name}`;
return (
<div key={doc.id}>
{renderDocumentBlocks(docKey, [doc])}
</div>
);
})}
</Space>
</div>
)}
</>
);
}}
</Form.Item>
);
})}
<Space style={{ marginTop: 24 }}>
<Button onClick={onPrev}> Назад</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Сохранить и продолжить
</Button>
</Space>
</Form>
{renderCustomUploads()}
</>
);
};
if (!formData.session_id) {
return (
<Result
status="warning"
title="Нет session_id"
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);
}
// ✅ НОВЫЙ ФЛОУ: Если есть 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 && !hasNewFlowDocs && (
<Button type="link" onClick={handleRefreshPlan}>
Обновить рекомендации
</Button>
)}
</div>
<Card
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
}}
>
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
{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}
alt="AI работает"
style={{ maxWidth: 320, width: '100%', marginBottom: 24 }}
/>
<Title level={4}>Мы собираем рекомендации для вашего случая</Title>
<Paragraph type="secondary" style={{ maxWidth: 420, margin: '0 auto 24px' }}>
Наш AI-ассистент анализирует ваше описание и подбирает вопросы и список документов,
которые помогут быстро решить проблему.
</Paragraph>
<Space direction="vertical">
<Skeleton.Button active size="large" style={{ width: 220 }} />
<Skeleton.Input active size="large" style={{ width: 260 }} />
</Space>
<div style={{ marginTop: 32, color: '#94a3b8' }}>
<LoadingOutlined style={{ fontSize: 28 }} spin /> Подождите несколько секунд
</div>
{connectionError && (
<div style={{ marginTop: 16 }}>
<Text type="danger">{connectionError}</Text>
<div>
<Button onClick={handleRefreshPlan} style={{ marginTop: 12 }}>
Попробовать снова
</Button>
</div>
</div>
)}
</div>
)}
{/* СТАРЫЙ ФЛОУ: Визард готов */}
{!hasNewFlowDocs && !isWaiting && plan && (
<div>
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
</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>
</div>
<Tag color={doc.required ? 'volcano' : 'geekblue'}>
{doc.required ? 'Обязательно' : 'Опционально'}
</Tag>
</div>
))}
</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()}
</div>
)}
</Card>
</div>
);
}