Files
aiform_prod/frontend/src/pages/ClaimForm.tsx
2026-02-20 09:31:13 +03:00

1737 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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 { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription';
// Step1Policy убран - старый ERV флоу
import StepDraftSelection from '../components/form/StepDraftSelection';
import StepWizardPlan from '../components/form/StepWizardPlan';
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
// Step2EventType, StepDocumentUpload убраны - старый ERV флоу
// Step3Payment убран - не используется
import DebugPanel from '../components/DebugPanel';
// getDocumentsForEventType убран - старый ERV флоу
import './ClaimForm.css';
// Используем относительные пути - Vite proxy перенаправит на backend
const { Step } = Steps;
/**
* Генерация UUID v4
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
*/
function generateUUIDv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
interface FormData {
// Шаг 1: Phone
phone?: string;
contact_id?: string;
unified_id?: string; // ✅ Unified ID пользователя из PostgreSQL
is_new_contact?: boolean;
smsCode?: string;
clientIp?: string;
smsDebugCode?: string;
// Шаг 2: Policy
voucher: string;
claim_id?: string;
session_id?: string;
project_id?: string; // ✅ ID проекта в vTiger (полис)
is_new_project?: boolean; // ✅ Флаг: создан новый проект
problemDescription?: string;
wizardPlan?: any;
wizardPlanStatus?: 'pending' | 'ready' | 'answered';
wizardAnswers?: Record<string, any>;
wizardPrefill?: Record<string, any>;
wizardPrefillArray?: Array<{ name: string; value: any }>;
wizardCoverageReport?: any;
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)
ticket_number?: string; // ✅ Номер заявки (HD001234)
// Шаги 4+: Documents
documents?: Record<string, {
uploaded: boolean;
data: any;
file_type: string;
skipped?: boolean;
}>;
// Последний шаг: Payment
fullName?: string;
email?: string;
paymentMethod?: string;
bankId?: string; // ID банка из NSPK API
bankName?: string; // Название банка для отображения
cardNumber?: string;
accountNumber?: string;
}
export default function ClaimForm() {
// ✅ claim_id будет создан n8n в Step1Phone после SMS верификации
// Не генерируем его локально!
// session_id будет получен от n8n при создании контакта
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [currentStep, setCurrentStep] = useState(0);
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
const [formData, setFormData] = useState<FormData>({
voucher: '',
claim_id: undefined, // ✅ Будет заполнен n8n в Step1Phone
session_id: sessionIdRef.current,
paymentMethod: 'sbp',
});
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
const [debugEvents, setDebugEvents] = useState<any[]>([]);
const [isSubmitted, setIsSubmitted] = useState(false);
const [showDraftSelection, setShowDraftSelection] = useState(false);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
const [hasDrafts, setHasDrafts] = useState(false);
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
const [tgDebug, setTgDebug] = useState<string>('');
/** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
/** Заход через MAX Mini App. */
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
console.log('🔥 ClaimForm v3.9 - 2025-12-29 - Auto redirect to drafts after success');
}, []);
// Определение: зашли с веба или из Telegram Mini App. Дефолт — веб; при TG вешаем класс для отдельного скина.
// Загружаем telegram-web-app.js только если есть признаки Telegram (чтобы не мусорить в консоли).
useEffect(() => {
const isTelegramContext = () => {
// Проверяем URL, referrer и user agent на признаки Telegram
const url = window.location.href;
const ref = document.referrer;
const ua = navigator.userAgent;
return (
url.includes('tgWebAppData') ||
url.includes('tgWebAppVersion') ||
ref.includes('telegram') ||
ua.includes('Telegram')
);
};
if (isTelegramContext()) {
// Загружаем скрипт Telegram SDK динамически
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-web-app.js';
script.async = true;
script.onload = () => {
setTimeout(() => {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
const hasInitData = webApp?.initData && webApp.initData.length > 0;
if (webApp && hasInitData) {
setIsTelegramMiniApp(true);
try {
webApp.ready?.();
webApp.expand?.();
} catch (_) {}
}
}, 100);
};
document.head.appendChild(script);
}
}, []);
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
useEffect(() => {
const tryTelegramAuth = async () => {
try {
// Только window: parent недоступен из-за cross-origin (iframe Telegram)
const getTg = () => (window as any).Telegram;
// Ждём появления initData: скрипт Telegram может подгрузиться с задержкой
const maxWaitMs = 2500;
const intervalMs = 150;
let webApp: TelegramWebApp | null = null;
let attempts = 0;
while (attempts * intervalMs < maxWaitMs) {
const tg = getTg();
webApp = tg?.WebApp ?? null;
if (webApp?.initData) {
console.log('[TG] initData появился через', attempts * intervalMs, 'ms, длина=', webApp.initData.length);
break;
}
attempts++;
await new Promise((r) => setTimeout(r, intervalMs));
}
if (!webApp?.initData) {
const tg = getTg();
console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth');
// Если Telegram не найден — пробуем MAX Mini App (window.WebApp от MAX Bridge)
let maxWebApp = (window as any).WebApp;
const maxWait = 4000;
for (let t = 0; t < maxWait; t += 200) {
await new Promise((r) => setTimeout(r, 200));
maxWebApp = (window as any).WebApp;
if (maxWebApp?.initData && maxWebApp.initData.length > 0) break;
}
if (maxWebApp?.initData && typeof maxWebApp.initData === 'string' && maxWebApp.initData.length > 0) {
const hasHash = maxWebApp.initData.includes('hash=');
console.log('[MAX] Обнаружен MAX WebApp, initData длина=', maxWebApp.initData.length, ', есть hash=', hasHash);
setIsMaxMiniApp(true);
try { maxWebApp.ready?.(); } catch (_) {}
const existingToken = localStorage.getItem('session_token');
if (existingToken) {
console.log('[MAX] session_token уже есть → max/auth не вызываем');
setTelegramAuthChecked(true);
return;
}
setTgDebug('MAX: POST /api/v1/max/auth...');
try {
const maxRes = await fetch('/api/v1/max/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ init_data: maxWebApp.initData }),
});
const maxData = await maxRes.json();
if (maxData.need_contact) {
setTgDebug('MAX: Нужен контакт — закрываем приложение');
try { maxWebApp.close?.(); } catch (_) {}
setTelegramAuthChecked(true);
return;
}
if (maxRes.ok && maxData.success) {
if (maxData.session_token) {
localStorage.setItem('session_token', maxData.session_token);
sessionIdRef.current = maxData.session_token;
}
setFormData((prev) => ({
...prev,
unified_id: maxData.unified_id,
phone: maxData.phone,
contact_id: maxData.contact_id,
session_id: maxData.session_token,
}));
setIsPhoneVerified(true);
if (maxData.has_drafts) {
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
setCurrentStep(1);
}
} else {
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
}
} catch (e) {
console.error('[MAX] Ошибка max/auth:', e);
}
setTelegramAuthChecked(true);
return;
}
setTelegramAuthChecked(true);
return;
}
// Логирование для отладки
if (webApp.initDataUnsafe?.user) {
const u = webApp.initDataUnsafe.user;
console.log('[TG] initDataUnsafe.user:', { id: u.id, username: u.username, first_name: u.first_name });
}
// Если сессия уже есть в localStorage — ничего не делаем, дальше сработает обычное restoreSession
const existingToken = localStorage.getItem('session_token');
if (existingToken) {
setTgDebug('TG: session_token уже есть → tg/auth не вызываем');
console.log('[TG] session_token уже в localStorage → tg/auth не вызываем');
setTelegramAuthChecked(true);
return;
}
setTgDebug('TG: POST /api/v1/tg/auth...');
console.log('[TG] Вызываем POST /api/v1/tg/auth, initData длина=', webApp.initData.length);
const response = await fetch('/api/v1/tg/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
init_data: webApp.initData,
}),
});
const data = await response.json();
console.log('[TG] /api/v1/tg/auth ответ: status=', response.status, 'ok=', response.ok, 'data=', data);
if (!response.ok || !data.success) {
console.warn('[TG] Telegram auth не успешен → показываем экран телефона/SMS. detail=', data.detail || data);
setTelegramAuthChecked(true);
return;
}
const sessionToken = data.session_token;
// Сохраняем session_token так же, как после SMS-логина
if (sessionToken) {
localStorage.setItem('session_token', sessionToken);
sessionIdRef.current = sessionToken;
}
// Сохраняем базовые данные пользователя (phone может быть пустым)
setFormData((prev) => ({
...prev,
unified_id: data.unified_id,
phone: data.phone,
contact_id: data.contact_id,
session_id: sessionToken,
}));
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
setIsPhoneVerified(true);
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
if (data.has_drafts) {
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
// Иначе переходим сразу к описанию проблемы
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
setCurrentStep(1);
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
setTgDebug(`TG: ошибка: ${msg}`);
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
} finally {
setTelegramAuthChecked(true);
}
};
tryTelegramAuth();
}, []);
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
useEffect(() => {
if (!telegramAuthChecked) {
// Ждём, пока не закончим попытку Telegram-авторизации,
// чтобы не гонять два параллельных restoreSession.
return;
}
const restoreSession = async () => {
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
console.log('🔑 Значения всех ключей:', JSON.stringify(localStorage));
const savedSessionToken = localStorage.getItem('session_token');
if (!savedSessionToken) {
console.log('❌ Session token NOT found in localStorage');
setSessionRestored(true);
return;
}
console.log('✅ Found session_token in localStorage, verifying:', savedSessionToken);
addDebugEvent('session', 'info', '🔑 Проверка сохранённой сессии');
try {
const response = await fetch('/api/v1/session/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: savedSessionToken })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('🔑 Session verify response:', data);
if (data.success && data.valid) {
// Сессия валидна! Восстанавливаем состояние
console.log('✅ Session valid! Restoring user data:', {
unified_id: data.unified_id,
phone: data.phone,
expires_in: data.expires_in_seconds
});
// Обновляем formData с данными сессии
updateFormData({
unified_id: data.unified_id,
phone: data.phone,
contact_id: data.contact_id,
session_id: savedSessionToken
});
// Устанавливаем session_id в ref
sessionIdRef.current = savedSessionToken;
// Помечаем телефон как верифицированный
setIsPhoneVerified(true);
// Проверяем черновики
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
if (hasDraftsResult) {
// Есть черновики - показываем список
setShowDraftSelection(true);
setHasDrafts(true);
// Переходим к шагу выбора черновика
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setCurrentStep(0);
});
});
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
} else {
// Нет черновиков - переходим к описанию
setCurrentStep(1);
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
}
} else {
// Сессия невалидна - удаляем из localStorage
console.log('❌ Session invalid or expired, removing from localStorage');
localStorage.removeItem('session_token');
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
}
} catch (error) {
console.error('❌ Error verifying session:', error);
localStorage.removeItem('session_token');
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
} finally {
setSessionRestored(true);
}
};
restoreSession();
}, [telegramAuthChecked]); // Запускаем только один раз, после попытки Telegram auth
// Получаем IP клиента один раз при монтировании
useEffect(() => {
const fetchClientIp = async () => {
try {
const response = await fetch('/api/v1/utils/client-ip');
if (!response.ok) return;
const data = await response.json();
if (data?.ip) {
setFormData((prev) => ({ ...prev, clientIp: data.ip }));
}
} catch {
// Тихо игнорируем, IP всегда можно взять на бэке из request
}
};
fetchClientIp();
}, []);
// Автоматический переход к шагу подтверждения, когда данные готовы
useEffect(() => {
if (formData.showClaimConfirmation && formData.claimPlanData) {
// Вычисляем индекс шага подтверждения динамически
// Шаг подтверждения добавляется после StepWizardPlan
// После выбора черновика showDraftSelection = false, поэтому:
// - Шаг 0 = Step1Phone
// - Шаг 1 = StepDescription
// - Шаг 2 = StepWizardPlan
// - Шаг 3 = StepClaimConfirmation (если showClaimConfirmation=true)
const confirmationStepIndex = 3; // Фиксированный индекс для шага подтверждения
console.log('✅ Данные заявления готовы, переходим к шагу подтверждения:', confirmationStepIndex);
setTimeout(() => {
setCurrentStep(confirmationStepIndex);
}, 100);
}
}, [formData.showClaimConfirmation, formData.claimPlanData]);
// Старый ERV флоу убран - documentConfigs больше не нужен
const addDebugEvent = (type: string, status: string, message: string, data?: any) => {
const event = {
timestamp: new Date().toLocaleTimeString('ru-RU'),
type,
status,
message,
data: {
...data,
claim_id: formData.claim_id // ✅ Используем claim_id из formData (от n8n)
}
};
setDebugEvents(prev => [event, ...prev]);
};
// ✅ claim_id будет залогирован в Step1Phone после получения от n8n
const updateFormData = useCallback((data: Partial<FormData>) => {
setFormData((prev) => ({ ...prev, ...data }));
}, []);
const nextStep = useCallback(() => {
console.log('⏩ nextStep called');
setCurrentStep((prev) => {
console.log('📍 Current step:', prev, '→ Next:', prev + 1);
return prev + 1;
});
}, []);
const prevStep = useCallback(() => {
console.log('⏪ prevStep called');
setCurrentStep((prev) => {
console.log('📍 Current step:', prev, '→ Prev:', prev - 1);
// ✅ Если возвращаемся к шагу 0 и есть черновики - показываем список
if (prev - 1 === 0 && formData.unified_id && hasDrafts) {
console.log('📍 Возврат к списку черновиков');
setShowDraftSelection(true);
setSelectedDraftId(null);
}
return prev - 1;
});
}, [formData.unified_id, hasDrafts]);
// ✅ Возврат к списку черновиков напрямую (без промежуточных шагов)
const backToDraftsList = useCallback(() => {
console.log('📋 Возврат к списку черновиков');
setShowDraftSelection(true);
setSelectedDraftId(null);
setCurrentStep(0);
}, []);
// Преобразование данных черновика в формат propertyName для формы подтверждения
const transformDraftToClaimPlanFormat = useCallback((data: {
claim: any;
payload: any;
body: any;
isTelegramFormat: boolean;
finalClaimId: string;
actualSessionId: string;
currentFormData: FormData;
}) => {
const { claim, payload, body, finalClaimId, actualSessionId, currentFormData } = data;
console.log('🔄 transformDraftToClaimPlanFormat: входные данные:', {
claimId: finalClaimId,
claimUnifiedId: claim.unified_id,
formDataUnifiedId: currentFormData.unified_id,
claimKeys: Object.keys(claim),
});
console.log('🔄 Данные из БД:', {
hasApplicantData: !!(body.applicant || payload.applicant),
hasCaseData: !!(body.case || payload.case),
hasContractData: !!(body.contract_or_service || payload.contract_or_service),
hasWizardAnswers: !!(body.answers || payload.answers || body.wizard_answers || payload.wizard_answers),
hasSendToFormApprove: !!(payload.send_to_form_approve && payload.send_to_form_approve.draft),
payloadKeys: Object.keys(payload),
bodyKeys: Object.keys(body),
});
// ✅ ПРИОРИТЕТ 1: Если есть данные в payload.send_to_form_approve.draft - используем их напрямую!
const sendToFormApproveDraft = payload.send_to_form_approve?.draft;
if (sendToFormApproveDraft) {
console.log('✅ Найдены данные в payload.send_to_form_approve.draft, используем их напрямую!');
console.log('✅ send_to_form_approve.draft:', sendToFormApproveDraft);
// Используем данные из send_to_form_approve.draft напрямую
const draftData = sendToFormApproveDraft;
// Формируем propertyName из draft данных
const propertyName = {
applicant: draftData.applicant || {},
case: draftData.case || {},
contract_or_service: draftData.contract_or_service || {},
offenders: draftData.offenders || [],
claim: draftData.claim || {},
meta: {
...(draftData.meta || {}),
claim_id: finalClaimId,
unified_id: draftData.meta?.unified_id || claim.unified_id || currentFormData.unified_id || null,
},
attachments: draftData.attachments || [],
attachments_count: draftData.attachments_count || 0,
attachments_names: draftData.attachments_names || [],
};
// Возвращаем данные в формате объекта (для компонента StepClaimConfirmation)
const result = {
propertyName: propertyName,
session_token: actualSessionId,
prefix: '',
telegram_id: null,
claim_id: finalClaimId,
unified_id: propertyName.meta.unified_id,
user_id: propertyName.meta.user_id || null,
};
console.log('🔄 transformDraftToClaimPlanFormat: результат из send_to_form_approve:', {
claim_id: result.claim_id,
unified_id: result.unified_id,
hasPropertyName: !!result.propertyName,
hasMeta: !!result.propertyName?.meta,
});
return result;
}
// ✅ ПРИОРИТЕТ 2: Если данных нет в send_to_form_approve, извлекаем из body/payload
// Извлекаем данные из body (telegram) или напрямую из payload (web_form)
const applicantData = body.applicant || payload.applicant || {};
const caseData = body.case || payload.case || {};
const contractData = body.contract_or_service || payload.contract_or_service || {};
const offendersData = body.offenders || payload.offenders || [];
const claimData = body.claim || payload.claim || {};
const metaData = body.meta || payload.meta || {};
const documentsMeta = body.documents_meta || payload.documents_meta || [];
// Извлекаем ответы на вопросы из wizard_answers
const wizardAnswers = body.answers || payload.answers || body.wizard_answers || payload.wizard_answers || {};
let answersParsed = wizardAnswers;
if (typeof wizardAnswers === 'string') {
try {
answersParsed = JSON.parse(wizardAnswers);
} catch (e) {
console.warn('⚠️ Не удалось распарсить answers:', e);
answersParsed = {};
}
}
console.log('🔄 wizard_answers parsed:', answersParsed);
// Преобразуем wizard_answers в формат propertyName, если данных нет в propertyName формате
// Маппинг полей из wizard_answers в propertyName структуру
const hasPropertyNameData = !!(applicantData.first_name || applicantData.last_name || caseData.category || contractData.subject);
if (!hasPropertyNameData && answersParsed && Object.keys(answersParsed).length > 0) {
console.log('🔄 Преобразуем wizard_answers в propertyName формат');
console.log('🔄 wizard_answers keys:', Object.keys(answersParsed));
// Маппинг полей из wizard_answers в contract_or_service
if (answersParsed.item && !contractData.subject) {
contractData.subject = answersParsed.item;
}
if (answersParsed.price && !contractData.amount_paid) {
// Нормализуем цену (убираем "рублей", пробелы и т.д.)
const priceStr = String(answersParsed.price).replace(/\s+/g, '').replace(/руб(лей|ль|\.)?/gi, '').replace(/₽|р\.|р$/gi, '');
contractData.amount_paid = priceStr;
contractData.amount_paid_fmt = priceStr;
}
if (answersParsed.place_date && !contractData.agreement_date) {
contractData.agreement_date = answersParsed.place_date;
contractData.agreement_date_fmt = answersParsed.place_date;
}
if (answersParsed.cancel_date && !contractData.period_start) {
contractData.period_start = answersParsed.cancel_date;
contractData.period_start_fmt = answersParsed.cancel_date;
}
// Маппинг полей из wizard_answers в claim
if (answersParsed.steps_taken && !claimData.description) {
claimData.description = answersParsed.steps_taken;
}
if (answersParsed.expectation && !claimData.reason) {
// expectation может быть "refund", "replacement", "compensation", "other"
claimData.reason = answersParsed.expectation === 'refund' ? 'consumer' : 'consumer';
}
// Маппинг в case
if (!caseData.category) {
caseData.category = 'consumer'; // По умолчанию consumer
}
if (!caseData.direction) {
caseData.direction = 'web_form';
}
// Если есть problem_description, используем его для claim.description
const problemDesc = payload.problem_description || body.problem_description;
if (problemDesc && !claimData.description) {
claimData.description = problemDesc;
}
if (problemDesc && !contractData.subject) {
contractData.subject = problemDesc;
}
}
// Данные заявителя берутся из других источников (phone, email из claim или formData)
// ФИО, дата рождения, ИНН будут заполняться в форме подтверждения
const applicantPhone = claim.phone || payload.phone || body.phone || currentFormData.phone || null;
const applicantEmail = claim.email || payload.email || body.email || currentFormData.email || null;
// Если есть данные заявителя в applicantData, используем их
if (!applicantData.phone && applicantPhone) {
applicantData.phone = applicantPhone;
}
if (!applicantData.email && applicantEmail) {
applicantData.email = applicantEmail;
}
// Формируем attachments_names из documents_meta
const attachmentsNames = documentsMeta.map((doc: any) => {
return doc.original_file_name || doc.file_name || doc.field_name || 'Документ';
});
// Формируем attachments с полной информацией
const attachments = documentsMeta.map((doc: any) => ({
label: doc.field_label || doc.original_file_name || doc.file_name || doc.field_name || 'Документ', // ✅ Используем field_label
field_label: doc.field_label || doc.field_name || doc.original_file_name || doc.file_name || 'Документ', // ✅ Добавляем field_label отдельно
url: doc.file_id ? `https://s3.twcstorage.ru${doc.file_id}` : '',
file_id: doc.file_id || '',
stored_file_name: doc.file_name || '',
original_file_name: doc.original_file_name || doc.file_name || '',
field_name: doc.field_name || '',
uploaded_at: doc.uploaded_at || new Date().toISOString(),
}));
// Формируем propertyName в нужном формате
const propertyName = {
applicant: {
first_name: applicantData.first_name || null,
middle_name: applicantData.middle_name || null,
last_name: applicantData.last_name || null,
full_name: applicantData.full_name || null,
birth_date: applicantData.birth_date || null,
birth_date_fmt: applicantData.birth_date_fmt || null,
birth_place: applicantData.birth_place || null,
inn: applicantData.inn || null,
address: applicantData.address || null,
phone: claim.phone || payload.phone || body.phone || currentFormData.phone || null,
email: claim.email || payload.email || body.email || currentFormData.email || null,
},
case: {
category: caseData.category || payload.case_type || 'consumer',
direction: caseData.direction || 'web_form',
country: caseData.country || null,
},
contract_or_service: {
agreement_date: contractData.agreement_date || null,
agreement_date_fmt: contractData.agreement_date_fmt || null,
amount_paid: contractData.amount_paid || null,
amount_paid_fmt: contractData.amount_paid_fmt || null,
subject: contractData.subject || payload.problem_description || body.problem_description || null,
period_start: contractData.period_start || null,
period_start_fmt: contractData.period_start_fmt || null,
period_end: contractData.period_end || null,
period_end_fmt: contractData.period_end_fmt || null,
period_text: contractData.period_text || null,
},
offenders: offendersData.length > 0 ? offendersData : [],
claim: {
reason: claimData.reason || caseData.category || 'consumer',
description: claimData.description || payload.problem_description || body.problem_description || null,
},
meta: {
claim_id: finalClaimId,
unified_id: claim.unified_id || currentFormData.unified_id || null,
status: claim.status_code || 'draft',
created_at: claim.created_at || new Date().toISOString(),
updated_at: claim.updated_at || new Date().toISOString(),
user_id: metaData.user_id || null,
},
attachments: attachments,
attachments_count: attachments.length,
attachments_names: attachmentsNames,
};
// Возвращаем данные в формате объекта (для компонента StepClaimConfirmation)
const result = {
propertyName: propertyName,
session_token: actualSessionId,
prefix: '',
telegram_id: null,
claim_id: finalClaimId,
unified_id: claim.unified_id || currentFormData.unified_id || null,
user_id: metaData.user_id || null,
};
console.log('🔄 transformDraftToClaimPlanFormat: результат:', {
claim_id: result.claim_id,
unified_id: result.unified_id,
hasPropertyName: !!result.propertyName,
hasMeta: !!result.propertyName?.meta,
});
return result;
}, []);
// Загрузка черновика
const loadDraft = useCallback(async (claimId: string) => {
try {
console.log('🔍 Загрузка черновика с ID:', claimId);
const url = `/api/v1/claims/drafts/${claimId}`;
console.log('🔍 URL запроса:', url);
const response = await fetch(url);
console.log('🔍 Статус ответа:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Ошибка загрузки черновика:', response.status, errorText);
throw new Error(`Не удалось загрузить черновик: ${response.status} ${errorText}`);
}
const data = await response.json();
console.log('🔍 Данные черновика загружены:', data);
const claim = data.claim;
const payload = claim.payload || {};
// ✅ Сохраняем флаги подтверждения данных контакта
const contact_data_confirmed = data.contact_data_confirmed || false;
const contact_data_can_edit = data.contact_data_can_edit !== false; // По умолчанию true
const contact_data_confirmed_at = data.contact_data_confirmed_at || null;
const contact_data_from_crm = data.contact_data_from_crm || null;
console.log('🔒 Статус данных контакта:', {
contact_data_confirmed,
contact_data_can_edit,
contact_data_confirmed_at,
has_crm_data: !!contact_data_from_crm
});
// ✅ Для telegram черновиков данные могут быть в payload.body
const body = payload.body || {};
const isTelegramFormat = !!payload.body;
console.log('🔍 Claim объект:', claim);
console.log('🔍 claim.claim_id:', claim.claim_id);
console.log('🔍 claim.id:', claim.id);
console.log('🔍 claim.unified_id:', claim.unified_id);
console.log('🔍 Payload черновика:', payload);
console.log('🔍 payload.body:', body);
console.log('🔍 Формат:', isTelegramFormat ? 'telegram (body)' : 'web_form (прямой)');
// ✅ Извлекаем данные из body (telegram) или напрямую из payload (web_form)
const wizardPlanRaw = body.wizard_plan || payload.wizard_plan;
const answersRaw = body.answers || payload.answers;
// Ищем problem_description в разных местах (может быть в разных форматах)
const problemDescription =
body.problem_description ||
payload.problem_description ||
body.description ||
payload.description ||
payload.body?.problem_description || // Для вложенных структур
payload.body?.description ||
null;
const documentsMeta = body.documents_meta || payload.documents_meta || [];
// ✅ Парсим wizard_plan и answers, если они строки (JSON)
let wizardPlan = wizardPlanRaw;
if (typeof wizardPlanRaw === 'string') {
try {
wizardPlan = JSON.parse(wizardPlanRaw);
} catch (e) {
console.warn('⚠️ Не удалось распарсить wizard_plan:', e);
}
}
let answers = answersRaw;
if (typeof answersRaw === 'string') {
try {
answers = JSON.parse(answersRaw);
} catch (e) {
console.warn('⚠️ Не удалось распарсить answers:', e);
}
}
// ✅ Проверяем, заполнены ли все шаги
// Для problem_description: если его нет в payload, но есть wizard_plan и answers,
// значит описание уже было введено ранее (wizard_plan генерируется на основе описания)
const hasDescription = !!problemDescription || (!!wizardPlan && !!answers); // Если есть план и ответы - описание было
const hasWizardPlan = !!wizardPlan;
const hasAnswers = !!answers && Object.keys(answers).length > 0;
const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0;
const isDraft = claim.status_code === 'draft';
// ✅ Запрещаем редактирование заявок "В работе"
if (claim.status_code === 'in_work') {
message.warning('Эта заявка уже в работе и не может быть изменена');
console.log('⚠️ Попытка открыть заявку "В работе" для редактирования - запрещено');
return;
}
// ✅ НОВОЕ: Проверяем наличие form_draft (собранные данные из RAG)
const formDraft = payload.form_draft;
const hasFormDraft = !!(formDraft && formDraft.user && formDraft.offenders);
const isDraftDocsComplete = claim.status_code === 'draft_docs_complete';
const allStepsFilled = hasDescription && hasWizardPlan && hasAnswers && hasDocuments;
const isReadyForConfirmation = (allStepsFilled && isDraft) || (hasFormDraft && isDraftDocsComplete);
console.log('🔍 Проверка полноты черновика:', {
hasDescription,
hasWizardPlan,
hasAnswers,
hasDocuments,
isDraft,
hasFormDraft,
isDraftDocsComplete,
allStepsFilled,
isReadyForConfirmation,
problemDescriptionFound: !!problemDescription,
inferredFromPlan: !problemDescription && !!wizardPlan && !!answers,
});
console.log('🔍 problem_description:', problemDescription ? 'есть' : (wizardPlan && answers ? 'выведено из наличия плана и ответов' : 'нет'));
console.log('🔍 wizard_plan:', wizardPlan ? 'есть' : 'нет');
console.log('🔍 answers:', answers ? 'есть' : 'нет');
console.log('🔍 documents_meta:', documentsMeta.length, 'документов');
console.log('🔍 Все ключи payload:', Object.keys(payload));
if (isTelegramFormat) {
console.log('🔍 Все ключи body:', Object.keys(body));
}
// ✅ Извлекаем claim_id из разных возможных мест
const finalClaimId = claim.claim_id || payload.claim_id || body.claim_id || claim.id || formData.claim_id || claimId;
console.log('🔍 Извлечённый claim_id:', finalClaimId);
// Восстанавливаем данные формы из черновика
console.log('🔄 Загрузка черновика: session_id из черновика:', claim.session_token);
console.log('🔄 Загрузка черновика: текущий sessionIdRef.current:', sessionIdRef.current);
console.log('🔄 Загрузка черновика: текущий formData.session_id:', formData.session_id);
// ✅ При загрузке черновика используем session_id из черновика (для продолжения работы с той же жалобой)
// Если session_id из черновика есть - используем его, иначе текущий
const actualSessionId = claim.session_token || sessionIdRef.current || formData.session_id;
console.log('🔄 Загрузка черновика: ИСПОЛЬЗУЕМ session_id:', actualSessionId);
// ✅ Обновляем sessionIdRef на сессию из черновика (если есть)
if (claim.session_token && claim.session_token !== sessionIdRef.current) {
sessionIdRef.current = claim.session_token;
console.log('🔄 Обновляем sessionIdRef на сессию из черновика:', claim.session_token);
}
// ✅ НОВЫЙ ФЛОУ: Извлекаем documents_required из payload
const documentsRequired = body.documents_required || payload.documents_required || [];
const documentsUploaded = body.documents_uploaded || payload.documents_uploaded || [];
const documentsSkipped = body.documents_skipped || payload.documents_skipped || [];
const currentDocIndex = body.current_doc_index ?? payload.current_doc_index ?? 0;
console.log('📋 Загрузка черновика - documents_required:', documentsRequired.length, 'шт.');
console.log('📋 Загрузка черновика - body.documents_required:', body.documents_required);
console.log('📋 Загрузка черновика - payload.documents_required:', payload.documents_required);
console.log('📋 Загрузка черновика - status_code:', claim.status_code);
console.log('📋 Загрузка черновика - все ключи payload:', Object.keys(payload));
updateFormData({
claim_id: finalClaimId, // ✅ Используем извлечённый claim_id
session_id: actualSessionId, // ✅ Используем session_id из черновика (если есть) или текущий
phone: body.phone || payload.phone || formData.phone,
email: body.email || payload.email || formData.email,
problemDescription: problemDescription || formData.problemDescription,
wizardPlan: wizardPlan || formData.wizardPlan,
wizardPlanStatus: wizardPlan ? (answers ? 'answered' : 'ready') : 'pending', // ✅ Устанавливаем статус
wizardAnswers: answers || formData.wizardAnswers,
wizardPrefill: (body.answers_prefill || payload.answers_prefill) ?
(body.answers_prefill || payload.answers_prefill).reduce((acc: any, item: any) => {
acc[item.name] = item.value;
return acc;
}, {}) : formData.wizardPrefill,
wizardPrefillArray: body.answers_prefill || payload.answers_prefill || formData.wizardPrefillArray,
wizardCoverageReport: body.coverage_report || payload.coverage_report || formData.wizardCoverageReport,
wizardUploads: {
documents: (body.documents_meta || payload.documents_meta) ? {} : formData.wizardUploads?.documents,
custom: formData.wizardUploads?.custom || [],
},
wizardSkippedDocuments: body.wizard_skipped_documents || payload.wizard_skipped_documents || formData.wizardSkippedDocuments,
eventType: body.event_type || payload.event_type || formData.eventType,
contact_id: body.contact_id || payload.contact_id || formData.contact_id,
project_id: body.project_id || payload.project_id || formData.project_id,
unified_id: formData.unified_id, // ✅ Сохраняем unified_id
// ✅ НОВЫЙ ФЛОУ: Документы
documents_required: documentsRequired,
documents_uploaded: documentsUploaded,
documents_skipped: documentsSkipped,
current_doc_index: currentDocIndex,
});
setSelectedDraftId(finalClaimId);
setShowDraftSelection(false);
// ✅ Если все шаги заполнены и статус = draft → переходим к форме подтверждения
if (isReadyForConfirmation) {
console.log('✅ Все шаги заполнены, преобразуем данные для формы подтверждения');
console.log('✅ hasFormDraft:', hasFormDraft, 'isDraftDocsComplete:', isDraftDocsComplete);
setIsPhoneVerified(true);
let claimPlanData;
// ✅ НОВОЕ: Если есть form_draft — используем его!
if (hasFormDraft && formDraft) {
console.log('✅ Используем form_draft из БД:', formDraft);
console.log('✅ project.description:', formDraft.project?.description);
console.log('✅ offenders:', formDraft.offenders);
console.log('✅ documentsMeta:', documentsMeta);
console.log('✅ documentsMeta[0]?.field_label:', documentsMeta[0]?.field_label);
const user = formDraft.user || {};
const project = formDraft.project || {};
// Преобразуем form_draft в формат propertyName (с правильными именами полей!)
claimPlanData = {
propertyName: {
applicant: {
// Маппинг полей user → applicant
first_name: user.firstname || '',
middle_name: user.secondname || '',
last_name: user.lastname || '',
phone: user.mobile || '',
email: user.email || '',
birth_date: user.birthday || '',
birth_place: user.birthplace || '',
address: user.mailingstreet || '',
inn: user.inn || '',
},
case: {
category: project.category || '',
direction: project.direction || '',
},
contract_or_service: {
subject: project.subject || '',
amount_paid: project.agrprice || '',
agreement_date: project.agrdate || '',
period_start: project.startdate || '',
period_end: project.finishdate || '',
country: project.country || '',
hotel: project.hotel || '',
},
offenders: (formDraft.offenders || []).map((o: any) => ({
name: o.accountname || '', // ✅ Форма ожидает 'name', а не 'accountname'
accountname: o.accountname || '', // Дублируем для совместимости
address: o.address || '',
email: o.email || '',
website: o.website || '',
phone: o.phone || '',
inn: o.inn || '',
ogrn: o.ogrn || '',
role: o.role || '',
})),
claim: {
description: project.description || problemDescription || '', // ✅ Описание проблемы
reason: project.category || '', // ✅ Причина обращения
},
meta: {
claim_id: finalClaimId,
unified_id: formData.unified_id || '',
session_token: actualSessionId,
},
// ✅ Используем field_label (человекочитаемые названия) вместо имён файлов
attachments_names: documentsMeta.map((d: any) => d.field_label || d.original_file_name || d.file_name || 'Документ'),
},
session_token: actualSessionId,
claim_id: finalClaimId,
prefix: 'clpr_',
};
console.log('✅ claimPlanData сформирован:', claimPlanData);
console.log('✅ claimPlanData.propertyName.claim.description:', claimPlanData.propertyName.claim.description);
console.log('✅ claimPlanData.propertyName.offenders:', claimPlanData.propertyName.offenders);
} else {
// Старый способ: преобразуем данные из БД
claimPlanData = transformDraftToClaimPlanFormat({
claim,
payload,
body,
isTelegramFormat,
finalClaimId,
actualSessionId,
currentFormData: formData,
});
}
console.log('✅ claimPlanData для формы подтверждения:', claimPlanData);
// ✅ Если данные подтверждены и есть данные из CRM - используем их
if (contact_data_confirmed && contact_data_from_crm) {
// Обновляем applicant данные из CRM
if (claimPlanData?.propertyName?.applicant) {
claimPlanData.propertyName.applicant = {
...claimPlanData.propertyName.applicant,
first_name: contact_data_from_crm.firstname || claimPlanData.propertyName.applicant.first_name,
last_name: contact_data_from_crm.lastname || claimPlanData.propertyName.applicant.last_name,
middle_name: contact_data_from_crm.cf_1157 || claimPlanData.propertyName.applicant.middle_name,
inn: contact_data_from_crm.cf_1257 || claimPlanData.propertyName.applicant.inn,
birth_date: contact_data_from_crm.birthday || claimPlanData.propertyName.applicant.birth_date,
birth_place: contact_data_from_crm.cf_1263 || claimPlanData.propertyName.applicant.birth_place,
address: contact_data_from_crm.mailingstreet || claimPlanData.propertyName.applicant.address,
email: contact_data_from_crm.email || claimPlanData.propertyName.applicant.email,
phone: contact_data_from_crm.mobile || claimPlanData.propertyName.applicant.phone,
};
}
}
// Сохраняем данные заявления в formData
updateFormData({
claimPlanData: claimPlanData,
showClaimConfirmation: true,
// ✅ Флаги подтверждения данных
contact_data_confirmed: contact_data_confirmed,
contact_data_can_edit: contact_data_can_edit,
contact_data_confirmed_at: contact_data_confirmed_at,
});
// Переход к шагу подтверждения произойдёт автоматически через useEffect
setCurrentStep(2); // StepWizardPlan (временно, useEffect переключит на подтверждение)
return;
}
// ✅ Определяем шаг для перехода на основе данных черновика
// Приоритет: если есть wizard_plan → переходим к визарду (даже если нет problem_description)
// После выбора черновика showDraftSelection = false, поэтому:
// - Шаг 0 = Step1Phone (но мы его пропускаем, т.к. телефон уже верифицирован)
// - Шаг 1 = StepDescription
// - Шаг 2 = StepWizardPlan
let targetStep = 1; // По умолчанию - описание (шаг 1)
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
if (documentsRequired.length > 0) {
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
console.log('✅ documents_required:', documentsRequired.length, 'документов');
} else if (wizardPlan) {
// ✅ СТАРЫЙ ФЛОУ: Если есть wizard_plan - переходим к визарду (шаг 2)
// Пользователь уже описывал проблему, и есть план вопросов
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - СТАРЫЙ ФЛОУ: есть wizard_plan');
console.log('✅ answers в черновике:', answers ? 'есть (показываем заполненную форму)' : 'нет (показываем пустую форму)');
} else if (problemDescription) {
// Если есть описание, но нет плана - переходим к визарду (шаг 2), чтобы получить план
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть описание, план будет получен через SSE');
} else {
// Если нет ничего - переходим к описанию (шаг 1)
targetStep = 1;
console.log('✅ Переходим к StepDescription (шаг 1) - нет описания и плана');
}
console.log('🔍 Устанавливаем currentStep:', targetStep);
// ✅ Устанавливаем isPhoneVerified = true, чтобы пропустить шаг телефона
setIsPhoneVerified(true);
setCurrentStep(targetStep);
} catch (error) {
console.error('Ошибка загрузки черновика:', error);
message.error('Не удалось загрузить черновик');
}
}, [formData, updateFormData]);
// Обработчик выбора черновика
const handleSelectDraft = useCallback((claimId: string) => {
loadDraft(claimId);
}, [loadDraft]);
// Проверка наличия черновиков
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;
}
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
console.log('🔍 Запрос черновиков:', url);
const response = await fetch(url);
console.log('🔍 Статус ответа:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText, errorText);
return false;
}
const data = await response.json();
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('❌ Stack trace:', (error as Error).stack);
return false;
}
}, []);
// Обработчик создания новой заявки
const handleNewClaim = useCallback(() => {
console.log('🆕 Начинаем новое обращение');
console.log('🆕 Текущий currentStep:', currentStep);
console.log('🆕 isPhoneVerified:', isPhoneVerified);
// ✅ Генерируем НОВУЮ сессию для новой жалобы
const newSessionId = 'sess_' + generateUUIDv4();
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
console.log('🆕 Старая сессия:', sessionIdRef.current);
// ✅ Обновляем sessionIdRef на новую сессию
sessionIdRef.current = newSessionId;
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
const savedSessionToken = localStorage.getItem('session_token');
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
setShowDraftSelection(false);
setSelectedDraftId(null);
setHasDrafts(false); // ✅ Сбрасываем флаг наличия черновиков
// ✅ Очищаем данные формы и устанавливаем НОВЫЙ session_id
// unified_id, phone, contact_id остаются прежними - авторизация сохранена!
updateFormData({
session_id: newSessionId, // ✅ Новая сессия для новой жалобы
claim_id: undefined,
problemDescription: undefined,
wizardPlan: undefined,
wizardAnswers: undefined,
wizardPrefill: undefined,
wizardPrefillArray: undefined,
wizardCoverageReport: undefined,
wizardUploads: undefined,
wizardSkippedDocuments: undefined,
eventType: undefined,
// ✅ unified_id, phone, contact_id НЕ очищаем - авторизация сохраняется!
});
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
// ✅ Переходим к шагу описания проблемы
// После сброса флагов черновиков, steps будут:
// Шаг 0 - Phone (уже верифицирован, но в массиве есть)
// Шаг 1 - Description (сюда переходим)
// Шаг 2 - WizardPlan
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
// ✅ Автоматический редирект на экран черновиков после успешной отправки
useEffect(() => {
if (isSubmitted) {
console.log('✅ Обращение успешно отправлено, ждём 2.5 секунды перед редиректом на черновики...');
const redirectTimer = setTimeout(async () => {
console.log('🔄 Выполняем редирект на экран черновиков');
// Проверяем наличие черновиков
const hasDraftsResult = await checkDrafts(
formData.unified_id,
formData.phone,
sessionIdRef.current
);
console.log('🔍 Результат проверки черновиков:', hasDraftsResult);
// Переходим на экран черновиков
setShowDraftSelection(true);
setHasDrafts(hasDraftsResult);
setIsSubmitted(false); // Сбрасываем флаг отправки
setSelectedDraftId(null); // Сбрасываем выбранный черновик
// Переходим на шаг 0 (черновики)
setTimeout(() => {
setCurrentStep(0);
console.log('✅ Переход на экран черновиков выполнен');
}, 100);
}, 2500); // Задержка 2.5 секунды
return () => {
clearTimeout(redirectTimer);
};
}
}, [isSubmitted, formData.unified_id, formData.phone, checkDrafts]);
const handleSubmit = useCallback(async () => {
try {
addDebugEvent('form', 'info', '📤 Отправка заявки в n8n через backend');
const payload = {
stage: 'final',
form_id: 'ticket_form',
session_id: formData.session_id ?? sessionIdRef.current,
client_ip: formData.clientIp,
sms_code: formData.smsCode,
// Базовые идентификаторы
claim_id: formData.claim_id,
contact_id: formData.contact_id,
project_id: formData.project_id,
ticket_id: formData.ticket_id,
is_new_contact: formData.is_new_contact,
is_new_project: formData.is_new_project,
// Основные поля формы (для удобства в n8n)
voucher: formData.voucher,
phone: formData.phone,
email: formData.email,
event_type: formData.eventType,
payment_method: formData.paymentMethod,
bank_id: formData.bankId, // ID банка из NSPK API
bank_name: formData.bankName, // Название банка для отображения
card_number: formData.cardNumber,
account_number: formData.accountNumber,
// Старый блок документов + новые загрузки визарда (пока как есть)
documents: formData.documents || {},
wizard_uploads: formData.wizardUploads || {},
// Всё состояние формы целиком — на всякий случай
form: formData,
};
const response = await fetch('/api/v1/claims/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const text = await response.text();
let parsed: any = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = null;
}
if (!response.ok) {
message.error('Ошибка при создании заявки (n8n)');
addDebugEvent('form', 'error', '❌ Ошибка создания заявки в n8n', {
status: response.status,
body: text,
});
return;
}
addDebugEvent('form', 'success', '✅ Финальный webhook в n8n отработал', {
response: parsed ?? text,
});
// Помечаем, что заявка отправлена, и показываем заглушку.
setIsSubmitted(true);
message.success('Поздравляем! Ваше обращение направлено в Клиентправ.');
} catch (error) {
message.error('Ошибка соединения с сервером');
addDebugEvent('form', 'error', '❌ Ошибка соединения', { error: String(error) });
console.error(error);
}
}, [formData, addDebugEvent]);
// Динамически генерируем шаги на основе выбранного eventType
const steps = useMemo(() => {
const stepsArray: any[] = [];
// Шаг 0: Выбор черновика (показывается только если есть черновики)
// ✅ unified_id уже означает, что телефон верифицирован
// Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts
if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
stepsArray.push({
title: 'Черновики',
description: 'Выбор заявки',
content: (
<StepDraftSelection
phone={formData.phone || ''}
session_id={sessionIdRef.current}
unified_id={formData.unified_id} // ✅ Передаём unified_id
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
onSelectDraft={handleSelectDraft}
onNewClaim={handleNewClaim}
/>
),
});
}
// Шаг 1: Phone (телефон + SMS верификация)
stepsArray.push({
title: 'Вход',
description: 'Подтверждение телефона',
content: (
<Step1Phone
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
updateFormData={(data: any) => {
updateFormData(data);
// ✅ Если n8n вернул session_id, обновляем ref
if (data.session_id && data.session_id !== sessionIdRef.current) {
console.log('🔄 Обновляем sessionIdRef на значение от n8n:', data.session_id);
sessionIdRef.current = data.session_id;
}
// ❌ Убрано: проверка черновиков здесь избыточна, т.к. она уже есть в onNext
}}
onNext={async (unified_id?: string) => {
console.log('🔥 onNext вызван с unified_id:', unified_id);
console.log('🔥 formData.unified_id:', formData.unified_id);
console.log('🔥 isPhoneVerified:', isPhoneVerified);
console.log('🔥 selectedDraftId:', selectedDraftId);
// После верификации проверяем черновики
// Используем unified_id из параметра (если передан) или из formData
const finalUnifiedId = unified_id || formData.unified_id;
console.log('🔥 finalUnifiedId:', finalUnifiedId);
// ✅ Если передан unified_id, значит телефон уже верифицирован (даже если isPhoneVerified ещё false)
// Проверяем черновики, если есть unified_id или телефон верифицирован
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
if (shouldCheckDrafts && !selectedDraftId) {
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);
// ✅ Используем setTimeout для гарантии, что React обновил состояние
setTimeout(() => {
console.log('🔄 Переходим на шаг 0 после установки флагов');
setCurrentStep(0); // Переходим к шагу выбора черновика
}, 100);
console.log('🛑 Остановка выполнения onNext - есть черновики');
return; // ✅ ВАЖНО: Не идём дальше, если есть черновики
} else {
console.log('❌ Нет черновиков, идем дальше к описанию проблемы');
// Нет черновиков - идём дальше
nextStep();
return;
}
} else {
console.log('⚠️ Условие не выполнено для проверки черновиков:', {
shouldCheckDrafts,
selectedDraftId,
finalUnifiedId,
phone: formData.phone,
isPhoneVerified
});
// Условие не выполнено - идём дальше
nextStep();
return;
}
// ❌ ЭТОТ КОД НЕ ДОЛЖЕН ВЫПОЛНЯТЬСЯ, если есть return выше
console.error('❌❌❌ КРИТИЧЕСКАЯ ОШИБКА: nextStep() вызван после return!');
nextStep();
}}
onPrev={prevStep}
isPhoneVerified={isPhoneVerified}
setIsPhoneVerified={(verified: boolean) => {
setIsPhoneVerified(verified);
// ❌ Убрано: проверка черновиков делается только в onNext
// onNext вызывается после успешной верификации и содержит unified_id
}}
addDebugEvent={addDebugEvent}
/>
),
});
// Шаг 2: свободное описание
stepsArray.push({
title: 'Обращение',
description: 'Опишите ситуацию',
content: (
<StepDescription
formData={formData}
updateFormData={updateFormData}
onPrev={prevStep}
onNext={nextStep}
/>
),
});
// Шаг 3: AI Рекомендации
stepsArray.push({
title: 'Документы',
description: 'Загрузка файлов',
content: (
<StepWizardPlan
formData={formData}
updateFormData={updateFormData}
onPrev={prevStep}
onNext={nextStep}
backToDraftsList={backToDraftsList}
addDebugEvent={addDebugEvent}
/>
),
});
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
if (formData.showClaimConfirmation && formData.claimPlanData) {
stepsArray.push({
title: 'Подтверждение',
description: 'Проверка данных',
content: (
<StepClaimConfirmation
claimPlanData={formData.claimPlanData}
contact_data_confirmed={formData.contact_data_confirmed}
onPrev={prevStep}
onNext={nextStep}
onSubmitted={() => setIsSubmitted(true)}
/>
),
});
}
// Step3Payment убран - не используется
return stepsArray;
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
const handleReset = () => {
console.log('🔄 Начать заново - возврат к списку черновиков');
// ✅ Генерируем новую сессию для новой заявки (но сохраняем авторизацию)
const newSessionId = 'sess_' + generateUUIDv4();
sessionIdRef.current = newSessionId;
setIsSubmitted(false);
setShowDraftSelection(false);
setSelectedDraftId(null);
// ✅ Очищаем данные формы, НО сохраняем авторизацию (unified_id, phone, contact_id, isPhoneVerified)
updateFormData({
session_id: newSessionId,
claim_id: undefined,
voucher: '',
paymentMethod: 'sbp',
problemDescription: undefined,
wizardPlan: undefined,
wizardAnswers: undefined,
wizardPrefill: undefined,
wizardPrefillArray: undefined,
wizardCoverageReport: undefined,
wizardUploads: undefined,
wizardSkippedDocuments: undefined,
eventType: undefined,
// ✅ unified_id, phone, contact_id, isPhoneVerified НЕ очищаем
});
// ✅ Проверяем черновики и возвращаемся к списку
if (formData.unified_id && hasDrafts) {
console.log('🔄 Есть черновики - показываем список');
setShowDraftSelection(true);
setCurrentStep(0);
} else {
console.log('🔄 Нет черновиков - переходим к новой заявке');
setCurrentStep(1); // StepDescription
}
message.info('Форма сброшена');
addDebugEvent('system', 'info', '🔄 Форма сброшена');
};
// Обработчик кнопки "Выход"
const handleExitToList = useCallback(async () => {
console.log('🚪 Выход из системы');
addDebugEvent('system', 'info', '🚪 Выход из системы');
// ✅ В Telegram Mini App — просто закрываем приложение
if (isTelegramMiniApp) {
try {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
if (webApp && typeof webApp.close === 'function') {
webApp.close();
}
} catch (error) {
console.warn('⚠️ Ошибка при закрытии Telegram Mini App:', error);
}
return;
}
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
// Получаем session_token из localStorage
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
if (sessionToken) {
try {
// Вызываем API logout для удаления сессии из Redis
const response = await fetch('/api/v1/session/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
});
if (response.ok) {
console.log('✅ Сессия удалена из Redis');
addDebugEvent('session', 'success', '✅ Сессия завершена');
}
} catch (error) {
console.warn('⚠️ Ошибка при завершении сессии:', error);
}
}
// Удаляем session_token из localStorage
localStorage.removeItem('session_token');
// Полный сброс: очищаем все данные авторизации и черновиков
setIsSubmitted(false);
setShowDraftSelection(false);
setHasDrafts(false);
setSelectedDraftId(null);
// Генерируем новую сессию для нового пользователя
const newSessionId = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
sessionIdRef.current = newSessionId;
// Полностью очищаем formData, включая unified_id и phone
setFormData({
voucher: '',
claim_id: undefined,
session_id: newSessionId,
paymentMethod: 'sbp',
unified_id: undefined,
phone: undefined,
contact_id: undefined,
is_new_contact: undefined,
isPhoneVerified: false,
});
// Сбрасываем флаг верификации телефона
setIsPhoneVerified(false);
// Переходим на экран входа (Step1Phone)
setCurrentStep(0);
message.info('Сессия завершена. До свидания!');
addDebugEvent('system', 'info', '🔄 Форма сброшена');
}, [formData.session_id, addDebugEvent, isTelegramMiniApp]);
// ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии
if (!telegramAuthChecked || !sessionRestored) {
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
<Row gutter={16}>
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
<Card
title="Подать обращение о защите прав потребителя"
className="claim-form-card"
extra={
!isSubmitted && (
<Space>
{/* Кнопка "Выход" - показываем если телефон верифицирован */}
{isPhoneVerified && (
<button
onClick={handleExitToList}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #ff4d4f',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#ff4d4f'
}}
>
🚪 Выход
</button>
)}
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
{currentStep > 0 && (
<button
onClick={handleReset}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
🔄 Начать заново
</button>
)}
</Space>
)
}
>
{isSubmitted ? (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
<p style={{ color: '#666666', maxWidth: 480, margin: '0 auto 24px' }}>
В ближайшее время на указанную Вами электронную почту поступит письмо, подтверждающее регистрацию вашего обращения.
</p>
</div>
) : (
<>
<Steps current={currentStep} className="steps">
{steps.map((item, index) => (
<Step
key={`step-${index}`}
title={item.title}
description={item.description}
/>
))}
</Steps>
<div className="steps-content">
{steps[currentStep] ? steps[currentStep].content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<p>Загрузка шага...</p>
</div>
)}
</div>
</>
)}
</Card>
</Col>
{/* Правая часть - Debug консоль (только в dev режиме) */}
{process.env.NODE_ENV === 'development' && (
<Col xs={24} lg={10}>
<DebugPanel events={debugEvents} formData={formData} />
</Col>
)}
</Row>
</div>
);
}