feat: Add claim plan confirmation flow via Redis SSE
Problem:
- After wizard form submission, need to wait for claim data from n8n
- Claim data comes via Redis channel claim:plan:{session_token}
- Need to display confirmation form with claim data
Solution:
1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token}
- Subscribes to Redis channel claim:plan:{session_token}
- Streams claim data from n8n to frontend
- Handles timeouts and errors gracefully
2. Frontend: Added subscription to claim:plan channel
- StepWizardPlan: After form submission, subscribes to SSE
- Waits for claim_plan_ready event
- Shows loading message while waiting
- On success: saves claimPlanData and shows confirmation step
3. New component: StepClaimConfirmation
- Displays claim confirmation form in iframe
- Receives claimPlanData from parent
- Generates HTML form (placeholder - should call n8n for real HTML)
- Handles confirmation/cancellation via postMessage
4. ClaimForm: Added conditional step for confirmation
- Shows StepClaimConfirmation when showClaimConfirmation=true
- Step appears after StepWizardPlan
- Only visible when claimPlanData is available
Flow:
1. User fills wizard form → submits
2. Form data sent to n8n via /api/v1/claims/wizard
3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token}
4. n8n processes data → publishes to Redis claim:plan:{session_token}
5. Backend receives → streams to frontend via SSE
6. Frontend receives → shows StepClaimConfirmation
7. User confirms → proceeds to next step
Files:
- backend/app/api/events.py: Added stream_claim_plan endpoint
- frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan
- frontend/src/components/form/StepClaimConfirmation.tsx: New component
- frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
This commit is contained in:
222
frontend/src/components/form/StepClaimConfirmation.tsx
Normal file
222
frontend/src/components/form/StepClaimConfirmation.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Card, Spin, message } from 'antd';
|
||||
|
||||
interface Props {
|
||||
claimPlanData: any; // Данные заявления от n8n
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
export default function StepClaimConfirmation({
|
||||
claimPlanData,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!claimPlanData) {
|
||||
message.error('Данные заявления не получены');
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем данные для формы подтверждения
|
||||
// Формат должен соответствовать тому, что ожидает HTML форма
|
||||
const formData = {
|
||||
case: {
|
||||
user: claimPlanData?.propertyName?.applicant || {},
|
||||
project: claimPlanData?.propertyName?.case || {},
|
||||
offenders: claimPlanData?.propertyName?.offenders || [],
|
||||
attachments: claimPlanData?.propertyName?.attachments_names || [],
|
||||
meta: {
|
||||
...claimPlanData?.propertyName?.meta,
|
||||
session_token: claimPlanData?.session_token || '',
|
||||
prefix: claimPlanData?.prefix || '',
|
||||
telegram_id: claimPlanData?.telegram_id || '',
|
||||
claim_id: claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '',
|
||||
unified_id: claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '',
|
||||
user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '',
|
||||
},
|
||||
},
|
||||
session_token: claimPlanData?.session_token || '',
|
||||
telegram_id: claimPlanData?.telegram_id || '',
|
||||
token: claimPlanData?.token || '',
|
||||
sms_meta: {
|
||||
session_token: claimPlanData?.session_token || '',
|
||||
prefix: claimPlanData?.prefix || '',
|
||||
telegram_id: claimPlanData?.telegram_id || '',
|
||||
claim_id: claimPlanData?.claim_id || claimPlanData?.propertyName?.meta?.claim_id || '',
|
||||
unified_id: claimPlanData?.unified_id || claimPlanData?.propertyName?.meta?.unified_id || '',
|
||||
user_id: claimPlanData?.user_id || claimPlanData?.propertyName?.meta?.user_id || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Здесь нужно будет получить HTML форму от n8n или использовать готовый шаблон
|
||||
// Пока используем заглушку - в реальности нужно будет вызывать n8n workflow для генерации HTML
|
||||
const html = generateConfirmationFormHTML(formData);
|
||||
setHtmlContent(html);
|
||||
setLoading(false);
|
||||
}, [claimPlanData]);
|
||||
|
||||
// Функция генерации HTML формы (временная заглушка)
|
||||
// В реальности это должен делать n8n workflow
|
||||
const generateConfirmationFormHTML = (data: any): string => {
|
||||
// Экранируем данные для безопасной вставки в HTML
|
||||
const caseJson = JSON.stringify(data)
|
||||
.replace(/</g, '\\u003c')
|
||||
.replace(/>/g, '\\u003e');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>Подтверждение данных</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #1f2937;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.info {
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.info p {
|
||||
margin: 8px 0;
|
||||
color: #1e40af;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📋 Подтверждение данных заявления</h1>
|
||||
|
||||
<div class="info">
|
||||
<p><strong>Статус:</strong> Данные заявления получены</p>
|
||||
<p><strong>Claim ID:</strong> ${data.case?.meta?.claim_id || 'не указан'}</p>
|
||||
<p><strong>Unified ID:</strong> ${data.case?.meta?.unified_id || 'не указан'}</p>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn-primary" onclick="window.parent.postMessage({type: 'claim_confirmed'}, '*')">
|
||||
✅ Подтвердить и отправить
|
||||
</button>
|
||||
<button class="btn-secondary" onclick="window.parent.postMessage({type: 'claim_cancelled'}, '*')">
|
||||
❌ Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="case-data" type="application/json">${caseJson}</script>
|
||||
<script>
|
||||
// Слушаем сообщения от родительского окна
|
||||
window.addEventListener('message', function(event) {
|
||||
console.log('Message received:', event.data);
|
||||
});
|
||||
|
||||
// Отправляем сообщение родителю при загрузке
|
||||
window.parent.postMessage({type: 'claim_form_loaded'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Слушаем сообщения от iframe
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
console.log('📨 Message from iframe:', event.data);
|
||||
|
||||
if (event.data.type === 'claim_confirmed') {
|
||||
message.success('Заявление подтверждено!');
|
||||
onNext();
|
||||
} else if (event.data.type === 'claim_cancelled') {
|
||||
message.info('Подтверждение отменено');
|
||||
onPrev();
|
||||
} else if (event.data.type === 'claim_form_loaded') {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [onNext, onPrev]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: '16px' }}>Загрузка формы подтверждения...</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={htmlContent}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '800px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
title="Форма подтверждения заявления"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function StepDraftSelection({
|
||||
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>Черновик {draft.claim_id}</Text>
|
||||
<Text strong>Черновик</Text>
|
||||
<Tag color="default">Черновик</Tag>
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -705,10 +705,17 @@ export default function StepWizardPlan({
|
||||
block.description || ''
|
||||
);
|
||||
|
||||
// Имя "поля" группы
|
||||
// Имя "поля" группы (используем docLabel если есть, иначе guessFieldName)
|
||||
const fieldLabel = block.docLabel || block.fieldName || guessFieldName(group);
|
||||
formPayload.append(
|
||||
`uploads_field_names[${i}]`,
|
||||
guessFieldName(group)
|
||||
fieldLabel
|
||||
);
|
||||
|
||||
// ✅ Добавляем реальное название поля (label) для использования в n8n
|
||||
formPayload.append(
|
||||
`uploads_field_labels[${i}]`,
|
||||
block.docLabel || block.description || fieldLabel
|
||||
);
|
||||
|
||||
// Файлы: uploads[i][j]
|
||||
@@ -754,17 +761,106 @@ export default function StepWizardPlan({
|
||||
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);
|
||||
}
|
||||
|
||||
onNext();
|
||||
};
|
||||
|
||||
// Функция подписки на канал 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 для более точного определения типа поля
|
||||
|
||||
@@ -5,6 +5,7 @@ import StepDescription from '../components/form/StepDescription';
|
||||
import Step1Policy from '../components/form/Step1Policy';
|
||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||
import Step2EventType from '../components/form/Step2EventType';
|
||||
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
||||
import Step3Payment from '../components/form/Step3Payment';
|
||||
@@ -42,6 +43,10 @@ interface FormData {
|
||||
wizardUploads?: Record<string, any>;
|
||||
wizardSkippedDocuments?: string[];
|
||||
|
||||
// Подтверждение заявления (после получения данных из claim:plan)
|
||||
showClaimConfirmation?: boolean;
|
||||
claimPlanData?: any; // Данные заявления от n8n из канала claim:plan
|
||||
|
||||
// Шаг 3: Event Type
|
||||
eventType?: string;
|
||||
ticket_id?: string; // ✅ ID заявки в vTiger (HelpDesk)
|
||||
@@ -392,15 +397,22 @@ export default function ClaimForm() {
|
||||
// Проверка наличия черновиков
|
||||
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
||||
try {
|
||||
console.log('🔍 ========== checkDrafts вызван ==========');
|
||||
console.log('🔍 Параметры:', { unified_id, phone, sessionId });
|
||||
|
||||
const params = new URLSearchParams();
|
||||
// Приоритет: unified_id > phone > session_id
|
||||
if (unified_id) {
|
||||
params.append('unified_id', unified_id);
|
||||
console.log('🔍 Используем unified_id:', unified_id);
|
||||
} else if (phone) {
|
||||
params.append('phone', phone);
|
||||
console.log('🔍 Используем phone:', phone);
|
||||
} else if (sessionId) {
|
||||
params.append('session_id', sessionId);
|
||||
console.log('🔍 Используем session_id:', sessionId);
|
||||
} else {
|
||||
console.warn('⚠️ Нет параметров для поиска черновиков');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -408,22 +420,29 @@ export default function ClaimForm() {
|
||||
console.log('🔍 Запрос черновиков:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
console.log('🔍 Статус ответа:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText);
|
||||
const errorText = await response.text();
|
||||
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText, errorText);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔍 Ответ API черновиков:', data);
|
||||
console.log('🔍 Debug info от backend:', data.debug);
|
||||
console.log('🔍 Полный ответ API черновиков:', JSON.stringify(data, null, 2));
|
||||
console.log('🔍 Debug info от backend:', data.debug_info || data.debug);
|
||||
const count = data.count || 0;
|
||||
console.log('🔍 Количество черновиков:', count);
|
||||
console.log('🔍 Список черновиков:', data.drafts);
|
||||
|
||||
setHasDrafts(count > 0);
|
||||
setShowDraftSelection(count > 0);
|
||||
console.log('🔍 Установлены флаги: hasDrafts=', count > 0, 'showDraftSelection=', count > 0);
|
||||
console.log('🔍 ========== checkDrafts завершён ==========');
|
||||
return count > 0;
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки черновиков:', error);
|
||||
console.error('❌ Ошибка проверки черновиков:', error);
|
||||
console.error('❌ Stack trace:', (error as Error).stack);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
@@ -592,27 +611,25 @@ export default function ClaimForm() {
|
||||
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
|
||||
|
||||
if (shouldCheckDrafts && !selectedDraftId) {
|
||||
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone);
|
||||
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone, 'sessionId:', sessionIdRef.current);
|
||||
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionIdRef.current);
|
||||
console.log('🔍 Результат checkDrafts:', hasDraftsResult);
|
||||
console.log('🔍 Текущие флаги после checkDrafts: hasDrafts=', hasDrafts, 'showDraftSelection=', showDraftSelection);
|
||||
|
||||
if (hasDraftsResult) {
|
||||
console.log('✅ Есть черновики, переходим к шагу 0');
|
||||
// ✅ ВАЖНО: Сначала устанавливаем флаги, потом переходим на шаг 0
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
// ✅ Ждём следующего тика, чтобы useMemo пересчитался с новыми флагами
|
||||
// Используем requestAnimationFrame для гарантии, что React обновил состояние
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
console.log('🔄 Переходим на шаг 0 после установки флагов');
|
||||
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||
});
|
||||
});
|
||||
// ✅ Используем setTimeout для гарантии, что React обновил состояние
|
||||
setTimeout(() => {
|
||||
console.log('🔄 Переходим на шаг 0 после установки флагов');
|
||||
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||
}, 100);
|
||||
console.log('🛑 Остановка выполнения onNext - есть черновики');
|
||||
console.log('🛑 RETURN - функция должна остановиться здесь');
|
||||
return; // ✅ ВАЖНО: Не идём дальше, если есть черновики
|
||||
} else {
|
||||
console.log('❌ Нет черновиков, идем дальше');
|
||||
console.log('❌ Нет черновиков, идем дальше к описанию проблемы');
|
||||
// Нет черновиков - идём дальше
|
||||
nextStep();
|
||||
return;
|
||||
@@ -675,6 +692,21 @@ export default function ClaimForm() {
|
||||
),
|
||||
});
|
||||
|
||||
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
||||
if (formData.showClaimConfirmation && formData.claimPlanData) {
|
||||
stepsArray.push({
|
||||
title: 'Подтверждение',
|
||||
description: 'Проверка данных',
|
||||
content: (
|
||||
<StepClaimConfirmation
|
||||
claimPlanData={formData.claimPlanData}
|
||||
onPrev={prevStep}
|
||||
onNext={nextStep}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 3: Policy (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Проверка полиса',
|
||||
|
||||
Reference in New Issue
Block a user