diff --git a/CHANGELOG_MINIAPP.md b/CHANGELOG_MINIAPP.md index e5fdd2b..148d1de 100644 --- a/CHANGELOG_MINIAPP.md +++ b/CHANGELOG_MINIAPP.md @@ -1,5 +1,29 @@ # Доработки мини-приложения 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` для вебхука «подробнее» по тикету. +- Модуль `backend/app/api/consultations.py`: эндпоинт `POST /api/v1/consultations/ticket-detail` (session + `ticket_id`), вызов вебхука, ответ как есть. + +--- + ## Системные баннеры на экране приветствия (2026-02) - **Баннер «Профиль не заполнен»** вынесен в отдельную зону справа от текста «Теперь ты в системе — можно продолжать» (на десктопе — колонка ~260px), чтобы не занимал полстраницы и не сдвигал контент. - Реализовано **единое место для системных баннеров**: массив `systemBanners`, при одном баннере показывается один Alert, при нескольких — карусель (Ant Design Carousel). В будущем сюда можно добавлять другие критические уведомления. diff --git a/backend/app/api/consultations.py b/backend/app/api/consultations.py new file mode 100644 index 0000000..eafdbdc --- /dev/null +++ b/backend/app/api/consultations.py @@ -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} diff --git a/backend/app/config.py b/backend/app/config.py index 28b070b..fe62d4a 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -207,6 +207,10 @@ class Settings(BaseSettings): 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.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 # 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) diff --git a/docs/n8n_CODE_CRM_NORMALIZE.js b/docs/n8n_CODE_CRM_NORMALIZE.js new file mode 100644 index 0000000..faf3356 --- /dev/null +++ b/docs/n8n_CODE_CRM_NORMALIZE.js @@ -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 } }]; diff --git a/docs/n8n_CODE_FLATTEN_DATA.js b/docs/n8n_CODE_FLATTEN_DATA.js new file mode 100644 index 0000000..3498fcd --- /dev/null +++ b/docs/n8n_CODE_FLATTEN_DATA.js @@ -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 } }]; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e486b68..0fa663b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,7 +3,9 @@ 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'; @@ -14,6 +16,7 @@ function App() { return p; }); const [avatarUrl, setAvatarUrl] = useState(() => localStorage.getItem('user_avatar_url') || ''); + const [profileNeedsAttention, setProfileNeedsAttention] = useState(false); const lastRouteTsRef = useRef(Date.now()); const lastPathRef = useRef(pathname); @@ -77,18 +80,31 @@ function App() { }, []); return ( -
- {pathname === '/profile' ? ( - - ) : pathname === '/support' ? ( - - ) : pathname.startsWith('/hello') ? ( - - ) : ( - - )} - -
+ +
+ {pathname === '/profile' ? ( + + ) : pathname === '/support' ? ( + + ) : pathname === '/consultations' ? ( + + ) : pathname.startsWith('/hello') ? ( + + ) : ( + + )} + +
+
); } diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index 86fe420..51a2dd4 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -43,16 +43,17 @@ export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttentio .catch(() => setSupportUnreadCount(0)); }, [currentPath]); - // В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться + // В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться. + // На /support кнопка «Назад» включена — возврат из чата в список или из списка в «Мои обращения». useEffect(() => { - if (isHome || isProfile || isSupport) { + if (isHome || isProfile) { setBackEnabled(false); return; } setBackEnabled(false); const t = window.setTimeout(() => setBackEnabled(true), 1200); return () => window.clearTimeout(t); - }, [isHome, isProfile, isSupport, currentPath]); + }, [isHome, isProfile, currentPath]); const handleBack = (e: React.MouseEvent) => { e.preventDefault(); diff --git a/frontend/src/components/form/StepComplaintsDashboard.tsx b/frontend/src/components/form/StepComplaintsDashboard.tsx index b90a404..0f708a6 100644 --- a/frontend/src/components/form/StepComplaintsDashboard.tsx +++ b/frontend/src/components/form/StepComplaintsDashboard.tsx @@ -7,25 +7,45 @@ import { useEffect, useState } from 'react'; import { Button, Card, Row, Col, Typography, Spin } from 'antd'; -import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle } from 'lucide-react'; +import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle, MessageCircle } from 'lucide-react'; import './StepComplaintsDashboard.css'; const { Title, Text } = Typography; -// Статусы для плиток (маппинг status_code → категория дашборда) -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'; +// Признак элемента из 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; + type_code?: string; } interface Counts { + consultations: number; pending: number; inWork: number; resolved: number; @@ -33,20 +53,27 @@ interface Counts { 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) { - const code = (d.status_code || '').toLowerCase(); - if (code === IN_WORK_CODE) inWork += 1; - else if (code === REJECTED_CODE) rejected += 1; - else if (RESOLVED_CODES.includes(code)) resolved += 1; - else if (PENDING_CODES.includes(code) || code === 'draft') pending += 1; - else pending += 1; // неизвестный — в «ожидании» + 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, @@ -61,45 +88,58 @@ 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 [loading, setLoading] = useState(true); - const [counts, setCounts] = useState({ pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 }); + const [counts, setCounts] = useState({ 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); - else if (phone) params.append('phone', phone); - else if (session_id) params.append('session_id', session_id); - if (!unified_id && !phone && !session_id) { - setLoading(false); - return; - } + 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; - const drafts = data.drafts || []; - setCounts(countByStatus(drafts)); + setCounts(countByStatus(data.drafts || [])); }) - .catch(() => { - if (!cancelled) setCounts((c) => ({ ...c, total: 0 })); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + .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; }; - }, [unified_id, phone, session_id]); + }, [draftsFromProps, unified_id, phone, session_id, entry_channel]); const tiles = [ { @@ -159,6 +199,47 @@ export default function StepComplaintsDashboard({ ) : ( <> + {/* Плитка «Консультации» — в самом верху; данные из CRM по вебхуку */} + {onNavigate && ( + onNavigate('/consultations')} + > +
+
+ +
+
+ + Консультации + + + {counts.consultations === 0 + ? 'Тикеты из CRM' + : counts.consultations === 1 + ? '1 тикет' + : counts.consultations < 5 + ? `${counts.consultations} тикета` + : `${counts.consultations} тикетов`} + +
+
+
+ )} + {tiles.map((t) => { const Icon = t.icon; @@ -203,7 +284,7 @@ export default function StepComplaintsDashboard({ handleTileClick('all' as const)} >
diff --git a/frontend/src/components/form/StepDraftSelection.tsx b/frontend/src/components/form/StepDraftSelection.tsx index 48d2b7c..7067815 100644 --- a/frontend/src/components/form/StepDraftSelection.tsx +++ b/frontend/src/components/form/StepDraftSelection.tsx @@ -14,7 +14,7 @@ */ import { useEffect, useState } from 'react'; -import { Button, Card, Typography, Space, Empty, message, Spin, Tooltip } from 'antd'; +import { Button, Card, Modal, Typography, Space, Empty, message, Spin, Tooltip } from 'antd'; import { FileTextOutlined, DeleteOutlined, @@ -45,8 +45,10 @@ import { Building, Shield, Ticket, + Headphones, type LucideIcon, } from 'lucide-react'; +import SupportChat from '../SupportChat'; const { Title, Text } = Typography; @@ -117,6 +119,23 @@ function getDraftCategory(statusCode: string): 'pending' | 'in_work' | 'resolved return 'pending'; } +/** Признак элемента из CRM (проект/тикет) — как в StepComplaintsDashboard */ +function isFromCrm(d: { payload?: Record; 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; 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: 'В ожидании', @@ -185,6 +204,12 @@ interface Props { session_id?: string; unified_id?: string; isTelegramMiniApp?: boolean; + entry_channel?: string; + /** Список обращений от родителя (один запрос в n8n) — если передан, свой запрос не делаем */ + drafts?: Draft[] | any[]; + loading?: boolean; + /** Вызов после удаления черновика, чтобы родитель перезапросил список */ + onRefreshDrafts?: () => void; /** ID черновика, открытого для просмотра описания (управляется из ClaimForm, чтобы не терять при пересчёте steps) */ draftDetailClaimId?: string | null; /** Показывать только обращения этой категории (с дашборда) */ @@ -253,6 +278,27 @@ const STATUS_CONFIG: Record, + label: 'В работе', + description: 'Дело из CRM', + action: 'Просмотреть', + }, + completed: { + color: 'green', + icon: , + label: 'Решено', + description: 'Дело завершено', + action: 'Просмотреть', + }, + rejected: { + color: 'red', + icon: , + label: 'Отклонено', + description: 'Дело отклонено', + action: 'Просмотреть', + }, legacy: { color: 'warning', icon: , @@ -262,11 +308,23 @@ const STATUS_CONFIG: Record { + 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, @@ -275,14 +333,17 @@ export default function StepDraftSelection({ onNewClaim, onRestartDraft, }: Props) { - const [drafts, setDrafts] = useState([]); + const [localDrafts, setLocalDrafts] = useState([]); + 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(null); - /** Список отфильтрован по категории с дашборда */ + /** Список отфильтрован по категории с дашборда (учёт и Postgres, и CRM) */ const filteredDrafts = categoryFilter === 'all' ? drafts - : drafts.filter((d) => getDraftCategory(d.status_code) === categoryFilter); - const [loading, setLoading] = useState(true); + : drafts.filter((d) => getItemCategory(d) === categoryFilter); const [deletingId, setDeletingId] = useState(null); /** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */ const [detailDraftPayload, setDetailDraftPayload] = useState<{ claimId: string; payload: Record } | null>(null); @@ -294,64 +355,34 @@ export default function StepDraftSelection({ : 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 { @@ -365,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('Не удалось удалить черновик'); @@ -379,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; }; @@ -489,10 +524,10 @@ export default function StepDraftSelection({ const displayText = fullText || 'Описание не сохранено'; return ( -
+
@@ -539,9 +574,36 @@ export default function StepDraftSelection({ К документам </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> ); } @@ -555,7 +617,7 @@ export default function StepDraftSelection({ }; return ( - <div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}> + <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 }}> @@ -584,7 +646,7 @@ export default function StepDraftSelection({ || (draft.problem_description ? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description) : 'Обращение'); - const category = getDraftCategory(draft.status_code); + const category = getItemCategory(draft); const dotColor = statusDotColor[category] || '#8c8c8c'; return ( @@ -618,6 +680,18 @@ export default function StepDraftSelection({ <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> ); @@ -627,7 +701,7 @@ export default function StepDraftSelection({ <Button type="link" icon={<ReloadOutlined />} - onClick={loadDrafts} + onClick={() => onRefreshDrafts ? onRefreshDrafts() : loadDrafts()} loading={loading} > Обновить список @@ -635,6 +709,28 @@ export default function StepDraftSelection({ </div> </Space> )} + + <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> ); } diff --git a/frontend/src/components/form/StepWizardPlan.tsx b/frontend/src/components/form/StepWizardPlan.tsx index 736aea6..52c7dcc 100644 --- a/frontend/src/components/form/StepWizardPlan.tsx +++ b/frontend/src/components/form/StepWizardPlan.tsx @@ -2732,28 +2732,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, - reason: responseEvent.message, - message: responseEvent.message, - action: 'contact_support', - timestamp: new Date().toISOString(), - }), - }); + 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('Не удалось отправить запрос. Попробуйте позже.'); + message.error(error instanceof Error ? error.message : 'Не удалось отправить запрос. Попробуйте позже.'); } }} > @@ -2840,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 : 'Не удалось отправить запрос. Попробуйте позже.'); } }} > diff --git a/frontend/src/context/DraftsContext.tsx b/frontend/src/context/DraftsContext.tsx new file mode 100644 index 0000000..afdf9a4 --- /dev/null +++ b/frontend/src/context/DraftsContext.tsx @@ -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)) + ); +} diff --git a/frontend/src/pages/ClaimForm.tsx b/frontend/src/pages/ClaimForm.tsx index ecca4ae..655cb77 100644 --- a/frontend/src/pages/ClaimForm.tsx +++ b/frontend/src/pages/ClaimForm.tsx @@ -13,6 +13,7 @@ import DebugPanel from '../components/DebugPanel'; // getDocumentsForEventType убран - старый ERV флоу import './ClaimForm.css'; import { miniappLog, miniappSendLogs } from '../utils/miniappLogger'; +import { useDrafts } from '../context/DraftsContext'; // Используем относительные пути - Vite proxy перенаправит на backend @@ -84,15 +85,30 @@ interface FormData { interface ClaimFormProps { /** Открыта страница «Подать жалобу» (/new) — не показывать список черновиков */ forceNewClaim?: boolean; + /** Навигация по приложению (для перехода на /support и т.д.) */ + onNavigate?: (path: string) => void; } -export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { +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); @@ -104,7 +120,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { 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); @@ -117,6 +133,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { /** Фильтр списка обращений при переходе с дашборда: по какой категории показывать (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>(''); @@ -296,9 +316,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { // На странице /new («Подать жалобу») не показываем черновики if (forceNewClaimRef.current) { - // Если сессия валидна — не возвращаем на экран телефона. В TG/MAX нет шага «Вход», первый шаг формы = индекс 0 (Обращение); в вебе первый = Вход (0), Обращение = 1. - const isMiniApp = !!(typeof window !== 'undefined' && ((window as any).Telegram?.WebApp?.initData || (window as any).WebApp?.initData)); - setCurrentStep(isMiniApp ? 0 : 1); + // После 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('Добро пожаловать!'); } @@ -1011,33 +1032,29 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { } // ✅ Определяем шаг для перехода на основе данных черновика - // Приоритет: если есть 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); @@ -1187,6 +1204,42 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { } }, []); + /** Один раз грузим список обращений в родителе — один запрос в 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('🆕 Начинаем новое обращение'); @@ -1228,10 +1281,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { }); console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)'); - // В TG/MAX нет шага «Вход», поэтому Обращение = индекс 0; в вебе Вход = 0, Обращение = 1. - const isMiniApp = !!(typeof window !== 'undefined' && ((window as any).Telegram?.WebApp?.initData || (window as any).WebApp?.initData)); - setCurrentStep(isMiniApp ? 0 : 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(() => { @@ -1360,11 +1413,15 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { 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} /> ), }); @@ -1377,6 +1434,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { session_id={sessionIdRef.current} 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} @@ -1525,7 +1586,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { // Step3Payment убран - не используется return stepsArray; - }, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked]); + }, [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(() => { diff --git a/frontend/src/pages/Consultations.css b/frontend/src/pages/Consultations.css new file mode 100644 index 0000000..54d12c6 --- /dev/null +++ b/frontend/src/pages/Consultations.css @@ -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; +} diff --git a/frontend/src/pages/Consultations.tsx b/frontend/src/pages/Consultations.tsx new file mode 100644 index 0000000..adc29b3 --- /dev/null +++ b/frontend/src/pages/Consultations.tsx @@ -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> + ); +} diff --git a/frontend/src/pages/Support.tsx b/frontend/src/pages/Support.tsx index a7b241c..03282f0 100644 --- a/frontend/src/pages/Support.tsx +++ b/frontend/src/pages/Support.tsx @@ -71,6 +71,19 @@ export default function Support({ onNavigate }: SupportProps) { 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]); + if (view === 'chat') { return ( <div style={{ padding: 24, maxWidth: 560, margin: '0 auto' }}>