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) => { 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>((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(null); const timeoutRef = useRef(null); const debugLoggerRef = useRef(addDebugEvent); const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan); const [connectionError, setConnectionError] = useState(null); const [plan, setPlan] = useState(formData.wizardPlan || null); const [prefillMap, setPrefillMap] = useState>( formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray) ); const [questionFileBlocks, setQuestionFileBlocks] = useState>( formData.wizardUploads?.documents || {} ); const [customFileBlocks, setCustomFileBlocks] = useState( formData.wizardUploads?.custom || [] ); const [skippedDocuments, setSkippedDocuments] = useState>( 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 = {}; 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(); 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> ) => { 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) => { 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>(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) => { // Проверяем каждый документ по его 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) => { 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(); 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 = {}; const labelIndexes: Record = {}; // Первый проход - считаем сколько раз встречается каждый 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 ( {question.options?.map((option) => ( {option.label} ))} ); } switch (question.control) { case 'textarea': case 'input[type="textarea"]': return (