From 4536210284cda6a6e882aef47e38cdab54344bb2 Mon Sep 17 00:00:00 2001 From: Fedor Date: Sat, 21 Feb 2026 22:08:30 +0300 Subject: [PATCH] Draft detail and Back button --- backend/app/api/claims.py | 35 +- backend/app/api/debug_session.py | 89 ++++ backend/app/api/documents.py | 26 + backend/app/api/documents_draft_open.py | 132 +++++ backend/app/main.py | 29 +- frontend/package.json | 2 +- frontend/scripts/crypto-polyfill.cjs | 18 + frontend/src/App.tsx | 43 +- frontend/src/components/BottomBar.css | 64 +++ frontend/src/components/BottomBar.tsx | 59 +++ .../src/components/form/StepDescription.tsx | 12 +- .../components/form/StepDraftSelection.tsx | 465 ++++++++--------- .../src/components/form/StepWizardPlan.tsx | 467 ++++++++++++------ .../components/form/documentsScreenMaps.tsx | 44 ++ frontend/src/pages/ClaimForm.css | 20 +- frontend/src/pages/ClaimForm.tsx | 216 +++++--- frontend/src/pages/HelloAuth.css | 116 ++++- frontend/src/pages/HelloAuth.tsx | 119 ++++- frontend/vite.config.ts | 2 + 19 files changed, 1454 insertions(+), 504 deletions(-) create mode 100644 backend/app/api/debug_session.py create mode 100644 backend/app/api/documents_draft_open.py create mode 100644 frontend/scripts/crypto-polyfill.cjs create mode 100644 frontend/src/components/BottomBar.css create mode 100644 frontend/src/components/BottomBar.tsx create mode 100644 frontend/src/components/form/documentsScreenMaps.tsx diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py index 4212cab..e9148b6 100644 --- a/backend/app/api/claims.py +++ b/backend/app/api/claims.py @@ -373,6 +373,16 @@ async def list_drafts( # Категория проблемы category = ai_analysis.get('category') or wizard_plan.get('category') or None + # Направление (для иконки плитки) + 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() + '…' + # Подробное описание (для превью) problem_text = payload.get('problem_description', '') @@ -418,6 +428,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 +457,13 @@ async def list_drafts( @router.get("/drafts/{claim_id}") async def get_draft(claim_id: str): """ - Получить полные данные черновика по claim_id - - Возвращает все данные формы для продолжения заполнения + Получить полные данные черновика по claim_id. + Поддерживаются форматы: голый UUID, claim_id_ (из MAX startapp). """ try: + # Формат из MAX диплинка: claim_id_ — извлекаем 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 +672,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_. """ 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 +882,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_. """ 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, diff --git a/backend/app/api/debug_session.py b/backend/app/api/debug_session.py new file mode 100644 index 0000000..8c431c5 --- /dev/null +++ b/backend/app/api/debug_session.py @@ -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""" + + + + Set session and redirect + + + +

Setting session and redirecting...

+

If you are not redirected, click here.

+ +""" + return HTMLResponse(content=html, status_code=200) + diff --git a/backend/app/api/documents.py b/backend/app/api/documents.py index 6a35796..c898b8b 100644 --- a/backend/app/api/documents.py +++ b/backend/app/api/documents.py @@ -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) diff --git a/backend/app/api/documents_draft_open.py b/backend/app/api/documents_draft_open.py new file mode 100644 index 0000000..ea7f69c --- /dev/null +++ b/backend/app/api/documents_draft_open.py @@ -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)}") + diff --git a/backend/app/main.py b/backend/app/main.py index 940e341..3013879 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,8 @@ 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, max_auth, auth2 +from .api import sms, claims, policy, upload, draft, events, n8n_proxy, session, documents, banks, telegram_auth, max_auth, auth2, documents_draft_open +from .api import debug_session # Настройка логирования logging.basicConfig( @@ -107,6 +108,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) @@ -121,6 +146,8 @@ 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(documents_draft_open.router) # 🆕 Documents draft-open (isolated) +app.include_router(debug_session.router) # 🔧 Debug helpers (set session + redirect) @app.get("/") diff --git a/frontend/package.json b/frontend/package.json index fc40fce..a7cf602 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "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", diff --git a/frontend/scripts/crypto-polyfill.cjs b/frontend/scripts/crypto-polyfill.cjs new file mode 100644 index 0000000..bdcb773 --- /dev/null +++ b/frontend/scripts/crypto-polyfill.cjs @@ -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; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d619fd1..f08b7f4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,40 @@ -import ClaimForm from './pages/ClaimForm' -import HelloAuth from './pages/HelloAuth' -import './App.css' +import { useState, useEffect, useCallback } from 'react'; +import ClaimForm from './pages/ClaimForm'; +import HelloAuth from './pages/HelloAuth'; +import BottomBar from './components/BottomBar'; +import './App.css'; function App() { - const pathname = window.location.pathname || ''; - if (pathname.startsWith('/hello')) { - return ; - } + const [pathname, setPathname] = useState(() => window.location.pathname || ''); + const [avatarUrl, setAvatarUrl] = useState(() => localStorage.getItem('user_avatar_url') || ''); + + useEffect(() => { + const onPopState = () => setPathname(window.location.pathname || ''); + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, []); + + useEffect(() => { + setAvatarUrl(localStorage.getItem('user_avatar_url') || ''); + }, [pathname]); + + const isNewClaimPage = pathname === '/new'; + + const navigateTo = useCallback((path: string) => { + window.history.pushState({}, '', path); + setPathname(path); + }, []); + return (
- + {pathname.startsWith('/hello') ? ( + + ) : ( + + )} +
- ) + ); } -export default App +export default App; diff --git a/frontend/src/components/BottomBar.css b/frontend/src/components/BottomBar.css new file mode 100644 index 0000000..4a17c40 --- /dev/null +++ b/frontend/src/components/BottomBar.css @@ -0,0 +1,64 @@ +.app-bottom-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + min-width: 100%; + max-width: 100vw; + 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; +} + +.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--active { + color: #2563EB; + font-weight: 600; +} + +.app-bar-item--active:hover { + color: #2563EB; +} + +.app-bar-item--exit:hover { + color: #dc2626; +} + +.app-bar-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; +} diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx new file mode 100644 index 0000000..125937b --- /dev/null +++ b/frontend/src/components/BottomBar.tsx @@ -0,0 +1,59 @@ +import { Home, Headphones, User, LogOut } from 'lucide-react'; +import './BottomBar.css'; + +interface BottomBarProps { + currentPath: string; + avatarUrl?: string; +} + +export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) { + const isHome = currentPath.startsWith('/hello'); + + const handleExit = (e: React.MouseEvent) => { + e.preventDefault(); + // Telegram Mini App + try { + const tg = (window as any).Telegram; + const webApp = tg?.WebApp; + if (webApp && typeof webApp.close === 'function') { + webApp.close(); + return; + } + } catch (_) {} + // MAX Mini App + try { + const maxWebApp = (window as any).WebApp; + if (maxWebApp && typeof maxWebApp.close === 'function') { + maxWebApp.close(); + return; + } + } catch (_) {} + // Fallback: переход на главную + window.location.href = '/hello'; + }; + + return ( + + ); +} diff --git a/frontend/src/components/form/StepDescription.tsx b/frontend/src/components/form/StepDescription.tsx index 876998d..208cf07 100644 --- a/frontend/src/components/form/StepDescription.tsx +++ b/frontend/src/components/form/StepDescription.tsx @@ -1,4 +1,5 @@ import { Form, Input, Button, Typography, message, Checkbox } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; import { useEffect, useState } from 'react'; import wizardPlanSample from '../../mocks/wizardPlanSample'; @@ -135,13 +136,9 @@ export default function StepDescription({ return (
- -
)} -
+
+ diff --git a/frontend/src/components/form/StepDraftSelection.tsx b/frontend/src/components/form/StepDraftSelection.tsx index c3e6828..d680120 100644 --- a/frontend/src/components/form/StepDraftSelection.tsx +++ b/frontend/src/components/form/StepDraftSelection.tsx @@ -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, Row, Col, Typography, Space, Empty, Popconfirm, message, Spin, Tooltip } from 'antd'; import { FileTextOutlined, DeleteOutlined, - PlusOutlined, ReloadOutlined, ClockCircleOutlined, CheckCircleOutlined, @@ -26,10 +25,55 @@ import { UploadOutlined, FileSearchOutlined, MobileOutlined, - ExclamationCircleOutlined + ExclamationCircleOutlined, + ArrowLeftOutlined, + FolderOpenOutlined } from '@ant-design/icons'; +import { + Package, + Wrench, + Wallet, + ShoppingCart, + Truck, + Plane, + GraduationCap, + Wifi, + Home, + Hammer, + HeartPulse, + Car, + Building, + Shield, + Ticket, + type LucideIcon, +} from 'lucide-react'; -const { Title, Text, Paragraph } = Typography; +const { Title, Text } = Typography; + +// Иконки по направлениям (категориям) для плиток +const DIRECTION_ICONS: Record = { + 'товары': 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) => { @@ -83,6 +127,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; @@ -184,6 +230,8 @@ export default function StepDraftSelection({ const [drafts, setDrafts] = useState([]); const [loading, setLoading] = useState(true); const [deletingId, setDeletingId] = useState(null); + /** Черновик, открытый для просмотра полного описания (по клику на карточку) */ + const [selectedDraft, setSelectedDraft] = useState(null); const loadDrafts = async () => { try { @@ -333,9 +381,80 @@ export default function StepDraftSelection({ ); }; + // Экран полного описания черновика (по клику на карточку) + if (selectedDraft) { + const fullText = selectedDraft.problem_description || selectedDraft.facts_short || selectedDraft.problem_title || '—'; + const draftId = selectedDraft.claim_id || selectedDraft.id; + return ( +
+ + + + + Обращение + +
+ {fullText} +
+
+ {selectedDraft.is_legacy && onRestartDraft ? ( + + ) : ( + + )} +
+
+
+
+ ); + } + return ( -
+
- - 📋 Ваши заявки + <Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}> + 📋 Мои обращения - - Выберите заявку для продолжения или создайте новую. -
- {/* Кнопка создания новой заявки - всегда вверху */} - - {loading ? (
@@ -374,217 +479,123 @@ export default function StepDraftSelection({ /> ) : ( <> - { + + {drafts.map((draft) => { const config = getStatusConfig(draft); - const docsProgress = getDocsProgress(draft); - + const directionOrCategory = draft.direction || draft.category; + const DirectionIcon = getDirectionIcon(directionOrCategory); + 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 borderColor = draft.is_legacy ? '#faad14' : '#e8e8e8'; + const bgColor = draft.is_legacy ? '#fffbe6' : '#fff'; + const iconBg = draft.is_legacy ? '#fff7e6' : '#f8fafc'; + const iconColor = draft.is_legacy ? '#faad14' : '#6366f1'; + return ( - - - {config.icon} -
- } - title={ -
- {config.label} - {draft.category && ( - {draft.category} + + setSelectedDraft(draft)} + > +
+ {DirectionIcon ? ( + + ) : ( + + {config.icon} + + )} +
+ + {tileTitle} + +
+ + {config.label} + {(draft.documents_total != null && draft.documents_total > 0) && ( + + {draft.documents_uploaded ?? 0}/{draft.documents_total} + )} -
- } - description={ - - {/* Заголовок - краткое описание проблемы */} - {draft.problem_title && ( - - {draft.problem_title} - - )} - - {/* Полное описание проблемы */} - {draft.problem_description && ( -
- {draft.problem_description.length > 250 - ? draft.problem_description.substring(0, 250) + '...' - : draft.problem_description - } -
- )} - - {/* Время обновления */} - - - - - {getRelativeTime(draft.updated_at)} - - - - - {/* Legacy предупреждение */} - {draft.is_legacy && ( - - )} - - {/* Список документов со статусами */} - {draft.documents_list && draft.documents_list.length > 0 && ( -
-
- - 📄 Документы - - - {draft.documents_uploaded || 0} / {draft.documents_total || 0} - -
-
- {draft.documents_list.map((doc, idx) => ( -
- {doc.uploaded ? ( - - ) : ( - - )} - - {doc.name} - {doc.required && !doc.uploaded && *} - -
- ))} -
-
- )} - - {/* Прогрессбар (если нет списка) */} - {(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && ( -
- -
- )} - - {/* Описание статуса */} - - {config.description} + + + + + {getRelativeTime(draft.updated_at)} - - {/* Кнопки действий */} -
- {getActionButton(draft)} - {/* Скрываем кнопку "Удалить" для заявок "В работе" */} - {draft.status_code !== 'in_work' && ( - handleDelete(draft.claim_id || draft.id)} - okText="Да, удалить" - cancelText="Отмена" - > - - - )} -
-
- } - /> - + +
+
e.stopPropagation()}> + {getActionButton(draft)} + {draft.status_code !== 'in_work' && ( + handleDelete(draft.claim_id || draft.id)} + okText="Да, удалить" + cancelText="Отмена" + > + + + )} +
+
+ ); - }} - /> + })} +
@@ -1587,8 +1587,10 @@ export default function StepWizardPlan({ } }, [currentDocIndex, documentsRequired.length, uploadedDocs, skippedDocs, findFirstUnprocessedDoc, updateFormData]); - const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить) - const [currentUploadedFiles, setCurrentUploadedFiles] = useState([]); // Массив загруженных файлов + const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); + const [currentUploadedFiles, setCurrentUploadedFiles] = useState([]); + const [selectedDocIndex, setSelectedDocIndex] = useState(null); // Плиточный стиль: какая плитка открыта в модалке + const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы» // Текущий документ для загрузки const currentDoc = documentsRequired[currentDocIndex]; @@ -2160,148 +2162,288 @@ export default function StepWizardPlan({ } }; - return ( -
-
- - {plan && !hasNewFlowDocs && ( - - )} -
- - - {/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */} - {hasNewFlowDocs && !allDocsProcessed && currentDocIndex < documentsRequired.length && currentDoc ? ( -
- {/* Прогресс */} -
-
- Документ {currentDocIndex + 1} из {documentsRequired.length} - {Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}% завершено -
- + const showDocumentsOnly = hasNewFlowDocs && documentsRequired.length > 0; + const stepContent = ( + <> + {/* ✅ Экран «Загрузка документов» по дизайн-спецификации */} + {hasNewFlowDocs && !allDocsProcessed && documentsRequired.length > 0 ? ( +
+ {/* Шапка: градиент синий, заголовок */} +
+ Загрузка документов
- - {/* Заголовок документа */} - - 📄 {currentDoc.name} - {currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>} - - - {currentDoc.hints && ( - - {currentDoc.hints} - - )} - - {/* Радио-кнопки выбора */} - { - setDocChoice(e.target.value); - if (e.target.value === 'none') { - setCurrentUploadedFiles([]); - } - }} - style={{ marginBottom: 16, display: 'block' }} +
+ + {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 ( + + { setCurrentDocIndex(index); setDocChoice(isSkipped ? 'none' : 'upload'); setCurrentUploadedFiles([]); setSelectedDocIndex(index); }} + > +
+ +
+ {doc.name} +
+ + + {statusLabel} + + {'subLabel' in status && isSkipped && {(status as { subLabel?: string }).subLabel}} +
+
+ + ); + })} + {/* Плитка: произвольные группы документов (название от пользователя при одной группе) */} + + 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 ( + <> +
+ +
+ {titleText} +
+ + + {hasGroups ? `Загружено (${customFileBlocks.length} ${customFileBlocks.length === 1 ? 'группа' : 'группы'})` : 'Добавить'} + +
+ + ); + })()} +
+ + {/* Плитка «Добавить ещё группу» — серая до загрузки, цветная после */} + + 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 ( + <> +
+ +
+ + Добавить ещё группу + + + ); + })()} +
+ +
+ {/* Кнопка «Отправить» внизу экрана с плитками (bottom: 90px — выше футера) */} +
+ +
+
+ setSelectedDocIndex(null)} + footer={null} + width={520} + destroyOnClose > - - - 📎 Загрузить документ - - - ❌ У меня нет этого документа - - -
- - {/* Загрузка файлов — показываем только если выбрано "Загрузить" */} - {docChoice === 'upload' && ( - 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 }} - > -

- -

-

- Перетащите файлы или нажмите для выбора -

-

- 📌 Можно загрузить несколько файлов (все страницы документа) -
- Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый) -

-
- )} - - {/* Предупреждение если "нет документа" для важного */} - {docChoice === 'none' && currentDoc.required && ( -
- - ⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже. - + {selectedDocIndex !== null && documentsRequired[selectedDocIndex] && (() => { + const doc = documentsRequired[selectedDocIndex]; + return ( +
+ {doc.hints && {doc.hints}} + { setDocChoice(e.target.value); if (e.target.value === 'none') setCurrentUploadedFiles([]); }} style={{ marginBottom: 16, display: 'block' }}> + + 📎 Загрузить документ + ❌ У меня нет этого документа + + + {docChoice === 'upload' && ( + 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 }}> +

+

Перетащите файлы или нажмите для выбора

+

Форматы: {doc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ)

+
+ )} + {docChoice === 'none' && doc.required && ( +
+ ⚠️ Документ важен для рассмотрения. Постарайтесь найти его позже. +
+ )} +
+ + +
+
+ ); + })()} + + {/* Модалка «Свои документы» — произвольные группы документов */} + setCustomDocsModalOpen(false)} + footer={null} + width={560} + destroyOnClose={false} + > +
+ {customFileBlocks.length === 0 && ( +
+ + Есть ещё документы, которые могут помочь? + + + Добавьте группу документов с названием (например: «Переписка в мессенджере», «Скриншоты»). + В каждой группе — своё название и файлы. + + +
+ )} + + {customFileBlocks.map((block, idx) => ( + Группа документов #{idx + 1}} + extra={} + > + +
+ Название группы * + 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() && ( + Укажите название группы + )} +
+
+ Категория (необязательно) + +
+ false} + fileList={block.files} + onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })} + accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic" + style={{ marginTop: 8 }} + > +

+

Перетащите файлы или нажмите для выбора

+
+
+
+ ))} +
+ {customFileBlocks.length > 0 && ( + + )} +
+ +
- )} - - {/* Кнопки */} - - - - - - {/* Уже загруженные */} - {uploadedDocs.length > 0 && ( -
- ✅ Загружено: -
    - {/* Убираем дубликаты и используем уникальные ключи */} - {Array.from(new Set(uploadedDocs)).map((docId, idx) => { - const doc = documentsRequired.find((d: any) => d.id === docId); - return
  • {doc?.name || docId}
  • ; - })} -
-
- )} +
- ) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? ( + ) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length && documentsRequired.length > 0 ? (
- ⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы массива ({documentsRequired.length}). + ⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы ({documentsRequired.length}).
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
@@ -2393,15 +2535,52 @@ export default function StepWizardPlan({ {/* ✅ Дополнительные документы */} {renderCustomUploads()} -
-
); })()} + + ); + + return showDocumentsOnly ? ( +
{stepContent}
+ ) : ( +
+ {plan && !hasNewFlowDocs && ( +
+ +
+ )} + + {stepContent} + {( + <> {/* СТАРЫЙ ФЛОУ: Ожидание визарда */} {!hasNewFlowDocs && isWaiting && !outOfScopeData && (
@@ -2616,6 +2795,8 @@ export default function StepWizardPlan({ {renderQuestions()}
)} + + )}
); diff --git a/frontend/src/components/form/documentsScreenMaps.tsx b/frontend/src/components/form/documentsScreenMaps.tsx new file mode 100644 index 0000000..151fdcb --- /dev/null +++ b/frontend/src/components/form/documentsScreenMaps.tsx @@ -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 = { + 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: 'По желанию' }; diff --git a/frontend/src/pages/ClaimForm.css b/frontend/src/pages/ClaimForm.css index 6d43626..afa90ff 100644 --- a/frontend/src/pages/ClaimForm.css +++ b/frontend/src/pages/ClaimForm.css @@ -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,13 +9,17 @@ } .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; } +.claim-form-card .ant-card-body { + padding: 16px 0; +} + .claim-form-card .ant-card-head { background: #fafafa; color: #000000; @@ -35,12 +39,12 @@ .steps-content { min-height: 400px; - padding: 20px; + padding: 20px 0; } @media (max-width: 768px) { .claim-form-container { - padding: 20px 10px; + padding: 20px 0; } .claim-form-card { @@ -48,7 +52,7 @@ } .steps-content { - padding: 10px; + padding: 10px 0; } } @@ -56,7 +60,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; } @@ -81,7 +85,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 +103,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 { diff --git a/frontend/src/pages/ClaimForm.tsx b/frontend/src/pages/ClaimForm.tsx index b406e79..c34f488 100644 --- a/frontend/src/pages/ClaimForm.tsx +++ b/frontend/src/pages/ClaimForm.tsx @@ -1,5 +1,6 @@ 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 { ArrowLeftOutlined } from '@ant-design/icons'; import Step1Phone from '../components/form/Step1Phone'; import StepDescription from '../components/form/StepDescription'; // Step1Policy убран - старый ERV флоу @@ -14,8 +15,6 @@ import './ClaimForm.css'; // Используем относительные пути - Vite proxy перенаправит на backend -const { Step } = Steps; - /** * Генерация UUID v4 * Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx @@ -81,13 +80,19 @@ interface FormData { accountNumber?: string; } -export default function ClaimForm() { +interface ClaimFormProps { + /** Открыта страница «Подать жалобу» (/new) — не показывать список черновиков */ + forceNewClaim?: boolean; +} + +export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) { // ✅ claim_id будет создан n8n в Step1Phone после SMS верификации // Не генерируем его локально! // session_id будет получен от n8n при создании контакта // Используем useRef чтобы sessionId не вызывал перерендер и был стабильным const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); + const autoLoadedClaimIdRef = useRef(null); const claimPlanEventSourceRef = useRef(null); const claimPlanTimeoutRef = useRef(null); @@ -112,6 +117,17 @@ export default function ClaimForm() { const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false); /** Заход через MAX Mini App. */ const [isMaxMiniApp, setIsMaxMiniApp] = useState(false); + const forceNewClaimRef = useRef(false); + + // Отдельная страница /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: Если видишь это в консоли - фронт обновился! @@ -388,6 +404,14 @@ export default function ClaimForm() { // Помечаем телефон как верифицированный setIsPhoneVerified(true); + // На странице /new («Подать жалобу») не показываем черновики + if (forceNewClaimRef.current) { + setCurrentStep(1); // сразу к описанию + message.success('Добро пожаловать!'); + addDebugEvent('session', 'success', '✅ Сессия восстановлена'); + return; + } + // Проверяем черновики const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken); @@ -1136,6 +1160,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; + 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 | 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_ (после редиректа из /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); @@ -1143,6 +1242,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 }); @@ -1362,8 +1465,8 @@ export default function ClaimForm() { // Шаг 0: Выбор черновика (показывается только если есть черновики) // ✅ unified_id уже означает, что телефон верифицирован - // Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts - if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) { + // Не показываем черновики на странице «Подать жалобу» (/new) + if (!forceNewClaimRef.current && (showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) { stepsArray.push({ title: 'Черновики', description: 'Выбор заявки', @@ -1409,7 +1512,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); @@ -1639,60 +1742,41 @@ export default function ClaimForm() { // ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии if (!telegramAuthChecked || !sessionRestored) { return ( -
+
); } + const isDocumentsStep = steps[currentStep]?.title === 'Документы'; + return ( -
- +
+ {/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */} - - {/* Кнопка "Выход" - показываем если телефон верифицирован */} - {isPhoneVerified && ( - - )} - {/* Кнопка "Начать заново" - показываем только после шага телефона */} - {currentStep > 0 && ( - - )} - - ) - } - > + {isDocumentsStep ? ( +
+ {currentStep > 0 && ( +
+ +
+ )} + {steps[currentStep] ? steps[currentStep].content : ( +

Загрузка шага...

+ )} +
+ ) : ( + + {!isSubmitted && currentStep > 0 && ( +
+ +
+ )} {isSubmitted ? (

Поздравляем! Ваше обращение направлено в Клиентправ.

@@ -1701,26 +1785,16 @@ export default function ClaimForm() {

) : ( - <> - - {steps.map((item, index) => ( - - ))} - -
- {steps[currentStep] ? steps[currentStep].content : ( -
-

Загрузка шага...

-
- )} -
- +
+ {steps[currentStep] ? steps[currentStep].content : ( +
+

Загрузка шага...

+
+ )} +
)}
+ )} {/* Правая часть - Debug консоль (только в dev режиме) */} diff --git a/frontend/src/pages/HelloAuth.css b/frontend/src/pages/HelloAuth.css index 578fe63..982922b 100644 --- a/frontend/src/pages/HelloAuth.css +++ b/frontend/src/pages/HelloAuth.css @@ -1,7 +1,9 @@ .hello-page { min-height: 100vh; padding: 32px; + padding-bottom: 90px; background: #f5f7fb; + --tile-h: 160px; } .hello-hero { @@ -66,18 +68,31 @@ 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); - min-height: 160px; + 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: space-between; + justify-content: center; flex-direction: column; - transition: transform 0.2s ease, box-shadow 0.2s ease; - padding: 24px 16px; - background: #ffffff; + gap: 10px; + padding: 18px 16px; text-align: center; } @@ -86,16 +101,41 @@ 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: 56px; - height: 56px; + 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-bottom: 12px; + margin-left: 0; + margin-right: 0; +} + +.tile-icon svg { + display: block; /* убирает baseline */ + width: 28px; + height: 28px; } .tile-title { @@ -103,13 +143,69 @@ 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; } - .tile-card { - min-height: 140px; + + .tile-card :where(.ant-card-body) { + padding: 16px 12px; + gap: 8px; } } diff --git a/frontend/src/pages/HelloAuth.tsx b/frontend/src/pages/HelloAuth.tsx index 643bd1d..ce74118 100644 --- a/frontend/src/pages/HelloAuth.tsx +++ b/frontend/src/pages/HelloAuth.tsx @@ -9,12 +9,20 @@ import { FileText, HelpCircle, Building2, + ClipboardList, + FileWarning, + MessageCircle, } from 'lucide-react'; import './HelloAuth.css'; type Status = 'idle' | 'loading' | 'success' | 'error'; -export default function HelloAuth() { +interface HelloAuthProps { + onAvatarChange?: (url: string) => void; + onNavigate?: (path: string) => void; +} + +export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) { const [status, setStatus] = useState('idle'); const [greeting, setGreeting] = useState('Привет!'); const [error, setError] = useState(''); @@ -57,8 +65,14 @@ export default function HelloAuth() { const data = await res.json(); if (res.ok && data.success) { setGreeting(data.greeting || 'Привет!'); - if (data.avatar_url) { - setAvatar(data.avatar_url); + let avatarUrl = data.avatar_url; + if (!avatarUrl && webApp?.initDataUnsafe?.user?.photo_url) { + avatarUrl = webApp.initDataUnsafe.user.photo_url; + } + if (avatarUrl) { + setAvatar(avatarUrl); + localStorage.setItem('user_avatar_url', avatarUrl); + onAvatarChange?.(avatarUrl); } setStatus('success'); return; @@ -87,6 +101,8 @@ export default function HelloAuth() { 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; @@ -97,6 +113,56 @@ export default function HelloAuth() { } // Fallback: SMS + // If there's a claim_id in URL/hash/MAX start_param, try to load draft and redirect to form + 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; + }; + // MAX может отдавать start_param строкой или объектом WebAppStartParam + 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; + 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 && typeof window !== 'undefined' && 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(); + // If backend provided session_token in draft, store it + const st = draftData?.claim?.session_token; + if (st) { + localStorage.setItem('session_token', st); + console.log('HelloAuth: session_token from draft saved', st); + } + // Redirect to root so ClaimForm can restore session and load the draft + window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`; + return; + } else { + console.warn('HelloAuth: draft not found or error', draftRes.status); + } + } catch (e) { + console.error('HelloAuth: error fetching draft by claim_id', e); + } + } setStatus('idle'); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -142,9 +208,11 @@ export default function HelloAuth() { const data = await res.json(); if (res.ok && data.success) { setGreeting(data.greeting || 'Привет!'); - if (data.avatar_url) { - setAvatar(data.avatar_url); - } +if (data.avatar_url) { + setAvatar(data.avatar_url); + localStorage.setItem('user_avatar_url', data.avatar_url); + onAvatarChange?.(data.avatar_url); + } setStatus('success'); return; } @@ -154,8 +222,10 @@ export default function HelloAuth() { } }; - const tiles = [ - { title: 'Профиль', icon: User, color: '#2563EB' }, + 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: MessageCircle, color: '#8B5CF6' }, { title: 'Членство', icon: IdCard, color: '#10B981' }, { title: 'Достижения', icon: Trophy, color: '#F59E0B' }, { title: 'Общественный контроллер', icon: ShieldCheck, color: '#22C55E' }, @@ -215,17 +285,34 @@ export default function HelloAuth() {
- + {tiles.map((tile) => { const Icon = tile.icon; + const active = !!tile.href; + const card = ( + { + // В 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} + > +
+ +
+
{tile.title}
+
+ ); return ( - - -
- -
-
{tile.title}
-
+ + {card} ); })} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a8c8386..57ceb84 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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',