Production fixes: n8n workflow auto-restart, user-friendly messages, fixed navigation buttons
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Claims API Routes - Обработка заявок
|
Claims API Routes - Обработка заявок
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Request, Query
|
from fastapi import APIRouter, HTTPException, Request, Query, BackgroundTasks
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import httpx
|
import httpx
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -16,6 +16,7 @@ import logging
|
|||||||
from ..services.redis_service import redis_service
|
from ..services.redis_service import redis_service
|
||||||
from ..services.database import db
|
from ..services.database import db
|
||||||
from ..services.crm_mysql_service import crm_mysql_service
|
from ..services.crm_mysql_service import crm_mysql_service
|
||||||
|
from ..services.n8n_service import check_workflow_status, restart_workflow, MIN_RESTART_INTERVAL
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||||
@@ -907,8 +908,55 @@ async def load_wizard_data(claim_id: str):
|
|||||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_and_restart_workflow_if_needed(channel: str):
|
||||||
|
"""
|
||||||
|
Проверяет и перезапускает workflow если нужно (в фоне)
|
||||||
|
Защита от частых перезапусков через Redis lock
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем lock - если недавно перезапускали, пропускаем
|
||||||
|
lock_key = f"workflow_restart_lock:{channel}"
|
||||||
|
lock_value = await redis_service.get(lock_key)
|
||||||
|
|
||||||
|
if lock_value:
|
||||||
|
logger.info(f"⏸️ Workflow недавно перезапускался, пропускаем (lock active)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем статус workflow
|
||||||
|
workflow_data = await check_workflow_status()
|
||||||
|
|
||||||
|
if workflow_data:
|
||||||
|
is_active = workflow_data.get("active", False)
|
||||||
|
if not is_active:
|
||||||
|
logger.warning(f"⚠️ Workflow НЕ активен! Активирую и перезапускаю...")
|
||||||
|
# Workflow выключен — нужно его ВКЛЮЧИТЬ
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"⚠️ Workflow активен, но нет подписчиков. Перезапускаю workflow..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Устанавливаем lock на MIN_RESTART_INTERVAL секунд
|
||||||
|
await redis_service.set(lock_key, "1", expire=MIN_RESTART_INTERVAL)
|
||||||
|
|
||||||
|
# Перезапускаем
|
||||||
|
success = await restart_workflow()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info("✅ Workflow успешно перезапущен")
|
||||||
|
else:
|
||||||
|
logger.error("❌ Не удалось перезапустить workflow")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Не удалось проверить статус workflow, пропускаем перезапуск")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"❌ Ошибка при проверке/перезапуске workflow: {e}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/description")
|
@router.post("/description")
|
||||||
async def publish_ticket_form_description(payload: TicketFormDescriptionRequest):
|
async def publish_ticket_form_description(
|
||||||
|
payload: TicketFormDescriptionRequest,
|
||||||
|
background_tasks: BackgroundTasks
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Публикует свободное описание проблемы в Redis канал ticket_form:description
|
Публикует свободное описание проблемы в Redis канал ticket_form:description
|
||||||
(слушается воркфлоу в n8n)
|
(слушается воркфлоу в n8n)
|
||||||
@@ -969,8 +1017,21 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"⚠️ WARNING: No subscribers on channel {channel}! "
|
f"⚠️ WARNING: No subscribers on channel {channel}! "
|
||||||
f"n8n workflow is not listening to this channel. "
|
f"n8n workflow is not listening to this channel. "
|
||||||
f"Event was published but will be lost."
|
f"Saving message to buffer and restarting workflow..."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Сохраняем сообщение в буфер для последующей отправки
|
||||||
|
buffer_message = {
|
||||||
|
"session_id": payload.session_id,
|
||||||
|
"claim_id": payload.claim_id,
|
||||||
|
"event": event,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
await redis_service.buffer_push("description", buffer_message)
|
||||||
|
logger.info(f"💾 Сообщение сохранено в буфер: session_id={payload.session_id}")
|
||||||
|
|
||||||
|
# Запускаем проверку и перезапуск workflow в фоне
|
||||||
|
background_tasks.add_task(_check_and_restart_workflow_if_needed, channel)
|
||||||
|
|
||||||
# Дополнительная проверка: логируем полный event для отладки
|
# Дополнительная проверка: логируем полный event для отладки
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -980,11 +1041,27 @@ async def publish_ticket_form_description(payload: TicketFormDescriptionRequest)
|
|||||||
"event": event,
|
"event": event,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return {
|
# Формируем ответ с информацией о подписчиках
|
||||||
|
response_data = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"channel": channel,
|
"channel": channel,
|
||||||
|
"subscribers_count": subscribers_count,
|
||||||
"event": event,
|
"event": event,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Если подписчиков нет - сообщаем что обработка займёт больше времени
|
||||||
|
if subscribers_count == 0:
|
||||||
|
buffer_size = await redis_service.buffer_size("description")
|
||||||
|
response_data["warning"] = (
|
||||||
|
"Обработка вашего обращения займёт немного больше времени. "
|
||||||
|
"Идёт автоматическое восстановление системы. "
|
||||||
|
"Ваше сообщение сохранено и будет обработано в ближайшее время."
|
||||||
|
)
|
||||||
|
response_data["workflow_recovering"] = True
|
||||||
|
response_data["message_buffered"] = True
|
||||||
|
response_data["buffer_size"] = buffer_size
|
||||||
|
|
||||||
|
return response_data
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("❌ Failed to publish ticket form description")
|
logger.exception("❌ Failed to publish ticket form description")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -171,8 +171,10 @@ class Settings(BaseSettings):
|
|||||||
return self.cors_origins
|
return self.cors_origins
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# N8N WEBHOOKS (скрыты от фронтенда)
|
# N8N API & WEBHOOKS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
n8n_url: str = "https://n8n.clientright.pro"
|
||||||
|
n8n_api_key: str = "" # Нужно задать в .env
|
||||||
n8n_policy_check_webhook: str = ""
|
n8n_policy_check_webhook: str = ""
|
||||||
n8n_file_upload_webhook: str = ""
|
n8n_file_upload_webhook: str = ""
|
||||||
n8n_create_contact_webhook: str = ""
|
n8n_create_contact_webhook: str = ""
|
||||||
|
|||||||
179
backend/app/services/n8n_service.py
Normal file
179
backend/app/services/n8n_service.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"""
|
||||||
|
Сервис для работы с n8n API
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import settings
|
||||||
|
from ..services.redis_service import redis_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Workflow ID для ticket_form:description
|
||||||
|
WORKFLOW_ID = "b4K4u851b4JFivyD"
|
||||||
|
N8N_URL = "https://n8n.clientright.pro"
|
||||||
|
MIN_RESTART_INTERVAL = 300 # Минимум 5 минут между перезапусками
|
||||||
|
|
||||||
|
|
||||||
|
async def check_workflow_status() -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Проверка статуса workflow через n8n API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с данными workflow или None при ошибке
|
||||||
|
"""
|
||||||
|
if not settings.n8n_api_key:
|
||||||
|
logger.warning("⚠️ N8N_API_KEY не настроен")
|
||||||
|
return None
|
||||||
|
|
||||||
|
headers = _get_headers()
|
||||||
|
if not headers:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ n8n API вернул статус {response.status_code}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при проверке статуса workflow: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def restart_workflow() -> bool:
|
||||||
|
"""
|
||||||
|
Перезапуск workflow через n8n API
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно, False при ошибке
|
||||||
|
"""
|
||||||
|
if not settings.n8n_api_key:
|
||||||
|
logger.error("❌ N8N_API_KEY не настроен! Не могу перезапустить workflow")
|
||||||
|
return False
|
||||||
|
|
||||||
|
headers = _get_headers()
|
||||||
|
if not headers:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
# Шаг 1: Деактивировать workflow
|
||||||
|
logger.info(f"🔄 Деактивирую workflow {WORKFLOW_ID}...")
|
||||||
|
deactivate_response = await client.post(
|
||||||
|
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/deactivate",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if deactivate_response.status_code not in [200, 404]:
|
||||||
|
logger.warning(
|
||||||
|
f"⚠️ Неожиданный статус при деактивации: "
|
||||||
|
f"{deactivate_response.status_code}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("✅ Workflow деактивирован")
|
||||||
|
|
||||||
|
# Задержка перед активацией
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# Шаг 2: Активировать workflow
|
||||||
|
logger.info(f"🔄 Активирую workflow {WORKFLOW_ID}...")
|
||||||
|
activate_response = await client.post(
|
||||||
|
f"{N8N_URL}/api/v1/workflows/{WORKFLOW_ID}/activate",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if activate_response.status_code == 200:
|
||||||
|
logger.info("✅ Workflow активирован")
|
||||||
|
|
||||||
|
# После успешного перезапуска отправляем сообщения из буфера
|
||||||
|
await _send_buffered_messages()
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
f"❌ Ошибка активации workflow: "
|
||||||
|
f"{activate_response.status_code} - {activate_response.text[:200]}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Неожиданная ошибка при перезапуске workflow: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_buffered_messages():
|
||||||
|
"""
|
||||||
|
Отправить все сообщения из буфера после восстановления workflow
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
buffer_key = "description" # Буфер для ticket_form:description
|
||||||
|
messages = await redis_service.buffer_get_all(buffer_key)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
logger.info("📭 Буфер пуст, нечего отправлять")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"📤 Отправляю {len(messages)} сообщений из буфера...")
|
||||||
|
|
||||||
|
import json
|
||||||
|
channel = f"{settings.redis_prefix}description"
|
||||||
|
sent_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
event_json = json.dumps(message.get("event", message), ensure_ascii=False)
|
||||||
|
subscribers = await redis_service.publish(channel, event_json)
|
||||||
|
|
||||||
|
if subscribers > 0:
|
||||||
|
sent_count += 1
|
||||||
|
logger.info(
|
||||||
|
f"✅ Буферированное сообщение отправлено: "
|
||||||
|
f"session_id={message.get('session_id', 'unknown')}, "
|
||||||
|
f"subscribers={subscribers}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
logger.warning(
|
||||||
|
f"⚠️ Буферированное сообщение не доставлено "
|
||||||
|
f"(подписчиков нет): session_id={message.get('session_id', 'unknown')}"
|
||||||
|
)
|
||||||
|
# Возвращаем обратно в буфер если не доставлено
|
||||||
|
await redis_service.buffer_push(buffer_key, message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed_count += 1
|
||||||
|
logger.error(f"❌ Ошибка отправки буферизованного сообщения: {e}")
|
||||||
|
# Возвращаем обратно в буфер
|
||||||
|
await redis_service.buffer_push(buffer_key, message)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"📊 Результат отправки буфера: {sent_count} отправлено, {failed_count} не доставлено"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"❌ Ошибка при отправке буферизованных сообщений: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_headers() -> Optional[dict]:
|
||||||
|
"""Получить заголовки для n8n API"""
|
||||||
|
if not settings.n8n_api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
api_key = settings.n8n_api_key
|
||||||
|
|
||||||
|
# Убираем "Bearer " если есть - n8n API использует X-N8N-API-KEY
|
||||||
|
clean_key = api_key.replace("Bearer ", "").strip()
|
||||||
|
|
||||||
|
# n8n API принимает ключ в заголовке X-N8N-API-KEY
|
||||||
|
return {"X-N8N-API-KEY": clean_key}
|
||||||
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
Redis Service для кеширования, rate limiting, сессий
|
Redis Service для кеширования, rate limiting, сессий
|
||||||
"""
|
"""
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, List
|
||||||
import json
|
import json
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
import logging
|
import logging
|
||||||
@@ -155,6 +155,58 @@ class RedisService:
|
|||||||
async def cache_delete(self, cache_key: str):
|
async def cache_delete(self, cache_key: str):
|
||||||
"""Удалить из кеша"""
|
"""Удалить из кеша"""
|
||||||
await self.delete(f"cache:{cache_key}")
|
await self.delete(f"cache:{cache_key}")
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# MESSAGE BUFFER (для буферизации сообщений при недоступности workflow)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
async def buffer_push(self, buffer_key: str, message: dict):
|
||||||
|
"""
|
||||||
|
Добавить сообщение в буфер (очередь)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer_key: Имя буфера (например, "description")
|
||||||
|
message: Сообщение для буферизации
|
||||||
|
"""
|
||||||
|
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
|
||||||
|
await self.client.lpush(full_key, json.dumps(message))
|
||||||
|
# Устанавливаем TTL на буфер (24 часа)
|
||||||
|
await self.client.expire(full_key, 86400)
|
||||||
|
|
||||||
|
async def buffer_get_all(self, buffer_key: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Получить все сообщения из буфера (и очистить буфер)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer_key: Имя буфера
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список сообщений
|
||||||
|
"""
|
||||||
|
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
|
||||||
|
|
||||||
|
# Используем транзакцию для атомарности
|
||||||
|
pipe = self.client.pipeline()
|
||||||
|
pipe.lrange(full_key, 0, -1) # Получить все
|
||||||
|
pipe.delete(full_key) # Удалить буфер
|
||||||
|
results = await pipe.execute()
|
||||||
|
|
||||||
|
messages_data = results[0] if results else []
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for msg_str in messages_data:
|
||||||
|
try:
|
||||||
|
messages.append(json.loads(msg_str))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"⚠️ Не удалось распарсить сообщение из буфера: {msg_str}")
|
||||||
|
|
||||||
|
# Возвращаем в правильном порядке (FIFO - сначала старые)
|
||||||
|
return list(reversed(messages))
|
||||||
|
|
||||||
|
async def buffer_size(self, buffer_key: str) -> int:
|
||||||
|
"""Получить размер буфера"""
|
||||||
|
full_key = f"{settings.redis_prefix}buffer:{buffer_key}"
|
||||||
|
return await self.client.llen(full_key)
|
||||||
|
|
||||||
|
|
||||||
# Глобальный экземпляр
|
# Глобальный экземпляр
|
||||||
|
|||||||
36
frontend/Dockerfile.prod
Normal file
36
frontend/Dockerfile.prod
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# React Frontend Dockerfile (PRODUCTION BUILD)
|
||||||
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
|
# Устанавливаем рабочую директорию
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем package.json
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Копируем исходный код
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Собираем production build
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Устанавливаем serve глобально
|
||||||
|
RUN npm install -g serve
|
||||||
|
|
||||||
|
# Копируем собранное приложение из builder stage
|
||||||
|
COPY --from=builder /app/dist /app/dist
|
||||||
|
|
||||||
|
# Устанавливаем рабочую директорию
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Открываем порт
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Запускаем serve для раздачи статических файлов
|
||||||
|
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
|||||||
@@ -49,9 +49,7 @@ export default function Step1Phone({
|
|||||||
message.success('Код отправлен на ваш телефон');
|
message.success('Код отправлен на ваш телефон');
|
||||||
setCodeSent(true);
|
setCodeSent(true);
|
||||||
updateFormData({ phone });
|
updateFormData({ phone });
|
||||||
if (result.debug_code) {
|
// DEBUG код не показываем в продакшене
|
||||||
message.info(`DEBUG: Код ${result.debug_code}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
|
addDebugEvent?.('sms', 'error', `❌ Ошибка SMS: ${result.detail}`, { error: result.detail });
|
||||||
message.error(result.detail || 'Ошибка отправки кода');
|
message.error(result.detail || 'Ошибка отправки кода');
|
||||||
@@ -336,38 +334,7 @@ export default function Step1Phone({
|
|||||||
)}
|
)}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 🔧 Технические кнопки для разработки */}
|
{/* DEV MODE секция удалена для продакшена */}
|
||||||
<div style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '2px dashed #999'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
|
||||||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
// Автозаполняем телефон и email
|
|
||||||
const devData = {
|
|
||||||
phone: '79001234567', // БЕЗ +
|
|
||||||
email: 'test@test.ru',
|
|
||||||
};
|
|
||||||
updateFormData(devData);
|
|
||||||
setIsPhoneVerified(true);
|
|
||||||
message.success('DEV: Телефон автоматически подтверждён');
|
|
||||||
onNext();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Далее → (Step 2) [пропустить]
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -656,36 +656,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 🔧 Технические кнопки для разработки */}
|
{/* DEV MODE секция удалена для продакшена */}
|
||||||
<div style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '2px dashed #999'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
|
||||||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
// Пропускаем валидацию, заполняем минимальные данные
|
|
||||||
const devData = {
|
|
||||||
voucher: 'E1000-123456789',
|
|
||||||
claim_id: `CLM-DEV-${Math.random().toString(36).substr(2, 6).toUpperCase()}`,
|
|
||||||
};
|
|
||||||
updateFormData(devData);
|
|
||||||
onNext();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Далее → (Step 2) [пропустить]
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -593,45 +593,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 🔧 Технические кнопки для разработки */}
|
{/* DEV MODE секция удалена для продакшена */}
|
||||||
<div style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '2px dashed #999'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
|
||||||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
onClick={onPrev}
|
|
||||||
size="small"
|
|
||||||
disabled={uploading}
|
|
||||||
>
|
|
||||||
← Назад (Step 1)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
const devData = {
|
|
||||||
eventType: 'delay_flight',
|
|
||||||
processedDocuments: {
|
|
||||||
boarding_or_ticket: { flight_number: 'DEV123', date: '2025-10-28' },
|
|
||||||
delay_confirmation: { delay_duration: '4h' }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
updateFormData(devData);
|
|
||||||
onNext();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Далее → (Step 3) [пропустить]
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,39 +147,7 @@ const Step2EventType: React.FC<Props> = ({ formData, updateFormData, onNext, onP
|
|||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{/* 🔧 DEV MODE */}
|
{/* DEV MODE секция удалена для продакшена */}
|
||||||
<div style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '2px dashed #999'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
|
||||||
🔧 DEV MODE - Быстрая навигация
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
onClick={onPrev}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
← Назад (Step 1)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
const devData = { eventType: 'cancel_flight' };
|
|
||||||
form.setFieldsValue(devData);
|
|
||||||
updateFormData(devData);
|
|
||||||
onNext();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
Далее → [Отмена рейса]
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -487,67 +487,7 @@ export default function Step3Payment({
|
|||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* 🔧 Технические кнопки для разработки */}
|
{/* DEV MODE секция удалена для продакшена */}
|
||||||
<div style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '2px dashed #999'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
|
||||||
🔧 DEV MODE - Быстрая навигация (без валидации)
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
onClick={onPrev}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
← Назад (Step 2)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
// Пропускаем валидацию телефона
|
|
||||||
setIsPhoneVerified(true);
|
|
||||||
const devData = {
|
|
||||||
fullName: 'Тест Тестов',
|
|
||||||
email: 'test@test.ru',
|
|
||||||
phone: '+79991234567',
|
|
||||||
paymentMethod: 'sbp',
|
|
||||||
bankId: banks.length > 0 ? banks[0].bankid : '100000000111', // Сбербанк по умолчанию
|
|
||||||
bankName: banks.length > 0 ? banks[0].bankname : 'Сбербанк',
|
|
||||||
};
|
|
||||||
updateFormData(devData);
|
|
||||||
message.success('DEV: Телефон автоматически подтверждён');
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
✅ Автоподтверждение телефона [dev]
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
onClick={() => {
|
|
||||||
// Автоматически отправляем заявку
|
|
||||||
setIsPhoneVerified(true);
|
|
||||||
const devData = {
|
|
||||||
fullName: 'Тест Тестов',
|
|
||||||
email: 'test@test.ru',
|
|
||||||
phone: '+79991234567',
|
|
||||||
paymentMethod: 'sbp',
|
|
||||||
bankId: banks.length > 0 ? banks[0].bankid : '100000000111', // Сбербанк по умолчанию
|
|
||||||
bankName: banks.length > 0 ? banks[0].bankname : 'Сбербанк',
|
|
||||||
};
|
|
||||||
updateFormData(devData);
|
|
||||||
onSubmit();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
🚀 Отправить [пропустить]
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export default function StepDescription({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [useMockWizard, setUseMockWizard] = useState(true);
|
// В проде всегда false, в dev - true для тестирования
|
||||||
|
const [useMockWizard, setUseMockWizard] = useState(process.env.NODE_ENV === 'development');
|
||||||
|
|
||||||
const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
|
const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
|
||||||
if (!prefill) {
|
if (!prefill) {
|
||||||
@@ -189,27 +190,30 @@ export default function StepDescription({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div
|
{/* DEV чекбокс - только в dev режиме */}
|
||||||
style={{
|
{process.env.NODE_ENV === 'development' && (
|
||||||
marginTop: 12,
|
<div
|
||||||
padding: 12,
|
style={{
|
||||||
borderRadius: 8,
|
marginTop: 12,
|
||||||
background: '#fafafa',
|
padding: 12,
|
||||||
border: '1px dashed #d9d9d9',
|
borderRadius: 8,
|
||||||
}}
|
background: '#fafafa',
|
||||||
>
|
border: '1px dashed #d9d9d9',
|
||||||
<Checkbox
|
}}
|
||||||
checked={useMockWizard}
|
|
||||||
onChange={(e) => setUseMockWizard(e.target.checked)}
|
|
||||||
>
|
>
|
||||||
Использовать сохранённые рекомендации (DEV)
|
<Checkbox
|
||||||
</Checkbox>
|
checked={useMockWizard}
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
onChange={(e) => setUseMockWizard(e.target.checked)}
|
||||||
Если включено, план вопросов берётся из локального файла и не запускает модель.
|
>
|
||||||
</Paragraph>
|
Использовать сохранённые рекомендации (DEV)
|
||||||
</div>
|
</Checkbox>
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
|
||||||
|
Если включено, план вопросов берётся из локального файла и не запускает модель.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
|
||||||
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
|
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
|
||||||
Продолжить →
|
Продолжить →
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -318,53 +318,7 @@ const StepDocumentUpload: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🔧 DEV MODE */}
|
{/* DEV MODE секция удалена для продакшена */}
|
||||||
<div style={{
|
|
||||||
marginTop: 24,
|
|
||||||
padding: 16,
|
|
||||||
background: '#f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
border: '2px dashed #999'
|
|
||||||
}}>
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#666', fontWeight: 'bold' }}>
|
|
||||||
🔧 DEV MODE - Быстрая навигация
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
console.log('🔙 DEV Кнопка Назад нажата');
|
|
||||||
onPrev();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
disabled={false}
|
|
||||||
>
|
|
||||||
← Назад
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
onClick={() => {
|
|
||||||
console.log('⏭️ DEV Пропустить нажата');
|
|
||||||
// Эмулируем загрузку документа
|
|
||||||
const updatedDocuments = {
|
|
||||||
...(formData.documents || {}),
|
|
||||||
[documentConfig.file_type]: {
|
|
||||||
uploaded: true,
|
|
||||||
data: { test: 'dev_mode_skip' },
|
|
||||||
file_type: documentConfig.file_type
|
|
||||||
}
|
|
||||||
};
|
|
||||||
updateFormData({ documents: updatedDocuments });
|
|
||||||
message.success('DEV: Документ пропущен');
|
|
||||||
onNext();
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
disabled={false}
|
|
||||||
>
|
|
||||||
Пропустить [dev] →
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Модалка обработки */}
|
{/* Модалка обработки */}
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export default function StepWizardPlan({
|
|||||||
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
|
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
|
||||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
|
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
|
||||||
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
||||||
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
||||||
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
||||||
@@ -391,17 +392,17 @@ export default function StepWizardPlan({
|
|||||||
const sessionId = formData.session_id;
|
const sessionId = formData.session_id;
|
||||||
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
|
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
sse_url: `/events/${sessionId}`,
|
sse_url: `/api/v1/events/${sessionId}`,
|
||||||
redis_channel: `ocr_events:${sessionId}`,
|
redis_channel: `ocr_events:${sessionId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const source = new EventSource(`/events/${sessionId}`);
|
const source = new EventSource(`/api/v1/events/${sessionId}`);
|
||||||
eventSourceRef.current = source;
|
eventSourceRef.current = source;
|
||||||
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
|
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
|
||||||
|
|
||||||
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
|
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
|
setConnectionError('Обработка занимает больше времени, чем обычно. Пожалуйста, попробуйте ещё раз.');
|
||||||
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
|
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
@@ -461,6 +462,27 @@ export default function StepWizardPlan({
|
|||||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ❌ OUT OF SCOPE: Вопрос не связан с защитой прав потребителей
|
||||||
|
if (eventType === 'out_of_scope') {
|
||||||
|
debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос вне скоупа', {
|
||||||
|
session_id: sessionId,
|
||||||
|
message: payload.message,
|
||||||
|
suggested_actions: payload.suggested_actions,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsWaiting(false);
|
||||||
|
setOutOfScopeData(payload); // Сохраняем полные данные
|
||||||
|
setConnectionError(null); // Не используем connectionError
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
source.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
||||||
if (eventType === 'documents_list_ready') {
|
if (eventType === 'documents_list_ready') {
|
||||||
const documentsRequired = payload.documents_required || [];
|
const documentsRequired = payload.documents_required || [];
|
||||||
@@ -2379,7 +2401,7 @@ export default function StepWizardPlan({
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
||||||
{!hasNewFlowDocs && isWaiting && (
|
{!hasNewFlowDocs && isWaiting && !outOfScopeData && (
|
||||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
<img
|
<img
|
||||||
src={AiWorkingIllustration}
|
src={AiWorkingIllustration}
|
||||||
@@ -2411,6 +2433,117 @@ export default function StepWizardPlan({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
|
||||||
|
{outOfScopeData && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<div style={{
|
||||||
|
background: '#fff7e6',
|
||||||
|
border: '1px solid #ffd591',
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 24,
|
||||||
|
maxWidth: 600,
|
||||||
|
margin: '0 auto'
|
||||||
|
}}>
|
||||||
|
<Title level={4} style={{ color: '#d48806', marginBottom: 16 }}>
|
||||||
|
⚠️ К сожалению, мы не можем помочь с этим вопросом
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ fontSize: 16, marginBottom: 16 }}>
|
||||||
|
{outOfScopeData.message || outOfScopeData.reason}
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
{outOfScopeData.ticket && (
|
||||||
|
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||||
|
Ваш запрос: <strong>{outOfScopeData.ticket}</strong>
|
||||||
|
{outOfScopeData.ticket_number && ` (№${outOfScopeData.ticket_number})`}
|
||||||
|
</Paragraph>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outOfScopeData.suggested_actions && outOfScopeData.suggested_actions.length > 0 && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<Paragraph strong style={{ marginBottom: 12 }}>Что можно сделать:</Paragraph>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
{outOfScopeData.suggested_actions.map((action: any, index: number) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
size="small"
|
||||||
|
style={{ textAlign: 'left', background: '#fafafa' }}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>{action.title}</div>
|
||||||
|
<div style={{ color: '#666', fontSize: 14 }}>{action.description}</div>
|
||||||
|
{action.actionType === 'external_link' && action.url && (
|
||||||
|
<a
|
||||||
|
href={action.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ marginTop: 8, display: 'inline-block' }}
|
||||||
|
>
|
||||||
|
{action.urlText || 'Перейти →'}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{action.actionType === 'contact_support' && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
style={{ marginTop: 8, padding: 0 }}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
message.loading('Отправляем запрос в поддержку...', 0);
|
||||||
|
await fetch('https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: formData.session_id,
|
||||||
|
phone: formData.phone,
|
||||||
|
email: formData.email,
|
||||||
|
unified_id: formData.unified_id,
|
||||||
|
ticket_number: outOfScopeData.ticket_number,
|
||||||
|
ticket: outOfScopeData.ticket,
|
||||||
|
reason: outOfScopeData.reason,
|
||||||
|
message: outOfScopeData.message,
|
||||||
|
action: 'contact_support',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.destroy();
|
||||||
|
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||||
|
// Возвращаемся на главную через перезагрузку
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
message.destroy();
|
||||||
|
message.error('Не удалось отправить запрос. Попробуйте позже.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Связаться с поддержкой →
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<Button onClick={onPrev} style={{ marginRight: 12 }}>
|
||||||
|
← Изменить описание
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => {
|
||||||
|
// Сбрасываем состояние и возвращаемся на первый экран
|
||||||
|
updateFormData({
|
||||||
|
wizardPlan: null,
|
||||||
|
wizardPlanStatus: null,
|
||||||
|
problemDescription: '',
|
||||||
|
});
|
||||||
|
window.location.href = '/';
|
||||||
|
}}>
|
||||||
|
Новое обращение
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* СТАРЫЙ ФЛОУ: Визард готов */}
|
{/* СТАРЫЙ ФЛОУ: Визард готов */}
|
||||||
{!hasNewFlowDocs && !isWaiting && plan && (
|
{!hasNewFlowDocs && !isWaiting && plan && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { Steps, Card, message, Row, Col, Space } from 'antd';
|
import { Steps, Card, message, Row, Col, Space } from 'antd';
|
||||||
import Step1Phone from '../components/form/Step1Phone';
|
import Step1Phone from '../components/form/Step1Phone';
|
||||||
import StepDescription from '../components/form/StepDescription';
|
import StepDescription from '../components/form/StepDescription';
|
||||||
import Step1Policy from '../components/form/Step1Policy';
|
// Step1Policy убран - старый ERV флоу
|
||||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||||
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||||
import Step2EventType from '../components/form/Step2EventType';
|
// Step2EventType, StepDocumentUpload убраны - старый ERV флоу
|
||||||
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
|
||||||
import Step3Payment from '../components/form/Step3Payment';
|
import Step3Payment from '../components/form/Step3Payment';
|
||||||
import DebugPanel from '../components/DebugPanel';
|
import DebugPanel from '../components/DebugPanel';
|
||||||
import { getDocumentsForEventType } from '../constants/documentConfigs';
|
// getDocumentsForEventType убран - старый ERV флоу
|
||||||
import './ClaimForm.css';
|
import './ClaimForm.css';
|
||||||
|
|
||||||
// Используем относительные пути - Vite proxy перенаправит на backend
|
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||||
@@ -244,9 +243,7 @@ export default function ClaimForm() {
|
|||||||
}, [formData.showClaimConfirmation, formData.claimPlanData]);
|
}, [formData.showClaimConfirmation, formData.claimPlanData]);
|
||||||
|
|
||||||
|
|
||||||
// Динамически определяем список шагов на основе выбранного eventType
|
// Старый ERV флоу убран - documentConfigs больше не нужен
|
||||||
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
|
|
||||||
const totalDocumentSteps = documentConfigs.length;
|
|
||||||
|
|
||||||
const addDebugEvent = (type: string, status: string, message: string, data?: any) => {
|
const addDebugEvent = (type: string, status: string, message: string, data?: any) => {
|
||||||
const event = {
|
const event = {
|
||||||
@@ -1206,12 +1203,7 @@ export default function ClaimForm() {
|
|||||||
<StepWizardPlan
|
<StepWizardPlan
|
||||||
formData={formData}
|
formData={formData}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
onPrev={() => {
|
onPrev={prevStep}
|
||||||
// Возвращаемся к списку заявок
|
|
||||||
setShowDraftSelection(true);
|
|
||||||
setSelectedDraftId(null);
|
|
||||||
setCurrentStep(0);
|
|
||||||
}}
|
|
||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
addDebugEvent={addDebugEvent}
|
addDebugEvent={addDebugEvent}
|
||||||
/>
|
/>
|
||||||
@@ -1235,63 +1227,6 @@ export default function ClaimForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаги для СТАРОГО флоу (страхование ERV) — НЕ показываем для нового флоу защиты прав
|
|
||||||
const isNewClaimFlow = formData.documents_required && formData.documents_required.length > 0;
|
|
||||||
|
|
||||||
if (!isNewClaimFlow) {
|
|
||||||
// Шаг 3: Policy (только для старого флоу)
|
|
||||||
stepsArray.push({
|
|
||||||
title: 'Проверка полиса',
|
|
||||||
description: 'Полис ERV',
|
|
||||||
content: (
|
|
||||||
<Step1Policy
|
|
||||||
formData={{ ...formData, session_id: sessionIdRef.current }}
|
|
||||||
updateFormData={updateFormData}
|
|
||||||
onNext={nextStep}
|
|
||||||
addDebugEvent={addDebugEvent}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Шаг 4: Event Type Selection (только для старого флоу)
|
|
||||||
stepsArray.push({
|
|
||||||
title: 'Тип события',
|
|
||||||
description: 'Выбор случая',
|
|
||||||
content: (
|
|
||||||
<Step2EventType
|
|
||||||
formData={formData}
|
|
||||||
updateFormData={updateFormData}
|
|
||||||
onNext={nextStep}
|
|
||||||
onPrev={prevStep}
|
|
||||||
addDebugEvent={addDebugEvent}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Шаги Document Upload (только для старого флоу — если выбран eventType)
|
|
||||||
if (!isNewClaimFlow && formData.eventType && documentConfigs.length > 0) {
|
|
||||||
documentConfigs.forEach((docConfig, index) => {
|
|
||||||
stepsArray.push({
|
|
||||||
title: `Документ ${index + 1}`,
|
|
||||||
description: docConfig.name,
|
|
||||||
content: (
|
|
||||||
<StepDocumentUpload
|
|
||||||
key={`doc-${docConfig.file_type}`}
|
|
||||||
documentConfig={docConfig}
|
|
||||||
formData={formData}
|
|
||||||
updateFormData={updateFormData}
|
|
||||||
onNext={nextStep}
|
|
||||||
onPrev={prevStep}
|
|
||||||
isLastDocument={index === documentConfigs.length - 1}
|
|
||||||
currentDocNumber={index + 1}
|
|
||||||
totalDocs={documentConfigs.length}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Последний шаг: Payment (всегда)
|
// Последний шаг: Payment (всегда)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Заявление',
|
title: 'Заявление',
|
||||||
@@ -1310,7 +1245,7 @@ export default function ClaimForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return stepsArray;
|
return stepsArray;
|
||||||
}, [formData, documentConfigs, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
}, [formData, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setIsSubmitted(false);
|
setIsSubmitted(false);
|
||||||
@@ -1364,8 +1299,8 @@ export default function ClaimForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}>
|
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{/* Левая часть - Форма */}
|
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
||||||
<Card
|
<Card
|
||||||
title="Подать обращение о защите прав потребителя"
|
title="Подать обращение о защите прав потребителя"
|
||||||
className="claim-form-card"
|
className="claim-form-card"
|
||||||
@@ -1439,10 +1374,12 @@ export default function ClaimForm() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* Правая часть - Debug консоль */}
|
{/* Правая часть - Debug консоль (только в dev режиме) */}
|
||||||
<Col xs={24} lg={10}>
|
{process.env.NODE_ENV === 'development' && (
|
||||||
<DebugPanel events={debugEvents} formData={formData} />
|
<Col xs={24} lg={10}>
|
||||||
</Col>
|
<DebugPanel events={debugEvents} formData={formData} />
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import react from '@vitejs/plugin-react'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
// Удаляем console.log в продакшен билде
|
||||||
|
esbuild: {
|
||||||
|
drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
|||||||
Reference in New Issue
Block a user