Compare commits
15 Commits
a4cc4f9de6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b9665b27f | ||
|
|
e630d03e67 | ||
|
|
66a0065df8 | ||
|
|
c39b12630e | ||
|
|
b5c31b43dd | ||
|
|
f2e144e9ca | ||
|
|
06b89d20e7 | ||
|
|
9c65b6a4ea | ||
|
|
62fc57f108 | ||
|
|
b3a7396d32 | ||
|
|
d8fe0b605b | ||
|
|
6350f9015b | ||
|
|
4536210284 | ||
|
|
1887336aba | ||
|
|
8c3e993eb7 |
60
CHANGELOG_MINIAPP.md
Normal file
60
CHANGELOG_MINIAPP.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Доработки мини-приложения Clientright (TG/MAX и веб)
|
||||
|
||||
## Консультации, CRM, кнопка «Назад» (2026-02-25)
|
||||
|
||||
### Консультации
|
||||
- **Страница «Консультации»**: список тикетов из тех же данных, что и «Мои обращения» (общий контекст `DraftsContext`), без отдельного эндпоинта списка.
|
||||
- По клику на тикет — запрос `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука «подробнее» (`n8n_ticket_form_podrobnee_webhook`), ответ показывается карточкой с полями: заголовок, статус, категория, описание, решение, приоритет (русские подписи).
|
||||
- Убраны подпись «Тикеты из CRM» и кнопка «Назад к списку» — возврат только через кнопку «Назад» в баре.
|
||||
- **Кнопка «Назад» в баре на консультациях**: подписка на `miniapp:goBack` в `Consultations.tsx` — в детали тикета возврат к списку, со списка переход на «Мои обращения» (`onNavigate('/')`).
|
||||
|
||||
### Поддержка
|
||||
- **Кнопка «Назад» в баре в чате поддержки**: на маршруте `/support` кнопка «Назад» больше не отключается (`BottomBar.tsx` — убран `isSupport` из условия отключения).
|
||||
- В `Support.tsx` добавлена подписка на `miniapp:goBack`: в режиме чата — возврат к списку обращений, в списке — переход на «Мои обращения» (`onNavigate('/')`).
|
||||
|
||||
### CRM и дашборд
|
||||
- **n8n**: Code-нода нормализации ответа CRM — из `projects_json` и `tickets_json` формируется массив элементов с полями для фронта (`type_code`, `payload`, `status_code`). Файл `docs/n8n_CODE_CRM_NORMALIZE.js`, на выходе объект с полем `crm_items`.
|
||||
- **n8n**: разворот ответа в плоский список — `docs/n8n_CODE_FLATTEN_DATA.js` разворачивает элементы вида `{ crm_items: [...] }` в плоский массив в `data`.
|
||||
- **Дашборд по категориям**: при клике по плиткам («В работе», «Решены» и т.д.) список фильтруется в том числе для элементов из CRM: добавлены `isFromCrm()`, `getItemCategory()` по `status_code`/payload, расширен `STATUS_CONFIG` для `active`/`completed`/`rejected`.
|
||||
- **Карточка обращения**: у контейнера и Card заданы `width: '100%'` и `boxSizing: 'border-box'` в `StepDraftSelection.tsx`.
|
||||
|
||||
### Бэкенд
|
||||
- В `config.py` — переменная `n8n_ticket_form_podrobnee_webhook` для вебхука «подробнее» по тикету.
|
||||
- В `config.py` — переменная **`n8n_project_form_podrobnee_webhook`** (`N8N_PROJECT_FORM_PODROBNEE_WEBHOOK` в .env) для вебхука «подробнее» по делу/проекту из CRM (по project_id). Эндпоинт, вызывающий этот хук, пока не добавлен — зарезервировано под будущий case-detail.
|
||||
- Модуль `backend/app/api/consultations.py`: эндпоинт `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука, ответ как есть.
|
||||
|
||||
---
|
||||
|
||||
## Системные баннеры на экране приветствия (2026-02)
|
||||
- **Баннер «Профиль не заполнен»** вынесен в отдельную зону справа от текста «Теперь ты в системе — можно продолжать» (на десктопе — колонка ~260px), чтобы не занимал полстраницы и не сдвигал контент.
|
||||
- Реализовано **единое место для системных баннеров**: массив `systemBanners`, при одном баннере показывается один Alert, при нескольких — карусель (Ant Design Carousel). В будущем сюда можно добавлять другие критические уведомления.
|
||||
- **Мобильная вёрстка**: баннер на всю ширину, нормальный перенос текста (без разбиения по слогам), кнопка «Заполнить профиль» переносится под текст, крестик закрытия остаётся в первой строке справа (через `order` и `flex-wrap`).
|
||||
- **Профиль**: убрана дублирующая ссылка «Домой» из шапки карточки профиля — навигация остаётся через нижний бар.
|
||||
|
||||
## UI и навигация
|
||||
- **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей».
|
||||
- Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок.
|
||||
- Список обращений по категориям в виде карточек с hover; фильтр по выбранной категории.
|
||||
- Кнопка **«Назад»** перенесена в нижний бар; убраны дублирующие кнопки «Назад» из контента (описание, документы).
|
||||
|
||||
## Telegram и MAX
|
||||
- **Выход**: корректное закрытие приложения — в TG вызывается `Telegram.WebApp.close()`, в MAX — `window.WebApp.close()` / `postEvent('web_app_close')`. Определение платформы по initData/URL.
|
||||
- Подключение скриптов по платформе: при наличии `tgWebAppData`/`tgWebAppVersion` в URL грузится только `telegram-web-app.js`, иначе — только `max-web-app.js` (устранены ошибки UnsupportedEvent в MAX).
|
||||
- В TG/MAX **не показывается экран ввода телефона** — шаг «Вход» только для обычного веба; раннее определение платформы (опрос `WebApp.initData`), флаг `platformChecked` чтобы не мелькал телефон до определения.
|
||||
|
||||
## Сессия и авторизация
|
||||
- Сессию не сбрасывать при сетевых/временных ошибках `session/verify` — удалять `session_token` только при явном ответе `valid: false`.
|
||||
- При нажатии «Назад» с авторизованного пользователя не вести на шаг «Вход» — переход на дашборд «Мои обращения» или на `/hello`.
|
||||
- Переход на «Подать обращение» через роут `/new` и `pushState` для стабильного флоу без возврата на телефон.
|
||||
|
||||
## Исправления
|
||||
- **TDZ-ошибка** (пустой экран после перехода с /hello): `useEffect` для `miniapp:goBack` перенесён после объявления `prevStep` (useCallback).
|
||||
- Тостер **«Добро пожаловать!»** показывается только в вебе (не в TG/MAX), проверка по `Telegram.WebApp.initData` и `WebApp.initData`.
|
||||
|
||||
## Отладка и логи
|
||||
- Клиентский логгер `miniappLogger`: сбор событий, ошибок, отправка на `POST /api/v1/utils/client-log`; идентификация бандла (build/moduleUrl); очистка логов при смене сборки.
|
||||
- Бэкенд: приём логов в `main.py`, запись в `logs/cursor-debug-*.log` (NDJSON), без PII.
|
||||
|
||||
## Файлы
|
||||
- Новые: `StepComplaintsDashboard.tsx/.css`, `StepDraftSelection.css`, `miniappLogger.ts`.
|
||||
- Правки: `ClaimForm.tsx`, `HelloAuth.tsx`, `BottomBar.tsx`, `StepDescription.tsx`, `StepWizardPlan.tsx`, `StepDraftSelection.tsx`, `App.tsx`, `main.tsx`, `index.html`, `main.py`, `api/claims.py`, `ClaimForm.css`, `BottomBar.css`, `Dockerfile.prod`.
|
||||
28
CHANGELOG_PROFILE_VALIDATION.md
Normal file
28
CHANGELOG_PROFILE_VALIDATION.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Изменения: форма профиля, валидация, DaData, банки
|
||||
|
||||
## Backend
|
||||
|
||||
### auth_universal.py
|
||||
- Чтение N8N_AUTH_WEBHOOK: fallback на `os.environ.get("N8N_AUTH_WEBHOOK")`, если в config нет поля `n8n_auth_webhook` (чтобы webhook auth_miniapp вызывался при отсутствии config.py на хосте).
|
||||
|
||||
### banks.py
|
||||
- URL списка банков берётся из .env: `BANK_IP` (в config — `bank_ip`), fallback на `bank_api_url` и запасной URL. Прокси запроса к внешнему API для мини-аппа.
|
||||
|
||||
### profile.py
|
||||
- Новый эндпоинт `GET /api/v1/profile/dadata/address?query=...&count=10` — подсказки адресов через DaData API (ключи FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). Ответ: `{ "suggestions": [ { "value", "unrestricted_value" } ] }`.
|
||||
|
||||
### config.py
|
||||
- Добавлены поля: `bank_ip` (BANK_IP), `bank_api_url`; `forma_dadata_api_key`, `forma_dadata_secret` (FORMA_DADATA_*).
|
||||
|
||||
## Frontend (Profile.tsx)
|
||||
|
||||
- **Дата рождения:** календарь (DatePicker), формат DD.MM.YYYY, нельзя выбрать будущую дату.
|
||||
- **ИНН:** строго 12 цифр, валидация и ввод только цифр; подсказка «Узнать свой ИНН вы можете здесь» со ссылкой на сервис ФНС (service.nalog.ru).
|
||||
- **Email:** валидация формата (type: email).
|
||||
- **Адрес регистрации / Почтовый адрес:** чекбокс «Совпадает с адресом регистрации» — при включении почтовый подставляется и блокируется; оба поля — AutoComplete с подсказками из DaData (запрос к /api/v1/profile/dadata/address).
|
||||
- **Банк для возмещения:** выпадающий список (Select) с поиском, данные с /api/v1/banks/nspk (API из BANK_IP); учтён формат ответа с полями bankId, bankName (camelCase).
|
||||
|
||||
## .env
|
||||
|
||||
- BANK_IP — URL API списка банков (например http://212.193.27.93/api/payouts/dictionaries/nspk-banks).
|
||||
- FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET — ключи DaData для подсказок адресов.
|
||||
18
CHANGES_AUTH2_HELLO.md
Normal file
18
CHANGES_AUTH2_HELLO.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## Что изменили
|
||||
|
||||
Добавлен **параллельный** (не ломающий текущий) флоу авторизации и приветственная страница:
|
||||
|
||||
- **Новый endpoint**: `POST /api/v1/auth2/login`
|
||||
- **platform=tg**: проверка `init_data` Telegram WebApp → вызов n8n webhook → создание сессии в Redis.
|
||||
- **platform=max**: проверка `init_data` MAX WebApp → вызов n8n webhook → создание сессии в Redis.
|
||||
- **platform=sms**: проверка SMS-кода → создание/поиск пользователя через n8n → создание сессии в Redis.
|
||||
- Ответ включает `greeting` и (для TG/MAX) `avatar_url`, чтобы можно было показать приветствие и аватар.
|
||||
|
||||
- **Новая страница**: `GET /hello`
|
||||
- После авторизации показывает “привет” и плитки в стиле **Soft UI / Modern SaaS** (Ant Design + Lucide outline icons).
|
||||
- Текущий основной UI/роуты/эндпоинты не менялись — это отдельная ветка для новой архитектуры.
|
||||
|
||||
## Зачем
|
||||
|
||||
Чтобы развивать новую архитектуру входа и “кабинет” **параллельно** со старым флоу, без риска что-то сломать.
|
||||
|
||||
5
COMMIT_MSG_PROFILE_EDIT.txt
Normal file
5
COMMIT_MSG_PROFILE_EDIT.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK
|
||||
|
||||
- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
|
||||
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
|
||||
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.
|
||||
8
COMMIT_MSG_SESSION_DUPLICATE_EXTERNAL_REDIS.txt
Normal file
8
COMMIT_MSG_SESSION_DUPLICATE_EXTERNAL_REDIS.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Session: дублировать сессии во внешний Redis для доступа из n8n
|
||||
|
||||
- backend/app/api/session.py: при записи сессии в локальный Redis (6383) теперь также дублируем те же ключи
|
||||
в внешний Redis (REDIS_HOST/REDIS_PORT) через redis_service.client.
|
||||
- Дублируются оба вида ключей:
|
||||
- session:{channel}:{channel_user_id}
|
||||
- session:{session_token}
|
||||
- Ошибки внешнего Redis не ломают авторизацию: при недоступности — warning в логах.
|
||||
4
COMMIT_MSG_SUPPORT_ROUTE.txt
Normal file
4
COMMIT_MSG_SUPPORT_ROUTE.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Support: маршрут /support на страницу чата поддержки
|
||||
|
||||
- App.tsx: добавлен импорт страницы Support и роутинг pathname === '/support' на компонент Support.
|
||||
- При клике на иконку «Поддержка» в нижнем баре теперь открывается список обращений и чат, а не форма «Мои обращения».
|
||||
23
DOCKER-COMPOSE-README.md
Normal file
23
DOCKER-COMPOSE-README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Docker Compose в этом каталоге
|
||||
|
||||
**Для сайта miniapp.clientright.ru используется один compose — в корне репозитория:**
|
||||
|
||||
```
|
||||
/var/www/fastuser/data/www/miniapp.clientright.ru/docker-compose.yml
|
||||
```
|
||||
|
||||
Он поднимает: `miniapp_frontend` (5179), `miniapp_backend` (8205), `miniapp_redis` (6383).
|
||||
Запуск из корня: `docker compose up -d`.
|
||||
|
||||
---
|
||||
|
||||
Файлы в **aiform_prod/**:
|
||||
|
||||
| Файл | Назначение | Порты |
|
||||
|------|------------|--------|
|
||||
| `docker-compose.yml` | Старый стек (ticket_form_*), не для miniapp.clientright.ru | 5175, host |
|
||||
| `docker-compose.prod.yml` | Другой прод (miniapp_front/back на 4176), не для miniapp.clientright.ru | 4176 |
|
||||
| `docker-compose.dev.yml` | Дев aiform (aiform_frontend_dev, aiform_backend_dev) | 5177, 8201 |
|
||||
| `docker-compose.full.yml` | Полный стек ERV (postgres, redis, pgadmin и т.д.) | 8100, 5173, … |
|
||||
|
||||
Их можно не поднимать для работы miniapp.clientright.ru. Оставлены для истории/других окружений.
|
||||
@@ -14,8 +14,8 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
|
||||
# Открываем порт
|
||||
EXPOSE 8200
|
||||
EXPOSE 4200
|
||||
|
||||
# Запускаем приложение
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8200"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "4200"]
|
||||
|
||||
|
||||
256
backend/app/api/auth2.py
Normal file
256
backend/app/api/auth2.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
Alternative auth endpoint (tg/max/sms) without touching existing flow.
|
||||
|
||||
/api/v1/auth2/login:
|
||||
- platform=tg|max|sms
|
||||
- Validates init_data for TG/MAX and calls n8n webhook
|
||||
- For SMS: verifies code, calls n8n contact webhook, creates session
|
||||
- Returns greeting message
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Literal, Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.sms_service import sms_service
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth2", tags=["auth2"])
|
||||
|
||||
|
||||
class Auth2LoginRequest(BaseModel):
|
||||
platform: Literal["tg", "max", "sms"]
|
||||
init_data: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
session_token: Optional[str] = None
|
||||
form_id: str = "ticket_form"
|
||||
|
||||
|
||||
class Auth2LoginResponse(BaseModel):
|
||||
success: bool
|
||||
greeting: str
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
import uuid
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/login", response_model=Auth2LoginResponse)
|
||||
async def login(request: Auth2LoginRequest):
|
||||
platform = request.platform
|
||||
logger.info("[AUTH2] login: platform=%s", platform)
|
||||
|
||||
if platform == "tg":
|
||||
if not request.init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен для tg")
|
||||
try:
|
||||
tg_user = extract_telegram_user(request.init_data)
|
||||
except TelegramAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
n8n_payload = {
|
||||
"telegram_user_id": tg_user["telegram_user_id"],
|
||||
"username": tg_user.get("username"),
|
||||
"first_name": tg_user.get("first_name"),
|
||||
"last_name": tg_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": request.form_id,
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_telegram_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
|
||||
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[AUTH2] TG: n8n need_contact — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.info("[AUTH2] TG: n8n не вернул unified_id — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(tg_user["telegram_user_id"]) if tg_user.get("telegram_user_id") is not None else None,
|
||||
))
|
||||
|
||||
first_name = tg_user.get("first_name") or ""
|
||||
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting=greeting,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=tg_user.get("photo_url") or None,
|
||||
)
|
||||
|
||||
if platform == "max":
|
||||
if not request.init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен для max")
|
||||
try:
|
||||
max_user = extract_max_user(request.init_data)
|
||||
except MaxAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
n8n_payload = {
|
||||
"max_user_id": max_user["max_user_id"],
|
||||
"username": max_user.get("username"),
|
||||
"first_name": max_user.get("first_name"),
|
||||
"last_name": max_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": request.form_id,
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
|
||||
_raw_nc = n8n_data.get("need_contact") or _result_dict.get("need_contact") or n8n_data.get("needContact") or _result_dict.get("needContact")
|
||||
need_contact = _raw_nc is True or _raw_nc == 1 or (isinstance(_raw_nc, str) and str(_raw_nc).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[AUTH2] MAX: n8n need_contact — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.info("[AUTH2] MAX: n8n не вернул unified_id — возвращаем need_contact=true")
|
||||
return Auth2LoginResponse(success=False, greeting="Привет!", need_contact=True)
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(max_user["max_user_id"]) if max_user.get("max_user_id") is not None else None,
|
||||
))
|
||||
|
||||
first_name = max_user.get("first_name") or ""
|
||||
greeting = f"Привет, {first_name}!" if first_name else "Привет!"
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting=greeting,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=max_user.get("photo_url") or None,
|
||||
)
|
||||
|
||||
if platform == "sms":
|
||||
phone = (request.phone or "").strip()
|
||||
code = (request.code or "").strip()
|
||||
if not phone or not code:
|
||||
raise HTTPException(status_code=400, detail="phone и code обязательны для sms")
|
||||
|
||||
is_valid = await sms_service.verify_code(phone, code)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="Неверный код или код истек")
|
||||
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: Dict[str, Any]):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_payload = {
|
||||
"phone": phone,
|
||||
"session_id": request.session_token or "",
|
||||
"form_id": request.form_id,
|
||||
}
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_create_contact(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
if isinstance(n8n_data, list) and n8n_data:
|
||||
n8n_data = n8n_data[0]
|
||||
|
||||
if not n8n_data or not isinstance(n8n_data, dict) or not n8n_data.get("success"):
|
||||
raise HTTPException(status_code=500, detail="Ошибка создания контакта в n8n")
|
||||
|
||||
result = n8n_data.get("result") or n8n_data
|
||||
unified_id = result.get("unified_id")
|
||||
contact_id = result.get("contact_id")
|
||||
phone_res = result.get("phone") or phone
|
||||
has_drafts = result.get("has_drafts")
|
||||
session_token = result.get("session") or request.session_token or _generate_session_token()
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id")
|
||||
|
||||
await session_api.create_session(session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone_res or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
))
|
||||
|
||||
return Auth2LoginResponse(
|
||||
success=True,
|
||||
greeting="Привет!",
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone_res,
|
||||
has_drafts=has_drafts,
|
||||
avatar_url=None,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=400, detail="Неподдерживаемая платформа")
|
||||
264
backend/app/api/auth_universal.py
Normal file
264
backend/app/api/auth_universal.py
Normal file
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
Универсальный auth: один endpoint для TG и MAX.
|
||||
Принимает channel (tg|max) и init_data, валидирует, дергает N8N_AUTH_WEBHOOK,
|
||||
пишет сессию в Redis по ключу session:{channel}:{channel_user_id} и session:{session_token}.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, Any, Dict, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..config import settings
|
||||
from ..services.telegram_auth import extract_telegram_user, TelegramAuthError
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth-universal"])
|
||||
|
||||
|
||||
class AuthUniversalRequest(BaseModel):
|
||||
channel: str # tg | max
|
||||
init_data: str
|
||||
|
||||
|
||||
class AuthUniversalResponse(BaseModel):
|
||||
success: bool
|
||||
need_contact: Optional[bool] = None
|
||||
message: Optional[str] = None
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_profile_confirm: Optional[bool] = None
|
||||
profile_needs_attention: Optional[bool] = None
|
||||
|
||||
|
||||
def _to_bool(v: Any) -> Optional[bool]:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, (int, float)):
|
||||
if v == 1:
|
||||
return True
|
||||
if v == 0:
|
||||
return False
|
||||
if isinstance(v, str):
|
||||
s = v.strip().lower()
|
||||
if s in ("1", "true", "yes", "y", "да"):
|
||||
return True
|
||||
if s in ("0", "false", "no", "n", "нет", ""):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
@router.post("", response_model=AuthUniversalResponse)
|
||||
async def auth_universal(request: AuthUniversalRequest):
|
||||
"""
|
||||
Универсальная авторизация: channel (tg|max) + init_data.
|
||||
Валидируем init_data, получаем channel_user_id, вызываем N8N_AUTH_WEBHOOK,
|
||||
при успехе пишем сессию в Redis по session:{channel}:{channel_user_id}.
|
||||
"""
|
||||
logger.info("[AUTH] POST /api/v1/auth вызван: channel=%s", request.channel)
|
||||
channel = (request.channel or "").strip().lower()
|
||||
if channel not in ("tg", "telegram", "max"):
|
||||
channel = "telegram" if channel.startswith("tg") else "max"
|
||||
# В n8n и Redis всегда передаём telegram, не tg
|
||||
if channel == "tg":
|
||||
channel = "telegram"
|
||||
|
||||
init_data = (request.init_data or "").strip()
|
||||
if not init_data:
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
logger.debug("[AUTH] init_data length=%s", len(init_data))
|
||||
|
||||
# 1) Извлечь channel_user_id из init_data
|
||||
channel_user_id: Optional[str] = None
|
||||
if channel == "telegram":
|
||||
try:
|
||||
user = extract_telegram_user(init_data)
|
||||
channel_user_id = user.get("telegram_user_id")
|
||||
except TelegramAuthError as e:
|
||||
logger.warning("[TG] Ошибка валидации init_data: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
else:
|
||||
try:
|
||||
user = extract_max_user(init_data)
|
||||
channel_user_id = user.get("max_user_id")
|
||||
except MaxAuthError as e:
|
||||
logger.warning("[MAX] Ошибка валидации init_data: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not channel_user_id:
|
||||
raise HTTPException(status_code=400, detail="Не удалось получить channel_user_id из init_data")
|
||||
|
||||
# URL из settings или напрямую из env (если в config нет поля n8n_auth_webhook)
|
||||
webhook_url = (getattr(settings, "n8n_auth_webhook", None) or os.environ.get("N8N_AUTH_WEBHOOK") or "").strip()
|
||||
if not webhook_url:
|
||||
logger.error("N8N_AUTH_WEBHOOK не задан в .env")
|
||||
raise HTTPException(status_code=503, detail="Сервис авторизации не настроен")
|
||||
|
||||
# 2) Вызвать n8n
|
||||
payload = {
|
||||
"channel": channel,
|
||||
"channel_user_id": channel_user_id,
|
||||
"init_data": init_data,
|
||||
}
|
||||
# При мультиботе (Telegram или MAX) передаём bot_id (из extract_telegram_user / extract_max_user)
|
||||
if user.get("bot_id"):
|
||||
payload["bot_id"] = user["bot_id"]
|
||||
logger.info("[AUTH] Вызов N8N_AUTH_WEBHOOK: channel=%s, channel_user_id=%s", channel, channel_user_id)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[AUTH] Таймаут N8N_AUTH_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Таймаут сервиса авторизации")
|
||||
except Exception as e:
|
||||
logger.exception("[AUTH] Ошибка вызова N8N_AUTH_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Ошибка сервиса авторизации")
|
||||
|
||||
# Лог: что пришло от n8n (сырой ответ)
|
||||
try:
|
||||
_body = response.text or ""
|
||||
logger.info("[AUTH] n8n ответ: status=%s, body_len=%s, body_preview=%s", response.status_code, len(_body), _body[:500] if _body else "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
raw = response.json()
|
||||
logger.info("[AUTH] raw type=%s, is_list=%s, len=%s", type(raw).__name__, isinstance(raw, list), len(raw) if isinstance(raw, (list, dict)) else 0)
|
||||
if isinstance(raw, list) and len(raw) > 0:
|
||||
logger.info("[AUTH] raw[0] keys=%s", list(raw[0].keys()) if isinstance(raw[0], dict) else type(raw[0]).__name__)
|
||||
|
||||
# n8n может вернуть: массив [{ json: { ... } }] или массив объектов напрямую [{ success, unified_id, ... }]
|
||||
if isinstance(raw, list) and len(raw) > 0 and isinstance(raw[0], dict):
|
||||
first = raw[0]
|
||||
if "json" in first:
|
||||
data = first["json"]
|
||||
logger.info("[AUTH] парсинг: взяли first['json'], data keys=%s", list(data.keys()) if isinstance(data, dict) else "?")
|
||||
elif "success" in first or "unified_id" in first:
|
||||
data = first
|
||||
logger.info("[AUTH] парсинг: взяли first как data, keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = {}
|
||||
logger.warning("[AUTH] парсинг: first без json/success/unified_id, data={}")
|
||||
elif isinstance(raw, dict):
|
||||
# n8n Respond to Webhook может вернуть { "json": { success, phone, ... } }
|
||||
if "json" in raw and isinstance(raw.get("json"), dict):
|
||||
data = raw["json"]
|
||||
logger.info("[AUTH] парсинг: raw — dict с json, data keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = raw
|
||||
logger.info("[AUTH] парсинг: raw — dict, keys=%s", list(data.keys()))
|
||||
else:
|
||||
data = {}
|
||||
logger.warning("[AUTH] парсинг: неизвестный формат raw, data={}")
|
||||
except Exception as e:
|
||||
logger.warning("[AUTH] Ответ n8n не JSON: %s", (response.text or "")[:300])
|
||||
raise HTTPException(status_code=502, detail="Некорректный ответ сервиса авторизации")
|
||||
|
||||
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
|
||||
|
||||
# Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт
|
||||
need_profile_confirm = _to_bool(
|
||||
data.get("need_profile_confirm")
|
||||
if "need_profile_confirm" in data
|
||||
else data.get("needProfileConfirm")
|
||||
)
|
||||
profile_needs_attention = _to_bool(
|
||||
data.get("profile_needs_attention")
|
||||
if "profile_needs_attention" in data
|
||||
else data.get("profileNeedsAttention")
|
||||
)
|
||||
if profile_needs_attention is None:
|
||||
profile_needs_attention = need_profile_confirm
|
||||
|
||||
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
|
||||
need_contact = (
|
||||
data.get("need_contact") is True
|
||||
or data.get("need_contact") == 1
|
||||
or (isinstance(data.get("need_contact"), str) and data.get("need_contact", "").strip().lower() in ("true", "1"))
|
||||
)
|
||||
if need_contact:
|
||||
logger.info("[AUTH] ответ: need_contact=true → закрыть приложение")
|
||||
return AuthUniversalResponse(
|
||||
success=False,
|
||||
need_contact=True,
|
||||
message=(data.get("message") or "Пользователь не найден. Поделитесь контактом в чате с ботом."),
|
||||
)
|
||||
if data.get("success") is False:
|
||||
# Ошибка/неуспех без требования контакта — не закрываем приложение, показываем сообщение
|
||||
msg = data.get("message") or "Ошибка авторизации."
|
||||
logger.info("[AUTH] ответ: success=false, need_contact=false → показать ошибку: message=%s", msg)
|
||||
logger.debug("[AUTH] полный data при success=false: %s", data)
|
||||
return AuthUniversalResponse(
|
||||
success=False,
|
||||
need_contact=False,
|
||||
message=msg,
|
||||
)
|
||||
|
||||
# 4) Успех: unified_id и т.д.
|
||||
unified_id = data.get("unified_id")
|
||||
if not unified_id and isinstance(data.get("result"), dict):
|
||||
unified_id = (data.get("result") or {}).get("unified_id")
|
||||
if not unified_id:
|
||||
logger.warning("[AUTH] n8n не вернул unified_id: %s", data)
|
||||
logger.info("[AUTH] ответ: нет unified_id → need_contact=true, закрыть приложение")
|
||||
return AuthUniversalResponse(success=False, need_contact=True, message="Контакт не найден.")
|
||||
|
||||
# 5) Записать сессию в Redis по session:{channel}:{channel_user_id} и session:{session_token}
|
||||
_phone = data.get("phone") or ((data.get("result") or {}).get("phone") if isinstance(data.get("result"), dict) else None)
|
||||
_contact_id = data.get("contact_id") or ((data.get("result") or {}).get("contact_id") if isinstance(data.get("result"), dict) else None)
|
||||
if _phone is not None and not isinstance(_phone, str):
|
||||
_phone = str(_phone).strip() or None
|
||||
elif isinstance(_phone, str):
|
||||
_phone = _phone.strip() or None
|
||||
session_data = {
|
||||
"unified_id": unified_id,
|
||||
"phone": _phone,
|
||||
"contact_id": _contact_id,
|
||||
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
|
||||
"chat_id": channel_user_id,
|
||||
"need_profile_confirm": need_profile_confirm,
|
||||
"profile_needs_attention": profile_needs_attention,
|
||||
}
|
||||
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
|
||||
try:
|
||||
await session_api.set_session_by_channel_user(channel, channel_user_id, session_data)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[AUTH] Ошибка записи сессии в Redis: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Ошибка сохранения сессии")
|
||||
|
||||
session_token = str(uuid.uuid4())
|
||||
try:
|
||||
await session_api.set_session_by_token(session_token, session_data)
|
||||
except Exception as e:
|
||||
logger.warning("[AUTH] Двойная запись session_token в Redis: %s", e)
|
||||
|
||||
logger.info("[AUTH] ответ: success=true, session_token=%s..., unified_id=%s", session_token[:8] if session_token else "", unified_id)
|
||||
return AuthUniversalResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=session_data.get("phone"),
|
||||
contact_id=session_data.get("contact_id"),
|
||||
has_drafts=session_data.get("has_drafts", False),
|
||||
need_profile_confirm=need_profile_confirm,
|
||||
profile_needs_attention=profile_needs_attention,
|
||||
)
|
||||
@@ -14,13 +14,10 @@ router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
|
||||
@router.get("/nspk")
|
||||
async def get_nspk_banks():
|
||||
"""
|
||||
Получить список банков СБП из внешнего API
|
||||
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
|
||||
Получить список банков из внешнего API (BANK_IP в .env или nspk_banks_api_url).
|
||||
"""
|
||||
external_api_url = (getattr(settings, "bank_ip", None) or getattr(settings, "bank_api_url", None) or "").strip() or "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
try:
|
||||
# URL внешнего API
|
||||
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(external_api_url)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
import os
|
||||
from ..services.redis_service import redis_service
|
||||
from ..services.database import db
|
||||
from ..services.crm_mysql_service import crm_mysql_service
|
||||
@@ -23,7 +24,10 @@ from ..config import settings
|
||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
|
||||
def _get_ticket_form_webhook() -> str:
|
||||
"""URL webhook n8n для wizard и create. Менять в .env: N8N_TICKET_FORM_FINAL_WEBHOOK"""
|
||||
return (getattr(settings, "n8n_ticket_form_final_webhook", None) or "").strip() or "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
|
||||
|
||||
@router.post("/wizard")
|
||||
@@ -59,16 +63,32 @@ async def submit_wizard(request: Request):
|
||||
},
|
||||
)
|
||||
|
||||
webhook_url = _get_ticket_form_webhook()
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||
webhook_url,
|
||||
data=data,
|
||||
files=files or None,
|
||||
)
|
||||
|
||||
text = response.text or ""
|
||||
|
||||
logger.info(
|
||||
"n8n wizard response: status=%s, body_length=%s, body_preview=%s",
|
||||
response.status_code,
|
||||
len(text),
|
||||
text[:1500] if len(text) > 1500 else text,
|
||||
extra={"claim_id": data.get("claim_id"), "session_id": data.get("session_id")},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
logger.info(
|
||||
"n8n wizard response (parsed): keys=%s",
|
||||
list(parsed.keys()) if isinstance(parsed, dict) else type(parsed).__name__,
|
||||
extra={"session_id": data.get("session_id")},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"✅ TicketForm wizard webhook OK",
|
||||
extra={"response_preview": text[:500]},
|
||||
@@ -121,9 +141,10 @@ async def create_claim(request: Request):
|
||||
)
|
||||
|
||||
# Проксируем запрос к n8n
|
||||
webhook_url = _get_ticket_form_webhook()
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||
webhook_url,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
@@ -373,8 +394,23 @@ async def list_drafts(
|
||||
# Категория проблемы
|
||||
category = ai_analysis.get('category') or wizard_plan.get('category') or None
|
||||
|
||||
# Подробное описание (для превью)
|
||||
problem_text = payload.get('problem_description', '')
|
||||
# Направление (для иконки плитки)
|
||||
direction = payload.get('direction') or wizard_plan.get('direction') or category
|
||||
|
||||
# facts_short из AI Agent (краткие факты — заголовок плитки)
|
||||
ai_agent1_facts = payload.get('ai_agent1_facts') or {}
|
||||
ai_analysis_facts = (payload.get('ai_analysis') or {}).get('facts_short')
|
||||
facts_short = ai_agent1_facts.get('facts_short') or ai_analysis_facts
|
||||
if facts_short and len(facts_short) > 200:
|
||||
facts_short = facts_short[:200].rstrip() + '…'
|
||||
|
||||
# Подробное описание (для превью); n8n может сохранять в description/chatInput
|
||||
problem_text = (
|
||||
payload.get('problem_description')
|
||||
or payload.get('description')
|
||||
or payload.get('chatInput')
|
||||
or ''
|
||||
)
|
||||
|
||||
# Считаем документы
|
||||
documents_meta = payload.get('documents_meta') or []
|
||||
@@ -418,6 +454,8 @@ async def list_drafts(
|
||||
# Полное описание
|
||||
"problem_description": problem_text[:500] if problem_text else None,
|
||||
"category": category,
|
||||
"direction": direction,
|
||||
"facts_short": facts_short,
|
||||
"wizard_plan": payload.get('wizard_plan') is not None,
|
||||
"wizard_answers": payload.get('answers') is not None,
|
||||
"has_documents": documents_uploaded > 0,
|
||||
@@ -445,11 +483,13 @@ async def list_drafts(
|
||||
@router.get("/drafts/{claim_id}")
|
||||
async def get_draft(claim_id: str):
|
||||
"""
|
||||
Получить полные данные черновика по claim_id
|
||||
|
||||
Возвращает все данные формы для продолжения заполнения
|
||||
Получить полные данные черновика по claim_id.
|
||||
Поддерживаются форматы: голый UUID, claim_id_<uuid> (из MAX startapp).
|
||||
"""
|
||||
try:
|
||||
# Формат из MAX диплинка: claim_id_<uuid> — извлекаем UUID
|
||||
if claim_id.startswith("claim_id_"):
|
||||
claim_id = claim_id[9:]
|
||||
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
|
||||
|
||||
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
|
||||
@@ -658,11 +698,11 @@ async def get_draft(claim_id: str):
|
||||
@router.delete("/drafts/{claim_id}")
|
||||
async def delete_draft(claim_id: str):
|
||||
"""
|
||||
Удалить черновик по claim_id
|
||||
|
||||
Удаляет черновики с любым статусом (кроме submitted/completed)
|
||||
Удалить черновик по claim_id. Поддерживается формат claim_id_<uuid>.
|
||||
"""
|
||||
try:
|
||||
if claim_id.startswith("claim_id_"):
|
||||
claim_id = claim_id[9:]
|
||||
query = """
|
||||
DELETE FROM clpr_claims
|
||||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||||
@@ -868,15 +908,14 @@ async def get_claim(claim_id: str):
|
||||
@router.get("/wizard/load/{claim_id}")
|
||||
async def load_wizard_data(claim_id: str):
|
||||
"""
|
||||
Загрузить данные визарда из PostgreSQL по claim_id
|
||||
|
||||
Используется после получения claim_id из ocr_events.
|
||||
Возвращает полные данные для построения формы (wizard_plan, problem_description и т.д.)
|
||||
Загрузить данные визарда по claim_id. Поддерживается формат claim_id_<uuid>.
|
||||
"""
|
||||
try:
|
||||
if claim_id.startswith("claim_id_"):
|
||||
claim_id = claim_id[9:]
|
||||
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
|
||||
|
||||
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
|
||||
# Ищем заявку по claim_id (UUID или CLM-...)
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
@@ -944,12 +983,44 @@ async def load_wizard_data(claim_id: str):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при загрузке данных визарда: {str(e)}")
|
||||
|
||||
|
||||
# Актуальный webhook для описания проблемы (n8n.clientright.ru). Старый aiform_description на .pro больше не используем.
|
||||
DESCRIPTION_WEBHOOK_DEFAULT = "https://n8n.clientright.ru/webhook/ticket_form_description"
|
||||
|
||||
DEBUG_LOG_PATH = "/app/logs/debug-2a4d38.log"
|
||||
|
||||
|
||||
def _debug_log(hy: str, msg: str, data: dict):
|
||||
try:
|
||||
import time
|
||||
line = json.dumps({
|
||||
"sessionId": "2a4d38",
|
||||
"hypothesisId": hy,
|
||||
"location": "claims.py:publish_ticket_form_description",
|
||||
"message": msg,
|
||||
"data": data,
|
||||
"timestamp": int(time.time() * 1000),
|
||||
}, ensure_ascii=False) + "\n"
|
||||
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _get_description_webhook_url() -> str:
|
||||
"""URL webhook для описания проблемы: только env N8N_DESCRIPTION_WEBHOOK или константа (старый .pro не используем)."""
|
||||
url = (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "").strip()
|
||||
if url:
|
||||
return url
|
||||
return DESCRIPTION_WEBHOOK_DEFAULT
|
||||
|
||||
|
||||
async def _send_buffered_messages_to_webhook():
|
||||
"""
|
||||
Отправляет все сообщения из буфера в n8n webhook (вместо Redis pub/sub)
|
||||
"""
|
||||
try:
|
||||
if not settings.n8n_description_webhook:
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
if not description_webhook_url:
|
||||
logger.error("❌ N8N description webhook не настроен, не могу отправить из буфера")
|
||||
return
|
||||
|
||||
@@ -980,7 +1051,7 @@ async def _send_buffered_messages_to_webhook():
|
||||
]
|
||||
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
@@ -1041,27 +1112,53 @@ async def publish_ticket_form_description(
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
"""
|
||||
Отправляет описание проблемы в n8n через webhook (вместо Redis pub/sub)
|
||||
Отправляет описание проблемы в n8n через webhook. URL: N8N_DESCRIPTION_WEBHOOK из env или константа (n8n.clientright.ru).
|
||||
"""
|
||||
# #region agent log
|
||||
_debug_log("H1_H4", "POST /description handler entered", {"session_id": getattr(payload, "session_id", None)})
|
||||
# #endregion
|
||||
try:
|
||||
if not settings.n8n_description_webhook:
|
||||
description_webhook_url = _get_description_webhook_url()
|
||||
# #region agent log
|
||||
_debug_log("H3_H5", "description webhook URL resolved", {"url": description_webhook_url[:80] if description_webhook_url else "", "env_N8N": (os.environ.get("N8N_DESCRIPTION_WEBHOOK") or "")[:80]})
|
||||
# #endregion
|
||||
if not description_webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="N8N description webhook не настроен"
|
||||
)
|
||||
|
||||
|
||||
# Если unified_id не передан — подставляем из сессии в Redis (tg/max auth создают сессию с unified_id)
|
||||
unified_id = payload.unified_id
|
||||
contact_id = payload.contact_id
|
||||
phone = payload.phone
|
||||
if not unified_id and payload.session_id:
|
||||
try:
|
||||
session_key = f"session:{payload.session_id}"
|
||||
session_raw = await redis_service.client.get(session_key)
|
||||
if session_raw:
|
||||
session_data = json.loads(session_raw)
|
||||
unified_id = unified_id or session_data.get("unified_id")
|
||||
contact_id = contact_id or session_data.get("contact_id")
|
||||
phone = phone or session_data.get("phone")
|
||||
if unified_id:
|
||||
logger.info("📝 unified_id/contact_id/phone подставлены из сессии Redis: session_key=%s", session_key)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось прочитать сессию из Redis для подстановки unified_id: %s", e)
|
||||
|
||||
# Формируем данные в формате, который ожидает n8n workflow
|
||||
channel = payload.channel or f"{settings.redis_prefix}description"
|
||||
message = {
|
||||
"type": "ticket_form_description",
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id, # Опционально - может быть None
|
||||
"phone": payload.phone,
|
||||
"phone": phone,
|
||||
"email": payload.email,
|
||||
"unified_id": payload.unified_id, # ✅ Unified ID пользователя
|
||||
"contact_id": payload.contact_id, # ✅ Contact ID пользователя
|
||||
"unified_id": unified_id, # из запроса или из сессии Redis
|
||||
"contact_id": contact_id,
|
||||
"description": payload.problem_description.strip(),
|
||||
"source": payload.source,
|
||||
"entry_channel": (payload.entry_channel or "web").strip() or "web", # telegram | max | web — для роутинга в n8n
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -1074,13 +1171,11 @@ async def publish_ticket_form_description(
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"📝 TicketForm description received",
|
||||
"📝 TicketForm description received → webhook=%s",
|
||||
description_webhook_url[:80] + ("..." if len(description_webhook_url) > 80 else ""),
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"claim_id": payload.claim_id or "not_set",
|
||||
"phone": payload.phone,
|
||||
"unified_id": payload.unified_id or "not_set",
|
||||
"contact_id": payload.contact_id or "not_set",
|
||||
"description_length": len(payload.problem_description),
|
||||
"channel": channel,
|
||||
},
|
||||
@@ -1096,23 +1191,44 @@ async def publish_ticket_form_description(
|
||||
f"🔄 Попытка {attempt}/{max_attempts}: отправка в n8n webhook",
|
||||
extra={"session_id": payload.session_id}
|
||||
)
|
||||
|
||||
# #region agent log
|
||||
_debug_log("H2_H4", "about to POST to n8n webhook", {"attempt": attempt, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
|
||||
# #endregion
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
settings.n8n_description_webhook,
|
||||
description_webhook_url,
|
||||
json=webhook_payload, # Отправляем в формате массива
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# #region agent log
|
||||
_debug_log("H4", "n8n webhook response", {"status": response.status_code, "url_short": description_webhook_url[:60] if description_webhook_url else ""})
|
||||
# #endregion
|
||||
if response.status_code == 200:
|
||||
response_body = response.text or ""
|
||||
logger.info(
|
||||
f"✅ Описание успешно отправлено в n8n webhook (попытка {attempt})",
|
||||
extra={
|
||||
"session_id": payload.session_id,
|
||||
"status_code": response.status_code,
|
||||
}
|
||||
"✅ Описание успешно отправлено в n8n webhook (попытка %s), ответ n8n (length=%s): %s",
|
||||
attempt,
|
||||
len(response_body),
|
||||
response_body[:2000] if len(response_body) > 2000 else response_body,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
try:
|
||||
parsed_n8n = json.loads(response_body)
|
||||
logger.info(
|
||||
"n8n description response (parsed): keys=%s",
|
||||
list(parsed_n8n.keys()) if isinstance(parsed_n8n, dict) else type(parsed_n8n).__name__,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# После описания фронт подписывается на SSE — логируем, на что именно
|
||||
logger.info(
|
||||
"📡 После описания в n8n клиент подпишется на: "
|
||||
"channel_ocr=ocr_events:%s (GET /api/v1/events/%s), "
|
||||
"channel_plan=claim:plan:%s (GET /api/v1/claim-plan/%s)",
|
||||
payload.session_id, payload.session_id, payload.session_id, payload.session_id,
|
||||
extra={"session_id": payload.session_id},
|
||||
)
|
||||
|
||||
# Успешно отправили - возвращаем успех
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
213
backend/app/api/consultations.py
Normal file
213
backend/app/api/consultations.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Консультации: тикеты из CRM (MySQL) через N8N_TICKET_FORM_CONSULTATION_WEBHOOK.
|
||||
|
||||
GET/POST /api/v1/consultations — верификация сессии, вызов webhook с тем же payload,
|
||||
что и у других хуков (session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id).
|
||||
Ответ webhook возвращается клиенту (список тикетов и т.д.).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
from app.api.session import SessionVerifyRequest, verify_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/consultations", tags=["consultations"])
|
||||
|
||||
|
||||
class ConsultationsPostBody(BaseModel):
|
||||
"""Тело запроса: session_token обязателен для идентификации."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
|
||||
|
||||
|
||||
class TicketDetailBody(BaseModel):
|
||||
"""Тело запроса «подробнее по тикету»."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
ticket_id: Any = Field(..., description="ID тикета в CRM (ticketid)")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа")
|
||||
|
||||
|
||||
def _get_consultation_webhook_url() -> str:
|
||||
url = (getattr(settings, "n8n_ticket_form_consultation_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_TICKET_FORM_CONSULTATION_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def _get_podrobnee_webhook_url() -> str:
|
||||
url = (getattr(settings, "n8n_ticket_form_podrobnee_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_TICKET_FORM_PODROBNEE_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
async def _call_consultation_webhook(
|
||||
session_token: str,
|
||||
entry_channel: str = "web",
|
||||
) -> dict:
|
||||
"""
|
||||
Верифицировать сессию, собрать payload как у других хуков, POST в webhook, вернуть ответ.
|
||||
"""
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if not getattr(verify_res, "valid", False):
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
|
||||
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"form_id": "ticket_form",
|
||||
"session_token": session_token,
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (entry_channel or "web").strip() or "web",
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
webhook_url = _get_consultation_webhook_url()
|
||||
logger.info("Consultation webhook: POST %s, keys=%s", webhook_url[:60], list(payload.keys()))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Сервис консультаций временно недоступен")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_TICKET_FORM_CONSULTATION_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис консультаций временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Consultation webhook вернул %s: %s",
|
||||
response.status_code,
|
||||
response.text[:500],
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Сервис консультаций вернул ошибку",
|
||||
)
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"raw": response.text or ""}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_consultations(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
entry_channel: Optional[str] = Query("web", description="Канал входа: telegram | max | web"),
|
||||
):
|
||||
"""
|
||||
Получить данные консультаций (тикеты из CRM) через n8n webhook.
|
||||
Передаётся тот же payload, что и на другие хуки: session_token, unified_id, contact_id, phone, chat_id, entry_channel, form_id.
|
||||
"""
|
||||
if not session_token or not str(session_token).strip():
|
||||
raise HTTPException(status_code=400, detail="Укажите session_token")
|
||||
return await _call_consultation_webhook(
|
||||
session_token=str(session_token).strip(),
|
||||
entry_channel=entry_channel or "web",
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def post_consultations(body: ConsultationsPostBody):
|
||||
"""То же по телу запроса."""
|
||||
return await _call_consultation_webhook(
|
||||
session_token=body.session_token.strip(),
|
||||
entry_channel=body.entry_channel or "web",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ticket-detail")
|
||||
async def get_ticket_detail(body: TicketDetailBody):
|
||||
"""
|
||||
Подробнее по тикету: верификация сессии, вызов N8N_TICKET_FORM_PODROBNEE_WEBHOOK
|
||||
с payload (session_token, unified_id, contact_id, phone, ticket_id, entry_channel, form_id).
|
||||
Ответ вебхука возвращается клиенту как есть (HTML в поле html/body или весь JSON).
|
||||
"""
|
||||
session_token = str(body.session_token or "").strip()
|
||||
if not session_token:
|
||||
raise HTTPException(status_code=400, detail="Укажите session_token")
|
||||
ticket_id = body.ticket_id
|
||||
if ticket_id is None or (isinstance(ticket_id, str) and not str(ticket_id).strip()):
|
||||
raise HTTPException(status_code=400, detail="Укажите ticket_id")
|
||||
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if not getattr(verify_res, "valid", False):
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия не содержит unified_id")
|
||||
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
entry_channel = (body.entry_channel or "web").strip() or "web"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"form_id": "ticket_form",
|
||||
"session_token": session_token,
|
||||
"unified_id": unified_id,
|
||||
"ticket_id": int(ticket_id) if isinstance(ticket_id, str) and ticket_id.isdigit() else ticket_id,
|
||||
"entry_channel": entry_channel,
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
webhook_url = _get_podrobnee_webhook_url()
|
||||
logger.info("Podrobnee webhook: POST %s, ticket_id=%s", webhook_url[:60], payload.get("ticket_id"))
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK")
|
||||
raise HTTPException(status_code=504, detail="Сервис временно недоступен")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_TICKET_FORM_PODROBNEE_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("Podrobnee webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Сервис вернул ошибку")
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except Exception:
|
||||
return {"html": response.text or "", "raw": True}
|
||||
89
backend/app/api/debug_session.py
Normal file
89
backend/app/api/debug_session.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import base64
|
||||
import json
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
WEBHOOK_DEBUG_URL = "https://n8n.clientright.ru/webhook/test"
|
||||
|
||||
router = APIRouter(prefix="/api/v1/debug", tags=["debug"])
|
||||
|
||||
|
||||
@router.post("/forward-to-webhook")
|
||||
async def forward_to_webhook(request: Request):
|
||||
"""
|
||||
Прокси: принимает JSON body и пересылает на n8n webhook (обход CORS с debug-webapp).
|
||||
Сначала POST; если n8n вернёт 404 (webhook только GET) — повторяем GET с ?data=base64(body).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.post(WEBHOOK_DEBUG_URL, json=body)
|
||||
if r.status_code == 404 and "POST" in (r.text or ""):
|
||||
b64 = base64.urlsafe_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode().rstrip("=")
|
||||
r = await client.get(f"{WEBHOOK_DEBUG_URL}?data={quote_plus(b64)}")
|
||||
ct = r.headers.get("content-type", "")
|
||||
if "application/json" in ct:
|
||||
try:
|
||||
content = r.json()
|
||||
except Exception:
|
||||
content = {"status": r.status_code, "text": (r.text or "")[:500]}
|
||||
else:
|
||||
content = {"status": r.status_code, "text": (r.text or "")[:500]}
|
||||
return JSONResponse(status_code=r.status_code, content=content)
|
||||
|
||||
|
||||
@router.get("/set_session_redirect", response_class=HTMLResponse)
|
||||
async def set_session_redirect(request: Request, session_token: str = "", claim_id: str = "", redirect_to: str = "/hello"):
|
||||
"""
|
||||
Temporary helper: returns an HTML page that sets localStorage.session_token and redirects to /hello?claim_id=...
|
||||
Use for manual testing: open this URL in a browser on the target origin.
|
||||
"""
|
||||
# Ensure values are safe for embedding
|
||||
js_session = session_token.replace('"', '\\"')
|
||||
target_claim = quote_plus(claim_id) if claim_id else ""
|
||||
# sanitize redirect_to - allow only absolute path starting with '/'
|
||||
if not redirect_to.startswith('/'):
|
||||
redirect_to = '/hello'
|
||||
if target_claim:
|
||||
# append query param correctly
|
||||
if '?' in redirect_to:
|
||||
redirect_url = f"{redirect_to}&claim_id={target_claim}"
|
||||
else:
|
||||
redirect_url = f"{redirect_to}?claim_id={target_claim}"
|
||||
else:
|
||||
redirect_url = redirect_to
|
||||
|
||||
html = f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Set session and redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
try {{
|
||||
const token = "{js_session}";
|
||||
if (token && token.length>0) {{
|
||||
localStorage.setItem('session_token', token);
|
||||
console.log('Set localStorage.session_token:', token);
|
||||
}} else {{
|
||||
console.log('No session_token provided');
|
||||
}}
|
||||
// give localStorage a tick then redirect
|
||||
setTimeout(() => {{
|
||||
window.location.href = "{redirect_url}";
|
||||
}}, 200);
|
||||
}} catch (e) {{
|
||||
document.body.innerText = 'Error: ' + e;
|
||||
}}
|
||||
</script>
|
||||
<p>Setting session and redirecting...</p>
|
||||
<p>If you are not redirected, click <a id="go" href="{redirect_url}">here</a>.</p>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
@@ -491,6 +491,32 @@ async def skip_document(
|
||||
},
|
||||
)
|
||||
|
||||
# Сохраняем documents_skipped в БД, чтобы при следующем заходе состояние не обнулялось
|
||||
claim_id_clean = claim_id.replace("claim_id_", "", 1) if claim_id.startswith("claim_id_") else claim_id
|
||||
try:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id, payload FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) ORDER BY updated_at DESC LIMIT 1",
|
||||
claim_id_clean,
|
||||
)
|
||||
if row:
|
||||
payload_raw = row.get("payload") or {}
|
||||
payload = json.loads(payload_raw) if isinstance(payload_raw, str) else (payload_raw if isinstance(payload_raw, dict) else {})
|
||||
skipped = list(payload.get("documents_skipped") or [])
|
||||
if document_type not in skipped:
|
||||
skipped.append(document_type)
|
||||
await db.execute(
|
||||
"""
|
||||
UPDATE clpr_claims
|
||||
SET payload = jsonb_set(COALESCE(payload, '{}'::jsonb), '{documents_skipped}', $1::jsonb)
|
||||
WHERE (payload->>'claim_id' = $2 OR id::text = $2)
|
||||
""",
|
||||
json.dumps(skipped),
|
||||
claim_id_clean,
|
||||
)
|
||||
logger.info("✅ documents_skipped сохранён в БД для claim_id=%s", claim_id_clean)
|
||||
except Exception as e:
|
||||
logger.warning("⚠️ Не удалось сохранить documents_skipped в БД: %s", e)
|
||||
|
||||
# Парсим ответ от n8n
|
||||
try:
|
||||
n8n_response = json.loads(response_text)
|
||||
|
||||
132
backend/app/api/documents_draft_open.py
Normal file
132
backend/app/api/documents_draft_open.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Documents draft-open endpoint
|
||||
|
||||
This file provides a single, isolated endpoint to fetch the documents list
|
||||
and minimal claim metadata for a given claim_id. It is implemented as a
|
||||
separate router to avoid touching existing document/claim routes.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import RedirectResponse
|
||||
from ..config import settings
|
||||
import logging
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from ..services.database import db
|
||||
|
||||
router = APIRouter(prefix="/api/v1/documents-draft", tags=["DocumentsDraft"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/open/{claim_id}")
|
||||
async def open_documents_draft(claim_id: str):
|
||||
"""
|
||||
Return minimal draft info focused on documents for the given claim_id.
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": True,
|
||||
"claim_id": "...",
|
||||
"session_token": "...",
|
||||
"status_code": "...",
|
||||
"documents_required": [...],
|
||||
"documents_meta": [...],
|
||||
"documents_count": 3,
|
||||
"created_at": "...",
|
||||
"updated_at": "..."
|
||||
}
|
||||
"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
payload->>'claim_id' AS claim_id,
|
||||
session_token,
|
||||
status_code,
|
||||
payload->'documents_required' AS documents_required,
|
||||
payload->'documents_meta' AS documents_meta,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM clpr_claims
|
||||
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
|
||||
|
||||
# Normalize JSONB fields which may be strings
|
||||
def parse_json_field(val: Any):
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except Exception:
|
||||
return []
|
||||
return val if isinstance(val, list) else []
|
||||
|
||||
documents_required = parse_json_field(row.get("documents_required"))
|
||||
documents_meta = parse_json_field(row.get("documents_meta"))
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"claim_id": row.get("claim_id") or str(row.get("id")),
|
||||
"session_token": row.get("session_token"),
|
||||
"status_code": row.get("status_code"),
|
||||
"documents_required": documents_required,
|
||||
"documents_meta": documents_meta,
|
||||
"documents_count": len(documents_required),
|
||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to open documents draft")
|
||||
raise HTTPException(status_code=500, detail=f"Error opening documents draft: {str(e)}")
|
||||
|
||||
|
||||
|
||||
@router.get("/open/launch/{claim_id}")
|
||||
async def launch_documents_draft(
|
||||
claim_id: str,
|
||||
target: str = Query("miniapp", description="Where to open: 'miniapp' or 'max'"),
|
||||
bot_name: str | None = Query(None, description="MAX bot name (required if target=max)"),
|
||||
):
|
||||
"""
|
||||
Convenience launcher:
|
||||
- target=miniapp (default) -> redirects to our miniapp URL with claim_id
|
||||
https://miniapp.clientright.ru/hello?claim_id=...
|
||||
- target=max -> redirects to MAX deep link:
|
||||
https://max.ru/{bot_name}?startapp={claim_id}
|
||||
This endpoint only redirects; it does not change persisted data.
|
||||
"""
|
||||
try:
|
||||
# ensure claim exists
|
||||
query = "SELECT 1 FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) LIMIT 1"
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
|
||||
|
||||
if target == "max":
|
||||
bot = bot_name or getattr(settings, "MAX_BOT_NAME", None)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=400, detail="bot_name is required when target=max")
|
||||
# claim_id is UUID with allowed chars (hex + hyphens) - OK for startapp
|
||||
url = f"https://max.ru/{bot}?startapp={claim_id}"
|
||||
return RedirectResponse(url)
|
||||
else:
|
||||
# default: open miniapp directly (hosted at /hello)
|
||||
url = f"https://miniapp.clientright.ru/hello?claim_id={claim_id}"
|
||||
return RedirectResponse(url)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to launch documents draft")
|
||||
raise HTTPException(status_code=500, detail=f"Error launching documents draft: {str(e)}")
|
||||
|
||||
@@ -9,12 +9,108 @@ from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
from app.services.redis_service import redis_service
|
||||
from app.services.database import db
|
||||
from app.config import settings
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||
|
||||
# Типы для единого отображения на фронте: тип + текст (+ data для consumer_complaint)
|
||||
DISPLAY_EVENT_TYPES = ("trash_message", "out_of_scope", "consumer_consultation", "consumer_complaint")
|
||||
|
||||
|
||||
def _normalize_display_event(actual_event: dict) -> dict:
|
||||
"""
|
||||
Приводит событие к формату { event_type, message [, data] } для единого отображения.
|
||||
event_type — один из: trash_message (красный), out_of_scope (жёлтый),
|
||||
consumer_consultation (синий), consumer_complaint (зелёный).
|
||||
"""
|
||||
raw_type = actual_event.get("event_type") or actual_event.get("type")
|
||||
payload = actual_event.get("payload") or actual_event.get("data") or {}
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload) if payload else {}
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
msg = (actual_event.get("message") or payload.get("message") or "").strip() or "Ответ получен"
|
||||
|
||||
# Если n8n уже прислал один из четырёх типов — не перезаписываем, отдаём как есть (синий/зелёный не превращаем в жёлтый)
|
||||
if raw_type in DISPLAY_EVENT_TYPES:
|
||||
return {
|
||||
"event_type": raw_type,
|
||||
"message": msg or "Ответ получен",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": (actual_event.get("suggested_actions") or payload.get("suggested_actions")) if raw_type == "out_of_scope" else None,
|
||||
}
|
||||
|
||||
if raw_type == "trash_message" or payload.get("intent") == "trash":
|
||||
return {
|
||||
"event_type": "trash_message",
|
||||
"message": msg or "К сожалению, это обращение не по тематике.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "out_of_scope":
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg or "К сожалению, мы не можем помочь с этим вопросом.",
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions") or payload.get("suggested_actions"),
|
||||
}
|
||||
if raw_type == "consumer_intent":
|
||||
intent = payload.get("intent") or actual_event.get("intent")
|
||||
if intent == "consultation":
|
||||
return {
|
||||
"event_type": "consumer_consultation",
|
||||
"message": msg or "Понял. Это похоже на консультацию.",
|
||||
"data": {},
|
||||
}
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Обращение принято.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
if raw_type == "documents_list_ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Подготовлен список документов.",
|
||||
"data": {
|
||||
**actual_event.get("data", {}),
|
||||
"documents_required": actual_event.get("documents_required"),
|
||||
"claim_id": actual_event.get("claim_id"),
|
||||
},
|
||||
}
|
||||
if raw_type in ("wizard_ready", "wizard_plan_ready", "claim_plan_ready"):
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "План готов.",
|
||||
"data": actual_event.get("data", actual_event),
|
||||
}
|
||||
if raw_type == "ocr_status" and actual_event.get("status") == "ready":
|
||||
return {
|
||||
"event_type": "consumer_complaint",
|
||||
"message": msg or "Данные подтверждены.",
|
||||
"data": actual_event.get("data", {}),
|
||||
}
|
||||
# Если есть текст сообщения, но тип неизвестен — считаем out_of_scope, чтобы фронт точно показал ответ
|
||||
if msg and msg.strip() and raw_type not in (
|
||||
"documents_list_ready", "document_uploaded", "document_ocr_completed",
|
||||
"ocr_status", "claim_ready", "claim_plan_ready", "claim_plan_error",
|
||||
):
|
||||
return {
|
||||
"event_type": "out_of_scope",
|
||||
"message": msg.strip(),
|
||||
"data": actual_event.get("data", {}),
|
||||
"suggested_actions": actual_event.get("suggested_actions"),
|
||||
}
|
||||
# Остальные события — прозрачно, только дополняем message
|
||||
out = dict(actual_event)
|
||||
if "message" not in out or not out.get("message"):
|
||||
out["message"] = msg
|
||||
return out
|
||||
|
||||
|
||||
class EventPublish(BaseModel):
|
||||
"""Модель для публикации события"""
|
||||
@@ -84,7 +180,10 @@ async def stream_events(task_id: str):
|
||||
Returns:
|
||||
StreamingResponse с событиями
|
||||
"""
|
||||
logger.info(f"🚀 SSE connection requested for session_token: {task_id}")
|
||||
logger.info(
|
||||
"🚀 SSE connection requested for session_token: %s → channel=ocr_events:%s (Redis %s:%s)",
|
||||
task_id, task_id, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def event_generator():
|
||||
"""Генератор событий из Redis Pub/Sub"""
|
||||
@@ -95,7 +194,10 @@ async def stream_events(task_id: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (проверка: redis-cli -h %s PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, settings.redis_host, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Подключено к событиям'})}\n\n"
|
||||
@@ -298,10 +400,14 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
|
||||
|
||||
# Единый формат для фронта: событие с полями event_type и message (и data при необходимости)
|
||||
raw_event_type = actual_event.get("event_type")
|
||||
raw_status = actual_event.get("status")
|
||||
actual_event = _normalize_display_event(actual_event)
|
||||
# Отправляем событие клиенту (плоский формат)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
||||
event_status = actual_event.get('status', 'unknown')
|
||||
event_type_sent = actual_event.get("event_type", "unknown")
|
||||
event_status = actual_event.get("status") or (actual_event.get("data") or {}).get("status") or "unknown"
|
||||
# Логируем размер и наличие данных
|
||||
data_info = actual_event.get('data', {})
|
||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
||||
@@ -310,18 +416,21 @@ async def stream_events(task_id: str):
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
# НЕ закрываем для documents_list_ready и document_ocr_completed (ждём ещё события)
|
||||
if event_status in ['completed', 'error'] and event_type_sent not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
if event_status in ['completed', 'error'] and (raw_event_type or event_type_sent) not in ['documents_list_ready', 'document_ocr_completed', 'document_uploaded']:
|
||||
logger.info(f"✅ Task {task_id} finished, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для финальных событий
|
||||
# Закрываем для финальных событий (raw_event_type до нормализации)
|
||||
if raw_event_type in ['claim_ready', 'claim_plan_ready', 'wizard_ready', 'wizard_plan_ready']:
|
||||
logger.info(f"✅ Final event {raw_event_type} sent, closing SSE")
|
||||
break
|
||||
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для ocr_status ready (форма заявления готова)
|
||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
||||
if raw_event_type == "ocr_status" and raw_status == "ready":
|
||||
logger.info("✅ OCR ready event sent, closing SSE")
|
||||
break
|
||||
else:
|
||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||
@@ -369,7 +478,10 @@ async def stream_claim_plan(session_token: str):
|
||||
}
|
||||
}
|
||||
"""
|
||||
logger.info(f"🚀 Claim plan SSE connection requested for session_token: {session_token}")
|
||||
logger.info(
|
||||
"🚀 Claim plan SSE: session_token=%s → channel=claim:plan:%s (Redis %s:%s)",
|
||||
session_token, session_token, settings.redis_host, settings.redis_port,
|
||||
)
|
||||
|
||||
async def claim_plan_generator():
|
||||
"""Генератор событий из Redis Pub/Sub для claim:plan канала"""
|
||||
@@ -379,7 +491,10 @@ async def stream_claim_plan(session_token: str):
|
||||
pubsub = redis_service.client.pubsub()
|
||||
await pubsub.subscribe(channel)
|
||||
|
||||
logger.info(f"📡 Client subscribed to {channel}")
|
||||
logger.info(
|
||||
"📡 Subscribed to channel=%s on Redis %s:%s (PUBSUB NUMSUB %s)",
|
||||
channel, settings.redis_host, settings.redis_port, channel,
|
||||
)
|
||||
|
||||
# Отправляем начальное событие
|
||||
yield f"data: {json.dumps({'status': 'connected', 'message': 'Ожидание данных заявления...'})}\n\n"
|
||||
|
||||
156
backend/app/api/max_auth.py
Normal file
156
backend/app/api/max_auth.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
MAX Mini App (WebApp) auth endpoint.
|
||||
|
||||
/api/v1/max/auth:
|
||||
- Принимает init_data от MAX Bridge (window.WebApp.initData)
|
||||
- Валидирует init_data и извлекает данные пользователя MAX
|
||||
- Проксирует max_user_id в n8n для получения unified_id/контакта
|
||||
- Создаёт сессию в Redis (аналогично Telegram — без SMS)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..services.max_auth import extract_max_user, MaxAuthError
|
||||
from ..config import settings
|
||||
from . import n8n_proxy
|
||||
from . import session as session_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/max", tags=["MAX"])
|
||||
|
||||
|
||||
class MaxAuthRequest(BaseModel):
|
||||
init_data: str
|
||||
session_token: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
|
||||
class MaxAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
import uuid
|
||||
return f"sess-{uuid.uuid4()}"
|
||||
|
||||
|
||||
@router.post("/auth", response_model=MaxAuthResponse)
|
||||
async def max_auth(request: MaxAuthRequest):
|
||||
"""
|
||||
Авторизация пользователя через MAX WebApp (Mini App).
|
||||
"""
|
||||
init_data = request.init_data or ""
|
||||
phone = (request.phone or "").strip()
|
||||
logger.info(
|
||||
"[MAX] POST /api/v1/max/auth: init_data длина=%s, phone=%s, session_token=%s",
|
||||
len(init_data),
|
||||
bool(phone),
|
||||
bool(request.session_token),
|
||||
)
|
||||
if not init_data:
|
||||
logger.warning("[MAX] init_data пустой")
|
||||
raise HTTPException(status_code=400, detail="init_data обязателен")
|
||||
|
||||
bot_configured = bool((getattr(settings, "max_bot_token", None) or "").strip())
|
||||
webhook_configured = bool((getattr(settings, "n8n_max_auth_webhook", None) or "").strip())
|
||||
logger.info("[MAX] Конфиг: MAX_BOT_TOKEN=%s, N8N_MAX_AUTH_WEBHOOK=%s", bot_configured, webhook_configured)
|
||||
|
||||
try:
|
||||
max_user = extract_max_user(request.init_data)
|
||||
except MaxAuthError as e:
|
||||
logger.warning("[MAX] Ошибка валидации initData: %s", e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
max_user_id = max_user["max_user_id"]
|
||||
logger.info("[MAX] MAX user валиден: id=%s, username=%s", max_user_id, max_user.get("username"))
|
||||
|
||||
session_token = request.session_token or _generate_session_token()
|
||||
|
||||
n8n_payload = {
|
||||
"max_user_id": max_user_id,
|
||||
"username": max_user.get("username"),
|
||||
"first_name": max_user.get("first_name"),
|
||||
"last_name": max_user.get("last_name"),
|
||||
"session_token": session_token,
|
||||
"form_id": "ticket_form",
|
||||
"init_data": request.init_data,
|
||||
}
|
||||
if phone:
|
||||
n8n_payload["phone"] = phone
|
||||
|
||||
logger.info("[MAX] Валидация OK → вызов n8n webhook (max_user_id=%s)", max_user_id)
|
||||
try:
|
||||
class _DummyRequest:
|
||||
def __init__(self, payload: dict):
|
||||
self._payload = payload
|
||||
async def json(self):
|
||||
return self._payload
|
||||
|
||||
n8n_response = await n8n_proxy.proxy_max_auth(_DummyRequest(n8n_payload)) # type: ignore[arg-type]
|
||||
n8n_data = jsonable_encoder(n8n_response)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
logger.info("[MAX] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
_raw = (
|
||||
n8n_data.get("need_contact")
|
||||
or _result_dict.get("need_contact")
|
||||
or n8n_data.get("needContact")
|
||||
or _result_dict.get("needContact")
|
||||
)
|
||||
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[MAX] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone_res = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||
|
||||
if not unified_id:
|
||||
logger.info("[MAX] n8n не вернул unified_id (юзер не в базе) — возвращаем need_contact=true. Ответ: %s", n8n_data)
|
||||
return MaxAuthResponse(success=False, need_contact=True)
|
||||
|
||||
session_request = session_api.SessionCreateRequest(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
phone=phone_res or phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(max_user_id) if max_user_id else None,
|
||||
)
|
||||
|
||||
try:
|
||||
await session_api.create_session(session_request)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка создания сессии в Redis")
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка создания сессии: {str(e)}")
|
||||
|
||||
return MaxAuthResponse(
|
||||
success=True,
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
contact_id=contact_id,
|
||||
phone=phone_res or phone,
|
||||
has_drafts=has_drafts,
|
||||
)
|
||||
@@ -75,4 +75,5 @@ class TicketFormDescriptionRequest(BaseModel):
|
||||
problem_description: str = Field(..., min_length=10, description="Свободное описание ситуации")
|
||||
source: str = Field("ticket_form", description="Источник события")
|
||||
channel: Optional[str] = Field(None, description="Переопределение Redis канала (опционально)")
|
||||
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web — для роутинга в n8n")
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ N8N_FILE_UPLOAD_WEBHOOK = settings.n8n_file_upload_webhook or None
|
||||
N8N_CREATE_CONTACT_WEBHOOK = settings.n8n_create_contact_webhook
|
||||
N8N_CREATE_CLAIM_WEBHOOK = settings.n8n_create_claim_webhook
|
||||
N8N_TG_AUTH_WEBHOOK = settings.n8n_tg_auth_webhook or None
|
||||
N8N_MAX_AUTH_WEBHOOK = getattr(settings, "n8n_max_auth_webhook", None) or None
|
||||
|
||||
|
||||
@router.post("/policy/check")
|
||||
@@ -286,6 +287,54 @@ async def proxy_telegram_auth(request: Request):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации Telegram: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/max/auth")
|
||||
async def proxy_max_auth(request: Request):
|
||||
"""
|
||||
Проксирует авторизацию MAX WebApp в n8n webhook.
|
||||
Используется /api/v1/max/auth: backend валидирует initData, затем вызывает этот роут.
|
||||
"""
|
||||
if not N8N_MAX_AUTH_WEBHOOK:
|
||||
logger.error("[MAX] N8N_MAX_AUTH_WEBHOOK не задан в .env")
|
||||
raise HTTPException(status_code=500, detail="N8N MAX auth webhook не настроен")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
logger.info(
|
||||
"[MAX] Proxy → n8n: max_user_id=%s, session_token=%s",
|
||||
body.get("max_user_id", "unknown"),
|
||||
body.get("session_token", "unknown"),
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
N8N_MAX_AUTH_WEBHOOK,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
response_text = response.text or ""
|
||||
logger.info("[MAX] n8n webhook ответ: status=%s, len=%s", response.status_code, len(response_text))
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error("[MAX] Парсинг JSON: %s. Response: %s", e, response_text[:500])
|
||||
raise HTTPException(status_code=500, detail="Ошибка парсинга ответа n8n")
|
||||
|
||||
logger.error("[MAX] n8n вернул %s: %s", response.status_code, response_text[:500])
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"N8N MAX auth error: {response_text}",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("[MAX] Таймаут n8n MAX auth webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (MAX auth)")
|
||||
except Exception as e:
|
||||
logger.exception("[MAX] Ошибка вызова n8n MAX auth: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка авторизации MAX: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/claim/create")
|
||||
async def proxy_create_claim(request: Request):
|
||||
"""
|
||||
|
||||
322
backend/app/api/profile.py
Normal file
322
backend/app/api/profile.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Профиль пользователя: контактные данные из CRM через n8n webhook.
|
||||
|
||||
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
|
||||
GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/profile", tags=["profile"])
|
||||
|
||||
|
||||
async def _resolve_profile_identity(
|
||||
session_token: Optional[str] = None,
|
||||
unified_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
entry_channel: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str], Optional[str]]:
|
||||
"""Возвращает (unified_id, contact_id, phone, chat_id). При ошибке — HTTPException(401/400)."""
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
|
||||
if not unified_id and channel and channel_user_id:
|
||||
try:
|
||||
from app.api.session import get_session_by_channel_user
|
||||
session_data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
|
||||
if session_data:
|
||||
unified_id = session_data.get("unified_id")
|
||||
contact_id = session_data.get("contact_id")
|
||||
phone = session_data.get("phone")
|
||||
if chat_id is None:
|
||||
chat_id = session_data.get("chat_id")
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка чтения сессии по channel: %s", e)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
|
||||
if not unified_id and session_token:
|
||||
try:
|
||||
from app.api.session import SessionVerifyRequest, verify_session
|
||||
verify_res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if getattr(verify_res, "valid", False):
|
||||
unified_id = getattr(verify_res, "unified_id", None)
|
||||
contact_id = getattr(verify_res, "contact_id", None)
|
||||
phone = getattr(verify_res, "phone", None)
|
||||
if chat_id is None:
|
||||
chat_id = getattr(verify_res, "chat_id", None)
|
||||
if not unified_id:
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна или истекла")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка верификации сессии для профиля: %s", e)
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Укажите session_token, (channel + channel_user_id) или unified_id",
|
||||
)
|
||||
return unified_id, contact_id, phone, chat_id
|
||||
|
||||
|
||||
class ProfileContactRequest(BaseModel):
|
||||
"""Запрос контактных данных: session_token, (channel + channel_user_id) или unified_id."""
|
||||
session_token: Optional[str] = Field(None, description="Токен сессии (unified_id подставится из Redis)")
|
||||
unified_id: Optional[str] = Field(None, description="Unified ID пользователя в CRM")
|
||||
channel: Optional[str] = Field(None, description="Канал: tg | max (для поиска сессии в Redis)")
|
||||
channel_user_id: Optional[str] = Field(None, description="ID пользователя в канале (tg/max)")
|
||||
entry_channel: Optional[str] = Field(None, description="Канал входа: telegram | max | web")
|
||||
chat_id: Optional[str] = Field(None, description="Telegram user id или Max user id (для передачи в n8n)")
|
||||
|
||||
|
||||
class ProfileContactUpdateRequest(BaseModel):
|
||||
"""Обновление контакта: session_token обязателен; остальные поля — редактируемые (все обязательны на фронте, кроме phone)."""
|
||||
session_token: str = Field(..., description="Токен сессии")
|
||||
entry_channel: Optional[str] = Field("web", description="Канал входа: telegram | max | web")
|
||||
last_name: str = Field("", description="Фамилия")
|
||||
first_name: str = Field("", description="Имя")
|
||||
middle_name: str = Field("", description="Отчество")
|
||||
birth_date: str = Field("", description="Дата рождения")
|
||||
birth_place: str = Field("", description="Место рождения")
|
||||
inn: str = Field("", description="ИНН")
|
||||
email: str = Field("", description="Email")
|
||||
registration_address: str = Field("", description="Адрес регистрации")
|
||||
mailing_address: str = Field("", description="Почтовый адрес")
|
||||
bank_for_compensation: str = Field("", description="Банк для возмещения")
|
||||
phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)")
|
||||
|
||||
|
||||
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
|
||||
|
||||
@router.get("/dadata/address")
|
||||
async def get_dadata_address_suggestions(
|
||||
query: str = Query(..., min_length=1, description="Строка поиска адреса"),
|
||||
count: int = Query(10, ge=1, le=20),
|
||||
):
|
||||
"""
|
||||
Подсказки адресов через DaData (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env).
|
||||
Возвращает список { value, unrestricted_value } для подстановки в форму профиля.
|
||||
"""
|
||||
api_key = (getattr(settings, "forma_dadata_api_key", None) or "").strip()
|
||||
secret = (getattr(settings, "forma_dadata_secret", None) or "").strip()
|
||||
if not api_key or not secret:
|
||||
raise HTTPException(status_code=503, detail="DaData не настроен (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET)")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.post(
|
||||
DADATA_SUGGEST_URL,
|
||||
json={"query": query.strip(), "count": count},
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"X-Secret": secret,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning("DaData address suggest вернул %s: %s", response.status_code, response.text[:300])
|
||||
return {"suggestions": []}
|
||||
data = response.json()
|
||||
suggestions = data.get("suggestions") or []
|
||||
return {"suggestions": [{"value": s.get("value", ""), "unrestricted_value": s.get("unrestricted_value", "")} for s in suggestions]}
|
||||
except httpx.TimeoutException:
|
||||
return {"suggestions": []}
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка DaData suggest: %s", e)
|
||||
return {"suggestions": []}
|
||||
|
||||
|
||||
@router.get("/contact")
|
||||
async def get_profile_contact(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
unified_id: Optional[str] = Query(None, description="Unified ID"),
|
||||
channel: Optional[str] = Query(None, description="Канал: tg | max"),
|
||||
channel_user_id: Optional[str] = Query(None, description="ID пользователя в канале"),
|
||||
entry_channel: Optional[str] = Query(None, description="Канал: telegram | max | web"),
|
||||
chat_id: Optional[str] = Query(None, description="Telegram/Max user id"),
|
||||
):
|
||||
"""
|
||||
Получить контактные данные из CRM через n8n webhook.
|
||||
Передайте session_token, (channel + channel_user_id) или unified_id.
|
||||
"""
|
||||
return await _fetch_contact(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
entry_channel=entry_channel,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/contact")
|
||||
async def post_profile_contact(body: ProfileContactRequest):
|
||||
"""То же по телу запроса."""
|
||||
return await _fetch_contact(
|
||||
session_token=body.session_token,
|
||||
unified_id=body.unified_id,
|
||||
channel=body.channel,
|
||||
channel_user_id=body.channel_user_id,
|
||||
entry_channel=body.entry_channel,
|
||||
chat_id=body.chat_id,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_contact(
|
||||
session_token: Optional[str] = None,
|
||||
unified_id: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
entry_channel: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
webhook_url = getattr(settings, "n8n_contact_webhook", None) or ""
|
||||
if not webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_CONTACT_WEBHOOK не настроен",
|
||||
)
|
||||
|
||||
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
|
||||
session_token=session_token,
|
||||
unified_id=unified_id,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
entry_channel=entry_channel,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
payload: dict = {
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (entry_channel or "web").strip() or "web",
|
||||
}
|
||||
if session_token:
|
||||
payload["session_token"] = session_token
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_CONTACT_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис контактов временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("N8N contact webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Сервис контактов вернул ошибку",
|
||||
)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
data = response.text or ""
|
||||
|
||||
if isinstance(data, list):
|
||||
return {"items": data if data else []}
|
||||
if isinstance(data, dict):
|
||||
if "items" in data and isinstance(data["items"], list):
|
||||
return {"items": data["items"]}
|
||||
if "contact" in data:
|
||||
c = data["contact"]
|
||||
return {"items": c if isinstance(c, list) else [c] if c else []}
|
||||
if "data" in data and isinstance(data["data"], list):
|
||||
return {"items": data["data"]}
|
||||
if data and isinstance(data, dict):
|
||||
return {"items": [data]}
|
||||
return {"items": []}
|
||||
return {"items": []}
|
||||
|
||||
|
||||
@router.post("/contact/update")
|
||||
async def post_profile_contact_update(body: ProfileContactUpdateRequest):
|
||||
"""
|
||||
Обновить контакт в CRM через N8N_PROFILE_UPDATE_WEBHOOK.
|
||||
Вызывается с фронта при verification="0". Сессия проверяется по session_token.
|
||||
"""
|
||||
webhook_url = (getattr(settings, "n8n_profile_update_webhook", None) or "").strip()
|
||||
if not webhook_url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_PROFILE_UPDATE_WEBHOOK не настроен",
|
||||
)
|
||||
|
||||
unified_id, contact_id, phone, chat_id = await _resolve_profile_identity(
|
||||
session_token=body.session_token,
|
||||
entry_channel=body.entry_channel,
|
||||
chat_id=None,
|
||||
)
|
||||
|
||||
payload: dict = {
|
||||
"unified_id": unified_id,
|
||||
"entry_channel": (body.entry_channel or "web").strip() or "web",
|
||||
"session_token": body.session_token,
|
||||
"last_name": (body.last_name or "").strip(),
|
||||
"first_name": (body.first_name or "").strip(),
|
||||
"middle_name": (body.middle_name or "").strip(),
|
||||
"birth_date": (body.birth_date or "").strip(),
|
||||
"birth_place": (body.birth_place or "").strip(),
|
||||
"inn": (body.inn or "").strip(),
|
||||
"email": (body.email or "").strip(),
|
||||
"registration_address": (body.registration_address or "").strip(),
|
||||
"mailing_address": (body.mailing_address or "").strip(),
|
||||
"bank_for_compensation": (body.bank_for_compensation or "").strip(),
|
||||
}
|
||||
if contact_id is not None:
|
||||
payload["contact_id"] = contact_id
|
||||
if body.phone is not None and str(body.phone).strip():
|
||||
payload["phone"] = str(body.phone).strip()
|
||||
elif phone is not None:
|
||||
payload["phone"] = phone
|
||||
if chat_id is not None and str(chat_id).strip():
|
||||
payload["chat_id"] = str(chat_id).strip()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N_PROFILE_UPDATE_WEBHOOK: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Не удалось сохранить профиль, попробуйте позже")
|
||||
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.warning("N8N profile update webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Не удалось сохранить профиль, попробуйте позже",
|
||||
)
|
||||
|
||||
result: dict = {"success": True}
|
||||
try:
|
||||
data = response.json()
|
||||
if isinstance(data, dict) and data:
|
||||
result.update(data)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
@@ -2,7 +2,8 @@
|
||||
Session management API endpoints
|
||||
|
||||
Обеспечивает управление сессиями пользователей через Redis:
|
||||
- Верификация существующей сессии
|
||||
- Верификация по session_token или по (channel, channel_user_id)
|
||||
- Ключ Redis: session:{channel}:{channel_user_id} для универсального auth
|
||||
- Logout (удаление сессии)
|
||||
"""
|
||||
|
||||
@@ -15,6 +16,8 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import redis.asyncio as redis
|
||||
|
||||
from ..services.redis_service import redis_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
||||
@@ -22,13 +25,103 @@ router = APIRouter(prefix="/api/v1/session", tags=["session"])
|
||||
# Redis connection (используем существующее подключение)
|
||||
redis_client: Optional[redis.Redis] = None
|
||||
|
||||
# TTL для сессии по channel+channel_user_id (секунды). 0 = без TTL.
|
||||
SESSION_BY_CHANNEL_TTL_HOURS = 24
|
||||
|
||||
def init_redis(redis_conn: redis.Redis):
|
||||
"""Initialize Redis connection"""
|
||||
|
||||
def init_redis(redis_conn: Optional[redis.Redis]):
|
||||
"""Initialize Redis connection (локальный Redis для сессий). None при shutdown."""
|
||||
global redis_client
|
||||
redis_client = redis_conn
|
||||
|
||||
|
||||
def _session_key_by_channel(channel: str, channel_user_id: str) -> str:
|
||||
"""Ключ Redis для сессии по каналу и id пользователя в канале."""
|
||||
return f"session:{channel}:{channel_user_id}"
|
||||
|
||||
|
||||
async def set_session_by_channel_user(
|
||||
channel: str,
|
||||
channel_user_id: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
Записать сессию в Redis по ключу session:{channel}:{channel_user_id}.
|
||||
data: unified_id, phone, contact_id, chat_id, has_drafts, ...
|
||||
"""
|
||||
if not redis_client:
|
||||
raise HTTPException(status_code=500, detail="Redis connection not initialized")
|
||||
key = _session_key_by_channel(channel, channel_user_id)
|
||||
payload = {
|
||||
"unified_id": data.get("unified_id") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"contact_id": data.get("contact_id") or "",
|
||||
"chat_id": str(channel_user_id),
|
||||
"has_drafts": data.get("has_drafts", False),
|
||||
"verified_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
|
||||
body = json.dumps(payload)
|
||||
if ttl:
|
||||
await redis_client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_client.set(key, body)
|
||||
# Дублируем сессию в внешний Redis, чтобы n8n мог читать по тем же ключам
|
||||
try:
|
||||
if redis_service.client:
|
||||
if ttl:
|
||||
await redis_service.client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_service.client.set(key, body)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось продублировать сессию в внешний Redis (channel): %s", e)
|
||||
logger.info("Сессия записана: %s, unified_id=%s", key, payload.get("unified_id"))
|
||||
|
||||
|
||||
async def get_session_by_channel_user(channel: str, channel_user_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Прочитать сессию из Redis по channel и channel_user_id. Если нет — None."""
|
||||
if not redis_client:
|
||||
return None
|
||||
key = _session_key_by_channel(channel, channel_user_id)
|
||||
raw = await redis_client.get(key)
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def set_session_by_token(session_token: str, data: Dict[str, Any]) -> None:
|
||||
"""Записать сессию в Redis по ключу session:{session_token} (для совместимости с profile/claims)."""
|
||||
if not redis_client:
|
||||
return
|
||||
key = f"session:{session_token}"
|
||||
payload = {
|
||||
"unified_id": data.get("unified_id") or "",
|
||||
"phone": data.get("phone") or "",
|
||||
"contact_id": data.get("contact_id") or "",
|
||||
"chat_id": data.get("chat_id") or "",
|
||||
"has_drafts": data.get("has_drafts", False),
|
||||
"verified_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
ttl = SESSION_BY_CHANNEL_TTL_HOURS * 3600 if SESSION_BY_CHANNEL_TTL_HOURS else None
|
||||
body = json.dumps(payload)
|
||||
if ttl:
|
||||
await redis_client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_client.set(key, body)
|
||||
# Дублируем сессию по токену в внешний Redis для доступа из n8n
|
||||
try:
|
||||
if redis_service.client:
|
||||
if ttl:
|
||||
await redis_service.client.setex(key, ttl, body)
|
||||
else:
|
||||
await redis_service.client.set(key, body)
|
||||
except Exception as e:
|
||||
logger.warning("Не удалось продублировать сессию в внешний Redis (token): %s", e)
|
||||
|
||||
|
||||
class SessionVerifyRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
@@ -39,10 +132,16 @@ class SessionVerifyResponse(BaseModel):
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
chat_id: Optional[str] = None # telegram_user_id или max_user_id
|
||||
verified_at: Optional[str] = None
|
||||
expires_in_seconds: Optional[int] = None
|
||||
|
||||
|
||||
class SessionVerifyByChannelRequest(BaseModel):
|
||||
channel: str # tg | max
|
||||
channel_user_id: str
|
||||
|
||||
|
||||
class SessionLogoutRequest(BaseModel):
|
||||
session_token: str
|
||||
|
||||
@@ -92,6 +191,7 @@ async def verify_session(request: SessionVerifyRequest):
|
||||
unified_id=session_data.get('unified_id'),
|
||||
phone=session_data.get('phone'),
|
||||
contact_id=session_data.get('contact_id'),
|
||||
chat_id=session_data.get('chat_id'),
|
||||
verified_at=session_data.get('verified_at'),
|
||||
expires_in_seconds=ttl if ttl > 0 else None
|
||||
)
|
||||
@@ -143,20 +243,47 @@ async def logout_session(request: SessionLogoutRequest):
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка при выходе: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/verify-by-channel", response_model=SessionVerifyResponse)
|
||||
async def verify_session_by_channel(request: SessionVerifyByChannelRequest):
|
||||
"""
|
||||
Проверить сессию по channel и channel_user_id (ключ Redis: session:{channel}:{channel_user_id}).
|
||||
Используется, когда клиент не хранит session_token и передаёт channel + channel_user_id.
|
||||
"""
|
||||
try:
|
||||
data = await get_session_by_channel_user(request.channel, request.channel_user_id)
|
||||
if not data:
|
||||
return SessionVerifyResponse(success=True, valid=False)
|
||||
ttl = await redis_client.ttl(_session_key_by_channel(request.channel, request.channel_user_id)) if redis_client else 0
|
||||
return SessionVerifyResponse(
|
||||
success=True,
|
||||
valid=True,
|
||||
unified_id=data.get("unified_id"),
|
||||
phone=data.get("phone"),
|
||||
contact_id=data.get("contact_id"),
|
||||
chat_id=data.get("chat_id"),
|
||||
verified_at=data.get("verified_at"),
|
||||
expires_in_seconds=ttl if ttl > 0 else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка verify-by-channel: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Ошибка проверки сессии")
|
||||
|
||||
|
||||
class SessionCreateRequest(BaseModel):
|
||||
session_token: str
|
||||
unified_id: str
|
||||
phone: str
|
||||
contact_id: str
|
||||
ttl_hours: int = 24
|
||||
chat_id: Optional[str] = None # telegram_user_id или max_user_id для передачи в n8n как chat_id
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_session(request: SessionCreateRequest):
|
||||
"""
|
||||
Создать новую сессию (вызывается после успешной SMS верификации)
|
||||
Создать новую сессию (вызывается после успешной SMS верификации или TG/MAX auth)
|
||||
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n.
|
||||
Обычно вызывается из Step1Phone после получения данных от n8n или из auth2/tg/max auth.
|
||||
"""
|
||||
try:
|
||||
if not redis_client:
|
||||
@@ -171,6 +298,8 @@ async def create_session(request: SessionCreateRequest):
|
||||
'verified_at': datetime.utcnow().isoformat(),
|
||||
'expires_at': (datetime.utcnow() + timedelta(hours=request.ttl_hours)).isoformat()
|
||||
}
|
||||
if request.chat_id is not None:
|
||||
session_data['chat_id'] = str(request.chat_id).strip()
|
||||
|
||||
# Сохраняем в Redis с TTL
|
||||
await redis_client.setex(
|
||||
|
||||
699
backend/app/api/support.py
Normal file
699
backend/app/api/support.py
Normal file
@@ -0,0 +1,699 @@
|
||||
"""
|
||||
Support API: диалог поддержки (треды + сообщения).
|
||||
POST /api/v1/support — multipart, создание/поиск треда, запись сообщения user, прокси в n8n.
|
||||
GET /api/v1/support/thread — получить тред и сообщения.
|
||||
GET /api/v1/support/stream — SSE: один канал на юзера, события из Postgres NOTIFY.
|
||||
POST /api/v1/support/incoming — webhook для n8n: добавить сообщение от поддержки.
|
||||
GET /api/v1/support/limits — лимиты вложений.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import asyncpg
|
||||
import httpx
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from ..config import settings
|
||||
from ..services.database import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/support", tags=["support"])
|
||||
|
||||
# Реестр SSE по unified_id: кому пушить при NOTIFY (один канал support_events)
|
||||
_support_stream_registry: Dict[str, Set[asyncio.Queue]] = {}
|
||||
_support_notify_inbox: asyncio.Queue = asyncio.Queue()
|
||||
_SUPPORT_EVENTS_CHANNEL = "support_events"
|
||||
|
||||
|
||||
def _get_support_webhook() -> str:
|
||||
url = (getattr(settings, "n8n_support_webhook", None) or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="N8N_SUPPORT_WEBHOOK не настроен",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
async def _resolve_session(
|
||||
session_token: Optional[str] = None,
|
||||
channel: Optional[str] = None,
|
||||
channel_user_id: Optional[str] = None,
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""Возвращает (unified_id, phone, email, session_id)."""
|
||||
unified_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
session_id: Optional[str] = session_token
|
||||
|
||||
if channel and channel_user_id:
|
||||
try:
|
||||
from .session import get_session_by_channel_user
|
||||
data = await get_session_by_channel_user(channel.strip(), str(channel_user_id).strip())
|
||||
if data:
|
||||
unified_id = data.get("unified_id")
|
||||
phone = data.get("phone")
|
||||
if session_token is None:
|
||||
session_id = data.get("session_token")
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка чтения сессии по channel: %s", e)
|
||||
|
||||
if not unified_id and session_token:
|
||||
try:
|
||||
from .session import SessionVerifyRequest, verify_session
|
||||
res = await verify_session(SessionVerifyRequest(session_token=session_token))
|
||||
if getattr(res, "valid", False):
|
||||
unified_id = getattr(res, "unified_id", None)
|
||||
phone = getattr(res, "phone", None)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("Ошибка верификации сессии для support: %s", e)
|
||||
raise HTTPException(status_code=401, detail="Сессия недействительна")
|
||||
|
||||
if not unified_id:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Укажите session_token или (channel + channel_user_id)",
|
||||
)
|
||||
|
||||
return (unified_id, phone, None, session_id or session_token)
|
||||
|
||||
|
||||
def _check_attachment_limits(
|
||||
files: List[Tuple[str, bytes, Optional[str]]],
|
||||
) -> None:
|
||||
"""Проверяет лимиты вложений; 0 или пусто = не проверять."""
|
||||
max_count = getattr(settings, "support_attachments_max_count", 0) or 0
|
||||
max_bytes = settings.support_attachments_max_size_bytes
|
||||
allowed = (getattr(settings, "support_attachments_allowed_types", None) or "").strip()
|
||||
|
||||
if max_count > 0 and len(files) > max_count:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Слишком много файлов: максимум {max_count}",
|
||||
)
|
||||
|
||||
if max_bytes > 0:
|
||||
for name, content, _ in files:
|
||||
if len(content) > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Файл {name} превышает допустимый размер ({max_bytes // (1024*1024)} МБ)",
|
||||
)
|
||||
|
||||
if allowed:
|
||||
allowed_list = [x.strip().lower() for x in allowed.split(",") if x.strip()]
|
||||
for filename, content, mime in files:
|
||||
mime = (mime or "").strip().lower()
|
||||
ext = ""
|
||||
if "." in filename:
|
||||
ext = "." + filename.rsplit(".", 1)[-1].lower()
|
||||
ok = False
|
||||
for a in allowed_list:
|
||||
if a.startswith("."):
|
||||
if ext == a:
|
||||
ok = True
|
||||
break
|
||||
elif "/" in a:
|
||||
if mime == a or (a.endswith("/*") and mime.split("/")[0] == a.split("/")[0]):
|
||||
ok = True
|
||||
break
|
||||
else:
|
||||
if ext == f".{a}" or mime == a:
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Тип файла «{filename}» не разрешён. Допустимые: {allowed}",
|
||||
)
|
||||
|
||||
|
||||
def _uuid_str(val: Any) -> str:
|
||||
if val is None:
|
||||
return ""
|
||||
return str(val)
|
||||
|
||||
|
||||
async def _get_or_create_thread(unified_id: str, claim_id: Optional[str], source: str) -> str:
|
||||
"""Найти тред по (unified_id, claim_id) или создать. Возвращает thread_id (UUID str)."""
|
||||
if claim_id and claim_id.strip():
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
|
||||
unified_id,
|
||||
claim_id.strip(),
|
||||
)
|
||||
else:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
|
||||
unified_id,
|
||||
)
|
||||
|
||||
if row:
|
||||
return _uuid_str(row["id"])
|
||||
|
||||
thread_id = uuid.uuid4()
|
||||
await db.execute(
|
||||
"INSERT INTO clpr_support_threads (id, unified_id, claim_id, source) VALUES ($1, $2, $3, $4)",
|
||||
thread_id,
|
||||
unified_id,
|
||||
claim_id.strip() if claim_id and claim_id.strip() else None,
|
||||
source or "bar",
|
||||
)
|
||||
return str(thread_id)
|
||||
|
||||
|
||||
def _support_notify_callback(conn: Any, pid: int, channel: str, payload: str) -> None:
|
||||
"""Вызывается asyncpg при NOTIFY support_events. Кладём payload во inbox."""
|
||||
try:
|
||||
_support_notify_inbox.put_nowait(payload)
|
||||
except Exception as e:
|
||||
logger.warning("Support notify inbox put: %s", e)
|
||||
|
||||
|
||||
async def _run_support_listener() -> None:
|
||||
"""
|
||||
Один подписчик на Postgres NOTIFY support_events.
|
||||
Держит соединение, слушает канал, раскидывает по unified_id в реестр.
|
||||
"""
|
||||
conn: Optional[asyncpg.Connection] = None
|
||||
try:
|
||||
conn = await asyncpg.connect(
|
||||
host=settings.postgres_host,
|
||||
port=settings.postgres_port,
|
||||
database=settings.postgres_db,
|
||||
user=settings.postgres_user,
|
||||
password=settings.postgres_password,
|
||||
)
|
||||
await conn.execute("LISTEN " + _SUPPORT_EVENTS_CHANNEL)
|
||||
conn.add_listener(_SUPPORT_EVENTS_CHANNEL, _support_notify_callback)
|
||||
logger.info("Support LISTEN %s started", _SUPPORT_EVENTS_CHANNEL)
|
||||
while True:
|
||||
payload = await _support_notify_inbox.get()
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
u_id = data.get("unified_id")
|
||||
if not u_id:
|
||||
continue
|
||||
queues = _support_stream_registry.get(u_id)
|
||||
if not queues:
|
||||
continue
|
||||
for q in list(queues):
|
||||
try:
|
||||
q.put_nowait(data)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Support stream put: %s", e)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Support notify payload not JSON: %s", e)
|
||||
except Exception as e:
|
||||
logger.exception("Support listener dispatch: %s", e)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Support listener cancelled")
|
||||
except Exception as e:
|
||||
logger.exception("Support listener error: %s", e)
|
||||
finally:
|
||||
if conn and not conn.is_closed():
|
||||
await conn.close()
|
||||
logger.info("Support LISTEN stopped")
|
||||
|
||||
|
||||
@router.get("/limits")
|
||||
async def get_support_limits():
|
||||
"""Лимиты вложений (0/пусто = без ограничений)."""
|
||||
max_count = getattr(settings, "support_attachments_max_count", 0) or 0
|
||||
max_bytes = settings.support_attachments_max_size_bytes
|
||||
allowed = (getattr(settings, "support_attachments_allowed_types", None) or "").strip()
|
||||
unlimited = max_count == 0 and max_bytes == 0 and not allowed
|
||||
return {
|
||||
"max_count": max_count,
|
||||
"max_size_per_file": max_bytes,
|
||||
"allowed_types": allowed,
|
||||
"unlimited": unlimited,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/threads")
|
||||
async def get_support_threads(
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Список всех тредов пользователя для экрана «Мои обращения».
|
||||
Сессия: session_token или channel + channel_user_id.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
|
||||
rows = await db.fetch_all(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.claim_id,
|
||||
t.source,
|
||||
t.ticket_id,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
(SELECT m.body FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1) AS last_body,
|
||||
(SELECT m.created_at FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1) AS last_at,
|
||||
(SELECT COUNT(*)::int FROM clpr_support_messages m WHERE m.thread_id = t.id) AS messages_count,
|
||||
(SELECT COUNT(*)::int FROM clpr_support_messages m
|
||||
WHERE m.thread_id = t.id AND m.direction = 'support'
|
||||
AND m.created_at > COALESCE(
|
||||
(SELECT r.last_read_at FROM clpr_support_reads r WHERE r.unified_id = t.unified_id AND r.thread_id = t.id),
|
||||
'1970-01-01'::timestamptz
|
||||
)) AS unread_count
|
||||
FROM clpr_support_threads t
|
||||
WHERE t.unified_id = $1
|
||||
ORDER BY COALESCE(
|
||||
(SELECT m.created_at FROM clpr_support_messages m WHERE m.thread_id = t.id ORDER BY m.created_at DESC LIMIT 1),
|
||||
t.updated_at,
|
||||
t.created_at
|
||||
) DESC
|
||||
""",
|
||||
unified_id,
|
||||
)
|
||||
threads = []
|
||||
for r in rows:
|
||||
last_at = r.get("last_at")
|
||||
if hasattr(last_at, "isoformat"):
|
||||
last_at = last_at.isoformat()
|
||||
elif last_at is not None:
|
||||
last_at = str(last_at)
|
||||
threads.append({
|
||||
"thread_id": _uuid_str(r["id"]),
|
||||
"claim_id": str(r["claim_id"]).strip() if r.get("claim_id") else None,
|
||||
"source": str(r.get("source") or "bar"),
|
||||
"ticket_id": str(r["ticket_id"]) if r.get("ticket_id") else None,
|
||||
"created_at": r["created_at"].isoformat() if hasattr(r.get("created_at"), "isoformat") else str(r.get("created_at") or ""),
|
||||
"updated_at": r["updated_at"].isoformat() if hasattr(r.get("updated_at"), "isoformat") else str(r.get("updated_at") or ""),
|
||||
"last_body": (r.get("last_body") or "")[:200] if r.get("last_body") else None,
|
||||
"last_at": last_at,
|
||||
"messages_count": r.get("messages_count") or 0,
|
||||
"unread_count": r.get("unread_count") or 0,
|
||||
})
|
||||
return {"threads": threads}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def get_support_unread_count(
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""Суммарное число непрочитанных сообщений от поддержки (для бейджа в баре)."""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
row = await db.fetch_one(
|
||||
"""
|
||||
SELECT COALESCE(SUM(cnt), 0)::int AS total
|
||||
FROM (
|
||||
SELECT COUNT(*)::int AS cnt
|
||||
FROM clpr_support_threads t
|
||||
JOIN clpr_support_messages m ON m.thread_id = t.id AND m.direction = 'support'
|
||||
WHERE t.unified_id = $1
|
||||
AND m.created_at > COALESCE(
|
||||
(SELECT r.last_read_at FROM clpr_support_reads r WHERE r.unified_id = t.unified_id AND r.thread_id = t.id),
|
||||
'1970-01-01'::timestamptz
|
||||
)
|
||||
GROUP BY t.id
|
||||
) s
|
||||
""",
|
||||
unified_id,
|
||||
)
|
||||
total = (row and row.get("total")) or 0
|
||||
return {"unread_count": total}
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_support_thread_read(
|
||||
request: Request,
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Отметить тред как прочитанный (пользователь открыл чат).
|
||||
Тело JSON: { "thread_id": "..." } или query thread_id= / claim_id=.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
thread_id = request.query_params.get("thread_id")
|
||||
claim_id = request.query_params.get("claim_id")
|
||||
if not thread_id:
|
||||
try:
|
||||
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
thread_id = body.get("thread_id")
|
||||
if not thread_id:
|
||||
claim_id = claim_id or body.get("claim_id")
|
||||
if claim_id and not thread_id:
|
||||
cid = str(claim_id).strip()
|
||||
if cid:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
|
||||
unified_id,
|
||||
cid,
|
||||
)
|
||||
else:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
|
||||
unified_id,
|
||||
)
|
||||
if row:
|
||||
thread_id = str(row["id"])
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id или claim_id обязателен")
|
||||
try:
|
||||
thread_uuid = uuid.UUID(thread_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Некорректный thread_id")
|
||||
# Проверяем, что тред принадлежит пользователю
|
||||
row = await db.fetch_one(
|
||||
"SELECT id FROM clpr_support_threads WHERE id = $1 AND unified_id = $2",
|
||||
thread_uuid,
|
||||
unified_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Тред не найден")
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO clpr_support_reads (unified_id, thread_id, last_read_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (unified_id, thread_id) DO UPDATE SET last_read_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
unified_id,
|
||||
thread_uuid,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/thread")
|
||||
async def get_support_thread(
|
||||
claim_id: Optional[str] = Query(None),
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Получить тред поддержки и сообщения. Query: claim_id (опционально).
|
||||
Сессия: session_token или channel + channel_user_id.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
|
||||
cid = claim_id.strip() if claim_id and str(claim_id).strip() else None
|
||||
if cid:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id, ticket_id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id = $2",
|
||||
unified_id,
|
||||
cid,
|
||||
)
|
||||
else:
|
||||
row = await db.fetch_one(
|
||||
"SELECT id, ticket_id FROM clpr_support_threads WHERE unified_id = $1 AND claim_id IS NULL",
|
||||
unified_id,
|
||||
)
|
||||
|
||||
if not row:
|
||||
return {"thread_id": None, "messages": [], "ticket_id": None}
|
||||
|
||||
thread_id = _uuid_str(row["id"])
|
||||
ticket_id = row.get("ticket_id")
|
||||
if ticket_id is not None:
|
||||
ticket_id = str(ticket_id)
|
||||
|
||||
rows = await db.fetch_all(
|
||||
"SELECT id, direction, body, attachments, created_at FROM clpr_support_messages WHERE thread_id = $1 ORDER BY created_at ASC",
|
||||
row["id"],
|
||||
)
|
||||
messages = []
|
||||
for r in rows:
|
||||
att = r.get("attachments")
|
||||
if att is not None and not isinstance(att, list):
|
||||
try:
|
||||
att = json.loads(att) if isinstance(att, str) else att
|
||||
except Exception:
|
||||
att = []
|
||||
messages.append({
|
||||
"id": _uuid_str(r["id"]),
|
||||
"direction": r["direction"],
|
||||
"body": r["body"] or "",
|
||||
"attachments": att or [],
|
||||
"created_at": r["created_at"].isoformat() if hasattr(r["created_at"], "isoformat") else str(r["created_at"]),
|
||||
})
|
||||
|
||||
return {
|
||||
"thread_id": thread_id,
|
||||
"messages": messages,
|
||||
"ticket_id": ticket_id,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def support_stream(
|
||||
session_token: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
channel_user_id: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
SSE: один поток на пользователя по unified_id. События приходят из Postgres NOTIFY (триггер на clpr_support_messages).
|
||||
Query: session_token или channel + channel_user_id.
|
||||
"""
|
||||
unified_id, _, _, _ = await _resolve_session(
|
||||
session_token=session_token, channel=channel, channel_user_id=channel_user_id
|
||||
)
|
||||
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||
if unified_id not in _support_stream_registry:
|
||||
_support_stream_registry[unified_id] = set()
|
||||
_support_stream_registry[unified_id].add(queue)
|
||||
|
||||
async def event_gen():
|
||||
try:
|
||||
yield f"data: {json.dumps({'event': 'connected', 'unified_id': unified_id}, ensure_ascii=False)}\n\n"
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
# Формат как в SupportChat: id, direction, body, attachments, created_at
|
||||
created_at = msg.get("created_at")
|
||||
if hasattr(created_at, "isoformat"):
|
||||
created_at = created_at.isoformat()
|
||||
elif created_at is not None:
|
||||
created_at = str(created_at)
|
||||
event = {
|
||||
"event": "support_message",
|
||||
"message": {
|
||||
"id": str(msg.get("message_id", "")),
|
||||
"direction": msg.get("direction", "support"),
|
||||
"body": msg.get("body", ""),
|
||||
"attachments": json.loads(msg.get("attachments", "[]")) if isinstance(msg.get("attachments"), str) else (msg.get("attachments") or []),
|
||||
"created_at": created_at,
|
||||
},
|
||||
"thread_id": str(msg.get("thread_id", "")),
|
||||
}
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
yield ": keepalive\n\n"
|
||||
finally:
|
||||
_support_stream_registry.get(unified_id, set()).discard(queue)
|
||||
if unified_id in _support_stream_registry and not _support_stream_registry[unified_id]:
|
||||
del _support_stream_registry[unified_id]
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def submit_support(request: Request):
|
||||
"""
|
||||
Отправить сообщение в поддержку. Multipart: message, subject?, claim_id?, source, thread_id?,
|
||||
session_token (или channel + channel_user_id), файлы. Создаёт/находит тред, пишет сообщение, проксирует в n8n.
|
||||
"""
|
||||
form = await request.form()
|
||||
message = form.get("message")
|
||||
if not message or not str(message).strip():
|
||||
raise HTTPException(status_code=400, detail="Поле message обязательно")
|
||||
message = str(message).strip()
|
||||
|
||||
subject = form.get("subject")
|
||||
subject = str(subject).strip() if subject else None
|
||||
claim_id = form.get("claim_id")
|
||||
claim_id = str(claim_id).strip() if claim_id else None
|
||||
source = form.get("source")
|
||||
source = str(source).strip() if source else "bar"
|
||||
thread_id_param = form.get("thread_id")
|
||||
thread_id_param = str(thread_id_param).strip() if thread_id_param else None
|
||||
session_token = form.get("session_token")
|
||||
session_token = str(session_token).strip() if session_token else None
|
||||
channel = form.get("channel")
|
||||
channel = str(channel).strip() if channel else None
|
||||
channel_user_id = form.get("channel_user_id")
|
||||
channel_user_id = str(channel_user_id).strip() if channel_user_id else None
|
||||
|
||||
file_items: List[Tuple[str, bytes, Optional[str]]] = []
|
||||
for key, value in form.multi_items():
|
||||
if hasattr(value, "read") and hasattr(value, "filename"):
|
||||
content = await value.read()
|
||||
file_items.append((value.filename or key, content, getattr(value, "content_type", None)))
|
||||
|
||||
_check_attachment_limits(file_items)
|
||||
|
||||
unified_id, phone, email, session_id = await _resolve_session(
|
||||
session_token=session_token,
|
||||
channel=channel,
|
||||
channel_user_id=channel_user_id,
|
||||
)
|
||||
|
||||
thread_id = await _get_or_create_thread(unified_id, claim_id or None, source)
|
||||
|
||||
attachments_json = json.dumps([{"filename": fn} for fn, _, _ in file_items])
|
||||
|
||||
message_id = uuid.uuid4()
|
||||
await db.execute(
|
||||
"INSERT INTO clpr_support_messages (id, thread_id, direction, body, attachments) VALUES ($1, $2, 'user', $3, $4)",
|
||||
message_id,
|
||||
uuid.UUID(thread_id),
|
||||
message,
|
||||
attachments_json,
|
||||
)
|
||||
|
||||
row = await db.fetch_one("SELECT ticket_id FROM clpr_support_threads WHERE id = $1", uuid.UUID(thread_id))
|
||||
ticket_id = str(row["ticket_id"]) if row and row.get("ticket_id") else None
|
||||
|
||||
webhook_url = _get_support_webhook()
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
data: Dict[str, str] = {
|
||||
"message": message,
|
||||
"source": source or "bar",
|
||||
"unified_id": unified_id or "",
|
||||
"phone": (phone or "").strip(),
|
||||
"email": (email or "").strip(),
|
||||
"session_id": (session_id or "").strip(),
|
||||
"timestamp": timestamp,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
if subject:
|
||||
data["subject"] = subject
|
||||
if claim_id:
|
||||
data["claim_id"] = claim_id
|
||||
if ticket_id:
|
||||
data["ticket_id"] = ticket_id
|
||||
|
||||
files_for_upload: Dict[str, Tuple[str, bytes, Optional[str]]] = {}
|
||||
for i, (filename, content, content_type) in enumerate(file_items):
|
||||
key = f"attachments[{i}]" if len(file_items) > 1 else "attachments"
|
||||
if key in files_for_upload:
|
||||
key = f"attachments[{i}]"
|
||||
files_for_upload[key] = (filename, content, content_type or "application/octet-stream")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
data=data,
|
||||
files=files_for_upload or None,
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Таймаут вызова N8N support webhook")
|
||||
raise HTTPException(status_code=504, detail="Таймаут подключения к сервису поддержки")
|
||||
except Exception as e:
|
||||
logger.exception("Ошибка вызова N8N support webhook: %s", e)
|
||||
raise HTTPException(status_code=502, detail="Сервис поддержки временно недоступен")
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("N8N support webhook вернул %s: %s", response.status_code, response.text[:500])
|
||||
raise HTTPException(status_code=502, detail="Сервис поддержки вернул ошибку")
|
||||
|
||||
try:
|
||||
resp_json = response.json()
|
||||
if isinstance(resp_json, dict) and resp_json.get("ticket_id"):
|
||||
tid = str(resp_json.get("ticket_id")).strip()
|
||||
if tid:
|
||||
await db.execute(
|
||||
"UPDATE clpr_support_threads SET ticket_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2",
|
||||
tid,
|
||||
uuid.UUID(thread_id),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"thread_id": thread_id,
|
||||
"message_id": str(message_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/incoming")
|
||||
async def support_incoming(
|
||||
request: Request,
|
||||
x_support_incoming_secret: Optional[str] = Header(None, alias="X-Support-Incoming-Secret"),
|
||||
):
|
||||
"""
|
||||
Webhook для n8n: добавить сообщение от поддержки в тред.
|
||||
Тело: JSON { "thread_id" или "ticket_id", "body", "attachments?" }.
|
||||
Заголовок X-Support-Incoming-Secret должен совпадать с SUPPORT_INCOMING_SECRET (если задан).
|
||||
"""
|
||||
secret = (getattr(settings, "support_incoming_secret", None) or "").strip()
|
||||
if secret:
|
||||
header_secret = x_support_incoming_secret or request.query_params.get("secret") or ""
|
||||
if header_secret.strip() != secret:
|
||||
raise HTTPException(status_code=403, detail="Invalid secret")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="JSON body required")
|
||||
|
||||
thread_id = body.get("thread_id")
|
||||
ticket_id = body.get("ticket_id")
|
||||
msg_body = (body.get("body") or "").strip()
|
||||
attachments = body.get("attachments")
|
||||
if isinstance(attachments, list):
|
||||
attachments = json.dumps(attachments)
|
||||
else:
|
||||
attachments = "[]"
|
||||
|
||||
if not thread_id and not ticket_id:
|
||||
raise HTTPException(status_code=400, detail="thread_id or ticket_id required")
|
||||
|
||||
if ticket_id and not thread_id:
|
||||
row = await db.fetch_one("SELECT id FROM clpr_support_threads WHERE ticket_id = $1", str(ticket_id))
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Thread not found by ticket_id")
|
||||
thread_id = str(row["id"])
|
||||
|
||||
try:
|
||||
thread_uuid = uuid.UUID(thread_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid thread_id")
|
||||
|
||||
msg_id = uuid.uuid4()
|
||||
await db.execute(
|
||||
"INSERT INTO clpr_support_messages (id, thread_id, direction, body, attachments) VALUES ($1, $2, 'support', $3, $4)",
|
||||
msg_id,
|
||||
thread_uuid,
|
||||
msg_body,
|
||||
attachments,
|
||||
)
|
||||
logger.info("Support incoming message added: thread_id=%s", thread_id)
|
||||
return {"success": True, "message_id": str(msg_id)}
|
||||
@@ -31,11 +31,12 @@ class TelegramAuthRequest(BaseModel):
|
||||
|
||||
class TelegramAuthResponse(BaseModel):
|
||||
success: bool
|
||||
session_token: str
|
||||
unified_id: str
|
||||
session_token: Optional[str] = None
|
||||
unified_id: Optional[str] = None
|
||||
contact_id: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
has_drafts: Optional[bool] = None
|
||||
need_contact: Optional[bool] = None
|
||||
|
||||
|
||||
def _generate_session_token() -> str:
|
||||
@@ -114,15 +115,35 @@ async def telegram_auth(request: TelegramAuthRequest):
|
||||
logger.exception("[TG] Ошибка вызова n8n Telegram auth webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"Ошибка обращения к n8n: {str(e)}")
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or (n8n_data.get("result") or {}).get("unified_id")
|
||||
contact_id = n8n_data.get("contact_id") or n8n_data.get("result", {}).get("contact_id")
|
||||
phone = n8n_data.get("phone") or n8n_data.get("result", {}).get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts")
|
||||
# Логируем сырой ответ n8n для отладки (ключи и need_contact/unified_id)
|
||||
logger.info("[TG] n8n ответ (ключи): %s", list(n8n_data.keys()) if isinstance(n8n_data, dict) else type(n8n_data).__name__)
|
||||
_result = n8n_data.get("result")
|
||||
_result_dict = _result if isinstance(_result, dict) else {}
|
||||
if _result_dict:
|
||||
logger.info("[TG] n8n result ключи: %s", list(_result_dict.keys()))
|
||||
|
||||
# Если n8n вернул need_contact — пользователя нет в базе, мини-апп должен закрыться
|
||||
_raw = (
|
||||
n8n_data.get("need_contact")
|
||||
or _result_dict.get("need_contact")
|
||||
or n8n_data.get("needContact")
|
||||
or _result_dict.get("needContact")
|
||||
)
|
||||
need_contact = _raw is True or _raw == 1 or (isinstance(_raw, str) and str(_raw).strip().lower() in ("true", "1"))
|
||||
if need_contact:
|
||||
logger.info("[TG] n8n: need_contact=true — возвращаем need_contact, фронт закроет приложение")
|
||||
return TelegramAuthResponse(success=False, need_contact=True)
|
||||
|
||||
# Ожидаем от n8n как минимум unified_id
|
||||
unified_id = n8n_data.get("unified_id") or _result_dict.get("unified_id") or n8n_data.get("unifiedId")
|
||||
contact_id = n8n_data.get("contact_id") or _result_dict.get("contact_id") or n8n_data.get("contactId")
|
||||
phone = n8n_data.get("phone") or _result_dict.get("phone")
|
||||
has_drafts = n8n_data.get("has_drafts") or _result_dict.get("has_drafts")
|
||||
|
||||
# Нет unified_id = пользователь не найден в базе → тоже возвращаем need_contact, чтобы фронт закрыл мини-апп
|
||||
if not unified_id:
|
||||
logger.error("[TG] n8n не вернул unified_id. Полный ответ: %s", n8n_data)
|
||||
raise HTTPException(status_code=500, detail="n8n не вернул unified_id для Telegram пользователя")
|
||||
logger.info("[TG] n8n не вернул unified_id (пользователь не в базе) — возвращаем need_contact=true. Ответ n8n: %s", n8n_data)
|
||||
return TelegramAuthResponse(success=False, need_contact=True)
|
||||
|
||||
# 4. Создаём сессию в Redis через существующий /api/v1/session/create
|
||||
# Для Telegram телефон может быть ещё неизвестен, поэтому передаём пустые строки при отсутствии.
|
||||
@@ -132,6 +153,7 @@ async def telegram_auth(request: TelegramAuthRequest):
|
||||
phone=phone or "",
|
||||
contact_id=contact_id or "",
|
||||
ttl_hours=24,
|
||||
chat_id=str(telegram_user_id) if telegram_user_id else None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Конфигурация приложения
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List, Optional
|
||||
@@ -62,20 +63,33 @@ class Settings(BaseSettings):
|
||||
return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}"
|
||||
|
||||
# ============================================
|
||||
# REDIS
|
||||
# REDIS (внешний — события, буферы, SMS и т.д.)
|
||||
# ============================================
|
||||
redis_host: str = "localhost"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = "CRM_Redis_Pass_2025_Secure!"
|
||||
redis_db: int = 0
|
||||
redis_prefix: str = "ticket_form:"
|
||||
|
||||
# Redis для сессий (локальный в Docker — miniapp_redis; снаружи — localhost:6383 или свой)
|
||||
redis_session_host: str = "localhost"
|
||||
redis_session_port: int = 6383
|
||||
redis_session_password: str = ""
|
||||
redis_session_db: int = 0
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Формирует URL для подключения к Redis"""
|
||||
"""Формирует URL для подключения к Redis (внешний)"""
|
||||
if self.redis_password:
|
||||
return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
@property
|
||||
def redis_session_url(self) -> str:
|
||||
"""URL для локального Redis сессий"""
|
||||
if self.redis_session_password:
|
||||
return f"redis://:{self.redis_session_password}@{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
|
||||
return f"redis://{self.redis_session_host}:{self.redis_session_port}/{self.redis_session_db}"
|
||||
|
||||
# ============================================
|
||||
# RABBITMQ
|
||||
@@ -125,9 +139,17 @@ class Settings(BaseSettings):
|
||||
aviationstack_base_url: str = "http://api.aviationstack.com/v1"
|
||||
|
||||
# ============================================
|
||||
# NSPK BANKS API
|
||||
# NSPK BANKS API (и альтернативный BANK_IP из .env)
|
||||
# ============================================
|
||||
nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json"
|
||||
bank_ip: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
bank_api_url: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
|
||||
# ============================================
|
||||
# DADATA (подсказки адресов в форме профиля)
|
||||
# ============================================
|
||||
forma_dadata_api_key: str = "" # FORMA_DADATA_API_KEY
|
||||
forma_dadata_secret: str = "" # FORMA_DADATA_SECRET
|
||||
|
||||
# ============================================
|
||||
# SMS SERVICE (SigmaSMS)
|
||||
@@ -184,14 +206,72 @@ class Settings(BaseSettings):
|
||||
n8n_file_upload_webhook: str = ""
|
||||
n8n_create_contact_webhook: str = "https://n8n.clientright.pro/webhook/511fde97-88bb-4fb4-bea5-cafdc364be27"
|
||||
n8n_create_claim_webhook: str = "https://n8n.clientright.pro/webhook/d5bf4ca6-9e44-44b9-9714-3186ea703e7d"
|
||||
n8n_description_webhook: str = "https://n8n.clientright.pro/webhook/aiform_description" # Webhook для обработки описания проблемы
|
||||
n8n_description_webhook: str = "https://n8n.clientright.ru/webhook/ticket_form_description" # Webhook для описания проблемы (переопределяется через N8N_DESCRIPTION_WEBHOOK в .env)
|
||||
# Консультации: тикеты из CRM (MySQL) — тот же payload, что и у других хуков
|
||||
n8n_ticket_form_consultation_webhook: str = "" # N8N_TICKET_FORM_CONSULTATION_WEBHOOK в .env
|
||||
# Подробнее по тикету: session + ticket_id → ответ вебхука (HTML/JSON)
|
||||
n8n_ticket_form_podrobnee_webhook: str = "" # N8N_TICKET_FORM_PODROBNEE_WEBHOOK в .env
|
||||
n8n_project_form_podrobnee_webhook: str = "" # N8N_PROJECT_FORM_PODROBNEE_WEBHOOK — детали дела/проекта из CRM по project_id
|
||||
# Wizard и финальная отправка заявки (create) — один webhook, меняется через .env
|
||||
n8n_ticket_form_final_webhook: str = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||
n8n_tg_auth_webhook: str = "" # Webhook для авторизации пользователей Telegram WebApp (Mini App)
|
||||
|
||||
# Контактные данные из CRM для раздела «Профиль» (массив или пусто)
|
||||
n8n_contact_webhook: str = "" # N8N_CONTACT_WEBHOOK в .env
|
||||
n8n_profile_update_webhook: str = "" # N8N_PROFILE_UPDATE_WEBHOOK в .env — обновление профиля (verification=0)
|
||||
|
||||
# ============================================
|
||||
# TELEGRAM BOT
|
||||
# ============================================
|
||||
telegram_bot_token: str = "" # Токен бота для проверки initData WebApp
|
||||
|
||||
|
||||
def get_telegram_bot_tokens(self) -> List[tuple]:
|
||||
"""Список (bot_id, token) для проверки подписи Telegram initData. Один токен — [('default', token)]."""
|
||||
token = (self.telegram_bot_token or "").strip()
|
||||
if token:
|
||||
return [("default", token)]
|
||||
return []
|
||||
|
||||
# ============================================
|
||||
# MAX (мессенджер) — Mini App auth
|
||||
# ============================================
|
||||
max_bot_token: str = "" # Токен бота MAX (один бот)
|
||||
max_bot_tokens: str = "" # Мультибот: JSON {"bot_id": "token", ...}. Если задан — используется вместо max_bot_token.
|
||||
|
||||
def get_max_bot_tokens(self) -> List[tuple]:
|
||||
"""Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)]."""
|
||||
s = (self.max_bot_tokens or os.environ.get("MAX_BOT_TOKENS") or "").strip()
|
||||
if s:
|
||||
try:
|
||||
d = json.loads(s)
|
||||
out = [(k, str(v).strip()) for k, v in d.items() if v and str(v).strip()]
|
||||
if out:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
token = (self.max_bot_token or os.environ.get("MAX_BOT_TOKEN") or "").strip()
|
||||
if token:
|
||||
return [("default", token)]
|
||||
return []
|
||||
|
||||
n8n_max_auth_webhook: str = "" # Webhook n8n: max_user_id → unified_id, contact_id, has_drafts
|
||||
n8n_auth_webhook: str = "" # Универсальный auth: channel + channel_user_id + init_data → unified_id, phone, contact_id, has_drafts
|
||||
|
||||
# ============================================
|
||||
# ПОДДЕРЖКА (чат, треды, n8n webhook)
|
||||
# ============================================
|
||||
n8n_support_webhook: str = "" # N8N_SUPPORT_WEBHOOK — URL webhook n8n (multipart). Обязателен для отправки сообщений.
|
||||
support_attachments_max_count: int = 0 # 0 = без ограничений
|
||||
support_attachments_max_size_mb: int = 0 # 0 = без ограничений
|
||||
support_attachments_allowed_types: str = "" # пусто = любые (например: .pdf,.jpg,image/*)
|
||||
support_incoming_secret: str = "" # Секрет для POST /api/v1/support/incoming (n8n → backend)
|
||||
|
||||
@property
|
||||
def support_attachments_max_size_bytes(self) -> int:
|
||||
if self.support_attachments_max_size_mb <= 0:
|
||||
return 0
|
||||
return self.support_attachments_max_size_mb * 1024 * 1024
|
||||
|
||||
# ============================================
|
||||
# LOGGING
|
||||
# ============================================
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
Ticket Form Intake Platform - FastAPI Backend
|
||||
"""
|
||||
from fastapi import FastAPI, Request
|
||||
import json
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import redis.asyncio as redis
|
||||
from .config import settings, get_cors_origins_live, get_settings
|
||||
from .services.database import db
|
||||
from .services.redis_service import redis_service
|
||||
@@ -13,14 +19,97 @@ from .services.rabbitmq_service import rabbitmq_service
|
||||
from .services.policy_service import policy_service
|
||||
from .services.crm_mysql_service import crm_mysql_service
|
||||
from .services.s3_service import s3_service
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth
|
||||
from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, auth_universal, documents_draft_open, profile, support
|
||||
from .api import debug_session
|
||||
|
||||
# Настройка логирования
|
||||
# Настройка логирования (уровень из config: LOG_LEVEL=DEBUG для отладки)
|
||||
import sys
|
||||
_level = getattr(logging, (getattr(get_settings(), "log_level", None) or "INFO").upper(), logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
level=_level,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
stream=sys.stdout,
|
||||
)
|
||||
# Применяем уровень ко всем логгерам приложения
|
||||
logging.getLogger("app").setLevel(_level)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("Backend log level: %s", logging.getLevelName(_level))
|
||||
|
||||
DEBUG_SESSION_ID = "2a4d38"
|
||||
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)
|
||||
DEBUG_LOG_PATH = "/app/logs/cursor-debug-2a4d38.log"
|
||||
|
||||
|
||||
def _debug_write(
|
||||
*,
|
||||
hypothesis_id: str,
|
||||
run_id: str,
|
||||
location: str,
|
||||
message: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
NDJSON debug log for Cursor Debug Mode.
|
||||
IMPORTANT: do not log secrets/PII (tokens, tg hash, full init_data, phone, etc).
|
||||
"""
|
||||
try:
|
||||
ts = int(time.time() * 1000)
|
||||
entry = {
|
||||
"sessionId": DEBUG_SESSION_ID,
|
||||
"id": f"log_{ts}_{uuid.uuid4().hex[:8]}",
|
||||
"timestamp": ts,
|
||||
"location": location,
|
||||
"message": message,
|
||||
"data": data,
|
||||
"runId": run_id,
|
||||
"hypothesisId": hypothesis_id,
|
||||
}
|
||||
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
# Never break prod request handling due to debug logging
|
||||
return
|
||||
|
||||
|
||||
def _extract_client_bundle_info(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Returns (moduleUrl, scriptSrc, build) from the last 'boot' entry if present.
|
||||
"""
|
||||
logs = payload.get("logs") or []
|
||||
if not isinstance(logs, list):
|
||||
return (None, None, None)
|
||||
for entry in reversed(logs):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("event") != "boot":
|
||||
continue
|
||||
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
||||
module_url = data.get("moduleUrl") if isinstance(data.get("moduleUrl"), str) else None
|
||||
script_src = data.get("scriptSrc") if isinstance(data.get("scriptSrc"), str) else None
|
||||
build = data.get("build") if isinstance(data.get("build"), str) else None
|
||||
return (module_url, script_src, build)
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def _extract_last_window_error(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
logs = payload.get("logs") or []
|
||||
if not isinstance(logs, list):
|
||||
return {}
|
||||
for entry in reversed(logs):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("event") != "window_error":
|
||||
continue
|
||||
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
||||
# Keep only safe fields
|
||||
return {
|
||||
"message": data.get("message"),
|
||||
"filename": data.get("filename"),
|
||||
"lineno": data.get("lineno"),
|
||||
"colno": data.get("colno"),
|
||||
"hasStack": bool(data.get("stack")),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -38,12 +127,23 @@ async def lifespan(app: FastAPI):
|
||||
logger.warning(f"⚠️ PostgreSQL not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем Redis
|
||||
# Подключаем внешний Redis (события, буферы, SMS и т.д.)
|
||||
await redis_service.connect()
|
||||
# Инициализируем session API с Redis connection
|
||||
session.init_redis(redis_service.client)
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Redis not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем локальный Redis для сессий (отдельно от внешнего)
|
||||
session_redis = await redis.from_url(
|
||||
settings.redis_session_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
await session_redis.ping()
|
||||
session.init_redis(session_redis)
|
||||
logger.info(f"✅ Session Redis connected: {settings.redis_session_host}:{settings.redis_session_port}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Session Redis not available: {e}")
|
||||
|
||||
try:
|
||||
# Подключаем RabbitMQ
|
||||
@@ -68,6 +168,14 @@ async def lifespan(app: FastAPI):
|
||||
s3_service.connect()
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ S3 storage not available: {e}")
|
||||
|
||||
# Postgres LISTEN support_events для доставки сообщений поддержки в реальном времени (SSE)
|
||||
support_listener_task = None
|
||||
try:
|
||||
support_listener_task = asyncio.create_task(support._run_support_listener())
|
||||
logger.info("✅ Support NOTIFY listener task started")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Support listener not started: {e}")
|
||||
|
||||
logger.info("✅ Ticket Form Intake Platform started successfully!")
|
||||
|
||||
@@ -75,9 +183,18 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# SHUTDOWN
|
||||
logger.info("🛑 Shutting down Ticket Form Intake Platform...")
|
||||
if support_listener_task and not support_listener_task.done():
|
||||
support_listener_task.cancel()
|
||||
try:
|
||||
await support_listener_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await db.disconnect()
|
||||
await redis_service.disconnect()
|
||||
if session.redis_client:
|
||||
await session.redis_client.close()
|
||||
session.init_redis(None)
|
||||
await rabbitmq_service.disconnect()
|
||||
await policy_service.close()
|
||||
await crm_mysql_service.close()
|
||||
@@ -107,6 +224,30 @@ async def refresh_config_on_request(request, call_next):
|
||||
get_settings()
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Temporary middleware for capturing incoming init_data / startapp / claim_id for debugging.
|
||||
@app.middleware("http")
|
||||
async def capture_initdata_middleware(request, call_next):
|
||||
try:
|
||||
# Check query string first
|
||||
qs = str(request.url.query or "")
|
||||
if qs and ("claim_id" in qs or "startapp" in qs or "start_param" in qs):
|
||||
logger.info("[CAPTURE Q] %s %s QUERY: %s", request.method, request.url.path, qs)
|
||||
|
||||
# Check JSON body for known keys
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
body = await request.body()
|
||||
if body:
|
||||
text = body.decode(errors="ignore")
|
||||
if any(k in text for k in ("init_data", "startapp", "start_param", "claim_id")):
|
||||
# Log truncated body (limit 10k chars)
|
||||
snippet = text if len(text) <= 10000 else (text[:10000] + "...[truncated]")
|
||||
logger.info("[CAPTURE B] %s %s BODY: %s", request.method, request.url.path, snippet)
|
||||
except Exception:
|
||||
logger.exception("❌ Error in capture_initdata_middleware")
|
||||
return await call_next(request)
|
||||
|
||||
# API Routes
|
||||
app.include_router(sms.router)
|
||||
app.include_router(claims.router)
|
||||
@@ -119,6 +260,13 @@ app.include_router(session.router) # 🔑 Session management через Redis
|
||||
app.include_router(documents.router) # 📄 Documents upload and processing
|
||||
app.include_router(banks.router) # 🏦 Banks API (NSPK banks list)
|
||||
app.include_router(telegram_auth.router) # 🤖 Telegram Mini App auth
|
||||
app.include_router(max_auth.router) # 📱 MAX Mini App auth
|
||||
app.include_router(auth2.router) # 🆕 Alt auth endpoint (tg/max/sms)
|
||||
app.include_router(auth_universal.router) # Универсальный auth: channel + init_data → N8N_AUTH_WEBHOOK, Redis session:{channel}:{channel_user_id}
|
||||
app.include_router(profile.router) # 👤 Профиль: контакты из CRM через N8N_CONTACT_WEBHOOK
|
||||
app.include_router(support.router) # 📞 Поддержка: форма из бара и карточек жалоб → n8n
|
||||
app.include_router(documents_draft_open.router) # 🆕 Documents draft-open (isolated)
|
||||
app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -217,6 +365,71 @@ async def get_client_ip(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/utils/client-log")
|
||||
async def client_log(request: Request):
|
||||
"""
|
||||
Принимает клиентские логи (для отладки webview/miniapp) и пишет в backend-логи.
|
||||
Формат: { reason, client: {...}, logs: [...] }
|
||||
"""
|
||||
client_host = request.client.host if request.client else None
|
||||
ua = request.headers.get("user-agent", "")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {"error": "invalid_json"}
|
||||
|
||||
# Cursor debug-mode evidence (sanitized)
|
||||
try:
|
||||
if isinstance(payload, dict):
|
||||
reason = payload.get("reason")
|
||||
client = payload.get("client") if isinstance(payload.get("client"), dict) else {}
|
||||
pathname = client.get("pathname") if isinstance(client.get("pathname"), str) else None
|
||||
origin = client.get("origin") if isinstance(client.get("origin"), str) else None
|
||||
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
|
||||
|
||||
module_url, script_src, build = _extract_client_bundle_info(payload)
|
||||
last_err = _extract_last_window_error(payload)
|
||||
first_err_file = None
|
||||
last_err_file = None
|
||||
if isinstance(logs, list):
|
||||
for e in logs:
|
||||
if isinstance(e, dict) and e.get("event") == "window_error":
|
||||
d = e.get("data") if isinstance(e.get("data"), dict) else {}
|
||||
fn = d.get("filename")
|
||||
if isinstance(fn, str):
|
||||
if first_err_file is None:
|
||||
first_err_file = fn
|
||||
last_err_file = fn
|
||||
|
||||
_debug_write(
|
||||
hypothesis_id="H1",
|
||||
run_id="pre-fix",
|
||||
location="backend/app/main.py:client_log",
|
||||
message="client_log_received",
|
||||
data={
|
||||
"ip": client_host,
|
||||
"uaPrefix": ua[:80] if isinstance(ua, str) else "",
|
||||
"reason": reason,
|
||||
"origin": origin,
|
||||
"pathname": pathname,
|
||||
"logsCount": len(logs) if isinstance(logs, list) else None,
|
||||
"boot": {"moduleUrl": module_url, "scriptSrc": script_src, "build": build},
|
||||
"windowErrorLast": last_err,
|
||||
"windowErrorFiles": {"first": first_err_file, "last": last_err_file},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ограничим размер вывода, но оставим самое важное
|
||||
try:
|
||||
s = json.dumps(payload, ensure_ascii=False)[:20000]
|
||||
except Exception:
|
||||
s = str(payload)[:20000]
|
||||
logger.warning(f"📱 CLIENT_LOG ip={client_host} ua={ua} payload={s}")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.get("/api/v1/info")
|
||||
async def info():
|
||||
"""Информация о платформе"""
|
||||
|
||||
120
backend/app/services/max_auth.py
Normal file
120
backend/app/services/max_auth.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
MAX WebApp (Mini App) auth helper.
|
||||
|
||||
Валидация initData от MAX Bridge — тот же алгоритм, что и у Telegram:
|
||||
secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN), data_check_string без hash, сравнение с hash.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxAuthError(Exception):
|
||||
"""Ошибка проверки подлинности MAX initData."""
|
||||
|
||||
|
||||
def _parse_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""Разбирает строку initData (query string) в словарь."""
|
||||
data: Dict[str, Any] = {}
|
||||
for key, value in parse_qsl(init_data, keep_blank_values=True):
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def _verify_with_token(parsed: Dict[str, Any], data_check_string: str, received_hash: str, bot_token: str) -> bool:
|
||||
"""Проверяет подпись initData одним MAX ботом. Возвращает True, если подпись верна."""
|
||||
secret_key = hmac.new(
|
||||
key="WebAppData".encode("utf-8"),
|
||||
msg=bot_token.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).digest()
|
||||
calculated_hash = hmac.new(
|
||||
key=secret_key,
|
||||
msg=data_check_string.encode("utf-8"),
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(calculated_hash, received_hash)
|
||||
|
||||
|
||||
def verify_max_init_data(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Проверяет подпись initData по правилам MAX (аналогично Telegram).
|
||||
|
||||
Поддерживает один бот (MAX_BOT_TOKEN) или несколько (MAX_BOT_TOKENS — JSON).
|
||||
Перебирает токены, пока один не подойдёт; в результат добавляется ключ bot_id.
|
||||
|
||||
- secret_key = HMAC_SHA256("WebAppData", BOT_TOKEN)
|
||||
- data_check_string: пары key=value без hash, сортировка по key, разделитель \n
|
||||
- hex(HMAC_SHA256(secret_key, data_check_string)) === hash из initData
|
||||
"""
|
||||
if not init_data:
|
||||
logger.warning("[MAX] verify_max_init_data: init_data пустой")
|
||||
raise MaxAuthError("init_data is empty")
|
||||
|
||||
tokens_list = settings.get_max_bot_tokens()
|
||||
if not tokens_list:
|
||||
logger.warning("[MAX] Ни MAX_BOT_TOKEN, ни MAX_BOT_TOKENS не заданы в .env")
|
||||
raise MaxAuthError("MAX bot token is not configured")
|
||||
|
||||
parsed = _parse_init_data(init_data)
|
||||
logger.info("[MAX] initData распарсен, ключи: %s", list(parsed.keys()))
|
||||
|
||||
received_hash = parsed.pop("hash", None)
|
||||
if not received_hash:
|
||||
logger.warning("[MAX] В initData отсутствует поле hash")
|
||||
raise MaxAuthError("Missing hash in init_data")
|
||||
|
||||
data_check_items = [f"{k}={parsed[k]}" for k in sorted(parsed.keys())]
|
||||
data_check_string = "\n".join(data_check_items)
|
||||
|
||||
for bot_id, bot_token in tokens_list:
|
||||
if _verify_with_token(parsed, data_check_string, received_hash, bot_token):
|
||||
parsed["bot_id"] = bot_id
|
||||
logger.info("[MAX] Подпись MAX initData проверена, bot_id=%s", bot_id)
|
||||
return parsed
|
||||
|
||||
logger.warning("[MAX] Подпись initData не совпадает ни с одним из токенов MAX ботов")
|
||||
raise MaxAuthError("Invalid init_data hash")
|
||||
|
||||
|
||||
def extract_max_user(init_data: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Валидирует initData и возвращает данные пользователя MAX.
|
||||
|
||||
В поле `user` — JSON с id, first_name, last_name, username, language_code, photo_url и т.д.
|
||||
"""
|
||||
parsed = verify_max_init_data(init_data)
|
||||
|
||||
user_raw = parsed.get("user")
|
||||
if not user_raw:
|
||||
logger.warning("[MAX] В initData отсутствует поле user")
|
||||
raise MaxAuthError("No user field in init_data")
|
||||
|
||||
try:
|
||||
user_obj = json.loads(user_raw)
|
||||
except Exception as e:
|
||||
raise MaxAuthError(f"Failed to parse user JSON: {e}") from e
|
||||
|
||||
if "id" not in user_obj:
|
||||
raise MaxAuthError("MAX user.id is missing")
|
||||
|
||||
result = {
|
||||
"max_user_id": str(user_obj.get("id")),
|
||||
"username": user_obj.get("username"),
|
||||
"first_name": user_obj.get("first_name"),
|
||||
"last_name": user_obj.get("last_name"),
|
||||
"language_code": user_obj.get("language_code"),
|
||||
"photo_url": user_obj.get("photo_url"),
|
||||
"raw": user_obj,
|
||||
}
|
||||
if "bot_id" in parsed:
|
||||
result["bot_id"] = parsed["bot_id"]
|
||||
return result
|
||||
32
backend/db/migrations/003_support_threads_messages.sql
Normal file
32
backend/db/migrations/003_support_threads_messages.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Треды и сообщения поддержки (диалог). Префикс таблиц: clpr_
|
||||
-- Один тред на (unified_id, claim_id или null); сообщения user/support
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clpr_support_threads (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
unified_id VARCHAR(255) NOT NULL,
|
||||
claim_id VARCHAR(255),
|
||||
source VARCHAR(50) NOT NULL DEFAULT 'bar',
|
||||
ticket_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_threads_unified_claim ON clpr_support_threads(unified_id, claim_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_threads_ticket ON clpr_support_threads(ticket_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clpr_support_messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
thread_id UUID NOT NULL REFERENCES clpr_support_threads(id) ON DELETE CASCADE,
|
||||
direction VARCHAR(20) NOT NULL CHECK (direction IN ('user', 'support')),
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
attachments JSONB DEFAULT '[]',
|
||||
external_id VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_messages_thread ON clpr_support_messages(thread_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_messages_created ON clpr_support_messages(thread_id, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_clpr_support_messages_external ON clpr_support_messages(thread_id, external_id) WHERE external_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE clpr_support_threads IS 'Треды обращений в поддержку: один на пользователя (бар) или по claim_id';
|
||||
COMMENT ON TABLE clpr_support_messages IS 'Сообщения в треде: user — от пользователя, support — от оператора';
|
||||
37
backend/db/migrations/004_support_notify_trigger.sql
Normal file
37
backend/db/migrations/004_support_notify_trigger.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- NOTIFY при INSERT в clpr_support_messages для доставки в реальном времени (SSE).
|
||||
-- Один канал support_events, в payload — unified_id и данные сообщения.
|
||||
-- Таблицы поддержки с префиксом clpr_
|
||||
|
||||
CREATE OR REPLACE FUNCTION support_messages_notify()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
u_id VARCHAR(255);
|
||||
payload TEXT;
|
||||
BEGIN
|
||||
SELECT unified_id INTO u_id FROM clpr_support_threads WHERE id = NEW.thread_id;
|
||||
IF u_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
payload := json_build_object(
|
||||
'unified_id', u_id,
|
||||
'thread_id', NEW.thread_id,
|
||||
'message_id', NEW.id,
|
||||
'direction', NEW.direction,
|
||||
'body', NEW.body,
|
||||
'attachments', COALESCE(NEW.attachments::TEXT, '[]'),
|
||||
'created_at', NEW.created_at
|
||||
)::TEXT;
|
||||
PERFORM pg_notify('support_events', payload);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS after_support_message_insert ON clpr_support_messages;
|
||||
CREATE TRIGGER after_support_message_insert
|
||||
AFTER INSERT ON clpr_support_messages
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE support_messages_notify();
|
||||
|
||||
COMMENT ON FUNCTION support_messages_notify() IS 'NOTIFY support_events при новом сообщении для SSE';
|
||||
13
backend/db/migrations/005_support_reads.sql
Normal file
13
backend/db/migrations/005_support_reads.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Отметки «прочитано» по тредам: когда пользователь последний раз видел тред.
|
||||
-- Непрочитанные = сообщения от support с created_at > last_read_at.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clpr_support_reads (
|
||||
unified_id VARCHAR(255) NOT NULL,
|
||||
thread_id UUID NOT NULL REFERENCES clpr_support_threads(id) ON DELETE CASCADE,
|
||||
last_read_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (unified_id, thread_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clpr_support_reads_unified ON clpr_support_reads(unified_id);
|
||||
|
||||
COMMENT ON TABLE clpr_support_reads IS 'Когда пользователь последний раз «прочитал» тред (открыл чат)';
|
||||
@@ -7,12 +7,12 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
ticket_form_frontend_prod:
|
||||
container_name: ticket_form_frontend_prod
|
||||
container_name: miniapp_front
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
ports:
|
||||
- "5176:3000"
|
||||
- "4176:3000"
|
||||
environment:
|
||||
- VITE_API_URL=https://aiform.clientright.ru
|
||||
- NODE_ENV=production
|
||||
@@ -28,7 +28,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
ticket_form_backend_prod:
|
||||
container_name: ticket_form_backend_prod
|
||||
container_name: miniapp_back
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
labels:
|
||||
- "environment=production"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8200/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:4200/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
51
docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js
Normal file
51
docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ========================================
|
||||
// Code Node: Формирование JSON для ответа N8N_CONTACT_WEBHOOK (профиль)
|
||||
// Данные берутся из ноды select_user1 (SQL/запрос контакта).
|
||||
// Выход этой ноды подаётся в "Respond to Webhook" как Response Body.
|
||||
// ========================================
|
||||
//
|
||||
// Вход из ноды select_user1 (массив строк или один item на строку):
|
||||
// contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet,
|
||||
// middle_name, birthplace, inn, verification, bank
|
||||
//
|
||||
// Выход для вебхука: { "items": [ { ...поля в snake_case... } ] } или { "items": [] }
|
||||
// ========================================
|
||||
|
||||
// Данные из ноды select_user1
|
||||
const rawItems = $('select_user1').all();
|
||||
let rows = [];
|
||||
if (rawItems.length === 1 && Array.isArray(rawItems[0].json)) {
|
||||
rows = rawItems[0].json;
|
||||
} else if (rawItems.length === 1 && Array.isArray(rawItems[0].json?.items)) {
|
||||
rows = rawItems[0].json.items;
|
||||
} else if (rawItems.length === 1 && rawItems[0].json && !Array.isArray(rawItems[0].json)) {
|
||||
rows = [rawItems[0].json];
|
||||
} else {
|
||||
rows = rawItems.map(i => i.json).filter(Boolean);
|
||||
}
|
||||
|
||||
function mapRow(r) {
|
||||
const v = (key) => {
|
||||
const x = r[key];
|
||||
return x !== undefined && x !== null && String(x).trim() !== '' ? String(x).trim() : '';
|
||||
};
|
||||
return {
|
||||
contact_id: r.contactid ?? r.contact_id ?? '',
|
||||
last_name: v('lastname') || v('last_name'),
|
||||
first_name: v('firstname') || v('first_name'),
|
||||
middle_name: v('middle_name') || v('middleName'),
|
||||
birth_date: v('birthday') || v('birth_date') || v('birthDate'),
|
||||
birth_place: v('birthplace') || v('birth_place') || v('birthPlace'),
|
||||
inn: v('inn'),
|
||||
email: v('email'),
|
||||
registration_address: v('mailingstreet') || v('registration_address') || v('address'),
|
||||
mailing_address: v('mailing_address') || v('postal_address'),
|
||||
bank_for_compensation: v('bank') || v('bank_for_compensation'),
|
||||
phone: v('mobile') || v('phone') || v('mobile_phone'),
|
||||
};
|
||||
}
|
||||
|
||||
const items = rows.map(mapRow);
|
||||
|
||||
// Один выходной item с телом ответа для Respond to Webhook
|
||||
return [{ json: { items } }];
|
||||
31
docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md
Normal file
31
docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Профиль: ответ N8N_CONTACT_WEBHOOK из SQL
|
||||
|
||||
## Цепочка в n8n
|
||||
|
||||
1. **Webhook** (POST) — получает от бэкенда `unified_id`, `entry_channel`, `chat_id`, `session_token`, `contact_id`, `phone`.
|
||||
2. **SQL** — по `unified_id`/`contact_id` выбирает контакт из БД. Возвращает массив строк в формате:
|
||||
- `contactid`, `firstname`, `lastname`, `email`, `mobile`, `phone`, `birthday`, `mailingstreet`, `middle_name`, `birthplace`, `inn`, `verification`, `bank`
|
||||
3. **Code** — преобразует строки в JSON для ответа вебхука (см. `N8N_CODE_PROFILE_CONTACT_RESPONSE.js`).
|
||||
4. **Respond to Webhook** — отдаёт ответ клиенту (тело = вывод Code).
|
||||
|
||||
## Формат ответа
|
||||
|
||||
- **Ничего не нашли:** вернуть **HTTP 200** и тело `{ "items": [] }`.
|
||||
- **Нашли контакт(ы):** **HTTP 200** и тело `{ "items": [ { ...поля в snake_case... } ] }`.
|
||||
|
||||
Поля контакта (уже в формате мини-апа после Code):
|
||||
|
||||
- `last_name`, `first_name`, `middle_name`
|
||||
- `birth_date`, `birth_place`
|
||||
- `inn`, `email`, `phone`
|
||||
- `registration_address` (в SQL: `mailingstreet` — адрес регистрации)
|
||||
- `mailing_address`, `bank_for_compensation`
|
||||
|
||||
## Подстановка Code-ноды
|
||||
|
||||
- Скопировать код из `aiform_prod/docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js` в ноду **Code**.
|
||||
- Вход Code — вывод SQL (один item с массивом в `json` или несколько items по одному контакту).
|
||||
- Выход Code — один item с `{ "items": [ ... ] }`.
|
||||
- В **Respond to Webhook** указать: ответить телом из предыдущей ноды (всё из Code), чтобы в ответ ушёл именно `{ "items": [...] }`.
|
||||
|
||||
Если SQL не нашёл строк — перед Code добавьте условие (IF): при пустом результате отдавать в Respond to Webhook тело `{ "items": [] }` и статус 200.
|
||||
95
docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md
Normal file
95
docs/PROFILE_AND_N8N_CONTACT_WEBHOOK.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Профиль пользователя и контакт-вебхук (N8N_CONTACT_WEBHOOK)
|
||||
|
||||
Описание изменений: раздел «Профиль» в мини-апе, передача `chat_id` в n8n, формат ответа вебхука и Code-нода для формирования JSON из SQL.
|
||||
|
||||
---
|
||||
|
||||
## 1. Раздел «Профиль» в мини-апе
|
||||
|
||||
- **Роут:** `/profile` (фронт), кнопка «Профиль» в нижней панели ведёт на него без перезагрузки.
|
||||
- **API:** `GET/POST /api/v1/profile/contact` — по `session_token` (или `unified_id`) запрашивает контактные данные из CRM через n8n-вебхук `N8N_CONTACT_WEBHOOK`.
|
||||
- **Фронт:** страница `Profile.tsx` показывает поля: фамилия, имя, отчество, дата/место рождения, ИНН, email, адрес регистрации, почтовый адрес, банк для возмещения, мобильный телефон. Поддерживаются snake_case и camelCase из ответа.
|
||||
|
||||
---
|
||||
|
||||
## 2. Конфиг и бэкенд
|
||||
|
||||
- **config.py:** добавлена настройка `n8n_contact_webhook` из переменной окружения `N8N_CONTACT_WEBHOOK`.
|
||||
- **main.py:** подключён роутер `profile`.
|
||||
- **profile.py:** реализованы `GET/POST /api/v1/profile/contact`, верификация сессии по `session_token`, сборка тела запроса к вебхуку и нормализация ответа n8n в формат `{ "items": [...] }`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Передача chat_id (Telegram / Max user id)
|
||||
|
||||
- **Сессия (session.py):**
|
||||
- В `SessionCreateRequest` добавлено опциональное поле `chat_id`.
|
||||
- При создании сессии в Redis сохраняется `chat_id`, если передан.
|
||||
- В `SessionVerifyResponse` и в ответе `verify_session` возвращается `chat_id`.
|
||||
|
||||
- **Где передаётся chat_id при создании сессии:**
|
||||
- **auth2 (TG):** `chat_id = str(tg_user["telegram_user_id"])`.
|
||||
- **auth2 (MAX):** `chat_id = str(max_user["max_user_id"])`.
|
||||
- **telegram_auth:** `chat_id = str(telegram_user_id)`.
|
||||
- **max_auth:** `chat_id = str(max_user_id)`.
|
||||
- SMS-флоу: `chat_id` не передаётся.
|
||||
|
||||
- **Профиль (profile.py):**
|
||||
- В запрос к API добавлен параметр `chat_id` (query/body).
|
||||
- При верификации сессии `chat_id` подставляется из сессии, если не передан явно.
|
||||
- В теле POST на `N8N_CONTACT_WEBHOOK` всегда добавляется поле `chat_id` (строка), когда оно известно.
|
||||
|
||||
- **Фронт (Profile.tsx):**
|
||||
- При запросе профиля передаётся `chat_id`: из `Telegram.WebApp.initDataUnsafe?.user?.id` или из `WebApp.initDataUnsafe?.user?.id` (MAX).
|
||||
|
||||
---
|
||||
|
||||
## 4. Формат запроса на N8N_CONTACT_WEBHOOK
|
||||
|
||||
**Тело POST от бэкенда к n8n:**
|
||||
|
||||
- `unified_id` (str) — идентификатор в CRM
|
||||
- `entry_channel` (str) — `"telegram"` | `"max"` | `"web"`
|
||||
- `chat_id` (str, опционально) — Telegram user id или Max user id
|
||||
- `session_token`, `contact_id`, `phone` (опционально)
|
||||
|
||||
---
|
||||
|
||||
## 5. Формат ответа из n8n (как возвращать и как маппится)
|
||||
|
||||
**Ничего не нашли:** HTTP 200, тело: `[]` или `{ "items": [] }`.
|
||||
|
||||
**Нашли контакт(ы):** HTTP 200, тело одно из:
|
||||
|
||||
- массив `[{...}, ...]` → нормализуется в `{ "items": [...] }`;
|
||||
- `{ "items": [...] }` — без изменений;
|
||||
- `{ "contact": {...} }` / `{ "contact": [...] }` → в `items`;
|
||||
- `{ "data": [...] }` → в `items`;
|
||||
- один объект `{...}` → `{ "items": [{...}] }`;
|
||||
- пустой объект `{}` → `{ "items": [] }`.
|
||||
|
||||
**Поля контакта** (snake_case или camelCase):
|
||||
`last_name`, `first_name`, `middle_name`, `birth_date`, `birth_place`, `inn`, `email`, `registration_address`, `mailing_address`, `bank_for_compensation`, `phone`.
|
||||
|
||||
Подробности и маппинг полей описаны в докстринге модуля `backend/app/api/profile.py`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code-нода n8n для ответа вебхука из SQL
|
||||
|
||||
- **Файл:** `docs/N8N_CODE_PROFILE_CONTACT_RESPONSE.js`
|
||||
- **Назначение:** после ноды **select_user1** (SQL) формирует JSON для ответа вебхука.
|
||||
- **Вход:** данные из ноды `select_user1` (массив строк с полями contactid, firstname, lastname, email, mobile, phone, birthday, mailingstreet, middle_name, birthplace, inn, bank и т.д.).
|
||||
- **Выход:** один item с `{ "items": [ {...}, ... ] }` в формате полей для мини-апа (snake_case). Пустой результат → `{ "items": [] }`.
|
||||
- **Маппинг:** mailingstreet → registration_address, birthday → birth_date, birthplace → birth_place, bank → bank_for_compensation, mobile/phone → phone и т.д.
|
||||
|
||||
Инструкция по цепочке Webhook → SQL → Code → Respond to Webhook: `docs/N8N_PROFILE_CONTACT_WEBHOOK_RESPONSE.md`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Прочие изменения (в рамках той же задачи)
|
||||
|
||||
- События SSE: единый формат `event_type` + `message`, цвета по типу (trash_message, out_of_scope, consumer_consultation, consumer_complaint), не показывать «Подключено к событиям» как ответ, не перезаписывать consumer_consultation в out_of_scope.
|
||||
- Кнопка «Домой» — программная навигация на главную.
|
||||
- Закрытие приложения при `need_contact` от вебхука (повторный вызов close, fallback без initData).
|
||||
- Передача в контакт-хук: unified_id, entry_channel, session_token, contact_id, phone, chat_id.
|
||||
36
docs/SUPPORT_FEATURE_SUMMARY.md
Normal file
36
docs/SUPPORT_FEATURE_SUMMARY.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Поддержка: чат, список тикетов, прочитано/непрочитано, SSE
|
||||
|
||||
## Что сделано
|
||||
|
||||
### БД (таблицы с префиксом `clpr_`)
|
||||
- **clpr_support_threads** — треды обращений (unified_id, claim_id, source, ticket_id).
|
||||
- **clpr_support_messages** — сообщения (thread_id, direction user/support, body, attachments).
|
||||
- **clpr_support_reads** — когда пользователь последний раз «прочитал» тред (unified_id, thread_id, last_read_at).
|
||||
- Триггер на INSERT в clpr_support_messages → NOTIFY `support_events` (payload: unified_id, thread_id, сообщение) для доставки в реальном времени.
|
||||
|
||||
### Backend API
|
||||
- **POST /api/v1/support** — отправить сообщение (multipart), создание/поиск треда, прокси в n8n.
|
||||
- **GET /api/v1/support/threads** — список всех тредов пользователя с unread_count.
|
||||
- **GET /api/v1/support/thread** — один тред и сообщения (по claim_id или бар).
|
||||
- **GET /api/v1/support/stream** — SSE: один поток на пользователя, события из Postgres NOTIFY.
|
||||
- **GET /api/v1/support/unread-count** — суммарное число непрочитанных (бейдж в баре).
|
||||
- **POST /api/v1/support/read** — отметить тред прочитанным (thread_id или claim_id).
|
||||
- **POST /api/v1/support/incoming** — webhook для n8n: добавить ответ оператора в тред.
|
||||
- **GET /api/v1/support/limits** — лимиты вложений.
|
||||
|
||||
При старте приложения запускается задача LISTEN на канал `support_events`; при NOTIFY события раскидываются по реестру стримов (unified_id → SSE).
|
||||
|
||||
### Frontend
|
||||
- **Страница «Поддержка»** — первый экран: список обращений (тикетов) с бейджем непрочитанных; кнопка «Новое обращение»; по клику — чат выбранного треда.
|
||||
- **SupportChat** — чат с SSE (новые сообщения от поддержки без перезагрузки); при открытии чата вызывается POST /read.
|
||||
- **Нижний бар** — на иконке «Поддержка» бейдж с общим числом непрочитанных.
|
||||
|
||||
### Документация
|
||||
- **docs/SUPPORT_N8N_WEBHOOK.md** — переменные окружения, API, миграции, тест SSE, прочитано/непрочитано и сценарии в n8n.
|
||||
|
||||
## Миграции
|
||||
- 003_support_threads_messages.sql — создание clpr_support_threads, clpr_support_messages.
|
||||
- 004_support_notify_trigger.sql — триггер NOTIFY support_events.
|
||||
- 005_support_reads.sql — таблица clpr_support_reads.
|
||||
|
||||
Креды Postgres из .env. Применение: из корня aiform_prod подставить POSTGRES_* и выполнить psql -f для каждой миграции.
|
||||
80
docs/SUPPORT_N8N_WEBHOOK.md
Normal file
80
docs/SUPPORT_N8N_WEBHOOK.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Поддержка: webhook n8n, диалог (треды), лимиты вложений
|
||||
|
||||
Функционал «Поддержка» реализован как диалог: треды и сообщения хранятся в БД. **Таблицы с префиксом `clpr_`:** `clpr_support_threads`, `clpr_support_messages`. Исходящие сообщения пользователя проксируются в n8n; входящие ответы оператора приходят в backend через webhook POST /api/v1/support/incoming (из n8n при ответе в CRM).
|
||||
|
||||
Подключение к PostgreSQL: креды берутся из `.env` — `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
В `.env` задаются:
|
||||
|
||||
| Переменная | Описание |
|
||||
|------------|----------|
|
||||
| `N8N_SUPPORT_WEBHOOK` | URL webhook n8n (multipart). Обязателен. |
|
||||
| `SUPPORT_ATTACHMENTS_MAX_COUNT` | Макс. количество файлов (0 = без ограничений). |
|
||||
| `SUPPORT_ATTACHMENTS_MAX_SIZE_MB` | Макс. размер одного файла в МБ (0 = без ограничений). |
|
||||
| `SUPPORT_ATTACHMENTS_ALLOWED_TYPES` | Допустимые типы (пусто = любые). |
|
||||
| `SUPPORT_INCOMING_SECRET` | Секрет для POST /api/v1/support/incoming (заголовок `X-Support-Incoming-Secret` или query `secret`). Если задан — только n8n с этим секретом может слать ответы в тред. |
|
||||
|
||||
Значение **0** или **пустая строка** для лимитов означает «без ограничений».
|
||||
|
||||
## Формат запроса от backend к n8n
|
||||
|
||||
Backend отправляет на `N8N_SUPPORT_WEBHOOK` **POST multipart/form-data**:
|
||||
|
||||
- **Поля:** `message`, `subject`, `claim_id`, `source`, `unified_id`, `phone`, `email`, `session_id`, `timestamp`, **`thread_id`** (UUID треда), **`ticket_id`** (если тред уже привязан к тикету в CRM).
|
||||
- **Файлы:** `attachments[0]`, … или `attachments`.
|
||||
|
||||
Ответ n8n может содержать **`ticket_id`** — backend сохранит его в `clpr_support_threads` для последующих сообщений и для входящего webhook.
|
||||
|
||||
## API backend
|
||||
|
||||
- **POST /api/v1/support** — multipart: message, subject?, claim_id?, source, **thread_id?**, session_token (или channel+channel_user_id), файлы. Создаёт/находит тред по (unified_id, claim_id), записывает сообщение (user), проксирует в n8n. Ответ: `{ "success": true, "thread_id": "...", "message_id": "..." }`.
|
||||
- **GET /api/v1/support/threads** — список всех тредов пользователя. В каждом элементе есть **`unread_count`** (число непрочитанных сообщений от поддержки). Ответ: `{ "threads": [{ "thread_id", "claim_id" | null, "source", "ticket_id", "created_at", "updated_at", "last_body", "last_at", "messages_count", "unread_count" }] }`.
|
||||
- **GET /api/v1/support/unread-count** — суммарное число непрочитанных по всем тредам (для бейджа в баре). Ответ: `{ "unread_count": number }`.
|
||||
- **POST /api/v1/support/read** — отметить тред как прочитанный (пользователь открыл чат). Query или body: `thread_id` или `claim_id`. Обновляет `clpr_support_reads`.
|
||||
- **GET /api/v1/support/thread** — query: `claim_id?`, `session_token` (или `channel` + `channel_user_id`). Возвращает один тред и сообщения: `{ "thread_id": "...", "messages": [...], "ticket_id": "..." }`. Если треда нет — `thread_id: null`, `messages: []`.
|
||||
- **POST /api/v1/support/incoming** — для n8n: добавить сообщение от поддержки в тред. Тело JSON: `{ "thread_id" или "ticket_id", "body", "attachments?": [] }`. Заголовок **`X-Support-Incoming-Secret`** или query **`secret`** должен совпадать с `SUPPORT_INCOMING_SECRET` (если задан). По `ticket_id` backend находит thread_id и вставляет сообщение с direction=support.
|
||||
- **GET /api/v1/support/limits** — лимиты вложений из env.
|
||||
- **GET /api/v1/support/stream** — SSE: один поток на пользователя (query `session_token` или `channel` + `channel_user_id`). Новые сообщения от поддержки приходят в реальном времени через Postgres NOTIFY (триггер на `clpr_support_messages`). События: `connected`, `support_message` (в теле — `thread_id`, `message`: id, direction, body, attachments, created_at).
|
||||
|
||||
## Доставка в реальном времени (Postgres NOTIFY)
|
||||
|
||||
При INSERT в `clpr_support_messages` срабатывает триггер, который делает `NOTIFY support_events` с payload (unified_id, thread_id, сообщение). Backend при старте подписывается на канал `support_events` одним LISTEN-соединением и раскидывает события по реестру стримов (unified_id → очереди SSE).
|
||||
|
||||
**Прочитано/непрочитано:** таблица `clpr_support_reads` (unified_id, thread_id, last_read_at). Пользователь «прочитал» тред, когда открывает чат — фронт вызывает POST /read. Непрочитанные = сообщения от support с created_at > last_read_at. По этим данным можно в n8n/CRM строить сценарии напоминаний (push, повторная отправка), если пользователь долго не читает.
|
||||
|
||||
**Миграции** (таблицы с префиксом `clpr_`): `003` — треды и сообщения; `004` — триггер NOTIFY; `005_support_reads.sql` — отметки прочтения. Применять к БД вручную. Креды Postgres — из `.env`:
|
||||
|
||||
```bash
|
||||
# из корня aiform_prod, креды из .env
|
||||
export $(grep -E '^POSTGRES_' .env | xargs)
|
||||
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/003_support_threads_messages.sql
|
||||
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/004_support_notify_trigger.sql
|
||||
psql -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f backend/db/migrations/005_support_reads.sql
|
||||
```
|
||||
|
||||
Если в БД уже есть таблицы без префикса (`support_threads`, `support_messages`), их нужно переименовать в `clpr_support_threads` и `clpr_support_messages` перед применением 004, либо пересоздать схему (миграция 003 с префиксом создаёт таблицы с `IF NOT EXISTS`).
|
||||
|
||||
## n8n
|
||||
|
||||
1. **Webhook приёма обращений** — multipart, при первом сообщении создаёт тикет в CRM, в ответе возвращает `ticket_id`. При последующих (есть thread_id/ticket_id) — добавляет комментарий к тикету.
|
||||
2. **Вызов нашего incoming** — когда оператор ответил в CRM, workflow n8n должен вызвать **POST https://.../api/v1/support/incoming** с заголовком `X-Support-Incoming-Secret: <SUPPORT_INCOMING_SECRET>` и телом `{ "thread_id": "..." или "ticket_id": "...", "body": "текст ответа" }`, чтобы сообщение появилось в чате мини-аппа.
|
||||
|
||||
---
|
||||
|
||||
## Как тестировать SSE (ответы в реальном времени)
|
||||
|
||||
1. **В мини-аппе:** зайти в поддержку (бар → «Поддержка» или страница /support), авторизоваться, отправить первое сообщение (или открыть уже существующий тред). Оставить чат открытым.
|
||||
2. **Узнать `thread_id`:** в DevTools → Network найти запрос `GET .../api/v1/support/thread` и в ответе скопировать `thread_id`, либо после отправки сообщения — ответ `POST .../api/v1/support` содержит `thread_id`.
|
||||
3. **Имитация ответа поддержки:** вызвать incoming (как будет делать n8n):
|
||||
|
||||
```bash
|
||||
# Подставить THREAD_ID и секрет из .env (SUPPORT_INCOMING_SECRET). Если секрет пустой — заголовок можно не передавать.
|
||||
curl -s -X POST 'https://miniapp.clientright.ru/api/v1/support/incoming' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Support-Incoming-Secret: ВАШ_SUPPORT_INCOMING_SECRET' \
|
||||
-d '{"thread_id":"THREAD_ID","body":"Тестовый ответ от поддержки"}'
|
||||
```
|
||||
|
||||
4. **Ожидание:** в открытом чате в мини-аппе в течение 1–2 секунд должно появиться новое сообщение **без перезагрузки и без повторного запроса** (доставка по SSE). Если сообщение появляется только после обновления страницы — проверить, что фронт пересобран с SSE (`docker compose build frontend && docker compose up -d frontend`) и что в Network есть запрос к `/api/v1/support/stream` со статусом pending (длинное соединение).
|
||||
71
docs/n8n_CODE_CRM_NORMALIZE.js
Normal file
71
docs/n8n_CODE_CRM_NORMALIZE.js
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* n8n Code node: нормализация ответа CRM (projects_json + tickets_json)
|
||||
* в массив элементов с метками для фронта (type_code, payload.source, status_code по projectstatus).
|
||||
*
|
||||
* Вход: один элемент с полями projects_json[], tickets_json[] (и опционально contactid, unified_id, mobile).
|
||||
* Выход: один элемент { crm_items: [...] } — массив готовых объектов для склейки с черновиками из Postgres.
|
||||
*/
|
||||
|
||||
const input = $input.first().json;
|
||||
const projects = input.projects_json || [];
|
||||
const tickets = input.tickets_json || [];
|
||||
|
||||
const normalized = [];
|
||||
|
||||
// Проекты из CRM → один элемент на проект (карточка «В работе» / «Решены» / «Отклонены»)
|
||||
for (const p of projects) {
|
||||
const projectstatus = (p.projectstatus || '').toString().toLowerCase();
|
||||
let status_code = 'active';
|
||||
if (projectstatus.includes('завершено') || projectstatus === 'completed') status_code = 'completed';
|
||||
else if (projectstatus.includes('отклонен')) status_code = 'rejected';
|
||||
|
||||
normalized.push({
|
||||
id: `crm_project_${p.projectid}`,
|
||||
claim_id: null,
|
||||
type_code: 'external_case',
|
||||
payload: {
|
||||
source: 'CRM',
|
||||
projectid: p.projectid,
|
||||
projectstatus: p.projectstatus,
|
||||
},
|
||||
status_code,
|
||||
channel: 'crm',
|
||||
problem_title: p.projectname || '',
|
||||
problem_description: '',
|
||||
created_at: p.createdtime || null,
|
||||
updated_at: p.createdtime || null,
|
||||
documents_total: 0,
|
||||
documents_uploaded: 0,
|
||||
unified_id: input.unified_id || null,
|
||||
contact_id: input.contactid != null ? String(input.contactid) : null,
|
||||
phone: input.mobile || input.phone || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Тикеты из CRM → один элемент на тикет (карточка «Консультации»)
|
||||
for (const t of tickets) {
|
||||
normalized.push({
|
||||
id: `crm_ticket_${t.ticketid}`,
|
||||
claim_id: null,
|
||||
type_code: 'consultation',
|
||||
payload: {
|
||||
source: 'CRM',
|
||||
ticketid: t.ticketid,
|
||||
ticket_no: t.ticket_no,
|
||||
},
|
||||
status_code: 'active',
|
||||
channel: 'crm',
|
||||
problem_title: t.title || t.ticket_no || '',
|
||||
problem_description: '',
|
||||
created_at: t.createdtime || null,
|
||||
updated_at: t.createdtime || null,
|
||||
documents_total: 0,
|
||||
documents_uploaded: 0,
|
||||
unified_id: input.unified_id || null,
|
||||
contact_id: input.contactid != null ? String(input.contactid) : null,
|
||||
phone: input.mobile || input.phone || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Отдаём один элемент с массивом crm_items (далее в workflow склеиваешь с data из Postgres)
|
||||
return [{ json: { crm_items: normalized } }];
|
||||
29
docs/n8n_CODE_FLATTEN_DATA.js
Normal file
29
docs/n8n_CODE_FLATTEN_DATA.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* n8n Code node: развернуть data в плоский список.
|
||||
* Если в data попал объект вида { "crm_items": [...] }, он заменяется на сами элементы crm_items.
|
||||
*
|
||||
* Вход: один элемент с полем data (массив), где часть элементов могут быть { crm_items: [...] }.
|
||||
* Выход: один элемент { data: [...] } — плоский массив только карточек (заявки Postgres + элементы CRM).
|
||||
*/
|
||||
|
||||
const input = $input.first().json;
|
||||
let data = input.data;
|
||||
if (data == null) data = input.items || input.drafts || [];
|
||||
if (!Array.isArray(data)) data = [data];
|
||||
|
||||
const flattened = [];
|
||||
for (const item of data) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === 'object' &&
|
||||
item.crm_items &&
|
||||
Array.isArray(item.crm_items) &&
|
||||
Object.keys(item).length === 1
|
||||
) {
|
||||
flattened.push(...item.crm_items);
|
||||
} else {
|
||||
flattened.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return [{ json: { ...input, data: flattened } }];
|
||||
@@ -1,36 +1,16 @@
|
||||
# React Frontend Dockerfile (PRODUCTION BUILD)
|
||||
# Продакшен: сборка + отдача dist (без dev-сервера).
|
||||
# После правок в коде: docker compose build frontend && docker compose up -d frontend
|
||||
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Устанавливаем зависимости
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
RUN node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build
|
||||
|
||||
# Собираем 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
|
||||
|
||||
# Открываем порт
|
||||
RUN npm install -g serve
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
|
||||
# Запускаем serve для раздачи статических файлов
|
||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||
|
||||
|
||||
@@ -3,9 +3,19 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
|
||||
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
|
||||
<script>
|
||||
(function() {
|
||||
var u = window.location.href || '';
|
||||
if (u.indexOf('tgWebAppData') !== -1 || u.indexOf('tgWebAppVersion') !== -1) {
|
||||
var s = document.createElement('script'); s.src = 'https://telegram.org/js/telegram-web-app.js'; document.head.appendChild(s);
|
||||
} else {
|
||||
var s = document.createElement('script'); s.src = 'https://st.max.ru/js/max-web-app.js'; document.head.appendChild(s);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"imask": "^7.6.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
@@ -3562,6 +3563,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7725,6 +7734,12 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"requires": {}
|
||||
},
|
||||
"math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -6,40 +6,40 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
"start": "serve -s dist -l 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"antd": "^5.21.6",
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"axios": "^1.7.7",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"zustand": "^5.0.1",
|
||||
"antd": "^5.21.6",
|
||||
"axios": "^1.7.7",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"imask": "^7.6.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"serve": "^14.2.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"browser-image-compression": "^2.0.2"
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"serve": "^14.2.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"eslint": "^9.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.11.0",
|
||||
"@typescript-eslint/parser": "^8.11.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.13"
|
||||
"eslint-plugin-react-refresh": "^0.4.13",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
18
frontend/scripts/crypto-polyfill.cjs
Normal file
18
frontend/scripts/crypto-polyfill.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Полифилл crypto.getRandomValues для Node 16 (нужен Vite при сборке).
|
||||
* Запуск: node -r ./scripts/crypto-polyfill.cjs node_modules/vite/bin/vite.js build
|
||||
*/
|
||||
const crypto = require('node:crypto');
|
||||
function getRandomValues(buffer) {
|
||||
if (!buffer) return buffer;
|
||||
const bytes = crypto.randomBytes(buffer.length);
|
||||
buffer.set(bytes);
|
||||
return buffer;
|
||||
}
|
||||
if (typeof crypto.getRandomValues !== 'function') {
|
||||
crypto.getRandomValues = getRandomValues;
|
||||
}
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.crypto = globalThis.crypto || {};
|
||||
globalThis.crypto.getRandomValues = getRandomValues;
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
@@ -27,8 +29,10 @@
|
||||
flex: 1;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
|
||||
@@ -1,12 +1,111 @@
|
||||
import ClaimForm from './pages/ClaimForm'
|
||||
import './App.css'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ClaimForm from './pages/ClaimForm';
|
||||
import HelloAuth from './pages/HelloAuth';
|
||||
import Profile from './pages/Profile';
|
||||
import Support from './pages/Support';
|
||||
import Consultations from './pages/Consultations';
|
||||
import BottomBar from './components/BottomBar';
|
||||
import { DraftsProvider } from './context/DraftsContext';
|
||||
import './App.css';
|
||||
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
|
||||
|
||||
function App() {
|
||||
const [pathname, setPathname] = useState<string>(() => {
|
||||
const p = window.location.pathname || '';
|
||||
if (p !== '/hello' && !p.startsWith('/hello')) return '/hello';
|
||||
return p;
|
||||
});
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||
const [profileNeedsAttention, setProfileNeedsAttention] = useState<boolean>(false);
|
||||
const lastRouteTsRef = useRef<number>(Date.now());
|
||||
const lastPathRef = useRef<string>(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname || '/';
|
||||
if (path !== '/hello' && !path.startsWith('/hello')) {
|
||||
window.history.replaceState({}, '', '/hello' + (window.location.search || '') + (window.location.hash || ''));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPathname(window.location.pathname || '');
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
// Логируем смену маршрута + ловим быстрый возврат на /hello (симптом бага)
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const prev = lastPathRef.current;
|
||||
lastPathRef.current = pathname;
|
||||
lastRouteTsRef.current = now;
|
||||
miniappLog('route', { prev, next: pathname });
|
||||
|
||||
if (pathname.startsWith('/hello') && !prev.startsWith('/hello')) {
|
||||
// Вернулись на /hello: отправим дамп, чтобы поймать “ложится”
|
||||
void miniappSendLogs('returned_to_hello');
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Ловим клики в первые 2с после смены маршрута (ghost click / попадание в бар)
|
||||
useEffect(() => {
|
||||
const onClickCapture = (e: MouseEvent) => {
|
||||
const dt = Date.now() - lastRouteTsRef.current;
|
||||
if (dt > 2000) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const inBar = !!t?.closest?.('.app-bottom-bar');
|
||||
miniappLog('click_capture', {
|
||||
dtFromRouteMs: dt,
|
||||
inBottomBar: inBar,
|
||||
tag: t?.tagName,
|
||||
id: t?.id,
|
||||
class: t?.className,
|
||||
x: (e as MouseEvent).clientX,
|
||||
y: (e as MouseEvent).clientY,
|
||||
});
|
||||
};
|
||||
window.addEventListener('click', onClickCapture, true);
|
||||
return () => window.removeEventListener('click', onClickCapture, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
|
||||
}, [pathname]);
|
||||
|
||||
const isNewClaimPage = pathname === '/new';
|
||||
|
||||
const navigateTo = useCallback((path: string) => {
|
||||
window.history.pushState({}, '', path);
|
||||
setPathname(path);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<ClaimForm />
|
||||
</div>
|
||||
)
|
||||
<DraftsProvider>
|
||||
<div className="App">
|
||||
{pathname === '/profile' ? (
|
||||
<Profile onNavigate={navigateTo} />
|
||||
) : pathname === '/support' ? (
|
||||
<Support onNavigate={navigateTo} />
|
||||
) : pathname === '/consultations' ? (
|
||||
<Consultations onNavigate={navigateTo} />
|
||||
) : pathname.startsWith('/hello') ? (
|
||||
<HelloAuth
|
||||
onAvatarChange={setAvatarUrl}
|
||||
onNavigate={navigateTo}
|
||||
onProfileNeedsAttentionChange={setProfileNeedsAttention}
|
||||
/>
|
||||
) : (
|
||||
<ClaimForm forceNewClaim={isNewClaimPage} onNavigate={navigateTo} />
|
||||
)}
|
||||
<BottomBar
|
||||
currentPath={pathname}
|
||||
avatarUrl={avatarUrl || undefined}
|
||||
profileNeedsAttention={profileNeedsAttention}
|
||||
onNavigate={navigateTo}
|
||||
/>
|
||||
</div>
|
||||
</DraftsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
122
frontend/src/components/BottomBar.css
Normal file
122
frontend/src/components/BottomBar.css
Normal file
@@ -0,0 +1,122 @@
|
||||
.app-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 64px;
|
||||
height: calc(64px + env(safe-area-inset-bottom, 0));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
background: #ffffff;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.app-bottom-bar--hidden {
|
||||
transform: translateY(120%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.app-bar-item:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.app-bar-item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.app-bar-item:disabled:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.app-bar-item--active {
|
||||
color: #2563EB;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-bar-item--active:hover {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
.app-bar-item--exit:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.app-bar-item-icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-bar-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.app-bar-profile-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #dc2626;
|
||||
border: 1.5px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-bar-support-badge {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #dc2626;
|
||||
border: 1.5px solid #fff;
|
||||
border-radius: 9px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
286
frontend/src/components/BottomBar.tsx
Normal file
286
frontend/src/components/BottomBar.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Home, Headphones, User, LogOut, ArrowLeft } from 'lucide-react';
|
||||
import './BottomBar.css';
|
||||
import { miniappLog } from '../utils/miniappLogger';
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface BottomBarProps {
|
||||
currentPath: string;
|
||||
avatarUrl?: string;
|
||||
profileNeedsAttention?: boolean;
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttention, onNavigate }: BottomBarProps) {
|
||||
const isHome = currentPath.startsWith('/hello');
|
||||
const isProfile = currentPath === '/profile';
|
||||
const isSupport = currentPath === '/support';
|
||||
const [backEnabled, setBackEnabled] = useState(false);
|
||||
const [supportUnreadCount, setSupportUnreadCount] = useState(0);
|
||||
const [keyboardOpen, setKeyboardOpen] = useState(false);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const [supportChatMode, setSupportChatMode] = useState(false);
|
||||
|
||||
// Непрочитанные в поддержке — для бейджа на иконке
|
||||
useEffect(() => {
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
setSupportUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
fetch(`/api/v1/support/unread-count?${params.toString()}`)
|
||||
.then((res) => (res.ok ? res.json() : { unread_count: 0 }))
|
||||
.then((data) => setSupportUnreadCount(data.unread_count ?? 0))
|
||||
.catch(() => setSupportUnreadCount(0));
|
||||
}, [currentPath]);
|
||||
|
||||
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться.
|
||||
// На /support кнопка «Назад» включена — возврат из чата в список или из списка в «Мои обращения».
|
||||
useEffect(() => {
|
||||
if (isHome || isProfile) {
|
||||
setBackEnabled(false);
|
||||
return;
|
||||
}
|
||||
setBackEnabled(false);
|
||||
const t = window.setTimeout(() => setBackEnabled(true), 1200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [isHome, isProfile, currentPath]);
|
||||
|
||||
// Если открыта клавиатура — прячем нижний бар, чтобы он не перекрывал поле ввода
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => {
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
setKeyboardOpen(inset > 80);
|
||||
};
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Универсально для любых WebView: если в фокусе поле ввода, нижний бар скрываем.
|
||||
useEffect(() => {
|
||||
const isEditable = (el: EventTarget | null): boolean => {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || el.isContentEditable;
|
||||
};
|
||||
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
if (isEditable(e.target)) setInputFocused(true);
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
window.setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
setInputFocused(isEditable(active));
|
||||
}, 30);
|
||||
};
|
||||
|
||||
window.addEventListener('focusin', handleFocusIn);
|
||||
window.addEventListener('focusout', handleFocusOut);
|
||||
return () => {
|
||||
window.removeEventListener('focusin', handleFocusIn);
|
||||
window.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onSupportChatMode = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ active?: boolean }>).detail;
|
||||
setSupportChatMode(!!detail?.active);
|
||||
};
|
||||
window.addEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
miniappLog('bottom_bar_back_click', { backEnabled, currentPath });
|
||||
if (!backEnabled) return;
|
||||
window.dispatchEvent(new CustomEvent('miniapp:goBack'));
|
||||
};
|
||||
|
||||
const handleExit = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const tgWebApp = (window as any).Telegram?.WebApp;
|
||||
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
||||
const hasTgContext =
|
||||
tgInitData.length > 0 ||
|
||||
window.location.href.includes('tgWebAppData') ||
|
||||
navigator.userAgent.includes('Telegram');
|
||||
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
||||
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
||||
const hasMaxContext =
|
||||
maxInitData.length > 0 ||
|
||||
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
|
||||
|
||||
// Если пользователь не поделился контактом, initData может быть пустым — всё равно пробуем close по наличию WebApp
|
||||
const hasTgWebApp = !!tgWebApp && typeof tgWebApp.close === 'function';
|
||||
const hasMaxWebApp = !!maxWebApp && (typeof maxWebApp.close === 'function' || typeof maxWebApp.postEvent === 'function');
|
||||
|
||||
miniappLog('bottom_bar_exit_click', {
|
||||
currentPath,
|
||||
hasTgContext,
|
||||
hasMaxContext,
|
||||
tgInitDataLen: tgInitData.length,
|
||||
maxInitDataLen: maxInitData.length,
|
||||
hasTgClose: hasTgWebApp,
|
||||
hasMaxClose: hasMaxWebApp,
|
||||
});
|
||||
|
||||
// ВАЖНО: выбираем платформу по контексту (URL/UA/initData). Если оба есть — приоритет у того, у кого есть initData.
|
||||
if (hasTgContext && hasTgWebApp && !hasMaxContext) {
|
||||
try {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
} catch (err) {
|
||||
miniappLog('bottom_bar_exit_error', { platform: 'tg', error: String(err) });
|
||||
}
|
||||
}
|
||||
if (hasMaxContext && hasMaxWebApp) {
|
||||
try {
|
||||
if (typeof maxWebApp.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp.postEvent === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
miniappLog('bottom_bar_exit_error', { platform: 'max', error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Когда контакт не дан, initData может быть пустым — пробуем закрыть по наличию объекта WebApp (без требования initData)
|
||||
if (hasTgWebApp && !hasMaxWebApp) {
|
||||
try {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg_no_init', note: 'close without initData' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (hasMaxWebApp && !hasTgWebApp) {
|
||||
try {
|
||||
if (typeof maxWebApp.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max_no_init', note: 'close without initData' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp.postEvent === 'function') {
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Fallback: переход на главную
|
||||
miniappLog('bottom_bar_exit_fallback', {});
|
||||
window.location.href = '/hello';
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`app-bottom-bar${keyboardOpen || inputFocused || supportChatMode ? ' app-bottom-bar--hidden' : ''}`}
|
||||
aria-label="Навигация"
|
||||
>
|
||||
{!isHome && !isProfile && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-bar-item"
|
||||
onClick={handleBack}
|
||||
disabled={!backEnabled}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} strokeWidth={1.8} />
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="/hello"
|
||||
className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !isHome) {
|
||||
e.preventDefault();
|
||||
onNavigate('/hello');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size={24} strokeWidth={1.8} />
|
||||
<span>Домой</span>
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
className={`app-bar-item ${isProfile ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !isProfile) {
|
||||
e.preventDefault();
|
||||
onNavigate('/profile');
|
||||
}
|
||||
}}
|
||||
aria-label={profileNeedsAttention ? 'Профиль — требуется подтверждение данных' : 'Профиль'}
|
||||
>
|
||||
<span className="app-bar-item-icon-wrap">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="app-bar-avatar" />
|
||||
) : (
|
||||
<User size={24} strokeWidth={1.8} />
|
||||
)}
|
||||
{profileNeedsAttention && <span className="app-bar-profile-badge" aria-hidden>!</span>}
|
||||
</span>
|
||||
<span>Профиль</span>
|
||||
</a>
|
||||
<a
|
||||
href="/support"
|
||||
className={`app-bar-item ${isSupport ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && currentPath !== '/support') {
|
||||
e.preventDefault();
|
||||
onNavigate('/support');
|
||||
}
|
||||
}}
|
||||
aria-label={supportUnreadCount > 0 ? `Поддержка: ${supportUnreadCount} непрочитанных` : 'Поддержка'}
|
||||
>
|
||||
<span className="app-bar-item-icon-wrap">
|
||||
<Headphones size={24} strokeWidth={1.8} />
|
||||
{supportUnreadCount > 0 && (
|
||||
<span className="app-bar-support-badge" aria-hidden>
|
||||
{supportUnreadCount > 99 ? '99+' : supportUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Поддержка</span>
|
||||
</a>
|
||||
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">
|
||||
<LogOut size={24} strokeWidth={1.8} />
|
||||
<span>Выход</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
479
frontend/src/components/SupportChat.tsx
Normal file
479
frontend/src/components/SupportChat.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* SupportChat — диалог поддержки: список сообщений + ввод.
|
||||
* Новые ответы приходят по SSE (Postgres NOTIFY), один канал на пользователя.
|
||||
* Если треда ещё нет — показывается форма первого сообщения; после отправки — чат.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, message, Spin, Typography } from 'antd';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface SupportMessage {
|
||||
id: string;
|
||||
direction: 'user' | 'support';
|
||||
body: string;
|
||||
attachments: Array<{ filename?: string; url?: string }>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SupportThreadResponse {
|
||||
thread_id: string | null;
|
||||
messages: SupportMessage[];
|
||||
ticket_id: string | null;
|
||||
}
|
||||
|
||||
export interface SupportChatProps {
|
||||
claimId?: string;
|
||||
source?: 'bar' | 'complaint_card';
|
||||
compact?: boolean;
|
||||
onSuccess?: () => void;
|
||||
hideClaimLabel?: boolean;
|
||||
}
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildThreadUrl(claimId?: string): string {
|
||||
const token = getSessionToken();
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('session_token', token);
|
||||
if (claimId) params.set('claim_id', claimId);
|
||||
return `/api/v1/support/thread?${params.toString()}`;
|
||||
}
|
||||
|
||||
function buildStreamUrl(): string {
|
||||
const token = getSessionToken();
|
||||
if (!token) return '';
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
return `/api/v1/support/stream?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default function SupportChat({
|
||||
claimId,
|
||||
source = 'bar',
|
||||
compact = false,
|
||||
onSuccess,
|
||||
hideClaimLabel = false,
|
||||
}: SupportChatProps) {
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<SupportMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const [keyboardInset, setKeyboardInset] = useState(0);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputBarRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const threadIdRef = useRef<string | null>(null);
|
||||
threadIdRef.current = threadId;
|
||||
|
||||
// При фокусе: в TG/MAX запрашиваем expand(); затем прокручиваем поле ввода в видимую зону (над клавиатурой)
|
||||
const scrollInputIntoView = useCallback(() => {
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
const tg = (win as unknown as { Telegram?: { WebApp?: { expand?: () => void } } })?.Telegram?.WebApp;
|
||||
const max = (win as unknown as { WebApp?: { expand?: () => void } })?.WebApp;
|
||||
if (tg?.expand) tg.expand();
|
||||
if (max?.expand) max.expand();
|
||||
|
||||
const scroll = () => inputBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const t1 = window.setTimeout(scroll, 350);
|
||||
const t2 = window.setTimeout(scroll, 700);
|
||||
return () => {
|
||||
window.clearTimeout(t1);
|
||||
window.clearTimeout(t2);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markRead = useCallback((tid: string) => {
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
params.set('thread_id', tid);
|
||||
fetch(`/api/v1/support/read?${params.toString()}`, { method: 'POST' }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const fetchThread = useCallback(async () => {
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(buildThreadUrl(claimId));
|
||||
if (!res.ok) return;
|
||||
const data: SupportThreadResponse = await res.json();
|
||||
setThreadId(data.thread_id || null);
|
||||
setMessages(data.messages || []);
|
||||
if (data.thread_id) markRead(data.thread_id);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [claimId, markRead]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchThread();
|
||||
}, [fetchThread]);
|
||||
|
||||
// SSE: один поток на пользователя, новые сообщения от поддержки приходят по Postgres NOTIFY
|
||||
useEffect(() => {
|
||||
const url = buildStreamUrl();
|
||||
if (!url) return;
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data || '{}');
|
||||
if (data.event !== 'support_message' || !data.message || !data.thread_id) return;
|
||||
if (data.thread_id !== threadIdRef.current) return;
|
||||
const msg = data.message as SupportMessage;
|
||||
const created_at =
|
||||
typeof msg.created_at === 'string'
|
||||
? msg.created_at
|
||||
: (msg.created_at as unknown as { isoformat?: () => string })?.isoformat?.() ?? new Date().toISOString();
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, { ...msg, created_at, attachments: msg.attachments || [] }];
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => {
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
setKeyboardInset(inset);
|
||||
};
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
const values = await form.getFieldsValue();
|
||||
const text = (values.message || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', text);
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
if (threadId) fd.append('thread_id', threadId);
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const detail = err.detail || res.statusText;
|
||||
if (res.status === 503) {
|
||||
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
|
||||
} else {
|
||||
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить сообщение.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.thread_id) setThreadId(data.thread_id);
|
||||
await fetchThread();
|
||||
form.setFieldValue('message', '');
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('Ошибка соединения. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFirstMessage = async () => {
|
||||
const values = await form.validateFields().catch(() => null);
|
||||
if (!values?.message?.trim()) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) return;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', values.message.trim());
|
||||
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
const detail = err.detail || res.statusText;
|
||||
if (res.status === 503) {
|
||||
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
|
||||
} else {
|
||||
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить обращение.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.thread_id) setThreadId(data.thread_id);
|
||||
await fetchThread();
|
||||
form.resetFields();
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
onSuccess?.();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('Ошибка соединения. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
setFiles((prev) => [...prev, ...selected]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showChat = threadId && messages.length > 0;
|
||||
|
||||
if (!showChat) {
|
||||
return (
|
||||
<div
|
||||
className={compact ? 'support-chat support-chat--compact' : 'support-chat'}
|
||||
style={{ paddingBottom: keyboardInset ? keyboardInset + 8 : 8 }}
|
||||
>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleFirstMessage}>
|
||||
<Form.Item
|
||||
name="message"
|
||||
label="Сообщение"
|
||||
rules={[{ required: true, message: 'Введите текст' }]}
|
||||
>
|
||||
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос..." maxLength={5000} showCount onFocus={scrollInputIntoView} />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label="Тема (необязательно)">
|
||||
<Input placeholder="Краткая тема" maxLength={200} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Прикрепить файлы">
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-chat-files"
|
||||
onChange={addFile}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
|
||||
onClick={() => document.getElementById('support-chat-files')?.click()}
|
||||
>
|
||||
Прикрепить
|
||||
</Button>
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
|
||||
{files.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? 'support-chat support-chat--compact' : 'support-chat'} style={{ display: 'flex', flexDirection: 'column', minHeight: compact ? 320 : 400 }}>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 8, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '12px 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
paddingBottom: keyboardInset ? keyboardInset + 8 : 8,
|
||||
}}
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
style={{
|
||||
alignSelf: msg.direction === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '85%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 12,
|
||||
background: msg.direction === 'user' ? '#e3f2fd' : '#f5f5f5',
|
||||
border: `1px solid ${msg.direction === 'user' ? '#90caf9' : '#e0e0e0'}`,
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{msg.body}
|
||||
</Typography.Text>
|
||||
{msg.attachments?.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: '#666' }}>
|
||||
{msg.attachments.map((a, i) => (
|
||||
<div key={i}>{a.filename || a.url || 'Файл'}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: '#999' }}>
|
||||
{new Date(msg.created_at).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<Form form={form} onFinish={handleSend} style={{ flexShrink: 0 }}>
|
||||
<div
|
||||
ref={inputBarRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
||||
paddingBottom: keyboardInset ? keyboardInset : 0,
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
<Form.Item name="message" style={{ flex: 1, marginBottom: 0 }}>
|
||||
<TextArea
|
||||
placeholder="Сообщение..."
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
maxLength={5000}
|
||||
onFocus={scrollInputIntoView}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
form.submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-chat-files-chat"
|
||||
onChange={addFile}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={18} />}
|
||||
size="large"
|
||||
onClick={() => document.getElementById('support-chat-files-chat')?.click()}
|
||||
/>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} size="large">
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
{files.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{files.map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '2px 8px',
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 4,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
frontend/src/components/SupportForm.tsx
Normal file
214
frontend/src/components/SupportForm.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* SupportForm — форма обращения в поддержку (переиспользуется на странице /support и в модалке карточки жалобы).
|
||||
* Отправка: POST /api/v1/support (multipart). Лимиты вложений опционально из GET /api/v1/support/limits.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, message as antMessage } from 'antd';
|
||||
import { Paperclip, X } from 'lucide-react';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
export interface SupportLimits {
|
||||
max_count: number;
|
||||
max_size_per_file: number;
|
||||
allowed_types: string;
|
||||
unlimited: boolean;
|
||||
}
|
||||
|
||||
export interface SupportFormProps {
|
||||
/** Привязка к обращению (из карточки жалобы) */
|
||||
claimId?: string;
|
||||
/** bar | complaint_card */
|
||||
source?: 'bar' | 'complaint_card';
|
||||
/** После успешной отправки */
|
||||
onSuccess?: () => void;
|
||||
/** Компактный вид (модалка) */
|
||||
compact?: boolean;
|
||||
/** Скрыть заголовок «По обращению №…» когда передан claimId */
|
||||
hideClaimLabel?: boolean;
|
||||
}
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function SupportForm({
|
||||
claimId,
|
||||
source = 'bar',
|
||||
onSuccess,
|
||||
compact = false,
|
||||
hideClaimLabel = false,
|
||||
}: SupportFormProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [limits, setLimits] = useState<SupportLimits | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/support/limits')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data: SupportLimits | null) => {
|
||||
if (data) setLimits(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const canAddFile = (): boolean => {
|
||||
if (!limits || limits.unlimited) return true;
|
||||
return files.length < limits.max_count;
|
||||
};
|
||||
|
||||
const isFileSizeOk = (file: File): boolean => {
|
||||
if (!limits || limits.unlimited || limits.max_size_per_file <= 0) return true;
|
||||
return file.size <= limits.max_size_per_file;
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = Array.from(e.target.files || []);
|
||||
if (!limits?.unlimited && limits && limits.max_count > 0) {
|
||||
const remaining = limits.max_count - files.length;
|
||||
if (selected.length > remaining) {
|
||||
antMessage.warning(`Можно прикрепить не более ${limits.max_count} файлов`);
|
||||
setFileInputKey((k) => k + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ok: File[] = [];
|
||||
for (const f of selected) {
|
||||
if (!isFileSizeOk(f)) {
|
||||
antMessage.warning(`Файл «${f.name}» превышает допустимый размер`);
|
||||
continue;
|
||||
}
|
||||
ok.push(f);
|
||||
}
|
||||
setFiles((prev) => [...prev, ...ok].slice(0, limits?.unlimited ? 999 : (limits?.max_count || 999)));
|
||||
setFileInputKey((k) => k + 1);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const values = await form.validateFields().catch(() => null);
|
||||
if (!values || !values.message?.trim()) return;
|
||||
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
antMessage.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', values.message.trim());
|
||||
if (values.subject?.trim()) fd.append('subject', values.subject.trim());
|
||||
fd.append('source', source);
|
||||
fd.append('session_token', token);
|
||||
if (claimId) fd.append('claim_id', claimId);
|
||||
|
||||
files.forEach((file, i) => {
|
||||
fd.append(`attachments[${i}]`, file, file.name);
|
||||
});
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/support', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText || 'Ошибка отправки');
|
||||
}
|
||||
antMessage.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время.');
|
||||
form.resetFields();
|
||||
setFiles([]);
|
||||
setFileInputKey((k) => k + 1);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
antMessage.error(err instanceof Error ? err.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limitHint =
|
||||
limits && !limits.unlimited
|
||||
? `Макс. ${limits.max_count || '—'} файл(ов)${limits.max_size_per_file ? `, до ${Math.round(limits.max_size_per_file / 1024 / 1024)} МБ каждый` : ''}${limits.allowed_types ? `. Типы: ${limits.allowed_types}` : ''}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={compact ? 'support-form support-form--compact' : 'support-form'}>
|
||||
{claimId && !hideClaimLabel && (
|
||||
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению №{claimId}</p>
|
||||
)}
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="message"
|
||||
label="Сообщение"
|
||||
rules={[{ required: true, message: 'Введите текст обращения' }]}
|
||||
>
|
||||
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос или проблему..." maxLength={5000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="subject" label="Тема (необязательно)">
|
||||
<Input placeholder="Краткая тема" maxLength={200} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Прикрепить файлы">
|
||||
{limitHint && <p style={{ fontSize: 12, color: '#888', marginBottom: 8 }}>{limitHint}</p>}
|
||||
<input
|
||||
key={fileInputKey}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
id="support-attachments-input"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<label htmlFor="support-attachments-input">
|
||||
<Button
|
||||
type="button"
|
||||
icon={<Paperclip size={16} style={{ verticalAlign: 'middle', marginRight: 6 }} />}
|
||||
disabled={!canAddFile()}
|
||||
onClick={() => document.getElementById('support-attachments-input')?.click()}
|
||||
>
|
||||
Прикрепить файлы
|
||||
</Button>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<ul style={{ marginTop: 8, paddingLeft: 20 }}>
|
||||
{files.map((f, i) => (
|
||||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Удалить"
|
||||
onClick={() => removeFile(i)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block={compact}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
frontend/src/components/form/StepComplaintsDashboard.css
Normal file
37
frontend/src/components/form/StepComplaintsDashboard.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Карточки дашборда — в стиле экрана hello: тень и подъём при наведении, одинаковая высота */
|
||||
.dashboard-tile {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
min-height: 88px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-tile:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-tile .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* чтобы все плитки в ряду были одной высоты */
|
||||
.dashboard-tile-row .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
.dashboard-tile-row .ant-col .dashboard-tile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* заголовок плитки — фиксированная высота под 2 строки, чтобы «Приняты к работе» не делал карточку выше */
|
||||
.dashboard-tile-title {
|
||||
min-height: 2.5em;
|
||||
line-height: 1.25;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
330
frontend/src/components/form/StepComplaintsDashboard.tsx
Normal file
330
frontend/src/components/form/StepComplaintsDashboard.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* StepComplaintsDashboard.tsx
|
||||
*
|
||||
* Экран «Мои обращения»: плитки по статусам + кнопка «Подать жалобу».
|
||||
* Показывается после нажатия «Мои обращения» на приветственном экране.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Row, Col, Typography, Spin } from 'antd';
|
||||
import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle, MessageCircle } from 'lucide-react';
|
||||
|
||||
import './StepComplaintsDashboard.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Признак элемента из CRM (проект/тикет)
|
||||
function isFromCrm(d: DraftItem): boolean {
|
||||
const p = (d as any).payload;
|
||||
return (d as any).type_code === 'external_case' || p?.source === 'CRM' || (p && 'projectid' in p);
|
||||
}
|
||||
|
||||
// Тикет из CRM (для карточки «Консультации»)
|
||||
function isCrmTicket(d: DraftItem): boolean {
|
||||
if (!isFromCrm(d)) return false;
|
||||
const p = (d as any).payload;
|
||||
return p?.ticketid != null || p?.ticket_no != null || (d as any).type_code === 'consultation';
|
||||
}
|
||||
|
||||
// Статус CRM: resolved | rejected | in_work (по status_code или payload.projectstatus)
|
||||
function getCrmStatus(d: DraftItem): 'resolved' | 'rejected' | 'in_work' {
|
||||
const code = ((d as any).status_code || '').toLowerCase();
|
||||
const p = (d as any).payload;
|
||||
const projectStatus = (p?.projectstatus || p?.status || '').toString().toLowerCase();
|
||||
if (code === 'completed' || code === 'submitted' || projectStatus.includes('завершено') || projectStatus === 'completed') return 'resolved';
|
||||
if (code === 'rejected' || projectStatus.includes('отклонен')) return 'rejected';
|
||||
return 'in_work';
|
||||
}
|
||||
|
||||
interface DraftItem {
|
||||
claim_id?: string;
|
||||
id?: string;
|
||||
status_code?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
type_code?: string;
|
||||
}
|
||||
|
||||
interface Counts {
|
||||
consultations: number;
|
||||
pending: number;
|
||||
inWork: number;
|
||||
resolved: number;
|
||||
rejected: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Правила: Консультации = тикеты из CRM; В работе = проекты из CRM не завершено + черновики; Решены/Отклонены = из CRM; В ожидании = все из Postgres
|
||||
function countByStatus(drafts: DraftItem[]): Counts {
|
||||
let consultations = 0;
|
||||
let pending = 0;
|
||||
let inWork = 0;
|
||||
let resolved = 0;
|
||||
let rejected = 0;
|
||||
for (const d of drafts) {
|
||||
if (isFromCrm(d)) {
|
||||
if (isCrmTicket(d)) consultations += 1;
|
||||
const crmStatus = getCrmStatus(d);
|
||||
if (crmStatus === 'resolved') resolved += 1;
|
||||
else if (crmStatus === 'rejected') rejected += 1;
|
||||
else inWork += 1;
|
||||
} else {
|
||||
// Всё из Postgres → «В ожидании»
|
||||
pending += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
consultations,
|
||||
pending,
|
||||
inWork,
|
||||
resolved,
|
||||
rejected,
|
||||
total: drafts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||
|
||||
interface StepComplaintsDashboardProps {
|
||||
unified_id?: string;
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
/** Канал входа: telegram | max | web */
|
||||
entry_channel?: string;
|
||||
/** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */
|
||||
drafts?: DraftItem[];
|
||||
loading?: boolean;
|
||||
onGoToList: (filter: DraftsListFilter) => void;
|
||||
onNewClaim: () => void;
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function StepComplaintsDashboard({
|
||||
unified_id,
|
||||
phone,
|
||||
session_id,
|
||||
entry_channel,
|
||||
drafts: draftsFromProps,
|
||||
loading: loadingFromProps,
|
||||
onGoToList,
|
||||
onNewClaim,
|
||||
onNavigate,
|
||||
}: StepComplaintsDashboardProps) {
|
||||
const [counts, setCounts] = useState<Counts>({ consultations: 0, pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 });
|
||||
const [localLoading, setLocalLoading] = useState(true);
|
||||
|
||||
const loading = loadingFromProps ?? localLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (draftsFromProps !== undefined) {
|
||||
setCounts(countByStatus(Array.isArray(draftsFromProps) ? draftsFromProps : []));
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!unified_id && !phone && !session_id) {
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const params = new URLSearchParams();
|
||||
if (unified_id) params.append('unified_id', unified_id);
|
||||
if (phone) params.append('phone', phone);
|
||||
if (session_id) params.append('session_id', session_id);
|
||||
params.append('entry_channel', (entry_channel || 'web').trim() || 'web');
|
||||
fetch(`/api/v1/claims/drafts/list?${params.toString()}`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить список'))))
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setCounts(countByStatus(data.drafts || []));
|
||||
})
|
||||
.catch(() => { if (!cancelled) setCounts((c) => ({ ...c, consultations: 0, pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 })); })
|
||||
.finally(() => { if (!cancelled) setLocalLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [draftsFromProps, unified_id, phone, session_id, entry_channel]);
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
key: 'pending' as const,
|
||||
title: 'В ожидании',
|
||||
count: counts.pending,
|
||||
label: counts.pending === 1 ? '1 дело' : counts.pending < 5 ? `${counts.pending} дела` : `${counts.pending} дел`,
|
||||
color: '#3B82F6',
|
||||
bg: '#EFF6FF',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
key: 'in_work' as const,
|
||||
title: 'Приняты к работе',
|
||||
count: counts.inWork,
|
||||
label: counts.inWork === 1 ? '1 дело' : counts.inWork < 5 ? `${counts.inWork} дела` : `${counts.inWork} дел`,
|
||||
color: '#EA580C',
|
||||
bg: '#FFF7ED',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
key: 'resolved' as const,
|
||||
title: 'Решены',
|
||||
count: counts.resolved,
|
||||
label: counts.resolved === 1 ? '1 дело' : counts.resolved < 5 ? `${counts.resolved} дела` : `${counts.resolved} дел`,
|
||||
color: '#16A34A',
|
||||
bg: '#F0FDF4',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
key: 'rejected' as const,
|
||||
title: 'Отклонены',
|
||||
count: counts.rejected,
|
||||
label: counts.rejected === 1 ? '1 дело' : counts.rejected < 5 ? `${counts.rejected} дела` : `${counts.rejected} дел`,
|
||||
color: '#DC2626',
|
||||
bg: '#FEF2F2',
|
||||
icon: XCircle,
|
||||
},
|
||||
];
|
||||
|
||||
const handleTileClick = (key: DraftsListFilter) => {
|
||||
onGoToList(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', paddingBottom: 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 4, color: '#111827', fontSize: 22 }}>
|
||||
Мои обращения
|
||||
</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 20 }}>
|
||||
Выберите категорию
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Плитка «Консультации» — в самом верху; данные из CRM по вебхуку */}
|
||||
{onNavigate && (
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: '#F5F3FF', marginBottom: 12 }}
|
||||
onClick={() => onNavigate('/consultations')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#8B5CF6',
|
||||
}}
|
||||
>
|
||||
<MessageCircle size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
|
||||
Консультации
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{counts.consultations === 0
|
||||
? 'Тикеты из CRM'
|
||||
: counts.consultations === 1
|
||||
? '1 тикет'
|
||||
: counts.consultations < 5
|
||||
? `${counts.consultations} тикета`
|
||||
: `${counts.consultations} тикетов`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }} className="dashboard-tile-row">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Col xs={12} key={t.key}>
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: t.bg }}
|
||||
onClick={() => handleTileClick(t.key)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: t.color,
|
||||
}}
|
||||
>
|
||||
<Icon size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }} className="dashboard-tile-title">
|
||||
{t.title}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: '#F9FAFB', marginBottom: 12 }}
|
||||
onClick={() => handleTileClick('all' as const)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#6366F1',
|
||||
}}
|
||||
>
|
||||
<FileSearch size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
|
||||
Все обращения
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{counts.total === 1 ? '1 дело всего' : counts.total < 5 ? `${counts.total} дела всего` : `${counts.total} дел всего`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<PlusCircle size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />}
|
||||
onClick={onNewClaim}
|
||||
style={{ height: 48, fontSize: 16, borderRadius: 12 }}
|
||||
>
|
||||
Подать жалобу
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
export default function StepDescription({
|
||||
formData,
|
||||
updateFormData,
|
||||
onPrev,
|
||||
onPrev: _onPrev,
|
||||
onNext,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
@@ -75,14 +75,18 @@ export default function StepDescription({
|
||||
return;
|
||||
}
|
||||
|
||||
const entryChannel =
|
||||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||||
: 'web';
|
||||
|
||||
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
entry_channel: entryChannel,
|
||||
description_length: safeDescription.length,
|
||||
description_preview: safeDescription.substring(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/description', {
|
||||
@@ -92,9 +96,10 @@ export default function StepDescription({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
problem_description: safeDescription,
|
||||
entry_channel: entryChannel, // telegram | max | web — для роутинга в n8n
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -114,6 +119,10 @@ export default function StepDescription({
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ Описание успешно отправлено:', responseData);
|
||||
console.log('📥 Ответ n8n (description):', responseData);
|
||||
if (responseData && typeof responseData === 'object') {
|
||||
console.log('📥 Ключи ответа n8n:', Object.keys(responseData));
|
||||
}
|
||||
|
||||
message.success('Описание отправлено, подбираем рекомендации...');
|
||||
updateFormData({
|
||||
@@ -135,13 +144,9 @@ export default function StepDescription({
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev} size="large">
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
marginTop: 0,
|
||||
padding: 24,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 8,
|
||||
|
||||
12
frontend/src/components/form/StepDraftSelection.css
Normal file
12
frontend/src/components/form/StepDraftSelection.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Карточки списка обращений — как на hello: тень и подъём при наведении */
|
||||
.draft-list-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.draft-list-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
@@ -14,11 +14,10 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
|
||||
import { Button, Card, Modal, Typography, Space, Empty, message, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
@@ -26,10 +25,57 @@ import {
|
||||
UploadOutlined,
|
||||
FileSearchOutlined,
|
||||
MobileOutlined,
|
||||
ExclamationCircleOutlined
|
||||
ExclamationCircleOutlined,
|
||||
FolderOpenOutlined
|
||||
} from '@ant-design/icons';
|
||||
import './StepDraftSelection.css';
|
||||
import {
|
||||
Package,
|
||||
Wrench,
|
||||
Wallet,
|
||||
ShoppingCart,
|
||||
Truck,
|
||||
Plane,
|
||||
GraduationCap,
|
||||
Wifi,
|
||||
Home,
|
||||
Hammer,
|
||||
HeartPulse,
|
||||
Car,
|
||||
Building,
|
||||
Shield,
|
||||
Ticket,
|
||||
Headphones,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import SupportChat from '../SupportChat';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Иконки по направлениям (категориям) для плиток
|
||||
const DIRECTION_ICONS: Record<string, LucideIcon> = {
|
||||
'товары': Package,
|
||||
'услуги': Wrench,
|
||||
'финансы и платежи': Wallet,
|
||||
'интернет-торговля и маркетплейсы': ShoppingCart,
|
||||
'доставка и логистика': Truck,
|
||||
'туризм и путешествия': Plane,
|
||||
'образование и онлайн-курсы': GraduationCap,
|
||||
'связь и интернет': Wifi,
|
||||
'жкх и коммунальные услуги': Home,
|
||||
'строительство и ремонт': Hammer,
|
||||
'медицина и платные клиники': HeartPulse,
|
||||
'транспорт и перевозки': Car,
|
||||
'недвижимость и аренда': Building,
|
||||
'страхование': Shield,
|
||||
'развлечения и мероприятия': Ticket,
|
||||
};
|
||||
|
||||
function getDirectionIcon(directionOrCategory: string | undefined): LucideIcon | null {
|
||||
if (!directionOrCategory || typeof directionOrCategory !== 'string') return null;
|
||||
const key = directionOrCategory.trim().toLowerCase();
|
||||
return DIRECTION_ICONS[key] || null;
|
||||
}
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr: string) => {
|
||||
@@ -46,6 +92,58 @@ const formatDate = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Короткая дата для карточек списка: "12 апреля 2024"
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
return `${day} ${month} ${year}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Маппинг status_code → категория дашборда (как в StepComplaintsDashboard)
|
||||
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
|
||||
const IN_WORK_CODE = 'in_work';
|
||||
const RESOLVED_CODES = ['completed', 'submitted'];
|
||||
const REJECTED_CODE = 'rejected';
|
||||
|
||||
function getDraftCategory(statusCode: string): 'pending' | 'in_work' | 'resolved' | 'rejected' {
|
||||
const code = (statusCode || '').toLowerCase();
|
||||
if (code === IN_WORK_CODE) return 'in_work';
|
||||
if (code === REJECTED_CODE) return 'rejected';
|
||||
if (RESOLVED_CODES.includes(code)) return 'resolved';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** Признак элемента из CRM (проект/тикет) — как в StepComplaintsDashboard */
|
||||
function isFromCrm(d: { payload?: Record<string, unknown>; type_code?: string }): boolean {
|
||||
const p = d.payload;
|
||||
return d.type_code === 'external_case' || (p as any)?.source === 'CRM' || (p && 'projectid' in (p || {}));
|
||||
}
|
||||
|
||||
/** Категория для фильтра и плитки: Postgres по status_code, CRM по status_code (active→in_work, completed→resolved, rejected→rejected) */
|
||||
function getItemCategory(draft: { status_code?: string; payload?: Record<string, unknown>; type_code?: string }): 'pending' | 'in_work' | 'resolved' | 'rejected' {
|
||||
if (isFromCrm(draft)) {
|
||||
const code = (draft.status_code || '').toLowerCase();
|
||||
if (code === 'completed' || (draft.payload as any)?.projectstatus === 'completed') return 'resolved';
|
||||
if (code === 'rejected') return 'rejected';
|
||||
return 'in_work'; // active и всё остальное (тикеты, черновики CRM и т.д.)
|
||||
}
|
||||
return getDraftCategory(draft.status_code || '');
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected', string> = {
|
||||
all: 'Все обращения',
|
||||
pending: 'В ожидании',
|
||||
in_work: 'Приняты к работе',
|
||||
resolved: 'Решены',
|
||||
rejected: 'Отклонены',
|
||||
};
|
||||
|
||||
// Относительное время
|
||||
const getRelativeTime = (dateStr: string) => {
|
||||
try {
|
||||
@@ -83,6 +181,8 @@ interface Draft {
|
||||
problem_title?: string; // Краткое описание (заголовок)
|
||||
problem_description?: string;
|
||||
category?: string; // Категория проблемы
|
||||
direction?: string; // Направление (для иконки плитки)
|
||||
facts_short?: string; // Краткие факты от AI — заголовок плитки
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
@@ -96,14 +196,29 @@ interface Draft {
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
}
|
||||
|
||||
/** Фильтр списка по категории (с дашборда) */
|
||||
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||
|
||||
interface Props {
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
unified_id?: string;
|
||||
isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App
|
||||
isTelegramMiniApp?: boolean;
|
||||
entry_channel?: string;
|
||||
/** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */
|
||||
drafts?: Draft[] | any[];
|
||||
loading?: boolean;
|
||||
/** Вызов после удаления черновика, чтобы родитель перезапросил список */
|
||||
onRefreshDrafts?: () => void;
|
||||
/** ID черновика, открытого для просмотра описания (управляется из ClaimForm, чтобы не терять при пересчёте steps) */
|
||||
draftDetailClaimId?: string | null;
|
||||
/** Показывать только обращения этой категории (с дашборда) */
|
||||
categoryFilter?: DraftsListFilter;
|
||||
onOpenDraftDetail?: (claimId: string) => void;
|
||||
onCloseDraftDetail?: () => void;
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
|
||||
onRestartDraft?: (claimId: string, description: string) => void;
|
||||
}
|
||||
|
||||
// === Конфиг статусов ===
|
||||
@@ -163,6 +278,27 @@ const STATUS_CONFIG: Record<string, {
|
||||
description: 'Заявка на рассмотрении',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
active: {
|
||||
color: 'cyan',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: 'В работе',
|
||||
description: 'Дело из CRM',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
completed: {
|
||||
color: 'green',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Решено',
|
||||
description: 'Дело завершено',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
rejected: {
|
||||
color: 'red',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
label: 'Отклонено',
|
||||
description: 'Дело отклонено',
|
||||
action: 'Просмотреть',
|
||||
},
|
||||
legacy: {
|
||||
color: 'warning',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
@@ -172,78 +308,81 @@ const STATUS_CONFIG: Record<string, {
|
||||
},
|
||||
};
|
||||
|
||||
function processDraftsFromApi(raw: any[]): Draft[] {
|
||||
return (raw || []).map((draft: Draft) => {
|
||||
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
|
||||
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
|
||||
return { ...draft, is_legacy: isLegacy };
|
||||
});
|
||||
}
|
||||
|
||||
export default function StepDraftSelection({
|
||||
phone,
|
||||
session_id,
|
||||
unified_id,
|
||||
isTelegramMiniApp,
|
||||
entry_channel,
|
||||
drafts: draftsFromProps,
|
||||
loading: loadingFromProps,
|
||||
onRefreshDrafts,
|
||||
draftDetailClaimId = null,
|
||||
categoryFilter = 'all',
|
||||
onOpenDraftDetail,
|
||||
onCloseDraftDetail,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
onRestartDraft,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [localDrafts, setLocalDrafts] = useState<Draft[]>([]);
|
||||
const [localLoading, setLocalLoading] = useState(true);
|
||||
const drafts = draftsFromProps !== undefined ? processDraftsFromApi(Array.isArray(draftsFromProps) ? draftsFromProps : []) : localDrafts;
|
||||
const loading = loadingFromProps !== undefined ? loadingFromProps : localLoading;
|
||||
const [supportModalClaimId, setSupportModalClaimId] = useState<string | null>(null);
|
||||
|
||||
/** Список отфильтрован по категории с дашборда (учёт и Postgres, и CRM) */
|
||||
const filteredDrafts =
|
||||
categoryFilter === 'all'
|
||||
? drafts
|
||||
: drafts.filter((d) => getItemCategory(d) === categoryFilter);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
/** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */
|
||||
const [detailDraftPayload, setDetailDraftPayload] = useState<{ claimId: string; payload: Record<string, unknown> } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
/** Черновик для экрана описания: из пропа draftDetailClaimId + список drafts */
|
||||
const selectedDraft = draftDetailClaimId
|
||||
? (drafts.find((d) => (d.claim_id || d.id) === draftDetailClaimId) ?? null)
|
||||
: null;
|
||||
|
||||
const loadDrafts = async () => {
|
||||
if (draftsFromProps !== undefined) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
setLocalLoading(true);
|
||||
if (!unified_id && !phone && !session_id) {
|
||||
setLocalLoading(false);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (unified_id) {
|
||||
params.append('unified_id', unified_id);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
|
||||
} else if (phone) {
|
||||
params.append('phone', phone);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по phone:', phone);
|
||||
} else if (session_id) {
|
||||
params.append('session_id', session_id);
|
||||
console.log('🔍 StepDraftSelection: загружаем черновики по session_id:', session_id);
|
||||
}
|
||||
|
||||
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
|
||||
console.log('🔍 StepDraftSelection: запрос:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить черновики');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔍 StepDraftSelection: ответ API:', data);
|
||||
|
||||
// Определяем legacy черновики (без documents_required в payload)
|
||||
let processedDrafts = (data.drafts || []).map((draft: Draft) => {
|
||||
// Legacy только если:
|
||||
// 1. Статус 'draft' (старый формат) ИЛИ
|
||||
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
|
||||
// И есть wizard_plan (старый формат)
|
||||
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
|
||||
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
|
||||
return {
|
||||
...draft,
|
||||
is_legacy: isLegacy,
|
||||
};
|
||||
});
|
||||
|
||||
// ✅ В Telegram Mini App скрываем заявки "В работе"
|
||||
if (isTelegramMiniApp) {
|
||||
processedDrafts = processedDrafts.filter((draft: Draft) => draft.status_code !== 'in_work');
|
||||
console.log('🔍 Telegram Mini App: заявки "В работе" скрыты');
|
||||
}
|
||||
|
||||
setDrafts(processedDrafts);
|
||||
if (unified_id) params.append('unified_id', unified_id);
|
||||
if (phone) params.append('phone', phone);
|
||||
if (session_id) params.append('session_id', session_id);
|
||||
params.append('entry_channel', (entry_channel || 'web').trim() || 'web');
|
||||
const res = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
|
||||
if (!res.ok) throw new Error('Не удалось загрузить черновики');
|
||||
const data = await res.json();
|
||||
setLocalDrafts(processDraftsFromApi(data.drafts || []));
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновиков:', error);
|
||||
message.error('Не удалось загрузить список черновиков');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLocalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (draftsFromProps !== undefined) return;
|
||||
loadDrafts();
|
||||
}, [phone, session_id, unified_id]);
|
||||
}, [phone, unified_id, entry_channel, draftsFromProps]);
|
||||
|
||||
const handleDelete = async (claimId: string) => {
|
||||
try {
|
||||
@@ -257,7 +396,7 @@ export default function StepDraftSelection({
|
||||
}
|
||||
|
||||
message.success('Черновик удален');
|
||||
await loadDrafts();
|
||||
if (onRefreshDrafts) await onRefreshDrafts(); else await loadDrafts();
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления черновика:', error);
|
||||
message.error('Не удалось удалить черновик');
|
||||
@@ -271,6 +410,10 @@ export default function StepDraftSelection({
|
||||
if (draft.is_legacy) {
|
||||
return STATUS_CONFIG.legacy;
|
||||
}
|
||||
if (isFromCrm(draft)) {
|
||||
const code = (draft.status_code || 'active').toLowerCase();
|
||||
return STATUS_CONFIG[code] || STATUS_CONFIG.active;
|
||||
}
|
||||
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
|
||||
};
|
||||
|
||||
@@ -284,6 +427,38 @@ export default function StepDraftSelection({
|
||||
return { uploaded, skipped, total, percent };
|
||||
};
|
||||
|
||||
// Открыть экран полного описания (загрузка payload — в useEffect по draftDetailClaimId)
|
||||
const openDraftDetail = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
onOpenDraftDetail?.(draftId);
|
||||
setDetailDraftPayload(null);
|
||||
setDetailLoading(true);
|
||||
};
|
||||
|
||||
const closeDraftDetail = () => {
|
||||
onCloseDraftDetail?.();
|
||||
setDetailDraftPayload(null);
|
||||
};
|
||||
|
||||
// Загрузка payload при открытии по draftDetailClaimId (клик по карточке или восстановление после пересчёта steps)
|
||||
useEffect(() => {
|
||||
if (!draftDetailClaimId) return;
|
||||
if (detailDraftPayload?.claimId === draftDetailClaimId) return;
|
||||
setDetailLoading(true);
|
||||
setDetailDraftPayload(null);
|
||||
const claimId = draftDetailClaimId;
|
||||
fetch(`/api/v1/claims/drafts/${claimId}`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить черновик'))))
|
||||
.then((data) => {
|
||||
const payload = data?.claim?.payload;
|
||||
if (payload && typeof payload === 'object') {
|
||||
setDetailDraftPayload({ claimId, payload });
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setDetailLoading(false));
|
||||
}, [draftDetailClaimId]);
|
||||
|
||||
// Обработка клика на черновик
|
||||
const handleDraftAction = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
@@ -333,273 +508,229 @@ export default function StepDraftSelection({
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
||||
📋 Ваши заявки
|
||||
// Экран полного описания черновика (draftDetailClaimId открыт; selectedDraft может быть null пока список не подгрузился)
|
||||
if (draftDetailClaimId) {
|
||||
const draftId = draftDetailClaimId;
|
||||
const payload = detailDraftPayload?.claimId === draftId ? detailDraftPayload.payload : null;
|
||||
const fromPayload =
|
||||
(payload && (payload.problem_description ?? payload.description ?? payload.chatInput)) ?? '';
|
||||
const fromDraft = selectedDraft
|
||||
? (selectedDraft.problem_description ||
|
||||
selectedDraft.facts_short ||
|
||||
selectedDraft.problem_title ||
|
||||
'')
|
||||
: '';
|
||||
const fullText = String(fromPayload || fromDraft || '').trim();
|
||||
const displayText = fullText || 'Описание не сохранено';
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0, width: '100%', boxSizing: 'border-box' }}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px 20px' }}
|
||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff', width: '100%', boxSizing: 'border-box' }}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Title level={4} style={{ marginBottom: 8, color: '#111827' }}>
|
||||
Обращение
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||
Выберите заявку для продолжения или создайте новую.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания новой заявки - всегда вверху */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewClaim}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Создать новую заявку
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: 80,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<Empty
|
||||
description="У вас пока нет незавершенных заявок"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
dataSource={drafts}
|
||||
renderItem={(draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const docsProgress = getDocsProgress(draft);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
display: 'block', // Вертикальный layout
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
color: draft.is_legacy ? '#faad14' : '#595959',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{config.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||
{draft.category && (
|
||||
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Заголовок - краткое описание проблемы */}
|
||||
{draft.problem_title && (
|
||||
<Text strong style={{
|
||||
fontSize: 15,
|
||||
color: '#1a1a1a',
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{draft.problem_title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Полное описание проблемы */}
|
||||
{draft.problem_description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
background: '#f5f5f5',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={draft.problem_description}
|
||||
>
|
||||
{draft.problem_description.length > 250
|
||||
? draft.problem_description.substring(0, 250) + '...'
|
||||
: draft.problem_description
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Время обновления */}
|
||||
<Space size="small">
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Tooltip title={formatDate(draft.updated_at)}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{getRelativeTime(draft.updated_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{/* Legacy предупреждение */}
|
||||
{draft.is_legacy && (
|
||||
<Alert
|
||||
message="Черновик в старом формате. Нажмите 'Начать заново'."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Список документов со статусами */}
|
||||
{draft.documents_list && draft.documents_list.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
📄 Документы
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
|
||||
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{draft.documents_list.map((doc, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{doc.uploaded ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
|
||||
) : (
|
||||
<span style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
|
||||
textDecoration: doc.uploaded ? 'none' : 'none',
|
||||
}}>
|
||||
{doc.name}
|
||||
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогрессбар (если нет списка) */}
|
||||
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#1890ff',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="draft-actions" style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{getActionButton(draft)}
|
||||
{/* Скрываем кнопку "Удалить" для заявок "В работе" */}
|
||||
{draft.status_code !== 'in_work' && (
|
||||
<Popconfirm
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{selectedDraft?.is_legacy && onRestartDraft ? (
|
||||
<Button
|
||||
type="link"
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadDrafts}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onRestartDraft(draftId, selectedDraft.problem_description || '');
|
||||
closeDraftDetail();
|
||||
}}
|
||||
>
|
||||
Обновить список
|
||||
Начать заново
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => {
|
||||
onSelectDraft(draftId);
|
||||
closeDraftDetail();
|
||||
}}
|
||||
>
|
||||
К документам
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="default"
|
||||
size="middle"
|
||||
icon={<Headphones size={16} style={{ verticalAlign: 'middle' }} />}
|
||||
onClick={() => setSupportModalClaimId(draftId)}
|
||||
>
|
||||
Написать в поддержку
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal
|
||||
title="Написать в поддержку"
|
||||
open={supportModalClaimId === draftId}
|
||||
onCancel={() => setSupportModalClaimId(null)}
|
||||
footer={null}
|
||||
width={480}
|
||||
destroyOnClose
|
||||
mask={false}
|
||||
>
|
||||
<SupportChat
|
||||
claimId={draftId}
|
||||
source="complaint_card"
|
||||
compact
|
||||
onSuccess={() => {
|
||||
setSupportModalClaimId(null);
|
||||
message.success('Запрос отправлен.');
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Цвет точки статуса по категории (как на макете — зелёный для «Приняты к работе»)
|
||||
const statusDotColor: Record<string, string> = {
|
||||
pending: '#1890ff',
|
||||
in_work: '#52c41a',
|
||||
resolved: '#52c41a',
|
||||
rejected: '#ff4d4f',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0, width: '100%', boxSizing: 'border-box' }}>
|
||||
{/* Шапка: заголовок + подзаголовок категории */}
|
||||
<div style={{ marginBottom: 16, padding: '16px 0 8px' }}>
|
||||
<Title level={3} style={{ margin: 0, color: '#111827', fontWeight: 700 }}>
|
||||
Мои обращения
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14, marginTop: 4, display: 'block' }}>
|
||||
{CATEGORY_LABELS[categoryFilter]}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredDrafts.length === 0 ? (
|
||||
<Empty
|
||||
description={categoryFilter === 'all' ? 'У вас пока нет обращений' : `Нет обращений в категории «${CATEGORY_LABELS[categoryFilter]}»`}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{filteredDrafts.map((draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const tileTitle = draft.facts_short
|
||||
|| draft.problem_title
|
||||
|| (draft.problem_description
|
||||
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
|
||||
: 'Обращение');
|
||||
const category = getItemCategory(draft);
|
||||
const dotColor = statusDotColor[category] || '#8c8c8c';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={draft.claim_id || draft.id}
|
||||
className="draft-list-card"
|
||||
hoverable
|
||||
style={{ background: '#fff', cursor: 'pointer' }}
|
||||
bodyStyle={{ padding: '14px 16px' }}
|
||||
onClick={() => openDraftDetail(draft)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Text strong style={{ fontSize: 15, color: '#111827', lineHeight: 1.35 }}>
|
||||
{tileTitle}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 13, color: dotColor }}>{config.label}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.4 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateShort(draft.updated_at)}
|
||||
</Text>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: 0, height: 'auto', marginTop: 4 }}
|
||||
icon={<Headphones size={14} style={{ verticalAlign: 'middle', marginRight: 4 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSupportModalClaimId(draft.claim_id || draft.id || '');
|
||||
}}
|
||||
>
|
||||
Поддержка
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => onRefreshDrafts ? onRefreshDrafts() : loadDrafts()}
|
||||
loading={loading}
|
||||
>
|
||||
Обновить список
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Написать в поддержку"
|
||||
open={!!supportModalClaimId}
|
||||
onCancel={() => setSupportModalClaimId(null)}
|
||||
footer={null}
|
||||
width={480}
|
||||
destroyOnClose
|
||||
mask={false}
|
||||
>
|
||||
{supportModalClaimId && (
|
||||
<SupportChat
|
||||
claimId={supportModalClaimId}
|
||||
source="complaint_card"
|
||||
compact
|
||||
onSuccess={() => {
|
||||
setSupportModalClaimId(null);
|
||||
message.success('Запрос отправлен.');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { Button, Card, Checkbox, Form, Input, Modal, Radio, Result, Row, Col, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { getDocTypeStyle, STATUS_UPLOADED, STATUS_NEEDED, STATUS_NOT_AVAILABLE, STATUS_OPTIONAL } from './documentsScreenMaps';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
@@ -51,6 +52,7 @@ interface Props {
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
|
||||
onNewClaim?: () => void; // ✅ Переход на форму нового обращения (шаг «Описание»)
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
@@ -93,6 +95,21 @@ const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
|
||||
|
||||
const YES_VALUES = ['да', 'yes', 'true', '1'];
|
||||
|
||||
/** Единое событие от бэкенда: тип + текст (+ data для consumer_complaint) */
|
||||
type DisplayEventType = 'trash_message' | 'out_of_scope' | 'consumer_consultation' | 'consumer_complaint';
|
||||
interface ResponseEvent {
|
||||
event_type: DisplayEventType;
|
||||
message: string;
|
||||
data?: Record<string, any>;
|
||||
suggested_actions?: any[];
|
||||
}
|
||||
const DISPLAY_STYLE: Record<DisplayEventType, { bg: string; border: string; title: string }> = {
|
||||
trash_message: { bg: '#fff2f0', border: '#ffccc7', title: 'Не по тематике' },
|
||||
out_of_scope: { bg: '#fff7e6', border: '#ffd591', title: 'Вне нашей компетенции' },
|
||||
consumer_consultation: { bg: '#e6f7ff', border: '#91d5ff', title: 'Консультация' },
|
||||
consumer_complaint: { bg: '#f6ffed', border: '#b7eb8f', title: 'Обращение принято' },
|
||||
};
|
||||
|
||||
const isAffirmative = (value: any) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
@@ -112,6 +129,7 @@ export default function StepWizardPlan({
|
||||
onNext,
|
||||
onPrev,
|
||||
backToDraftsList,
|
||||
onNewClaim,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
||||
@@ -122,6 +140,8 @@ export default function StepWizardPlan({
|
||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
|
||||
/** Единое событие от бэка: тип + текст — одно окошко с цветом по типу */
|
||||
const [responseEvent, setResponseEvent] = useState<ResponseEvent | null>(null);
|
||||
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
||||
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
||||
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
||||
@@ -464,83 +484,143 @@ export default function StepWizardPlan({
|
||||
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,
|
||||
// Не показывать служебное сообщение подключения SSE как ответ пользователю
|
||||
if (payload.status === 'connected' && payload.message === 'Подключено к событиям') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Единый формат от бэка: event_type + message (тип и текст)
|
||||
const displayTypes: DisplayEventType[] = ['trash_message', 'out_of_scope', 'consumer_consultation', 'consumer_complaint'];
|
||||
let isDisplayEvent = payload.event_type && displayTypes.includes(payload.event_type as DisplayEventType) && payload.message != null;
|
||||
// Fallback: пришло только message без event_type — показываем как out_of_scope (но не служебное "Подключено к событиям")
|
||||
if (!isDisplayEvent && payload.message != null && String(payload.message).trim() && payload.message !== 'Подключено к событиям') {
|
||||
payload.event_type = payload.event_type || 'out_of_scope';
|
||||
payload.event_type = displayTypes.includes(payload.event_type as DisplayEventType) ? payload.event_type : 'out_of_scope';
|
||||
isDisplayEvent = true;
|
||||
}
|
||||
|
||||
if (isDisplayEvent) {
|
||||
const ev: ResponseEvent = {
|
||||
event_type: payload.event_type as DisplayEventType,
|
||||
message: payload.message || 'Ответ получен',
|
||||
data: payload.data,
|
||||
suggested_actions: payload.suggested_actions,
|
||||
});
|
||||
|
||||
};
|
||||
setResponseEvent(ev);
|
||||
setIsWaiting(false);
|
||||
setOutOfScopeData(payload); // Сохраняем полные данные
|
||||
setConnectionError(null); // Не используем connectionError
|
||||
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// consumer_complaint с data: список документов или план — обновляем formData и при необходимости план
|
||||
if (ev.event_type === 'consumer_complaint' && ev.data) {
|
||||
const docs = ev.data.documents_required ?? payload.documents_required;
|
||||
if (docs && Array.isArray(docs)) {
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: docs.length,
|
||||
});
|
||||
updateFormData({
|
||||
documents_required: docs,
|
||||
claim_id: ev.data.claim_id || payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready',
|
||||
});
|
||||
message.success(`Получен список документов: ${docs.length} шт.`);
|
||||
}
|
||||
const wizardPlan = ev.data.wizard_plan ?? extractWizardPayload(payload)?.wizard_plan;
|
||||
if (wizardPlan) {
|
||||
const wizardPayload = extractWizardPayload(payload) || { wizard_plan: wizardPlan, answers_prefill: ev.data.answers_prefill, coverage_report: ev.data.coverage_report };
|
||||
const answersPrefill = wizardPayload.answers_prefill ?? ev.data.answers_prefill;
|
||||
const coverageReport = wizardPayload.coverage_report ?? ev.data.coverage_report;
|
||||
const prefill = buildPrefillMap(answersPrefill);
|
||||
setPlan(wizardPlan);
|
||||
setPrefillMap(prefill);
|
||||
updateFormData({
|
||||
wizardPlan: wizardPlan,
|
||||
wizardPrefill: prefill,
|
||||
wizardPrefillArray: answersPrefill,
|
||||
wizardCoverageReport: coverageReport,
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Для trash и out_of_scope закрываем SSE
|
||||
if (ev.event_type === 'trash_message' || ev.event_type === 'out_of_scope') {
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Обратная совместимость: старый формат без нормализации (out_of_scope, trash_message, documents_list_ready, wizard)
|
||||
if (eventType === 'out_of_scope') {
|
||||
setResponseEvent({
|
||||
event_type: 'out_of_scope',
|
||||
message: payload.message || 'К сожалению, мы не можем помочь с этим вопросом.',
|
||||
suggested_actions: payload.suggested_actions,
|
||||
});
|
||||
setOutOfScopeData(payload);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (eventType === 'trash_message' || payload?.payload?.intent === 'trash') {
|
||||
const msg = payload?.payload?.message || payload?.message || 'К сожалению, это обращение не по тематике защиты прав потребителей.';
|
||||
setResponseEvent({
|
||||
event_type: 'trash_message',
|
||||
message: msg,
|
||||
suggested_actions: payload?.payload?.suggested_actions || payload?.suggested_actions,
|
||||
});
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
||||
if (eventType === 'documents_list_ready') {
|
||||
const documentsRequired = payload.documents_required || [];
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: documentsRequired.length,
|
||||
documents: documentsRequired.map((d: any) => d.name),
|
||||
setResponseEvent({
|
||||
event_type: 'consumer_complaint',
|
||||
message: `Подготовлен список документов: ${documentsRequired.length} шт.`,
|
||||
data: { documents_required: documentsRequired, claim_id: payload.claim_id },
|
||||
});
|
||||
|
||||
console.log('📋 documents_list_ready:', {
|
||||
claim_id: payload.claim_id,
|
||||
documents_required: documentsRequired,
|
||||
});
|
||||
|
||||
// Сохраняем в formData для нового флоу
|
||||
updateFormData({
|
||||
documents_required: documentsRequired,
|
||||
claim_id: payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready', // Новый статус
|
||||
wizardPlanStatus: 'documents_ready',
|
||||
});
|
||||
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||
|
||||
// TODO: onNext() для перехода к StepDocumentsNew
|
||||
return;
|
||||
}
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
|
||||
if (eventType?.includes('wizard') || hasWizardPlan) {
|
||||
const wizardPlan = wizardPayload?.wizard_plan;
|
||||
const answersPrefill = wizardPayload?.answers_prefill;
|
||||
const coverageReport = wizardPayload?.coverage_report;
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
|
||||
session_id: sessionId,
|
||||
questions: wizardPlan?.questions?.length || 0,
|
||||
setResponseEvent({
|
||||
event_type: 'consumer_complaint',
|
||||
message: payload.message || 'План готов.',
|
||||
data: { wizard_plan: wizardPlan, answers_prefill: answersPrefill, coverage_report: coverageReport },
|
||||
});
|
||||
|
||||
const prefill = buildPrefillMap(answersPrefill);
|
||||
setPlan(wizardPlan);
|
||||
setPrefillMap(prefill);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
updateFormData({
|
||||
wizardPlan: wizardPlan,
|
||||
wizardPrefill: prefill,
|
||||
@@ -548,11 +628,7 @@ export default function StepWizardPlan({
|
||||
wizardCoverageReport: coverageReport,
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
@@ -859,6 +935,11 @@ export default function StepWizardPlan({
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
console.log('📥 Ответ n8n (wizard):', parsed);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
console.log('📥 Ключи ответа n8n:', Object.keys(parsed));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
||||
@@ -1439,7 +1520,6 @@ export default function StepWizardPlan({
|
||||
})}
|
||||
|
||||
<Space style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
Сохранить и продолжить →
|
||||
</Button>
|
||||
@@ -1456,7 +1536,6 @@ export default function StepWizardPlan({
|
||||
status="warning"
|
||||
title="Нет session_id"
|
||||
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
|
||||
extra={<Button onClick={onPrev}>Вернуться</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1587,8 +1666,10 @@ export default function StepWizardPlan({
|
||||
}
|
||||
}, [currentDocIndex, documentsRequired.length, uploadedDocs, skippedDocs, findFirstUnprocessedDoc, updateFormData]);
|
||||
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload');
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]);
|
||||
const [selectedDocIndex, setSelectedDocIndex] = useState<number | null>(null); // Плиточный стиль: какая плитка открыта в модалке
|
||||
const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы»
|
||||
|
||||
// Текущий документ для загрузки
|
||||
const currentDoc = documentsRequired[currentDocIndex];
|
||||
@@ -2160,148 +2241,288 @@ export default function StepWizardPlan({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && currentDocIndex < documentsRequired.length && currentDoc ? (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
{/* Прогресс */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
|
||||
<Text type="secondary">{Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}% завершено</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
const showDocumentsOnly = hasNewFlowDocs && documentsRequired.length > 0;
|
||||
const stepContent = (
|
||||
<>
|
||||
{/* ✅ Экран «Загрузка документов» по дизайн-спецификации */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && documentsRequired.length > 0 ? (
|
||||
<div style={{ background: '#f5f7fb', margin: '-1px -1px 0', borderRadius: '16px 16px 0 0', overflow: 'hidden', minHeight: 360 }}>
|
||||
{/* Шапка: градиент синий, заголовок */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', padding: '16px 16px', textAlign: 'center' }}>
|
||||
<Typography.Text strong style={{ color: '#fff', fontSize: 18 }}>Загрузка документов</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* Заголовок документа */}
|
||||
<Title level={4} style={{ marginBottom: 8 }}>
|
||||
📄 {currentDoc.name}
|
||||
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* Радио-кнопки выбора */}
|
||||
<Radio.Group
|
||||
value={docChoice}
|
||||
onChange={(e) => {
|
||||
setDocChoice(e.target.value);
|
||||
if (e.target.value === 'none') {
|
||||
setCurrentUploadedFiles([]);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: 16, display: 'block' }}
|
||||
<div style={{ padding: '16px 16px 100px' }}>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 80 }}>
|
||||
{documentsRequired.map((doc: any, index: number) => {
|
||||
const docId = doc.id || doc.name;
|
||||
const isUploaded = uploadedDocs.includes(docId);
|
||||
const isSkipped = skippedDocs.includes(docId);
|
||||
const fileCount = (formData.documents_uploaded || []).filter((d: any) => (d.type || d.id) === docId).length;
|
||||
const { Icon: DocIcon, color: docColor } = getDocTypeStyle(docId);
|
||||
const isSelected = selectedDocIndex === index;
|
||||
const status = isUploaded ? STATUS_UPLOADED : isSkipped ? STATUS_NOT_AVAILABLE : (doc.required ? STATUS_NEEDED : STATUS_OPTIONAL);
|
||||
const StatusIcon = status.Icon;
|
||||
const statusLabel = isUploaded ? (fileCount > 0 ? `${status.label} (${fileCount})` : status.label) : status.label;
|
||||
const tileBg = isUploaded ? '#ECFDF5' : isSkipped ? '#F3F4F6' : '#FFFBEB';
|
||||
const tileBorder = isSelected ? '#2563eb' : isUploaded ? '#22C55E' : isSkipped ? '#9ca3af' : '#F59E0B';
|
||||
return (
|
||||
<Col xs={12} key={docId}>
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: `1px solid ${tileBorder}`,
|
||||
background: tileBg,
|
||||
boxShadow: isSelected ? '0 0 0 2px rgba(37,99,235,0.25)' : '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
|
||||
onClick={() => { setCurrentDocIndex(index); setDocChoice(isSkipped ? 'none' : 'upload'); setCurrentUploadedFiles([]); setSelectedDocIndex(index); }}
|
||||
>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${docColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: docColor }}>
|
||||
<DocIcon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{doc.name}</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Space size={6} style={{ fontSize: 12, color: status.color }}>
|
||||
<StatusIcon size={14} strokeWidth={2} />
|
||||
<span>{statusLabel}</span>
|
||||
</Space>
|
||||
{'subLabel' in status && isSkipped && <Text type="secondary" style={{ fontSize: 11 }}>{(status as { subLabel?: string }).subLabel}</Text>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
{/* Плитка: произвольные группы документов (название от пользователя при одной группе) */}
|
||||
<Col xs={12} key="__custom_docs__">
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: `1px solid #e5e7eb`,
|
||||
background: '#fff',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
|
||||
onClick={() => setCustomDocsModalOpen(true)}
|
||||
>
|
||||
{(() => {
|
||||
const { Icon: CustomIcon, color: customColor } = getDocTypeStyle('__custom_docs__');
|
||||
const StatusIcon = customFileBlocks.length > 0 ? STATUS_UPLOADED.Icon : CustomIcon;
|
||||
const statusColor = customFileBlocks.length > 0 ? STATUS_UPLOADED.color : '#8c8c8c';
|
||||
const hasGroups = customFileBlocks.length > 0;
|
||||
const titleText = hasGroups && customFileBlocks.length === 1 && customFileBlocks[0].description?.trim()
|
||||
? (customFileBlocks[0].description.trim().length > 25 ? customFileBlocks[0].description.trim().slice(0, 22) + '…' : customFileBlocks[0].description.trim())
|
||||
: 'Свои документы';
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${customColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: customColor }}>
|
||||
<CustomIcon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{titleText}</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Space size={6} style={{ fontSize: 12, color: statusColor }}>
|
||||
<StatusIcon size={14} strokeWidth={2} />
|
||||
<span>{hasGroups ? `Загружено (${customFileBlocks.length} ${customFileBlocks.length === 1 ? 'группа' : 'группы'})` : 'Добавить'}</span>
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Col>
|
||||
{/* Плитка «Добавить ещё группу» — серая до загрузки, цветная после */}
|
||||
<Col xs={12} key="__custom_docs_add__">
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: '1px solid #e5e7eb',
|
||||
background: customFileBlocks.length > 0 ? '#f5f3ff' : '#fafafa',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 8 }}
|
||||
onClick={() => setCustomDocsModalOpen(true)}
|
||||
>
|
||||
{(() => {
|
||||
const { Icon: AddIcon, color: addColor } = getDocTypeStyle('__custom_docs__');
|
||||
const isColored = customFileBlocks.length > 0;
|
||||
const iconColor = isColored ? addColor : '#9ca3af';
|
||||
const bgColor = isColored ? `${addColor}18` : '#f3f4f6';
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 14, background: bgColor, display: 'flex', alignItems: 'center', justifyContent: 'center', color: iconColor }}>
|
||||
<AddIcon size={26} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text style={{ fontSize: 13, color: isColored ? '#374151' : '#9ca3af', lineHeight: 1.3 }}>
|
||||
Добавить ещё группу
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* Кнопка «Отправить» внизу экрана с плитками (bottom: 90px — выше футера) */}
|
||||
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '24px 0 0', marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleAllDocsComplete}
|
||||
disabled={!allDocsProcessed}
|
||||
title={!allDocsProcessed ? `Сначала отметьте все документы (${uploadedDocs.length + skippedDocs.length}/${documentsRequired.length})` : undefined}
|
||||
style={{
|
||||
background: allDocsProcessed ? 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' : undefined,
|
||||
border: 'none',
|
||||
borderRadius: 28,
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title={currentDoc ? `📄 ${currentDoc.name}` : 'Документ'}
|
||||
open={selectedDocIndex !== null && !!documentsRequired[selectedDocIndex]}
|
||||
onCancel={() => setSelectedDocIndex(null)}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 16 }}>
|
||||
📎 Загрузить документ
|
||||
</Radio>
|
||||
<Radio value="none" style={{ fontSize: 16 }}>
|
||||
❌ У меня нет этого документа
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger
|
||||
multiple={true}
|
||||
beforeUpload={() => false}
|
||||
fileList={currentUploadedFiles}
|
||||
onChange={({ fileList }) => handleFilesChange(fileList)}
|
||||
onRemove={(file) => {
|
||||
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
|
||||
return true;
|
||||
}}
|
||||
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
|
||||
disabled={submitting}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Перетащите файлы или нажмите для выбора
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
📌 Можно загрузить несколько файлов (все страницы документа)
|
||||
<br />
|
||||
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
{/* Предупреждение если "нет документа" для важного */}
|
||||
{docChoice === 'none' && currentDoc.required && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Text type="warning">
|
||||
⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
|
||||
</Text>
|
||||
{selectedDocIndex !== null && documentsRequired[selectedDocIndex] && (() => {
|
||||
const doc = documentsRequired[selectedDocIndex];
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{doc.hints && <Paragraph type="secondary" style={{ marginBottom: 16 }}>{doc.hints}</Paragraph>}
|
||||
<Radio.Group value={docChoice} onChange={(e) => { setDocChoice(e.target.value); if (e.target.value === 'none') setCurrentUploadedFiles([]); }} style={{ marginBottom: 16, display: 'block' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 15 }}>📎 Загрузить документ</Radio>
|
||||
<Radio value="none" style={{ fontSize: 15 }}>❌ У меня нет этого документа</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger multiple beforeUpload={() => false} fileList={currentUploadedFiles} onChange={({ fileList }) => handleFilesChange(fileList)} onRemove={(file) => { setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid)); return true; }} accept={doc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'} disabled={submitting} style={{ marginBottom: 16 }}>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 32 }} /></p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
|
||||
<p className="ant-upload-hint">Форматы: {doc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ)</p>
|
||||
</Dragger>
|
||||
)}
|
||||
{docChoice === 'none' && doc.required && (
|
||||
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, marginBottom: 16 }}>
|
||||
<Text type="warning">⚠️ Документ важен для рассмотрения. Постарайтесь найти его позже.</Text>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setSelectedDocIndex(null)}>Отмена</Button>
|
||||
<Button type="primary" onClick={async () => { await handleDocContinue(); setSelectedDocIndex(null); }} disabled={!canContinue || submitting} loading={submitting}>{submitting ? 'Загружаем...' : 'Готово'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
{/* Модалка «Свои документы» — произвольные группы документов */}
|
||||
<Modal
|
||||
title="Дополнительные документы"
|
||||
open={customDocsModalOpen}
|
||||
onCancel={() => setCustomDocsModalOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnClose={false}
|
||||
>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{customFileBlocks.length === 0 && (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fafafa', borderRadius: 8 }}>
|
||||
<Paragraph style={{ marginBottom: 8 }}>
|
||||
<Text strong>Есть ещё документы, которые могут помочь?</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
Добавьте группу документов с названием (например: «Переписка в мессенджере», «Скриншоты»).
|
||||
В каждой группе — своё название и файлы.
|
||||
</Paragraph>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={addCustomBlock} block size="large">
|
||||
Добавить группу документов
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{customFileBlocks.map((block, idx) => (
|
||||
<Card
|
||||
key={block.id}
|
||||
size="small"
|
||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
|
||||
title={<span><FileTextOutlined style={{ color: '#595959', marginRight: 8 }} />Группа документов #{idx + 1}</span>}
|
||||
extra={<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>Удалить</Button>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>Название группы <Text type="danger">*</Text></Text>
|
||||
<Input
|
||||
placeholder="Например: Переписка в WhatsApp с менеджером"
|
||||
value={block.description}
|
||||
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
|
||||
maxLength={500}
|
||||
showCount
|
||||
style={{ marginBottom: 12 }}
|
||||
status={block.files.length > 0 && !block.description?.trim() ? 'error' : ''}
|
||||
/>
|
||||
{block.files.length > 0 && !block.description?.trim() && (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>Укажите название группы</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>Категория (необязательно)</Text>
|
||||
<Select
|
||||
value={block.category}
|
||||
placeholder="Выберите или оставьте пустым"
|
||||
onChange={(value) => updateCustomBlock(block.id, { category: value })}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{customCategoryOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Dragger
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
fileList={block.files}
|
||||
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 24 }} /></p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
|
||||
</Dragger>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
{customFileBlocks.length > 0 && (
|
||||
<Button type="dashed" onClick={addCustomBlock} icon={<PlusOutlined />} block style={{ marginTop: 12 }}>
|
||||
Добавить ещё группу
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={() => setCustomDocsModalOpen(false)}>Готово</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={backToDraftsList || onPrev}>← К списку заявок</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
disabled={!canContinue || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{submitting ? 'Загружаем...' : 'Продолжить →'}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* Уже загруженные */}
|
||||
{uploadedDocs.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
|
||||
<Text strong>✅ Загружено:</Text>
|
||||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
{/* Убираем дубликаты и используем уникальные ключи */}
|
||||
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
|
||||
const doc = documentsRequired.find((d: any) => d.id === docId);
|
||||
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? (
|
||||
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length && documentsRequired.length > 0 ? (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||
<Text type="warning">
|
||||
⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы массива ({documentsRequired.length}).
|
||||
⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы ({documentsRequired.length}).
|
||||
<br />
|
||||
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
|
||||
</Text>
|
||||
@@ -2393,15 +2614,52 @@ export default function StepWizardPlan({
|
||||
{/* ✅ Дополнительные документы */}
|
||||
{renderCustomUploads()}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '20px 0', background: '#f5f7fb', marginTop: 24 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleAllDocsComplete}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
|
||||
border: 'none',
|
||||
borderRadius: 28,
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
return showDocumentsOnly ? (
|
||||
<div style={{ marginTop: 0 }}>{stepContent}</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{stepContent}
|
||||
{(
|
||||
<>
|
||||
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
||||
{!hasNewFlowDocs && isWaiting && !outOfScopeData && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
@@ -2435,8 +2693,104 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
|
||||
{outOfScopeData && (
|
||||
{/* Единое окошко: тип + текст, цвет по event_type */}
|
||||
{responseEvent && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{
|
||||
background: DISPLAY_STYLE[responseEvent.event_type].bg,
|
||||
border: `1px solid ${DISPLAY_STYLE[responseEvent.event_type].border}`,
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
{responseEvent.event_type === 'trash_message' && '❌ '}
|
||||
{responseEvent.event_type === 'out_of_scope' && '⚠️ '}
|
||||
{responseEvent.event_type === 'consumer_consultation' && 'ℹ️ '}
|
||||
{responseEvent.event_type === 'consumer_complaint' && '✅ '}
|
||||
{DISPLAY_STYLE[responseEvent.event_type].title}
|
||||
</Title>
|
||||
<Paragraph style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{responseEvent.message}
|
||||
</Paragraph>
|
||||
{responseEvent.suggested_actions && responseEvent.suggested_actions.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Paragraph strong style={{ marginBottom: 12 }}>Что можно сделать:</Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{responseEvent.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 () => {
|
||||
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||
if (!sessionToken) {
|
||||
message.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
message.loading('Отправляем запрос в поддержку...', 0);
|
||||
const fd = new FormData();
|
||||
fd.append('message', responseEvent.message || '');
|
||||
fd.append('source', 'complaint_card');
|
||||
fd.append('session_token', sessionToken);
|
||||
if (formData.claim_id) fd.append('claim_id', formData.claim_id);
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
message.destroy();
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText);
|
||||
}
|
||||
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Связаться с поддержкой →
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{(responseEvent.event_type === 'trash_message' || responseEvent.event_type === 'out_of_scope') && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setResponseEvent(null);
|
||||
setOutOfScopeData(null);
|
||||
if (onNewClaim) onNewClaim();
|
||||
else {
|
||||
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||
window.history.pushState({}, '', '/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Новое обращение
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OUT OF SCOPE (старый формат, если пришло без event_type/message) */}
|
||||
{!responseEvent && outOfScopeData && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{
|
||||
background: '#fff7e6',
|
||||
@@ -2487,33 +2841,29 @@ export default function StepWizardPlan({
|
||||
type="link"
|
||||
style={{ marginTop: 8, padding: 0 }}
|
||||
onClick={async () => {
|
||||
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||
if (!sessionToken) {
|
||||
message.error('Сессия не найдена. Войдите снова.');
|
||||
return;
|
||||
}
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
const fd = new FormData();
|
||||
fd.append('message', outOfScopeData.message || outOfScopeData.reason || '');
|
||||
fd.append('source', 'complaint_card');
|
||||
fd.append('session_token', sessionToken);
|
||||
if (formData.claim_id) fd.append('claim_id', formData.claim_id);
|
||||
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
|
||||
message.destroy();
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || res.statusText);
|
||||
}
|
||||
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||
// Возвращаемся на главную через перезагрузку
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error('Не удалось отправить запрос. Попробуйте позже.');
|
||||
message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -2527,17 +2877,15 @@ export default function StepWizardPlan({
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev} style={{ marginRight: 12 }}>
|
||||
← Изменить описание
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => {
|
||||
// Сбрасываем состояние и возвращаемся на первый экран
|
||||
updateFormData({
|
||||
wizardPlan: null,
|
||||
wizardPlanStatus: null,
|
||||
problemDescription: '',
|
||||
});
|
||||
window.location.href = '/';
|
||||
setOutOfScopeData(null);
|
||||
if (onNewClaim) {
|
||||
onNewClaim(); // переход на форму «Описание проблемы», без дашборда
|
||||
} else {
|
||||
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||
window.history.pushState({}, '', '/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}>
|
||||
Новое обращение
|
||||
</Button>
|
||||
@@ -2616,6 +2964,8 @@ export default function StepWizardPlan({
|
||||
{renderQuestions()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
44
frontend/src/components/form/documentsScreenMaps.tsx
Normal file
44
frontend/src/components/form/documentsScreenMaps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Маппинг типов документов и статусов для экрана «Загрузка документов».
|
||||
* Спецификация: дизайн «Документы кейса», Lucide-иконки.
|
||||
*/
|
||||
import {
|
||||
FileSignature,
|
||||
Receipt,
|
||||
ClipboardList,
|
||||
MessagesSquare,
|
||||
FileWarning,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Clock3,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export const DOC_TYPE_MAP: Record<string, { Icon: LucideIcon; color: string }> = {
|
||||
contract: { Icon: FileSignature, color: '#1890ff' },
|
||||
payment: { Icon: Receipt, color: '#52c41a' },
|
||||
receipt: { Icon: Receipt, color: '#52c41a' },
|
||||
cheque: { Icon: Receipt, color: '#52c41a' },
|
||||
correspondence: { Icon: MessagesSquare, color: '#722ed1' },
|
||||
acts: { Icon: ClipboardList, color: '#fa8c16' },
|
||||
claim: { Icon: FileWarning, color: '#ff4d4f' },
|
||||
other: { Icon: FolderOpen, color: '#595959' },
|
||||
/** Плитка «Свои документы» — произвольные группы документов */
|
||||
__custom_docs__: { Icon: FolderPlus, color: '#722ed1' },
|
||||
};
|
||||
|
||||
export function getDocTypeStyle(docId: string): { Icon: LucideIcon; color: string } {
|
||||
const key = (docId || '').toLowerCase().replace(/\s+/g, '_');
|
||||
return DOC_TYPE_MAP[key] ?? { Icon: FileText, color: '#1890ff' };
|
||||
}
|
||||
|
||||
/** Цвета и иконки статусов по спецификации */
|
||||
export const STATUS_UPLOADED = { Icon: CheckCircle2, color: '#22C55E', label: 'Загружено' };
|
||||
export const STATUS_NEEDED = { Icon: AlertTriangle, color: '#F59E0B', label: 'Нужно' };
|
||||
export const STATUS_EXPECTED = { Icon: Clock3, color: '#F59E0B', label: 'Ожидаем завтра' };
|
||||
export const STATUS_NOT_AVAILABLE = { Icon: Ban, color: '#8c8c8c', label: 'Не будет', subLabel: 'Утеряно' };
|
||||
export const STATUS_OPTIONAL = { Icon: Clock3, color: '#8c8c8c', label: 'По желанию' };
|
||||
50
frontend/src/context/DraftsContext.tsx
Normal file
50
frontend/src/context/DraftsContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Общий список обращений (черновики + CRM), загруженный при открытии «Мои обращения».
|
||||
* Используется на странице «Консультации» — показываем только тикеты из этого списка, без отдельного запроса.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react';
|
||||
|
||||
export interface DraftItem {
|
||||
id?: string;
|
||||
claim_id?: string | null;
|
||||
type_code?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
status_code?: string;
|
||||
problem_title?: string;
|
||||
problem_description?: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type SetDrafts = (drafts: DraftItem[] | ((prev: DraftItem[]) => DraftItem[])) => void;
|
||||
|
||||
const DraftsContext = createContext<{
|
||||
drafts: DraftItem[];
|
||||
setDrafts: SetDrafts;
|
||||
} | null>(null);
|
||||
|
||||
export function DraftsProvider({ children }: { children: ReactNode }) {
|
||||
const [drafts, setDrafts] = useState<DraftItem[]>([]);
|
||||
return (
|
||||
<DraftsContext.Provider value={{ drafts, setDrafts }}>
|
||||
{children}
|
||||
</DraftsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDrafts() {
|
||||
const ctx = useContext(DraftsContext);
|
||||
return ctx ?? { drafts: [], setDrafts: () => {} };
|
||||
}
|
||||
|
||||
/** Только тикеты (консультации) из drafts */
|
||||
export function useConsultationItems(): DraftItem[] {
|
||||
const { drafts } = useDrafts();
|
||||
return drafts.filter(
|
||||
(d) =>
|
||||
d.type_code === 'consultation' ||
|
||||
(d.payload && (d.payload.ticketid != null || d.payload.ticket_no != null))
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,26 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #ffffff;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,71 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { miniappLog, miniappSendLogs } from './utils/miniappLogger'
|
||||
|
||||
// #region agent log (build tag)
|
||||
// В прод-сборке это будет URL текущего JS-бандла (/assets/index-XXXX.js)
|
||||
(window as any).__MINIAPP_BUILD__ = (import.meta as any).url;
|
||||
// #endregion agent log
|
||||
|
||||
// Логирование при загрузке — по нему видно, какой фронт отдаётся и куда идут запросы
|
||||
const bootLog = {
|
||||
ts: new Date().toISOString(),
|
||||
href: window.location.href,
|
||||
origin: window.location.origin,
|
||||
pathname: window.location.pathname,
|
||||
host: window.location.host,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
marker: 'MINIAPP_AIFORM_PROD',
|
||||
// #region agent log (bundle identity)
|
||||
moduleUrl: (import.meta as any).url,
|
||||
scriptSrc:
|
||||
document
|
||||
.querySelector('script[type="module"][src*="/assets/index-"]')
|
||||
?.getAttribute('src') || undefined,
|
||||
build: (window as any).__MINIAPP_BUILD__,
|
||||
// #endregion agent log
|
||||
};
|
||||
console.log('[MINIAPP] Boot', bootLog);
|
||||
miniappLog('boot', bootLog);
|
||||
|
||||
// Логирование всех запросов к /api — куда реально уходят запросы (относительный URL = текущий origin)
|
||||
const _fetch = window.fetch;
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api') || url.startsWith('/api')) {
|
||||
const full = url.startsWith('http') ? url : window.location.origin + (url.startsWith('/') ? url : '/' + url);
|
||||
console.log('[MINIAPP] API request', { url, full, method: init?.method || 'GET' });
|
||||
miniappLog('api_request', { url, full, method: init?.method || 'GET' });
|
||||
}
|
||||
return _fetch.apply(this, arguments as any);
|
||||
};
|
||||
|
||||
// Ловим JS-ошибки и отправляем дамп на бэк
|
||||
window.addEventListener('error', (e) => {
|
||||
const ev = e as ErrorEvent;
|
||||
miniappLog('window_error', {
|
||||
message: ev.message,
|
||||
filename: ev.filename,
|
||||
lineno: ev.lineno,
|
||||
colno: ev.colno,
|
||||
name: (ev.error && (ev.error as any).name) || undefined,
|
||||
stack: (ev.error && (ev.error as any).stack) || undefined,
|
||||
});
|
||||
void miniappSendLogs('window_error');
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const ev = e as PromiseRejectionEvent;
|
||||
const reason = ev.reason;
|
||||
miniappLog('unhandledrejection', {
|
||||
reason: reason ? String(reason) : '(empty)',
|
||||
name: reason && (reason as any).name ? String((reason as any).name) : undefined,
|
||||
stack: reason && (reason as any).stack ? String((reason as any).stack) : undefined,
|
||||
});
|
||||
void miniappSendLogs('unhandledrejection');
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* ========== ВЕБ (дефолт): как в aiform_dev ========== */
|
||||
.claim-form-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
padding: 40px 0;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -9,11 +9,15 @@
|
||||
}
|
||||
|
||||
.claim-form-card {
|
||||
max-width: 800px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-head {
|
||||
@@ -35,12 +39,13 @@
|
||||
|
||||
.steps-content {
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.claim-form-container {
|
||||
padding: 20px 10px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.claim-form-card {
|
||||
@@ -48,7 +53,7 @@
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
padding: 10px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@
|
||||
.claim-form-container.telegram-mini-app {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 12px 10px max(16px, env(safe-area-inset-bottom));
|
||||
padding: 12px 0 max(16px, env(safe-area-inset-bottom));
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -64,8 +69,8 @@
|
||||
.claim-form-container.telegram-mini-app .claim-form-card {
|
||||
max-width: 100%;
|
||||
box-shadow: none;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {
|
||||
@@ -81,7 +86,7 @@
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-body {
|
||||
padding: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps {
|
||||
@@ -99,7 +104,7 @@
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps-content {
|
||||
min-height: 280px;
|
||||
padding: 8px 4px 12px;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .ant-btn {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
|
||||
import { Card, message, Row, Col, Spin, Button } from 'antd';
|
||||
import Step1Phone from '../components/form/Step1Phone';
|
||||
import StepDescription from '../components/form/StepDescription';
|
||||
// Step1Policy убран - старый ERV флоу
|
||||
import StepComplaintsDashboard from '../components/form/StepComplaintsDashboard';
|
||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||
@@ -11,11 +12,11 @@ import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||
import DebugPanel from '../components/DebugPanel';
|
||||
// getDocumentsForEventType убран - старый ERV флоу
|
||||
import './ClaimForm.css';
|
||||
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
|
||||
import { useDrafts } from '../context/DraftsContext';
|
||||
|
||||
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
/**
|
||||
* Генерация UUID v4
|
||||
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
@@ -81,22 +82,45 @@ interface FormData {
|
||||
accountNumber?: string;
|
||||
}
|
||||
|
||||
export default function ClaimForm() {
|
||||
interface ClaimFormProps {
|
||||
/** Открыта страница «Подать жалобу» (/new) — не показывать список черновиков */
|
||||
forceNewClaim?: boolean;
|
||||
/** Навигация по приложению (для перехода на /support и т.д.) */
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
function getInitialSessionToken(): string | undefined {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s && s.trim()) return s.trim();
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const s = localStorage.getItem('session_token');
|
||||
if (s && s.trim()) return s.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function ClaimForm({ forceNewClaim = false, onNavigate }: ClaimFormProps) {
|
||||
// ✅ claim_id будет создан n8n в Step1Phone после SMS верификации
|
||||
// Не генерируем его локально!
|
||||
|
||||
// session_id будет получен от n8n при создании контакта
|
||||
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
|
||||
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
||||
// session_id: при заходе из мини-аппа берём сохранённый session_token, иначе — временный sess-xxx для веба
|
||||
const fallbackSessionId = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const initialSessionToken = getInitialSessionToken();
|
||||
const sessionIdRef = useRef(initialSessionToken || fallbackSessionId);
|
||||
const autoLoadedClaimIdRef = useRef<string | null>(null);
|
||||
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
|
||||
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Защита от «ghost click» после навигации с /hello: игнорируем back первые ~1500мс
|
||||
const barBackIgnoreUntilRef = useRef<number>(Date.now() + 1500);
|
||||
|
||||
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,
|
||||
session_id: initialSessionToken || sessionIdRef.current,
|
||||
paymentMethod: 'sbp',
|
||||
});
|
||||
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
|
||||
@@ -104,12 +128,84 @@ export default function ClaimForm() {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
|
||||
/** ID черновика, открытого для просмотра описания (состояние в родителе, чтобы не терять при пересчёте steps) */
|
||||
const [draftDetailClaimId, setDraftDetailClaimId] = useState<string | null>(null);
|
||||
/** Фильтр списка обращений при переходе с дашборда: по какой категории показывать (all = все) */
|
||||
const [draftsListFilter, setDraftsListFilter] = useState<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected'>('all');
|
||||
const [hasDrafts, setHasDrafts] = useState(false);
|
||||
/** Список обращений — один раз грузим в родителе, передаём в дашборд и список (один запрос в n8n) */
|
||||
const [draftsList, setDraftsList] = useState<any[]>([]);
|
||||
const [draftsListLoading, setDraftsListLoading] = useState(false);
|
||||
const { setDrafts } = useDrafts();
|
||||
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);
|
||||
/** Платформа определена (TG/MAX/веб) — до этого шаг «Вход» не показываем, чтобы в MAX не мелькал экран телефона. */
|
||||
const [platformChecked, setPlatformChecked] = useState(false);
|
||||
const forceNewClaimRef = useRef(false);
|
||||
|
||||
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении.
|
||||
// 1) По URL (TG iframe приходит с tgWebAppData/tgWebAppVersion). 2) По initData. 3) По наличию SDK (WebApp / Telegram.WebApp).
|
||||
useEffect(() => {
|
||||
const url = typeof window !== 'undefined' ? window.location.href || '' : '';
|
||||
if (url.indexOf('tgWebAppData') !== -1 || url.indexOf('tgWebAppVersion') !== -1) {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return;
|
||||
}
|
||||
const detect = () => {
|
||||
const tgInitData = (window as any).Telegram?.WebApp?.initData;
|
||||
const maxInitData = (window as any).WebApp?.initData;
|
||||
if (tgInitData && typeof tgInitData === 'string' && tgInitData.length > 0) {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
if (maxInitData && typeof maxInitData === 'string' && maxInitData.length > 0) {
|
||||
setIsMaxMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
// В MAX подключается только max-web-app.js → есть window.WebApp; в TG — telegram-web-app.js → есть Telegram.WebApp.
|
||||
if (typeof (window as any).Telegram?.WebApp === 'object') {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
if (typeof (window as any).WebApp === 'object') {
|
||||
setIsMaxMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (detect()) return;
|
||||
const interval = setInterval(() => {
|
||||
if (detect()) clearInterval(interval);
|
||||
}, 50);
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
setPlatformChecked(true);
|
||||
}, 3000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
|
||||
useEffect(() => {
|
||||
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
|
||||
if (isNewPage) {
|
||||
forceNewClaimRef.current = true;
|
||||
setShowDraftSelection(false);
|
||||
setHasDrafts(false);
|
||||
}
|
||||
}, [forceNewClaim]);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
@@ -155,113 +251,10 @@ export default function ClaimForm() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
|
||||
// Авторизация выполняется на /hello (универсальный auth). Здесь только помечаем, что платформа проверена.
|
||||
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');
|
||||
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();
|
||||
setTelegramAuthChecked(true);
|
||||
setPlatformChecked(true);
|
||||
}, []);
|
||||
|
||||
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
|
||||
@@ -274,13 +267,9 @@ export default function ClaimForm() {
|
||||
|
||||
const restoreSession = async () => {
|
||||
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
|
||||
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
|
||||
console.log('🔑 Значения всех ключей:', JSON.stringify(localStorage));
|
||||
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
|
||||
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|
||||
|| localStorage.getItem('session_token');
|
||||
if (!savedSessionToken) {
|
||||
console.log('❌ Session token NOT found in localStorage');
|
||||
setSessionRestored(true);
|
||||
return;
|
||||
}
|
||||
@@ -295,14 +284,15 @@ export default function ClaimForm() {
|
||||
body: JSON.stringify({ session_token: savedSessionToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
let data: any = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
data = null;
|
||||
}
|
||||
console.log('🔑 Session verify response:', { ok: response.ok, status: response.status, data });
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔑 Session verify response:', data);
|
||||
|
||||
if (data.success && data.valid) {
|
||||
if (response.ok && data?.success && data?.valid) {
|
||||
// Сессия валидна! Восстанавливаем состояние
|
||||
console.log('✅ Session valid! Restoring user data:', {
|
||||
unified_id: data.unified_id,
|
||||
@@ -324,39 +314,48 @@ export default function ClaimForm() {
|
||||
// Помечаем телефон как верифицированный
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
// На странице /new («Подать жалобу») не показываем черновики
|
||||
if (forceNewClaimRef.current) {
|
||||
// После refresh в TG initData может быть пустым/поздним, поэтому нельзя
|
||||
// выбирать шаг по наличию initData: иначе попадаем сразу в StepWizardPlan (крутилка).
|
||||
const isWebFlow = platformChecked && !isTelegramMiniApp && !isMaxMiniApp;
|
||||
setCurrentStep(isWebFlow ? 1 : 0);
|
||||
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||
message.success('Добро пожаловать!');
|
||||
}
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем черновики
|
||||
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
|
||||
|
||||
if (hasDraftsResult) {
|
||||
// Есть черновики - показываем список
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
|
||||
// Переходим к шагу выбора черновика
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentStep(0);
|
||||
});
|
||||
});
|
||||
|
||||
setShowDraftSelection(!!hasDraftsResult);
|
||||
setHasDrafts(!!hasDraftsResult);
|
||||
setCurrentStep(0); // дашборд «Мои обращения»
|
||||
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
|
||||
} else {
|
||||
// Нет черновиков - переходим к описанию
|
||||
setCurrentStep(1);
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||
}
|
||||
} else {
|
||||
// Сессия невалидна - удаляем из localStorage
|
||||
console.log('❌ Session invalid or expired, removing from localStorage');
|
||||
addDebugEvent('session', 'success', hasDraftsResult ? '✅ Сессия восстановлена, найдены черновики' : '✅ Сессия восстановлена');
|
||||
}
|
||||
|
||||
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
|
||||
if (response.ok && data?.success && data?.valid === false) {
|
||||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||
localStorage.removeItem('session_token');
|
||||
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
|
||||
return;
|
||||
}
|
||||
|
||||
// Сетевые/серверные проблемы — токен не трогаем (иначе “порой разлогин”).
|
||||
if (!response.ok || !data?.success) {
|
||||
console.warn('⚠️ Session verify failed (token kept)', { ok: response.ok, status: response.status });
|
||||
addDebugEvent('session', 'warning', '⚠️ Не удалось проверить сессию (токен сохранён)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error verifying session:', error);
|
||||
localStorage.removeItem('session_token');
|
||||
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
|
||||
// Не удаляем session_token на сетевых ошибках — это вызывает “рандомный разлогин”
|
||||
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии (токен сохранён)');
|
||||
} finally {
|
||||
setSessionRestored(true);
|
||||
}
|
||||
@@ -1033,33 +1032,29 @@ export default function ClaimForm() {
|
||||
}
|
||||
|
||||
// ✅ Определяем шаг для перехода на основе данных черновика
|
||||
// Приоритет: если есть wizard_plan → переходим к визарду (даже если нет problem_description)
|
||||
// После выбора черновика showDraftSelection = false, поэтому:
|
||||
// - Шаг 0 = Step1Phone (но мы его пропускаем, т.к. телефон уже верифицирован)
|
||||
// - Шаг 1 = StepDescription
|
||||
// - Шаг 2 = StepWizardPlan
|
||||
|
||||
let targetStep = 1; // По умолчанию - описание (шаг 1)
|
||||
|
||||
// При forceNewClaim (/new) шаги только [StepDescription(0), StepWizardPlan(1)].
|
||||
// Без forceNewClaim: [Dashboard(0), DraftSelection(1), StepDescription(2), StepWizardPlan(3)] (или + Step1Phone для веба).
|
||||
const isForceNew = forceNewClaimRef.current;
|
||||
const stepDescription = isForceNew ? 0 : 2;
|
||||
const stepWizard = isForceNew ? 1 : 3;
|
||||
|
||||
let targetStep = stepDescription; // По умолчанию — экран «Обращение» (описание)
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
|
||||
if (documentsRequired.length > 0) {
|
||||
targetStep = 2;
|
||||
console.log('✅ Переходим к StepWizardPlan (шаг 2) - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
|
||||
targetStep = stepWizard;
|
||||
console.log('✅ Переходим к StepWizardPlan - НОВЫЙ ФЛОУ: есть documents_required, показываем загрузку документов');
|
||||
console.log('✅ documents_required:', documentsRequired.length, 'документов');
|
||||
} else if (wizardPlan) {
|
||||
// ✅ СТАРЫЙ ФЛОУ: Если есть wizard_plan - переходим к визарду (шаг 2)
|
||||
// Пользователь уже описывал проблему, и есть план вопросов
|
||||
targetStep = 2;
|
||||
console.log('✅ Переходим к StepWizardPlan (шаг 2) - СТАРЫЙ ФЛОУ: есть wizard_plan');
|
||||
targetStep = stepWizard;
|
||||
console.log('✅ Переходим к StepWizardPlan - СТАРЫЙ ФЛОУ: есть wizard_plan');
|
||||
console.log('✅ answers в черновике:', answers ? 'есть (показываем заполненную форму)' : 'нет (показываем пустую форму)');
|
||||
} else if (problemDescription) {
|
||||
// Если есть описание, но нет плана - переходим к визарду (шаг 2), чтобы получить план
|
||||
targetStep = 2;
|
||||
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть описание, план будет получен через SSE');
|
||||
targetStep = stepWizard;
|
||||
console.log('✅ Переходим к StepWizardPlan - есть описание, план будет получен через SSE');
|
||||
} else {
|
||||
// Если нет ничего - переходим к описанию (шаг 1)
|
||||
targetStep = 1;
|
||||
console.log('✅ Переходим к StepDescription (шаг 1) - нет описания и плана');
|
||||
targetStep = stepDescription;
|
||||
console.log('✅ Переходим к StepDescription (индекс', stepDescription, ') - нет описания и плана');
|
||||
}
|
||||
|
||||
console.log('🔍 Устанавливаем currentStep:', targetStep);
|
||||
@@ -1072,6 +1067,81 @@ export default function ClaimForm() {
|
||||
}
|
||||
}, [formData, updateFormData]);
|
||||
|
||||
// Нормализовать start_param: MAX может отдавать строку или объект WebAppStartParam
|
||||
const startParamToString = useCallback((v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.value === 'string') return o.value;
|
||||
if (typeof o.payload === 'string') return o.payload;
|
||||
if (typeof o.start_param === 'string') return o.start_param;
|
||||
if (typeof o.data === 'string') return o.data;
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
return String(v);
|
||||
}, []);
|
||||
|
||||
// Извлечь claim_id из строки startapp/start_param (форматы: claim_id=uuid, claim_id_uuid, или голый uuid)
|
||||
const parseClaimIdFromStartParam = useCallback((startParam: string | Record<string, unknown> | null | undefined): string | null => {
|
||||
const s = startParamToString(startParam);
|
||||
if (!s) return null;
|
||||
const decoded = decodeURIComponent(s.trim());
|
||||
let m = decoded.match(/(?:^|[?&])claim_id=([^&]+)/i) || decoded.match(/(?:^|[?&])claim_id_([0-9a-f-]{36})/i);
|
||||
if (!m) m = decoded.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}, [startParamToString]);
|
||||
|
||||
// Автозагрузка черновика из URL или из MAX WebApp start_param после восстановления сессии
|
||||
useEffect(() => {
|
||||
if (!sessionRestored) return;
|
||||
(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// claim_id может прийти как UUID или как claim_id_<uuid> (после редиректа из /hello?WebAppStartParam=...)
|
||||
let claimFromUrl = parseClaimIdFromStartParam(params.get('claim_id') || '') || params.get('claim_id');
|
||||
// Query: startapp=... или WebAppStartParam=... (MAX подставляет при открытии по диплинку)
|
||||
if (!claimFromUrl) claimFromUrl = parseClaimIdFromStartParam(params.get('startapp') || params.get('WebAppStartParam') || '');
|
||||
// Hash (MAX иногда кладёт параметры в #)
|
||||
if (!claimFromUrl && window.location.hash) {
|
||||
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const fromHash = parseClaimIdFromStartParam(hashParams.get('claim_id') || '') || hashParams.get('claim_id');
|
||||
claimFromUrl = fromHash || parseClaimIdFromStartParam(hashParams.get('startapp') || hashParams.get('WebAppStartParam') || '');
|
||||
}
|
||||
// MAX WebApp: initDataUnsafe.start_param (появляется после загрузки скрипта st.max.ru)
|
||||
if (!claimFromUrl) {
|
||||
const wa = (window as any).WebApp;
|
||||
const startParam = wa?.initDataUnsafe?.start_param;
|
||||
if (startParam) {
|
||||
claimFromUrl = parseClaimIdFromStartParam(startParam);
|
||||
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param:', claimFromUrl);
|
||||
}
|
||||
}
|
||||
// Повторная проверка через 1.2s на случай, если MAX bridge подставил start_param с задержкой
|
||||
if (!claimFromUrl) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const wa = (window as any).WebApp;
|
||||
const startParam = wa?.initDataUnsafe?.start_param;
|
||||
if (startParam) {
|
||||
claimFromUrl = parseClaimIdFromStartParam(startParam);
|
||||
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param (отложенно):', claimFromUrl);
|
||||
}
|
||||
}
|
||||
if (claimFromUrl) {
|
||||
if (autoLoadedClaimIdRef.current === claimFromUrl) return;
|
||||
autoLoadedClaimIdRef.current = claimFromUrl;
|
||||
// Сразу помечаем черновик как выбранный и скрываем список — чтобы не показывать шаг «Черновики», сразу перейти к документам
|
||||
setSelectedDraftId(claimFromUrl);
|
||||
setShowDraftSelection(false);
|
||||
console.log('🔗 Автозагрузка черновика из URL claim_id=', claimFromUrl, '(сразу на документы)');
|
||||
await loadDraft(claimFromUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка автозагрузки черновика из URL:', e);
|
||||
}
|
||||
})();
|
||||
}, [sessionRestored, loadDraft, parseClaimIdFromStartParam]);
|
||||
|
||||
// Обработчик выбора черновика
|
||||
const handleSelectDraft = useCallback((claimId: string) => {
|
||||
loadDraft(claimId);
|
||||
@@ -1079,6 +1149,10 @@ export default function ClaimForm() {
|
||||
|
||||
// Проверка наличия черновиков
|
||||
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
||||
if (forceNewClaimRef.current) {
|
||||
console.log('🔍 forceNewClaim: пропускаем проверку черновиков');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
console.log('🔍 ========== checkDrafts вызван ==========');
|
||||
console.log('🔍 Параметры:', { unified_id, phone, sessionId });
|
||||
@@ -1130,12 +1204,49 @@ export default function ClaimForm() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Один раз грузим список обращений в родителе — один запрос в n8n, данные передаём в дашборд и список */
|
||||
const loadDraftsList = useCallback(async () => {
|
||||
const uid = formData.unified_id;
|
||||
const ph = formData.phone || '';
|
||||
const sid = sessionIdRef.current;
|
||||
if (!uid && !ph && !sid) return;
|
||||
const entryChannel = isTelegramMiniApp ? 'telegram' : isMaxMiniApp ? 'max' : 'web';
|
||||
const params = new URLSearchParams();
|
||||
if (uid) params.append('unified_id', uid);
|
||||
if (ph) params.append('phone', ph);
|
||||
if (sid) params.append('session_id', sid);
|
||||
params.append('entry_channel', entryChannel);
|
||||
setDraftsListLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const list = data.drafts || data.items || [];
|
||||
setDraftsList(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setDraftsList([]);
|
||||
} finally {
|
||||
setDraftsListLoading(false);
|
||||
}
|
||||
}, [formData.unified_id, formData.phone, isTelegramMiniApp, isMaxMiniApp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceNewClaimRef.current || !formData.unified_id) return;
|
||||
loadDraftsList();
|
||||
}, [formData.unified_id]);
|
||||
|
||||
// Синхронизируем список обращений в контекст для страницы «Консультации»
|
||||
useEffect(() => {
|
||||
setDrafts(Array.isArray(draftsList) ? draftsList : []);
|
||||
}, [draftsList, setDrafts]);
|
||||
|
||||
// Обработчик создания новой заявки
|
||||
const handleNewClaim = useCallback(() => {
|
||||
console.log('🆕 Начинаем новое обращение');
|
||||
console.log('🆕 Текущий currentStep:', currentStep);
|
||||
console.log('🆕 isPhoneVerified:', isPhoneVerified);
|
||||
|
||||
// ✅ Режим «новая жалоба»: без дашборда/черновиков, первый шаг = форма «Описание»
|
||||
forceNewClaimRef.current = true;
|
||||
window.history.pushState({}, '', '/new');
|
||||
|
||||
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
||||
const newSessionId = 'sess_' + generateUUIDv4();
|
||||
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
|
||||
@@ -1144,8 +1255,7 @@ export default function ClaimForm() {
|
||||
// ✅ Обновляем sessionIdRef на новую сессию
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
||||
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
||||
|
||||
@@ -1171,14 +1281,10 @@ export default function ClaimForm() {
|
||||
});
|
||||
|
||||
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
||||
|
||||
// ✅ Переходим к шагу описания проблемы
|
||||
// После сброса флагов черновиков, steps будут:
|
||||
// Шаг 0 - Phone (уже верифицирован, но в массиве есть)
|
||||
// Шаг 1 - Description (сюда переходим)
|
||||
// Шаг 2 - WizardPlan
|
||||
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
|
||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
||||
// После refresh в TG initData может быть пустым, поэтому индекс определяем по флагам платформы, а не по initData.
|
||||
const isWebFlow = platformChecked && !isTelegramMiniApp && !isMaxMiniApp;
|
||||
setCurrentStep(isWebFlow ? 1 : 0);
|
||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone, platformChecked, isTelegramMiniApp, isMaxMiniApp]);
|
||||
|
||||
// ✅ Автоматический редирект на экран черновиков после успешной отправки
|
||||
useEffect(() => {
|
||||
@@ -1296,10 +1402,29 @@ export default function ClaimForm() {
|
||||
const steps = useMemo(() => {
|
||||
const stepsArray: any[] = [];
|
||||
|
||||
// Шаг 0: Выбор черновика (показывается только если есть черновики)
|
||||
// ✅ unified_id уже означает, что телефон верифицирован
|
||||
// Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts
|
||||
if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
|
||||
// Шаги «Мои обращения»: дашборд с плитками + список черновиков — для любого авторизованного (unified_id) на главной
|
||||
// Не показываем на странице «Подать жалобу» (/new)
|
||||
if (!forceNewClaimRef.current && formData.unified_id && !selectedDraftId) {
|
||||
stepsArray.push({
|
||||
title: 'Мои обращения',
|
||||
description: 'Ваши обращения',
|
||||
content: (
|
||||
<StepComplaintsDashboard
|
||||
unified_id={formData.unified_id}
|
||||
phone={formData.phone || ''}
|
||||
session_id={sessionIdRef.current}
|
||||
entry_channel={isTelegramMiniApp ? 'telegram' : isMaxMiniApp ? 'max' : 'web'}
|
||||
drafts={draftsList}
|
||||
loading={draftsListLoading}
|
||||
onGoToList={(filter) => {
|
||||
setDraftsListFilter(filter);
|
||||
nextStep();
|
||||
}}
|
||||
onNewClaim={handleNewClaim}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
),
|
||||
});
|
||||
stepsArray.push({
|
||||
title: 'Черновики',
|
||||
description: 'Выбор заявки',
|
||||
@@ -1307,8 +1432,16 @@ export default function ClaimForm() {
|
||||
<StepDraftSelection
|
||||
phone={formData.phone || ''}
|
||||
session_id={sessionIdRef.current}
|
||||
unified_id={formData.unified_id} // ✅ Передаём unified_id
|
||||
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
|
||||
unified_id={formData.unified_id}
|
||||
isTelegramMiniApp={isTelegramMiniApp}
|
||||
entry_channel={isTelegramMiniApp ? 'telegram' : isMaxMiniApp ? 'max' : 'web'}
|
||||
drafts={draftsList}
|
||||
loading={draftsListLoading}
|
||||
onRefreshDrafts={loadDraftsList}
|
||||
draftDetailClaimId={draftDetailClaimId}
|
||||
categoryFilter={draftsListFilter}
|
||||
onOpenDraftDetail={setDraftDetailClaimId}
|
||||
onCloseDraftDetail={() => setDraftDetailClaimId(null)}
|
||||
onSelectDraft={handleSelectDraft}
|
||||
onNewClaim={handleNewClaim}
|
||||
/>
|
||||
@@ -1316,12 +1449,13 @@ export default function ClaimForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 1: Phone (телефон + SMS верификация)
|
||||
stepsArray.push({
|
||||
title: 'Вход',
|
||||
description: 'Подтверждение телефона',
|
||||
content: (
|
||||
<Step1Phone
|
||||
// Шаг «Вход» (телефон + SMS) только для обычного веба и только после определения платформы (в MAX/TG не показываем, и пока не проверили — тоже не показываем).
|
||||
if (platformChecked && !isTelegramMiniApp && !isMaxMiniApp) {
|
||||
stepsArray.push({
|
||||
title: 'Вход',
|
||||
description: 'Подтверждение телефона',
|
||||
content: (
|
||||
<Step1Phone
|
||||
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
||||
updateFormData={(data: any) => {
|
||||
updateFormData(data);
|
||||
@@ -1345,7 +1479,7 @@ export default function ClaimForm() {
|
||||
|
||||
// ✅ Если передан unified_id, значит телефон уже верифицирован (даже если isPhoneVerified ещё false)
|
||||
// Проверяем черновики, если есть unified_id или телефон верифицирован
|
||||
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
|
||||
const shouldCheckDrafts = (finalUnifiedId || (formData.phone && isPhoneVerified)) && !forceNewClaimRef.current;
|
||||
|
||||
if (shouldCheckDrafts && !selectedDraftId) {
|
||||
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone, 'sessionId:', sessionIdRef.current);
|
||||
@@ -1399,6 +1533,7 @@ export default function ClaimForm() {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 2: свободное описание
|
||||
stepsArray.push({
|
||||
@@ -1425,6 +1560,7 @@ export default function ClaimForm() {
|
||||
onPrev={prevStep}
|
||||
onNext={nextStep}
|
||||
backToDraftsList={backToDraftsList}
|
||||
onNewClaim={handleNewClaim}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
@@ -1450,7 +1586,75 @@ export default function ClaimForm() {
|
||||
// Step3Payment убран - не используется
|
||||
|
||||
return stepsArray;
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked, draftsList, draftsListLoading, loadDraftsList]);
|
||||
|
||||
// Синхронизация currentStep при выходе за границы (например после смены списка шагов в TG/MAX)
|
||||
useEffect(() => {
|
||||
if (steps.length === 0) return;
|
||||
const safe = Math.min(currentStep, steps.length - 1);
|
||||
if (currentStep < 0 || currentStep >= steps.length || currentStep !== safe) {
|
||||
setCurrentStep(Math.max(0, safe));
|
||||
}
|
||||
}, [steps.length, currentStep]);
|
||||
|
||||
// Кнопка «Назад» в нижнем баре: обработка через событие (вместо кнопок в контенте)
|
||||
// ВАЖНО: держим effect ниже prevStep и steps (иначе TDZ/стейл шаги).
|
||||
useEffect(() => {
|
||||
const onGoBack = () => {
|
||||
const now = Date.now();
|
||||
const currentTitle = steps[currentStep]?.title;
|
||||
const prevTitle = currentStep > 0 ? steps[currentStep - 1]?.title : null;
|
||||
const isAuthed = !!formData.unified_id || isPhoneVerified || !!(typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || !!localStorage.getItem('session_token');
|
||||
|
||||
miniappLog('claim_form_go_back_event', {
|
||||
currentStep,
|
||||
currentTitle,
|
||||
prevTitle,
|
||||
isAuthed,
|
||||
hasUnifiedId: !!formData.unified_id,
|
||||
isPhoneVerified,
|
||||
forceNewClaim: forceNewClaimRef.current,
|
||||
ignoreUntil: barBackIgnoreUntilRef.current,
|
||||
now,
|
||||
});
|
||||
if (now < barBackIgnoreUntilRef.current) return;
|
||||
|
||||
// Если открыта деталка черновика — закрываем её
|
||||
if (draftDetailClaimId) {
|
||||
miniappLog('claim_form_go_back_action', { action: 'close_draft_detail' });
|
||||
setDraftDetailClaimId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если “назад” ведёт на шаг «Вход», но мы уже авторизованы — не показываем телефон (это выглядит как разлогин)
|
||||
if (isAuthed && prevTitle === 'Вход') {
|
||||
const hasDashboard = steps[0]?.title === 'Мои обращения';
|
||||
if (hasDashboard) {
|
||||
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_dashboard' });
|
||||
setCurrentStep(0);
|
||||
return;
|
||||
}
|
||||
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_hello' });
|
||||
void miniappSendLogs('go_back_skip_phone');
|
||||
window.history.pushState({}, '', '/hello');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 0) {
|
||||
miniappLog('claim_form_go_back_action', { action: 'to_hello' });
|
||||
void miniappSendLogs('go_back_to_hello');
|
||||
window.history.pushState({}, '', '/hello');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return;
|
||||
}
|
||||
|
||||
miniappLog('claim_form_go_back_action', { action: 'prev_step' });
|
||||
prevStep();
|
||||
};
|
||||
window.addEventListener('miniapp:goBack', onGoBack);
|
||||
return () => window.removeEventListener('miniapp:goBack', onGoBack);
|
||||
}, [currentStep, draftDetailClaimId, formData.unified_id, isPhoneVerified, prevStep, steps]);
|
||||
|
||||
const handleReset = () => {
|
||||
console.log('🔄 Начать заново - возврат к списку черновиков');
|
||||
@@ -1515,8 +1719,7 @@ export default function ClaimForm() {
|
||||
}
|
||||
|
||||
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
|
||||
// Получаем session_token из localStorage
|
||||
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
|
||||
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token') || formData.session_id;
|
||||
|
||||
if (sessionToken) {
|
||||
try {
|
||||
@@ -1536,7 +1739,7 @@ export default function ClaimForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем session_token из localStorage
|
||||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||
localStorage.removeItem('session_token');
|
||||
|
||||
// Полный сброс: очищаем все данные авторизации и черновиков
|
||||
@@ -1575,60 +1778,39 @@ export default function ClaimForm() {
|
||||
// ✅ Показываем 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' }}>
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin size="large" tip="Загрузка..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Пустой список шагов — не показывать форму с «Загрузка шага», показать общий loader
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin size="large" tip="Подготовка формы..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если currentStep вышел за границы — показываем последний валидный шаг; всегда есть steps[0]
|
||||
const safeStepIndex = Math.min(currentStep, Math.max(0, steps.length - 1));
|
||||
const stepToShow = steps[safeStepIndex] ?? steps[0];
|
||||
const isDocumentsStep = stepToShow?.title === 'Документы';
|
||||
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
|
||||
<Row gutter={16}>
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
|
||||
<Row gutter={[0, 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>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDocumentsStep ? (
|
||||
<div className="steps-content" style={{ marginTop: 0 }}>
|
||||
{stepToShow ? stepToShow.content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card title={null} className="claim-form-card" bordered={false}>
|
||||
{isSubmitted ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
|
||||
@@ -1637,26 +1819,16 @@ export default function ClaimForm() {
|
||||
</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>
|
||||
</>
|
||||
<div className="steps-content">
|
||||
{stepToShow ? stepToShow.content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<p>Загрузка шага...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* Правая часть - Debug консоль (только в dev режиме) */}
|
||||
|
||||
266
frontend/src/pages/Consultations.css
Normal file
266
frontend/src/pages/Consultations.css
Normal file
@@ -0,0 +1,266 @@
|
||||
/* Консультации — список и экран «Подробнее» */
|
||||
|
||||
.consultations-page {
|
||||
padding: 20px 16px 100px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.consultations-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
padding: 6px 0;
|
||||
color: #6366f1;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.consultations-back:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.consultations-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.consultations-header h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.consultations-header .subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Список тикетов */
|
||||
.consultations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.consultations-list-item {
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.consultations-list-item:hover {
|
||||
border-color: #c7d2fe;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.consultations-list-item:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.consultations-list-item .item-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.consultations-list-item .item-date {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* Пустой список */
|
||||
.consultations-empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.consultations-empty p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Экран «Подробнее» */
|
||||
.consultations-detail {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.consultations-detail .detail-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.consultations-detail-card {
|
||||
margin-top: 16px;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 20px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.35;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
background: #dcfce7;
|
||||
border-radius: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-status-pill svg {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px 20px 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-tag-category {
|
||||
color: #1e40af;
|
||||
background: #dbeafe;
|
||||
border: 1px solid #93c5fd;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-tag-priority {
|
||||
color: #475569;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-tag svg {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-section {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #64748b;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-section-body {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #334155;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.consultations-detail-card .card-section-body:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.consultations-detail-error {
|
||||
margin-top: 16px;
|
||||
padding: 16px 18px;
|
||||
background: #fef2f2;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #fecaca;
|
||||
font-size: 14px;
|
||||
color: #b91c1c;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.consultations-detail-empty {
|
||||
margin-top: 24px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
background: #f8fafc;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.consultations-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
padding: 48px 0;
|
||||
}
|
||||
276
frontend/src/pages/Consultations.tsx
Normal file
276
frontend/src/pages/Consultations.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* Страница «Консультации» — тикеты из общего списка обращений (контекст, без отдельного эндпоинта).
|
||||
* Клик по тикету → POST /api/v1/consultations/ticket-detail (N8N_TICKET_FORM_PODROBNEE_WEBHOOK).
|
||||
* Ответ вебхука только в формате: [{ title, status, category, entity_description, solution, priority }].
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { Compass, Zap, Circle } from 'lucide-react';
|
||||
import { useConsultationItems } from '../context/DraftsContext';
|
||||
import type { DraftItem } from '../context/DraftsContext';
|
||||
import './Consultations.css';
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatConsultationDate(raw: unknown): string {
|
||||
if (raw == null) return '';
|
||||
const s = String(raw).trim();
|
||||
if (!s) return '';
|
||||
try {
|
||||
const d = new Date(s);
|
||||
if (Number.isNaN(d.getTime())) return s;
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${day}.${month}.${year} ${h}:${m}`;
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function getItemTitle(item: DraftItem): string {
|
||||
const payload = item?.payload;
|
||||
const title =
|
||||
(item?.problem_title as string)?.trim() ||
|
||||
(item?.title as string)?.trim() ||
|
||||
(item?.subject as string)?.trim() ||
|
||||
(item?.name as string)?.trim() ||
|
||||
(payload?.ticket_no as string)?.trim() ||
|
||||
(item?.ticket_no as string)?.trim();
|
||||
return title || 'Тикет';
|
||||
}
|
||||
|
||||
function getItemDate(item: DraftItem): string {
|
||||
const raw = item?.created_at ?? item?.createdtime ?? item?.date;
|
||||
return formatConsultationDate(raw);
|
||||
}
|
||||
|
||||
/** Достать ticket_id для вебхука: payload.ticketid или из id вида crm_ticket_520630 */
|
||||
function getTicketId(item: DraftItem): string | number | null {
|
||||
const p = item?.payload;
|
||||
if (p && (p.ticketid != null || p.ticketid === 0)) return p.ticketid as number;
|
||||
if (p && p.ticket_no != null) return String(p.ticket_no);
|
||||
const id = item?.id ?? item?.claim_id;
|
||||
if (typeof id === 'string' && id.startsWith('crm_ticket_')) {
|
||||
const num = id.replace('crm_ticket_', '');
|
||||
return /^\d+$/.test(num) ? parseInt(num, 10) : num;
|
||||
}
|
||||
return id != null ? String(id) : null;
|
||||
}
|
||||
|
||||
interface ConsultationsProps {
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
/** Формат ответа вебхука «подробнее» — только [{ title, status, category, entity_description, solution, priority }] */
|
||||
interface TicketDetailShape {
|
||||
title?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
entity_description?: string;
|
||||
solution?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
function parseTicketDetail(raw: unknown): TicketDetailShape | null {
|
||||
if (raw == null) return null;
|
||||
const arr = Array.isArray(raw) ? raw : (raw as any)?.data != null ? (Array.isArray((raw as any).data) ? (raw as any).data : [(raw as any).data]) : [raw];
|
||||
const first = arr[0];
|
||||
if (first && typeof first === 'object') {
|
||||
return {
|
||||
title: first.title != null ? String(first.title) : undefined,
|
||||
status: first.status != null ? String(first.status) : undefined,
|
||||
category: first.category != null ? String(first.category) : undefined,
|
||||
entity_description: first.entity_description != null ? String(first.entity_description) : undefined,
|
||||
solution: first.solution != null ? String(first.solution) : undefined,
|
||||
priority: first.priority != null ? String(first.priority) : undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const STATUS_RU: Record<string, string> = {
|
||||
open: 'Открыт', opened: 'Открыт', closed: 'Закрыт', in_progress: 'В работе', 'in progress': 'В работе',
|
||||
pending: 'В ожидании', resolved: 'Решён', completed: 'Завершён', rejected: 'Отклонён', cancelled: 'Отменён',
|
||||
};
|
||||
const PRIORITY_RU: Record<string, string> = {
|
||||
low: 'Низкий', normal: 'Обычный', medium: 'Средний', high: 'Высокий', critical: 'Критический', urgent: 'Срочный',
|
||||
};
|
||||
function labelStatus(s: string): string { const k = s.trim().toLowerCase(); return STATUS_RU[k] ?? s; }
|
||||
function labelPriority(s: string): string { const k = s.trim().toLowerCase(); return PRIORITY_RU[k] ?? s; }
|
||||
|
||||
export default function Consultations({ onNavigate }: ConsultationsProps) {
|
||||
const listItems = useConsultationItems();
|
||||
const [detailTicketId, setDetailTicketId] = useState<string | number | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailContent, setDetailContent] = useState<TicketDetailShape | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
|
||||
const handleItemClick = (item: DraftItem) => {
|
||||
const ticketId = getTicketId(item);
|
||||
if (ticketId == null) return;
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
setDetailError('Войдите в систему');
|
||||
return;
|
||||
}
|
||||
setDetailError(null);
|
||||
setDetailContent(null);
|
||||
setDetailTicketId(ticketId);
|
||||
setDetailLoading(true);
|
||||
fetch('/api/v1/consultations/ticket-detail', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_token: token,
|
||||
ticket_id: typeof ticketId === 'number' ? ticketId : String(ticketId),
|
||||
entry_channel: 'web',
|
||||
}),
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.status === 401 ? 'Сессия истекла' : res.status === 502 ? 'Сервис недоступен' : 'Ошибка загрузки');
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
const parsed = parseTicketDetail(json);
|
||||
setDetailContent(parsed);
|
||||
setDetailError(parsed ? null : 'Неверный формат ответа');
|
||||
})
|
||||
.catch((e) => {
|
||||
setDetailError(e.message || 'Не удалось загрузить');
|
||||
setDetailContent(null);
|
||||
})
|
||||
.finally(() => setDetailLoading(false));
|
||||
};
|
||||
|
||||
const showDetail = detailTicketId != null;
|
||||
const ticketDetail = detailContent;
|
||||
|
||||
const handleBack = () => {
|
||||
setDetailTicketId(null);
|
||||
setDetailContent(null);
|
||||
setDetailError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onGoBack = () => {
|
||||
if (showDetail) {
|
||||
setDetailTicketId(null);
|
||||
setDetailContent(null);
|
||||
setDetailError(null);
|
||||
} else {
|
||||
onNavigate?.('/');
|
||||
}
|
||||
};
|
||||
window.addEventListener('miniapp:goBack', onGoBack);
|
||||
return () => window.removeEventListener('miniapp:goBack', onGoBack);
|
||||
}, [showDetail, onNavigate]);
|
||||
|
||||
return (
|
||||
<div className="consultations-page">
|
||||
{!showDetail ? (
|
||||
<>
|
||||
<header className="consultations-header">
|
||||
<h1>Консультации</h1>
|
||||
</header>
|
||||
|
||||
{listItems.length === 0 ? (
|
||||
<div className="consultations-empty">
|
||||
<p>Нет тикетов. Откройте «Мои обращения» на главной, чтобы подгрузить список.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="consultations-list">
|
||||
{listItems.map((item: DraftItem, idx: number) => (
|
||||
<li
|
||||
key={String(item?.id ?? item?.ticketid ?? item?.ticket_id ?? idx)}
|
||||
className="consultations-list-item"
|
||||
onClick={() => handleItemClick(item)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleItemClick(item)}
|
||||
>
|
||||
<p className="item-title">{getItemTitle(item)}</p>
|
||||
{getItemDate(item) && <p className="item-date">{getItemDate(item)}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="consultations-detail">
|
||||
<h2 className="detail-title">Подробнее</h2>
|
||||
|
||||
{detailLoading && (
|
||||
<div className="consultations-loading">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!detailLoading && detailError && (
|
||||
<div className="consultations-detail-error" role="alert">
|
||||
{detailError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!detailLoading && !detailError && ticketDetail != null && (
|
||||
<article className="consultations-detail-card">
|
||||
<div className="card-head">
|
||||
{ticketDetail.title != null && <h3 className="card-title">{ticketDetail.title}</h3>}
|
||||
{ticketDetail.status != null && (
|
||||
<span className="card-status-pill">
|
||||
<Circle size={8} fill="currentColor" stroke="none" />
|
||||
{labelStatus(ticketDetail.status)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(ticketDetail.category != null || ticketDetail.priority != null) && (
|
||||
<div className="card-tags">
|
||||
{ticketDetail.category != null && (
|
||||
<span className="card-tag card-tag-category">
|
||||
<Compass size={14} />
|
||||
{ticketDetail.category}
|
||||
</span>
|
||||
)}
|
||||
{ticketDetail.priority != null && (
|
||||
<span className="card-tag card-tag-priority">
|
||||
<Zap size={14} />
|
||||
{labelPriority(ticketDetail.priority)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ticketDetail.entity_description != null && ticketDetail.entity_description !== '' && (
|
||||
<div className="card-section">
|
||||
<p className="card-section-title">Описание</p>
|
||||
<p className="card-section-body">{ticketDetail.entity_description}</p>
|
||||
</div>
|
||||
)}
|
||||
{ticketDetail.solution != null && ticketDetail.solution !== '' && (
|
||||
<div className="card-section">
|
||||
<p className="card-section-title">Решение</p>
|
||||
<p className="card-section-body">{ticketDetail.solution}</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
|
||||
{!detailLoading && !detailError && ticketDetail == null && (
|
||||
<div className="consultations-detail-empty">Нет данных</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
frontend/src/pages/HelloAuth.css
Normal file
287
frontend/src/pages/HelloAuth.css
Normal file
@@ -0,0 +1,287 @@
|
||||
.hello-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
padding-bottom: 90px;
|
||||
background: #f5f7fb;
|
||||
--tile-h: 160px;
|
||||
}
|
||||
|
||||
.hello-hero {
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
background: #ffffff;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hello-hero-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hello-hero-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hello-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.hello-avatar.placeholder {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(37, 99, 235, 0.4));
|
||||
}
|
||||
|
||||
.hello-hero-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hello-hero-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.hello-hero-body {
|
||||
padding: 8px 20px 16px;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hello-hero-error {
|
||||
color: #d4380d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hello-hero-success {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hello-hero-success-main {
|
||||
flex: 1 1 auto;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.hello-hero-system {
|
||||
flex: 0 0 260px;
|
||||
max-width: 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hello-hero-system :where(.ant-alert) {
|
||||
padding: 8px 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hello-hero-system :where(.ant-alert-content) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.hello-hero-system :where(.ant-alert-message) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hello-hero-system :where(.ant-alert-description) {
|
||||
font-size: 12px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hello-hero-system-carousel .hello-hero-system-slide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hello-grid {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.tile-col,
|
||||
.hello-grid .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tile-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
height: var(--tile-h);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tile-card :where(.ant-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tile-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tile-card--inactive {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tile-card--inactive .tile-icon {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.tile-card--inactive .tile-title {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tile-card--inactive:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.tile-icon svg {
|
||||
display: block; /* убирает baseline */
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
text-align: center;
|
||||
line-height: 18px;
|
||||
min-height: 36px;
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Нижний таб-бар */
|
||||
.hello-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: #ffffff;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.hello-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.hello-bar-item:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hello-bar-item--active {
|
||||
color: #2563EB;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hello-bar-item--active:hover {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hello-page {
|
||||
padding: 16px;
|
||||
padding-bottom: 90px;
|
||||
--tile-h: 140px;
|
||||
}
|
||||
|
||||
.hello-hero-success {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hello-hero-system {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* На узких экранах баннер на всю ширину, кнопка под текстом — текст не сжимается */
|
||||
.hello-hero-system :where(.ant-alert) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hello-hero-system :where(.ant-alert-action) {
|
||||
flex-basis: 100%;
|
||||
order: 1;
|
||||
margin-top: 8px;
|
||||
margin-left: 0;
|
||||
}
|
||||
.hello-hero-system :where(.ant-alert-close-icon) {
|
||||
order: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.tile-card :where(.ant-card-body) {
|
||||
padding: 16px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
514
frontend/src/pages/HelloAuth.tsx
Normal file
514
frontend/src/pages/HelloAuth.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Button, Input, Space, Spin, message, Row, Col, Alert, Carousel } from 'antd';
|
||||
import {
|
||||
User,
|
||||
IdCard,
|
||||
Trophy,
|
||||
ShieldCheck,
|
||||
CalendarDays,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
FileWarning,
|
||||
MessageCircle,
|
||||
Scale,
|
||||
} from 'lucide-react';
|
||||
import './HelloAuth.css';
|
||||
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
interface HelloAuthProps {
|
||||
onAvatarChange?: (url: string) => void;
|
||||
onNavigate?: (path: string) => void;
|
||||
onProfileNeedsAttentionChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const INIT_DATA_WAIT_MS = 5500;
|
||||
const INIT_DATA_POLL_MS = 200;
|
||||
const INIT_DATA_BG_RECOVERY_MS = 15000;
|
||||
const INIT_DATA_BG_TICK_MS = 250;
|
||||
|
||||
export default function HelloAuth({ onAvatarChange, onNavigate, onProfileNeedsAttentionChange }: HelloAuthProps) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [greeting, setGreeting] = useState<string>('Привет!');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [avatar, setAvatar] = useState<string>('');
|
||||
const [profileNeedsAttention, setProfileNeedsAttention] = useState<boolean>(false);
|
||||
const [profileBannerDismissed, setProfileBannerDismissed] = useState<boolean>(() => {
|
||||
try {
|
||||
return sessionStorage.getItem('profile_attention_banner_dismissed_v1') === '1';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [phone, setPhone] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [codeSent, setCodeSent] = useState<boolean>(false);
|
||||
const [noInitDataAfterTimeout, setNoInitDataAfterTimeout] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isTelegramContext = () => {
|
||||
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')
|
||||
);
|
||||
};
|
||||
|
||||
const getChannelAndInitData = (): { channel: 'telegram' | 'max'; initData: string } | null => {
|
||||
const tg = (window as any).Telegram?.WebApp;
|
||||
const max = (window as any).WebApp;
|
||||
const tgData = typeof tg?.initData === 'string' && tg.initData.length > 0 ? tg.initData : null;
|
||||
const maxData = typeof max?.initData === 'string' && max.initData.length > 0 ? max.initData : null;
|
||||
if (tgData && isTelegramContext()) return { channel: 'telegram', initData: tgData };
|
||||
if (maxData) return { channel: 'max', initData: maxData };
|
||||
if (tgData) return { channel: 'telegram', initData: tgData };
|
||||
return null;
|
||||
};
|
||||
|
||||
const authWithInitData = async (channel: 'telegram' | 'max', initData: string): Promise<'success' | 'need_contact' | 'error'> => {
|
||||
miniappLog('auth_start', { channel, initDataLen: initData?.length ?? 0 });
|
||||
const res = await fetch('/api/v1/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channel, init_data: initData }),
|
||||
});
|
||||
const data: Record<string, unknown> = await res.json().catch((e) => {
|
||||
miniappLog('auth_json_error', { err: String(e), status: res.status });
|
||||
return {};
|
||||
});
|
||||
miniappLog('auth_response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
keys: Object.keys(data),
|
||||
success: data.success,
|
||||
need_contact: data.need_contact,
|
||||
message: data.message,
|
||||
detail: data.detail,
|
||||
});
|
||||
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
|
||||
if (needContact) {
|
||||
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
|
||||
const doClose = () => {
|
||||
try {
|
||||
if (typeof webApp?.close === 'function') webApp.close();
|
||||
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
|
||||
} catch (_) {}
|
||||
};
|
||||
doClose();
|
||||
setTimeout(doClose, 200);
|
||||
return 'need_contact';
|
||||
}
|
||||
if (res.ok && data.success) {
|
||||
const needsAttentionRaw =
|
||||
(data.profile_needs_attention as unknown) ??
|
||||
(data.profileNeedsAttention as unknown) ??
|
||||
(data.need_profile_confirm as unknown) ??
|
||||
(data.needProfileConfirm as unknown);
|
||||
const needsAttention = needsAttentionRaw === true || needsAttentionRaw === 1 || needsAttentionRaw === '1' || needsAttentionRaw === 'true';
|
||||
setProfileNeedsAttention(needsAttention);
|
||||
onProfileNeedsAttentionChange?.(needsAttention);
|
||||
|
||||
const token = data.session_token as string | undefined;
|
||||
if (token) {
|
||||
try {
|
||||
sessionStorage.setItem('session_token', token);
|
||||
localStorage.setItem('session_token', token); // запас для TG: WebView иногда теряет sessionStorage при переходах
|
||||
} catch (_) {}
|
||||
}
|
||||
setGreeting('Привет!');
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
|
||||
const user = tgUser || maxUser;
|
||||
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
|
||||
const avatarUrl = user?.photo_url || (data.avatar_url as string);
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return 'success';
|
||||
}
|
||||
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
|
||||
setStatus('error');
|
||||
void miniappSendLogs('auth_error');
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const tryAuth = async () => {
|
||||
setStatus('loading');
|
||||
setNoInitDataAfterTimeout(false);
|
||||
setError('');
|
||||
try {
|
||||
if (isTelegramContext() && !(window as any).Telegram?.WebApp) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => resolve();
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
// 1) Ждём появления initData (TG или MAX) с таймаутом
|
||||
let channelInit = getChannelAndInitData();
|
||||
if (!channelInit) {
|
||||
const deadline = Date.now() + INIT_DATA_WAIT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, INIT_DATA_POLL_MS));
|
||||
channelInit = getChannelAndInitData();
|
||||
if (channelInit) break;
|
||||
}
|
||||
}
|
||||
if (channelInit) {
|
||||
const result = await authWithInitData(channelInit.channel, channelInit.initData);
|
||||
if (result === 'success' || result === 'need_contact' || result === 'error') return;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) initData не появился за таймаут — пробуем восстановить сессию по session_token (после обновления страницы)
|
||||
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
|
||||
if (likelyMiniapp) {
|
||||
let token: string | null = null;
|
||||
try {
|
||||
token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
|
||||
} catch (_) {}
|
||||
if (token) {
|
||||
try {
|
||||
const verifyRes = await fetch('/api/v1/session/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: token }),
|
||||
});
|
||||
const verifyData = await verifyRes.json().catch(() => ({}));
|
||||
if (verifyData?.valid === true) {
|
||||
const needsAttentionRaw =
|
||||
verifyData.profile_needs_attention ??
|
||||
verifyData.profileNeedsAttention ??
|
||||
verifyData.need_profile_confirm ??
|
||||
verifyData.needProfileConfirm;
|
||||
const needsAttention = needsAttentionRaw === true || needsAttentionRaw === 1 || needsAttentionRaw === '1' || needsAttentionRaw === 'true';
|
||||
setProfileNeedsAttention(needsAttention);
|
||||
onProfileNeedsAttentionChange?.(needsAttention);
|
||||
|
||||
setGreeting('Привет!');
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
|
||||
const user = tgUser || maxUser;
|
||||
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 2.5) TG после refresh иногда отдаёт initData с задержкой.
|
||||
// Фоном ждём ещё немного и тихо повторяем auth, не показывая сразу экран ошибки.
|
||||
const bgStart = Date.now();
|
||||
const bgDeadline = bgStart + INIT_DATA_BG_RECOVERY_MS;
|
||||
while (Date.now() < bgDeadline) {
|
||||
await new Promise((r) => setTimeout(r, INIT_DATA_BG_TICK_MS));
|
||||
const lateChannelInit = getChannelAndInitData();
|
||||
if (!lateChannelInit) continue;
|
||||
miniappLog('hello_init_data_recovered_bg', {
|
||||
waited_ms: Date.now() - bgStart,
|
||||
channel: lateChannelInit.channel,
|
||||
});
|
||||
const result = await authWithInitData(lateChannelInit.channel, lateChannelInit.initData);
|
||||
if (result === 'success' || result === 'need_contact' || result === 'error') return;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
url: window.location.href,
|
||||
hasTgWebApp: !!(window as any).Telegram?.WebApp,
|
||||
hasMaxWebApp: !!(window as any).WebApp,
|
||||
tgInitDataLen: typeof (window as any).Telegram?.WebApp?.initData === 'string' ? (window as any).Telegram.WebApp.initData.length : 0,
|
||||
maxInitDataLen: typeof (window as any).WebApp?.initData === 'string' ? (window as any).WebApp.initData.length : 0,
|
||||
};
|
||||
miniappLog('hello_no_init_data_after_timeout', ctx);
|
||||
miniappSendLogs('no_init_data_after_timeout').catch(() => {});
|
||||
setNoInitDataAfterTimeout(true);
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Веб: claim_id в URL и SMS fallback
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let claimFromUrl: string | null = params.get('claim_id');
|
||||
const parseStart = (s: string | null) => {
|
||||
if (!s) return null;
|
||||
const d = decodeURIComponent(s.trim());
|
||||
const m = d.match(/claim_id=([^&]+)/i) || d.match(/claim_id_([0-9a-f-]{36})/i) || d.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
};
|
||||
const startParamToStr = (v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.value === 'string') return o.value;
|
||||
if (typeof o.payload === 'string') return o.payload;
|
||||
if (typeof o.start_param === 'string') return o.start_param;
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
|
||||
if (!claimFromUrl && window.location.hash) {
|
||||
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
|
||||
}
|
||||
const maxStartParam = (window as any).WebApp?.initDataUnsafe?.start_param;
|
||||
if (!claimFromUrl && maxStartParam) claimFromUrl = parseStart(startParamToStr(maxStartParam));
|
||||
if (claimFromUrl) {
|
||||
try {
|
||||
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
|
||||
if (draftRes.ok) {
|
||||
const draftData = await draftRes.json();
|
||||
const st = draftData?.claim?.session_token;
|
||||
if (st) localStorage.setItem('session_token', st);
|
||||
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
setStatus('idle');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
tryAuth();
|
||||
}, [onAvatarChange, onNavigate, onProfileNeedsAttentionChange]);
|
||||
|
||||
if (noInitDataAfterTimeout && status === 'idle') {
|
||||
return (
|
||||
<div className="hello-auth-page">
|
||||
<Card className="hello-auth-card">
|
||||
<h2>Не удалось определить приложение</h2>
|
||||
<p style={{ marginBottom: 16 }}>Если вы открыли мини-приложение из Telegram или MAX — обновите страницу.</p>
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Обновите страницу
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
if (!phone || phone.length < 10) {
|
||||
message.error('Введите номер телефона');
|
||||
return;
|
||||
}
|
||||
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
|
||||
const res = await fetch('/api/v1/sms/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: normalized }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
message.error(data.detail || 'Ошибка отправки кода');
|
||||
return;
|
||||
}
|
||||
setCodeSent(true);
|
||||
message.success('Код отправлен');
|
||||
} catch (e) {
|
||||
message.error('Ошибка соединения');
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCode = async () => {
|
||||
try {
|
||||
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
|
||||
const res = await fetch('/api/v1/auth2/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform: 'sms', phone: normalized, code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
localStorage.setItem('user_avatar_url', data.avatar_url);
|
||||
onAvatarChange?.(data.avatar_url);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
message.error(data.detail || 'Неверный код');
|
||||
} catch (e) {
|
||||
message.error('Ошибка соединения');
|
||||
}
|
||||
};
|
||||
|
||||
const systemBanners: Array<{ key: string; node: JSX.Element }> = [];
|
||||
|
||||
if (status === 'success' && profileNeedsAttention && !profileBannerDismissed) {
|
||||
systemBanners.push({
|
||||
key: 'profile-not-complete',
|
||||
node: (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
message="Профиль не заполнен"
|
||||
description="Пожалуйста, заполните обязательные поля профиля, чтобы продолжить работу."
|
||||
onClose={() => {
|
||||
try {
|
||||
sessionStorage.setItem('profile_attention_banner_dismissed_v1', '1');
|
||||
} catch (_) {}
|
||||
setProfileBannerDismissed(true);
|
||||
}}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (onNavigate) onNavigate('/profile');
|
||||
else window.location.href = '/profile';
|
||||
}}
|
||||
>
|
||||
Заполнить профиль
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const hasSystemBanners = systemBanners.length > 0;
|
||||
|
||||
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
|
||||
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
|
||||
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
|
||||
{ title: 'Экспертиза', icon: Scale, color: '#0EA5E9' },
|
||||
{ title: 'Консультации', icon: MessageCircle, color: '#8B5CF6' },
|
||||
{ title: 'Членство', icon: IdCard, color: '#10B981' },
|
||||
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },
|
||||
{ title: 'Общественный контроллер', icon: ShieldCheck, color: '#22C55E' },
|
||||
{ title: 'Календарь мероприятий', icon: CalendarDays, color: '#4F46E5' },
|
||||
{ title: 'Образцы документов', icon: FileText, color: '#1E40AF' },
|
||||
{ title: 'FAQ', icon: HelpCircle, color: '#0EA5E9' },
|
||||
{ title: 'Регистрация компании', icon: Building2, color: '#0F766E' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="hello-page">
|
||||
<Card className="hello-hero" bordered={false}>
|
||||
<div className="hello-hero-header">
|
||||
<div className="hello-hero-profile">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="avatar" className="hello-avatar" />
|
||||
) : (
|
||||
<div className="hello-avatar placeholder" />
|
||||
)}
|
||||
<div>
|
||||
<div className="hello-hero-title">{greeting}</div>
|
||||
<div className="hello-hero-subtitle">Добро пожаловать в кабинет</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hello-hero-body">
|
||||
{status === 'loading' ? (
|
||||
<Spin size="large" tip="Авторизация..." />
|
||||
) : status === 'success' ? (
|
||||
<div className={`hello-hero-success${hasSystemBanners ? ' hello-hero-success--with-banner' : ''}`}>
|
||||
<div className="hello-hero-success-main">
|
||||
<p>Теперь ты в системе — можно продолжать</p>
|
||||
</div>
|
||||
{hasSystemBanners && (
|
||||
<div className="hello-hero-system">
|
||||
{systemBanners.length === 1 ? (
|
||||
systemBanners[0].node
|
||||
) : (
|
||||
<Carousel
|
||||
autoplay
|
||||
dots
|
||||
adaptiveHeight
|
||||
className="hello-hero-system-carousel"
|
||||
>
|
||||
{systemBanners.map((banner) => (
|
||||
<div key={banner.key} className="hello-hero-system-slide">
|
||||
{banner.node}
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : status === 'error' ? (
|
||||
<p className="hello-hero-error">{error}</p>
|
||||
) : (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Телефон (10 цифр)"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
/>
|
||||
<Button type="primary" onClick={sendCode} block>
|
||||
Отправить код
|
||||
</Button>
|
||||
{codeSent && (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Код из SMS"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Button type="primary" onClick={verifyCode} block>
|
||||
Проверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} className="hello-grid" align="stretch">
|
||||
{tiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
const active = !!tile.href;
|
||||
const card = (
|
||||
<Card
|
||||
className={`tile-card ${active ? '' : 'tile-card--inactive'}`}
|
||||
hoverable={active}
|
||||
bordered={false}
|
||||
onClick={tile.href ? () => {
|
||||
// В TG при полной перезагрузке теряется initData — переходим без reload (SPA)
|
||||
if (onNavigate) {
|
||||
onNavigate(tile.href!);
|
||||
} else {
|
||||
window.location.href = tile.href! + (window.location.search || '');
|
||||
}
|
||||
} : undefined}
|
||||
style={tile.href ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
<div className="tile-icon" style={{ color: tile.color }}>
|
||||
<Icon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="tile-title">{tile.title}</div>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Col key={tile.title} xs={12} sm={8} md={6} className="tile-col">
|
||||
{card}
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
frontend/src/pages/Profile.css
Normal file
34
frontend/src/pages/Profile.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
padding-bottom: 90px;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-card .ant-card-head {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.profile-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.profile-card .ant-descriptions-item-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: #f9fafb !important;
|
||||
}
|
||||
|
||||
.profile-card .ant-descriptions-item-content {
|
||||
color: #111827;
|
||||
}
|
||||
481
frontend/src/pages/Profile.tsx
Normal file
481
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button, Card, Checkbox, Descriptions, Form, Input, Select, DatePicker, AutoComplete, Spin, Typography, message } from 'antd';
|
||||
import { User } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import './Profile.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const DATE_FORMAT = 'DD.MM.YYYY';
|
||||
|
||||
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
|
||||
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
|
||||
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия', editable: true },
|
||||
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя', editable: true },
|
||||
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество', editable: true },
|
||||
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения', editable: true },
|
||||
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения', editable: true },
|
||||
{ key: 'inn', keys: ['inn'], label: 'ИНН', editable: true },
|
||||
{ key: 'email', keys: ['email'], label: 'Электронная почта', editable: true },
|
||||
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации', editable: true },
|
||||
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес', editable: true },
|
||||
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения', editable: true },
|
||||
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон', editable: false },
|
||||
];
|
||||
|
||||
function getValue(obj: Record<string, unknown>, keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = obj[k];
|
||||
if (v != null && String(v).trim() !== '') return String(v).trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Парсим дату из строки (DD.MM.YYYY, YYYY-MM-DD и т.д.) в dayjs или null */
|
||||
function parseBirthDate(s: string): dayjs.Dayjs | null {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return null;
|
||||
const d = dayjs(trimmed, [DATE_FORMAT, 'YYYY-MM-DD', 'DD.MM.YYYY'], true);
|
||||
return d.isValid() ? d : null;
|
||||
}
|
||||
|
||||
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
|
||||
function canEditProfile(contact: Record<string, unknown>): boolean {
|
||||
const v = contact?.verification ?? contact?.Verification;
|
||||
return v === '0' || v === 0;
|
||||
}
|
||||
|
||||
interface BankOption {
|
||||
id?: string;
|
||||
name?: string;
|
||||
bankid?: string;
|
||||
bankname?: string;
|
||||
value?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface ProfileProps {
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function Profile({ onNavigate }: ProfileProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [sameAsRegistration, setSameAsRegistration] = useState(false);
|
||||
const [banks, setBanks] = useState<BankOption[]>([]);
|
||||
const [banksLoading, setBanksLoading] = useState(false);
|
||||
const [addressOptionsReg, setAddressOptionsReg] = useState<{ value: string }[]>([]);
|
||||
const [addressOptionsMail, setAddressOptionsMail] = useState<{ value: string }[]>([]);
|
||||
const [dadataLoadingReg, setDadataLoadingReg] = useState(false);
|
||||
const [dadataLoadingMail, setDadataLoadingMail] = useState(false);
|
||||
|
||||
const loadBanks = useCallback(async () => {
|
||||
setBanksLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/banks/nspk');
|
||||
if (!res.ok) {
|
||||
setBanks([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => []);
|
||||
const list = Array.isArray(data) ? data : (data?.data || data?.items || []);
|
||||
const normalized: BankOption[] = list.map((b: Record<string, unknown> | string) => {
|
||||
if (typeof b === 'string') return { value: b, label: b };
|
||||
const name = (b?.bankName ?? b?.bankname ?? b?.name ?? b?.title ?? b?.value ?? '').toString().trim();
|
||||
const id = (b?.bankId ?? b?.bankid ?? b?.id ?? b?.value ?? name).toString().trim();
|
||||
return { bankid: id, bankname: name, value: name, label: name };
|
||||
}).filter((b: BankOption) => b.value || b.label);
|
||||
setBanks(normalized);
|
||||
} catch {
|
||||
setBanks([]);
|
||||
} finally {
|
||||
setBanksLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchAddress = useCallback(async (query: string, setOptions: (o: { value: string }[]) => void, setLoading: (v: boolean) => void) => {
|
||||
if (!query || query.length < 2) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/profile/dadata/address?query=${encodeURIComponent(query)}&count=10`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const suggestions = (data?.suggestions || []).map((s: { value?: string; unrestricted_value?: string }) => ({
|
||||
value: (s.unrestricted_value || s.value || '').trim(),
|
||||
})).filter((s: { value: string }) => s.value);
|
||||
setOptions(suggestions);
|
||||
} catch {
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (canEditProfile(contact || {})) loadBanks();
|
||||
}, [contact, loadBanks]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const token = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|
||||
|| localStorage.getItem('session_token');
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
onNavigate?.('/hello');
|
||||
return;
|
||||
}
|
||||
const entryChannel =
|
||||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||||
: 'web';
|
||||
const chatId = (() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const tg = (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id;
|
||||
if (tg != null) return String(tg);
|
||||
const max = (window as any).WebApp?.initDataUnsafe?.user?.id;
|
||||
if (max != null) return String(max);
|
||||
return undefined;
|
||||
})();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams({ session_token: token, entry_channel: entryChannel });
|
||||
if (chatId) params.set('chat_id', chatId);
|
||||
fetch(`/api/v1/profile/contact?${params.toString()}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||
localStorage.removeItem('session_token');
|
||||
throw new Error('Сессия истекла');
|
||||
}
|
||||
throw new Error('Ошибка загрузки');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { items?: unknown[] }) => {
|
||||
if (cancelled) return;
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
const first = items.length > 0 && typeof items[0] === 'object' && items[0] !== null
|
||||
? (items[0] as Record<string, unknown>)
|
||||
: null;
|
||||
setContact(first);
|
||||
if (first && canEditProfile(first)) {
|
||||
const initial: Record<string, string | dayjs.Dayjs | null> = {};
|
||||
PROFILE_FIELDS.forEach(({ key, keys }) => {
|
||||
const raw = getValue(first, keys) || '';
|
||||
if (key === 'birth_date') {
|
||||
initial[key] = parseBirthDate(raw) || (raw ? dayjs(raw) : null);
|
||||
} else {
|
||||
initial[key] = raw;
|
||||
}
|
||||
});
|
||||
const regAddr = (initial.registration_address as string) || '';
|
||||
const mailAddr = (initial.mailing_address as string) || '';
|
||||
setSameAsRegistration(!!regAddr && regAddr === mailAddr);
|
||||
form.setFieldsValue(initial);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [onNavigate, form]);
|
||||
|
||||
const handleSameAsRegistrationChange = (e: { target: { checked: boolean } }) => {
|
||||
const checked = e.target.checked;
|
||||
setSameAsRegistration(checked);
|
||||
if (checked) {
|
||||
const reg = form.getFieldValue('registration_address') || '';
|
||||
form.setFieldsValue({ mailing_address: reg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationAddressChange = () => {
|
||||
if (sameAsRegistration) {
|
||||
const reg = form.getFieldValue('registration_address') || '';
|
||||
form.setFieldsValue({ mailing_address: reg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!contact || !canEditProfile(contact)) return;
|
||||
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
|
||||
if (!token) {
|
||||
message.error('Сессия истекла');
|
||||
onNavigate?.('/hello');
|
||||
return;
|
||||
}
|
||||
const entryChannel =
|
||||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||||
: 'web';
|
||||
let values: Record<string, string>;
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const birthDateVal = values.birth_date;
|
||||
const birthDateStr = dayjs.isDayjs(birthDateVal) ? birthDateVal.format(DATE_FORMAT) : (birthDateVal && String(birthDateVal).trim()) || '';
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/profile/contact/update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_token: token,
|
||||
entry_channel: entryChannel,
|
||||
last_name: values.last_name ?? '',
|
||||
first_name: values.first_name ?? '',
|
||||
middle_name: values.middle_name ?? '',
|
||||
birth_date: birthDateStr,
|
||||
birth_place: values.birth_place ?? '',
|
||||
inn: values.inn ?? '',
|
||||
email: values.email ?? '',
|
||||
registration_address: values.registration_address ?? '',
|
||||
mailing_address: values.mailing_address ?? '',
|
||||
bank_for_compensation: values.bank_for_compensation ?? '',
|
||||
phone: getValue(contact, ['phone', 'mobile', 'mobile_phone']) || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const detail = typeof data?.detail === 'string' ? data.detail : data?.detail?.[0]?.msg || 'Не удалось сохранить профиль';
|
||||
message.error(detail);
|
||||
return;
|
||||
}
|
||||
message.success('Профиль сохранён');
|
||||
setContact({ ...contact, ...values, birth_date: birthDateStr });
|
||||
} catch (e) {
|
||||
message.error('Не удалось сохранить профиль, попробуйте позже');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card">
|
||||
<div className="profile-loading">
|
||||
<Spin size="large" tip="Загрузка профиля..." />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card">
|
||||
<Title level={4}>Профиль</Title>
|
||||
<Text type="danger">{error}</Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={() => onNavigate?.('/hello')}>
|
||||
Войти снова
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card">
|
||||
<Title level={4}>Профиль</Title>
|
||||
<Text type="secondary">Контактных данных пока нет. Они появятся после обработки ваших обращений.</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canEdit = canEditProfile(contact);
|
||||
|
||||
if (canEdit) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card
|
||||
className="profile-card"
|
||||
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => {
|
||||
if (key === 'birth_date') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Укажите дату рождения' }]}
|
||||
>
|
||||
<DatePicker
|
||||
format={DATE_FORMAT}
|
||||
placeholder="Выберите дату"
|
||||
style={{ width: '100%' }}
|
||||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'inn') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[
|
||||
{ required: true, message: 'Введите ИНН' },
|
||||
{ pattern: /^\d{12}$/, message: 'ИНН должен содержать ровно 12 цифр' },
|
||||
]}
|
||||
help={
|
||||
<span style={{ fontSize: 12, color: 'var(--ant-color-text-secondary, rgba(0,0,0,0.45))' }}>
|
||||
Узнать свой ИНН вы можете{' '}
|
||||
<a href="https://service.nalog.ru/static/personal-data.html?svc=inn&from=%2Finn.do" target="_blank" rel="noopener noreferrer">
|
||||
здесь
|
||||
</a>
|
||||
{' '}(сервис ФНС России).
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
maxLength={12}
|
||||
placeholder="12 цифр"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 12);
|
||||
form.setFieldValue('inn', v);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'email') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[
|
||||
{ required: true, message: 'Введите электронную почту' },
|
||||
{ type: 'email', message: 'Введите корректный email' },
|
||||
]}
|
||||
>
|
||||
<Input type="email" placeholder="example@mail.ru" />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'registration_address') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Обязательное поле' }]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={addressOptionsReg}
|
||||
placeholder="Начните вводить адрес"
|
||||
onSearch={(q) => searchAddress(q, setAddressOptionsReg, setDadataLoadingReg)}
|
||||
onChange={handleRegistrationAddressChange}
|
||||
notFoundContent={dadataLoadingReg ? 'Загрузка...' : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'mailing_address') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item style={{ marginBottom: 8 }}>
|
||||
<Checkbox checked={sameAsRegistration} onChange={handleSameAsRegistrationChange}>
|
||||
Совпадает с адресом регистрации
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Обязательное поле' }]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={addressOptionsMail}
|
||||
placeholder="Начните вводить адрес"
|
||||
onSearch={(q) => searchAddress(q, setAddressOptionsMail, setDadataLoadingMail)}
|
||||
disabled={sameAsRegistration}
|
||||
notFoundContent={dadataLoadingMail ? 'Загрузка...' : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (key === 'bank_for_compensation') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Выберите банк' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder={banksLoading ? 'Загрузка банков...' : 'Выберите банк'}
|
||||
loading={banksLoading}
|
||||
optionFilterProp="label"
|
||||
filterOption={(input, opt) => (opt?.label ?? '').toString().toLowerCase().includes((input || '').toLowerCase())}
|
||||
options={banks.map((b) => ({ value: b.value || b.bankname || b.label, label: b.label || b.bankname || b.value }))}
|
||||
notFoundContent={banksLoading ? 'Загрузка...' : 'Банк не найден'}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
|
||||
>
|
||||
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
Сохранить изменения
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = PROFILE_FIELDS.map(({ keys, label }) => ({
|
||||
key: keys[0],
|
||||
label,
|
||||
children: getValue(contact, keys) || '—',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card" title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
{items.map((item) => (
|
||||
<Descriptions.Item key={item.key} label={item.label}>
|
||||
{item.children}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
frontend/src/pages/Support.tsx
Normal file
269
frontend/src/pages/Support.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Страница «Поддержка» — сначала список тикетов/тредов, по клику — чат или новое обращение.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, List, Spin, Typography, message } from 'antd';
|
||||
import { ArrowLeft, MessageCirclePlus } from 'lucide-react';
|
||||
import SupportChat from '../components/SupportChat';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export interface SupportThreadItem {
|
||||
thread_id: string;
|
||||
claim_id: string | null;
|
||||
source: string;
|
||||
ticket_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_body: string | null;
|
||||
last_at: string | null;
|
||||
messages_count: number;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
function getSessionToken(): string | null {
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
const s = sessionStorage.getItem('session_token');
|
||||
if (s) return s;
|
||||
}
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
return localStorage.getItem('session_token');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface SupportProps {
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
function getViewportHeight(): number {
|
||||
if (typeof window === 'undefined') return 600;
|
||||
const vv = window.visualViewport;
|
||||
return (vv?.height ?? window.innerHeight) || 600;
|
||||
}
|
||||
|
||||
export default function Support({ onNavigate }: SupportProps) {
|
||||
const [view, setView] = useState<'list' | 'chat'>('list');
|
||||
const [threads, setThreads] = useState<SupportThreadItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedClaimId, setSelectedClaimId] = useState<string | null | undefined>(undefined);
|
||||
const [viewportHeight, setViewportHeight] = useState(getViewportHeight);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'list') return;
|
||||
const token = getSessionToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('session_token', token);
|
||||
fetch(`/api/v1/support/threads?${params.toString()}`)
|
||||
.then((res) => {
|
||||
if (res.status === 401) {
|
||||
message.error('Сессия истекла. Обновите страницу или войдите снова.');
|
||||
return { threads: [] };
|
||||
}
|
||||
if (!res.ok) {
|
||||
message.error('Не удалось загрузить список обращений. Попробуйте позже.');
|
||||
return { threads: [] };
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setThreads(data.threads || []);
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('Ошибка соединения. Проверьте интернет и попробуйте снова.');
|
||||
setThreads([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [view]);
|
||||
|
||||
const handleOpenThread = (claimId: string | null) => {
|
||||
setSelectedClaimId(claimId);
|
||||
setView('chat');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setView('list');
|
||||
setSelectedClaimId(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onGoBack = () => {
|
||||
if (view === 'chat') {
|
||||
setView('list');
|
||||
setSelectedClaimId(undefined);
|
||||
} else {
|
||||
onNavigate?.('/');
|
||||
}
|
||||
};
|
||||
window.addEventListener('miniapp:goBack', onGoBack);
|
||||
return () => window.removeEventListener('miniapp:goBack', onGoBack);
|
||||
}, [view, onNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('miniapp:supportChatMode', {
|
||||
detail: { active: view === 'chat' },
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('miniapp:supportChatMode', {
|
||||
detail: { active: false },
|
||||
}),
|
||||
);
|
||||
};
|
||||
}, [view]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== 'chat') return;
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const update = () => setViewportHeight(getViewportHeight());
|
||||
update();
|
||||
vv.addEventListener('resize', update);
|
||||
vv.addEventListener('scroll', update);
|
||||
return () => {
|
||||
vv.removeEventListener('resize', update);
|
||||
vv.removeEventListener('scroll', update);
|
||||
};
|
||||
}, [view]);
|
||||
|
||||
if (view === 'chat') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: viewportHeight,
|
||||
overflow: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto', flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeft size={18} />}
|
||||
onClick={handleBack}
|
||||
style={{ marginBottom: 16, paddingLeft: 0, flexShrink: 0 }}
|
||||
>
|
||||
К списку обращений
|
||||
</Button>
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<SupportChat
|
||||
claimId={selectedClaimId === null ? undefined : selectedClaimId ?? undefined}
|
||||
source="bar"
|
||||
onSuccess={() => {
|
||||
handleBack();
|
||||
if (onNavigate) onNavigate('/hello');
|
||||
else {
|
||||
window.history.pushState({}, '', '/hello');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto' }}>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>
|
||||
Поддержка
|
||||
</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
|
||||
Ваши обращения и переписка с поддержкой.
|
||||
</Text>
|
||||
|
||||
{getSessionToken() && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<MessageCirclePlus size={18} style={{ marginRight: 6 }} />}
|
||||
onClick={() => handleOpenThread(null)}
|
||||
style={{ marginBottom: 24, width: '100%' }}
|
||||
>
|
||||
Новое обращение
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : !getSessionToken() ? (
|
||||
<Text type="secondary">Войдите в аккаунт, чтобы видеть обращения и писать в поддержку.</Text>
|
||||
) : threads.length === 0 ? (
|
||||
<Text type="secondary">Пока нет обращений. Нажмите «Новое обращение», чтобы написать.</Text>
|
||||
) : (
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={threads}
|
||||
renderItem={(item) => {
|
||||
const title = item.claim_id
|
||||
? `По заявке №${item.claim_id}`
|
||||
: 'Общее обращение';
|
||||
const sub = item.last_at
|
||||
? new Date(item.last_at).toLocaleString('ru-RU')
|
||||
: new Date(item.created_at).toLocaleString('ru-RU');
|
||||
const preview = item.last_body
|
||||
? item.last_body.replace(/\s+/g, ' ').trim().slice(0, 80) + (item.last_body.length > 80 ? '…' : '')
|
||||
: 'Нет сообщений';
|
||||
return (
|
||||
<List.Item
|
||||
key={item.thread_id}
|
||||
style={{
|
||||
padding: '14px 16px',
|
||||
background: '#fafafa',
|
||||
borderRadius: 12,
|
||||
marginBottom: 10,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #f0f0f0',
|
||||
}}
|
||||
onClick={() => handleOpenThread(item.claim_id)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Text strong>{title}</Text>
|
||||
{item.unread_count > 0 && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
padding: '0 6px',
|
||||
borderRadius: 10,
|
||||
background: '#ff4d4f',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
title="Непрочитанные сообщения"
|
||||
>
|
||||
{item.unread_count > 99 ? '99+' : item.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{sub}</div>
|
||||
<div style={{ fontSize: 13, color: '#666', marginTop: 6 }}>{preview}</div>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>
|
||||
{item.messages_count} сообщ.
|
||||
</Text>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/utils/miniappLogger.ts
Normal file
112
frontend/src/utils/miniappLogger.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
type MiniappLogEntry = {
|
||||
ts: number;
|
||||
iso: string;
|
||||
event: string;
|
||||
build?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MINIAPP_LOGS__?: MiniappLogEntry[];
|
||||
__MINIAPP_BUILD__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const LS_KEY = 'miniapp_debug_logs_v1';
|
||||
const LS_BUILD_KEY = 'miniapp_debug_build_v1';
|
||||
const MAX_ENTRIES = 250;
|
||||
|
||||
function safeJsonParse<T>(s: string | null): T | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBuffer(): MiniappLogEntry[] {
|
||||
if (!window.__MINIAPP_LOGS__) {
|
||||
window.__MINIAPP_LOGS__ = safeJsonParse<MiniappLogEntry[]>(localStorage.getItem(LS_KEY)) || [];
|
||||
}
|
||||
return window.__MINIAPP_LOGS__;
|
||||
}
|
||||
|
||||
function getBuildTag(): string | undefined {
|
||||
const b = window.__MINIAPP_BUILD__;
|
||||
return typeof b === 'string' && b.length ? b : undefined;
|
||||
}
|
||||
|
||||
function persist(buf: MiniappLogEntry[]) {
|
||||
try {
|
||||
const trimmed = buf.slice(-MAX_ENTRIES);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(trimmed));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function miniappLog(event: string, data?: unknown) {
|
||||
const entry: MiniappLogEntry = {
|
||||
ts: Date.now(),
|
||||
iso: new Date().toISOString(),
|
||||
event,
|
||||
build: getBuildTag(),
|
||||
data,
|
||||
};
|
||||
const buf = getBuffer();
|
||||
buf.push(entry);
|
||||
if (buf.length > MAX_ENTRIES * 2) {
|
||||
window.__MINIAPP_LOGS__ = buf.slice(-MAX_ENTRIES);
|
||||
}
|
||||
persist(window.__MINIAPP_LOGS__ || buf);
|
||||
// В TG/MAX консоль не всегда доступна, но пусть будет
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[MINIAPP][LOG]', entry.event, entry.data || '');
|
||||
}
|
||||
|
||||
export function miniappDumpLogs() {
|
||||
return getBuffer().slice(-MAX_ENTRIES);
|
||||
}
|
||||
|
||||
export async function miniappSendLogs(reason: string) {
|
||||
// Если билд сменился — не мешаем старые логи с новыми.
|
||||
// Это не “фикс бага”, а гарантия чистой диагностики.
|
||||
try {
|
||||
const cur = getBuildTag();
|
||||
const prev = localStorage.getItem(LS_BUILD_KEY) || '';
|
||||
if (cur && prev && cur !== prev) {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
window.__MINIAPP_LOGS__ = [];
|
||||
}
|
||||
if (cur) localStorage.setItem(LS_BUILD_KEY, cur);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const payload = {
|
||||
reason,
|
||||
client: {
|
||||
href: window.location.href,
|
||||
origin: window.location.origin,
|
||||
pathname: window.location.pathname,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
ua: navigator.userAgent,
|
||||
referrer: document.referrer,
|
||||
ts: Date.now(),
|
||||
},
|
||||
logs: miniappDumpLogs(),
|
||||
};
|
||||
try {
|
||||
await fetch('/api/v1/utils/client-log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
// Полифилл crypto.getRandomValues для Node 16 — подключать через: node -r ./scripts/crypto-polyfill.cjs ...
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -10,6 +11,7 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://host.docker.internal:8201',
|
||||
|
||||
Reference in New Issue
Block a user