Draft detail and Back button

This commit is contained in:
Fedor
2026-02-21 22:08:30 +03:00
parent 1887336aba
commit 4536210284
19 changed files with 1454 additions and 504 deletions

View File

@@ -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_<uuid> (из MAX startapp).
"""
try:
# Формат из MAX диплинка: claim_id_<uuid> — извлекаем UUID
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
logger.info(f"🔍 Загрузка черновика: claim_id={claim_id}")
# Ищем черновик по claim_id (может быть в payload->>'claim_id' или id = UUID)
@@ -658,11 +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_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
query = """
DELETE FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
@@ -868,15 +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_<uuid>.
"""
try:
if claim_id.startswith("claim_id_"):
claim_id = claim_id[9:]
logger.info(f"🔍 Загрузка данных визарда для claim_id={claim_id}")
# Ищем заявку по claim_id (может быть UUID или строка CLM-...)
# Ищем заявку по claim_id (UUID или CLM-...)
query = """
SELECT
id,

View File

@@ -0,0 +1,89 @@
import base64
import json
import httpx
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse
from urllib.parse import quote_plus
WEBHOOK_DEBUG_URL = "https://n8n.clientright.ru/webhook/test"
router = APIRouter(prefix="/api/v1/debug", tags=["debug"])
@router.post("/forward-to-webhook")
async def forward_to_webhook(request: Request):
"""
Прокси: принимает JSON body и пересылает на n8n webhook (обход CORS с debug-webapp).
Сначала POST; если n8n вернёт 404 (webhook только GET) — повторяем GET с ?data=base64(body).
"""
try:
body = await request.json()
except Exception:
body = {}
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(WEBHOOK_DEBUG_URL, json=body)
if r.status_code == 404 and "POST" in (r.text or ""):
b64 = base64.urlsafe_b64encode(json.dumps(body, ensure_ascii=False).encode()).decode().rstrip("=")
r = await client.get(f"{WEBHOOK_DEBUG_URL}?data={quote_plus(b64)}")
ct = r.headers.get("content-type", "")
if "application/json" in ct:
try:
content = r.json()
except Exception:
content = {"status": r.status_code, "text": (r.text or "")[:500]}
else:
content = {"status": r.status_code, "text": (r.text or "")[:500]}
return JSONResponse(status_code=r.status_code, content=content)
@router.get("/set_session_redirect", response_class=HTMLResponse)
async def set_session_redirect(request: Request, session_token: str = "", claim_id: str = "", redirect_to: str = "/hello"):
"""
Temporary helper: returns an HTML page that sets localStorage.session_token and redirects to /hello?claim_id=...
Use for manual testing: open this URL in a browser on the target origin.
"""
# Ensure values are safe for embedding
js_session = session_token.replace('"', '\\"')
target_claim = quote_plus(claim_id) if claim_id else ""
# sanitize redirect_to - allow only absolute path starting with '/'
if not redirect_to.startswith('/'):
redirect_to = '/hello'
if target_claim:
# append query param correctly
if '?' in redirect_to:
redirect_url = f"{redirect_to}&claim_id={target_claim}"
else:
redirect_url = f"{redirect_to}?claim_id={target_claim}"
else:
redirect_url = redirect_to
html = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Set session and redirect</title>
</head>
<body>
<script>
try {{
const token = "{js_session}";
if (token && token.length>0) {{
localStorage.setItem('session_token', token);
console.log('Set localStorage.session_token:', token);
}} else {{
console.log('No session_token provided');
}}
// give localStorage a tick then redirect
setTimeout(() => {{
window.location.href = "{redirect_url}";
}}, 200);
}} catch (e) {{
document.body.innerText = 'Error: ' + e;
}}
</script>
<p>Setting session and redirecting...</p>
<p>If you are not redirected, click <a id="go" href="{redirect_url}">here</a>.</p>
</body>
</html>"""
return HTMLResponse(content=html, status_code=200)

View File

@@ -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)

View File

@@ -0,0 +1,132 @@
"""
Documents draft-open endpoint
This file provides a single, isolated endpoint to fetch the documents list
and minimal claim metadata for a given claim_id. It is implemented as a
separate router to avoid touching existing document/claim routes.
"""
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import RedirectResponse
from ..config import settings
import logging
import json
from typing import Any, Dict
from ..services.database import db
router = APIRouter(prefix="/api/v1/documents-draft", tags=["DocumentsDraft"])
logger = logging.getLogger(__name__)
@router.get("/open/{claim_id}")
async def open_documents_draft(claim_id: str):
"""
Return minimal draft info focused on documents for the given claim_id.
Response:
{
"success": True,
"claim_id": "...",
"session_token": "...",
"status_code": "...",
"documents_required": [...],
"documents_meta": [...],
"documents_count": 3,
"created_at": "...",
"updated_at": "..."
}
"""
try:
query = """
SELECT
id,
payload->>'claim_id' AS claim_id,
session_token,
status_code,
payload->'documents_required' AS documents_required,
payload->'documents_meta' AS documents_meta,
created_at,
updated_at
FROM clpr_claims
WHERE (payload->>'claim_id' = $1 OR id::text = $1)
ORDER BY updated_at DESC
LIMIT 1
"""
row = await db.fetch_one(query, claim_id)
if not row:
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
# Normalize JSONB fields which may be strings
def parse_json_field(val: Any):
if val is None:
return []
if isinstance(val, str):
try:
return json.loads(val)
except Exception:
return []
return val if isinstance(val, list) else []
documents_required = parse_json_field(row.get("documents_required"))
documents_meta = parse_json_field(row.get("documents_meta"))
result = {
"success": True,
"claim_id": row.get("claim_id") or str(row.get("id")),
"session_token": row.get("session_token"),
"status_code": row.get("status_code"),
"documents_required": documents_required,
"documents_meta": documents_meta,
"documents_count": len(documents_required),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
}
return result
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to open documents draft")
raise HTTPException(status_code=500, detail=f"Error opening documents draft: {str(e)}")
@router.get("/open/launch/{claim_id}")
async def launch_documents_draft(
claim_id: str,
target: str = Query("miniapp", description="Where to open: 'miniapp' or 'max'"),
bot_name: str | None = Query(None, description="MAX bot name (required if target=max)"),
):
"""
Convenience launcher:
- target=miniapp (default) -> redirects to our miniapp URL with claim_id
https://miniapp.clientright.ru/hello?claim_id=...
- target=max -> redirects to MAX deep link:
https://max.ru/{bot_name}?startapp={claim_id}
This endpoint only redirects; it does not change persisted data.
"""
try:
# ensure claim exists
query = "SELECT 1 FROM clpr_claims WHERE (payload->>'claim_id' = $1 OR id::text = $1) LIMIT 1"
row = await db.fetch_one(query, claim_id)
if not row:
raise HTTPException(status_code=404, detail=f"Draft not found: {claim_id}")
if target == "max":
bot = bot_name or getattr(settings, "MAX_BOT_NAME", None)
if not bot:
raise HTTPException(status_code=400, detail="bot_name is required when target=max")
# claim_id is UUID with allowed chars (hex + hyphens) - OK for startapp
url = f"https://max.ru/{bot}?startapp={claim_id}"
return RedirectResponse(url)
else:
# default: open miniapp directly (hosted at /hello)
url = f"https://miniapp.clientright.ru/hello?claim_id={claim_id}"
return RedirectResponse(url)
except HTTPException:
raise
except Exception as e:
logger.exception("Failed to launch documents draft")
raise HTTPException(status_code=500, detail=f"Error launching documents draft: {str(e)}")

View File

@@ -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("/")

View File

@@ -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",

View File

@@ -0,0 +1,18 @@
/**
* Полифилл crypto.getRandomValues для Node 16 (нужен Vite при сборке).
* Запуск: node -r ./scripts/crypto-polyfill.cjs node_modules/vite/bin/vite.js build
*/
const crypto = require('node:crypto');
function getRandomValues(buffer) {
if (!buffer) return buffer;
const bytes = crypto.randomBytes(buffer.length);
buffer.set(bytes);
return buffer;
}
if (typeof crypto.getRandomValues !== 'function') {
crypto.getRandomValues = getRandomValues;
}
if (typeof globalThis !== 'undefined') {
globalThis.crypto = globalThis.crypto || {};
globalThis.crypto.getRandomValues = getRandomValues;
}

View File

@@ -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 <HelloAuth />;
}
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
const [avatarUrl, setAvatarUrl] = useState<string>(() => 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 (
<div className="App">
<ClaimForm />
{pathname.startsWith('/hello') ? (
<HelloAuth onAvatarChange={setAvatarUrl} onNavigate={navigateTo} />
) : (
<ClaimForm forceNewClaim={isNewClaimPage} />
)}
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} />
</div>
)
);
}
export default App
export default App;

View File

@@ -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;
}

View File

@@ -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 (
<nav className="app-bottom-bar" aria-label="Навигация">
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
<Home size={24} strokeWidth={1.8} />
<span>Домой</span>
</a>
<a href="/hello" className="app-bar-item">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="app-bar-avatar" />
) : (
<User size={24} strokeWidth={1.8} />
)}
<span>Профиль</span>
</a>
<a href="/hello" className="app-bar-item">
<Headphones size={24} strokeWidth={1.8} />
<span>Поддержка</span>
</a>
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">
<LogOut size={24} strokeWidth={1.8} />
<span>Выход</span>
</button>
</nav>
);
}

View File

@@ -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 (
<div style={{ marginTop: 24 }}>
<Button onClick={onPrev} size="large">
Назад
</Button>
<div
style={{
marginTop: 24,
marginTop: 0,
padding: 24,
background: '#f6f8fa',
borderRadius: 8,
@@ -213,7 +210,10 @@ export default function StepDescription({
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 16 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onPrev}>
Назад
</Button>
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
Продолжить
</Button>

View File

@@ -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<string, LucideIcon> = {
'товары': Package,
'услуги': Wrench,
'финансы и платежи': Wallet,
'интернет-торговля и маркетплейсы': ShoppingCart,
'доставка и логистика': Truck,
'туризм и путешествия': Plane,
'образование и онлайн-курсы': GraduationCap,
'связь и интернет': Wifi,
'жкх и коммунальные услуги': Home,
'строительство и ремонт': Hammer,
'медицина и платные клиники': HeartPulse,
'транспорт и перевозки': Car,
'недвижимость и аренда': Building,
'страхование': Shield,
'развлечения и мероприятия': Ticket,
};
function getDirectionIcon(directionOrCategory: string | undefined): LucideIcon | null {
if (!directionOrCategory || typeof directionOrCategory !== 'string') return null;
const key = directionOrCategory.trim().toLowerCase();
return DIRECTION_ICONS[key] || null;
}
// Форматирование даты
const formatDate = (dateStr: string) => {
@@ -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<Draft[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
/** Черновик, открытый для просмотра полного описания (по клику на карточку) */
const [selectedDraft, setSelectedDraft] = useState<Draft | null>(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 (
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
<div style={{ padding: '12px 16px' }}>
<Card
bodyStyle={{ padding: '16px 20px' }}
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => setSelectedDraft(null)}
style={{ paddingLeft: 0 }}
>
Назад
</Button>
<Title level={4} style={{ marginBottom: 8, color: '#111827' }}>
Обращение
</Title>
<div
style={{
padding: '16px',
background: '#f8fafc',
borderRadius: 8,
border: '1px solid #e2e8f0',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: 80,
maxHeight: 320,
overflow: 'auto',
}}
>
{fullText}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{selectedDraft.is_legacy && onRestartDraft ? (
<Button
type="primary"
size="large"
icon={<ReloadOutlined />}
onClick={() => {
onRestartDraft(draftId, selectedDraft.problem_description || '');
setSelectedDraft(null);
}}
>
Начать заново
</Button>
) : (
<Button
type="primary"
size="large"
icon={<FolderOpenOutlined />}
onClick={() => {
onSelectDraft(draftId);
setSelectedDraft(null);
}}
>
К документам
</Button>
)}
</div>
</Space>
</Card>
</div>
);
}
return (
<div style={{ padding: '12px 16px' }}>
<Card
bodyStyle={{ padding: '16px 0' }}
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
@@ -344,25 +463,11 @@ export default function StepDraftSelection({
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
📋 Ваши заявки
<Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}>
📋 Мои обращения
</Title>
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
Выберите заявку для продолжения или создайте новую.
</Paragraph>
</div>
{/* Кнопка создания новой заявки - всегда вверху */}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onNewClaim}
size="large"
style={{ width: '100%' }}
>
Создать новую заявку
</Button>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
@@ -374,191 +479,98 @@ export default function StepDraftSelection({
/>
) : (
<>
<List
dataSource={drafts}
renderItem={(draft) => {
<Row gutter={[16, 16]}>
{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 (
<List.Item
<Col xs={12} sm={8} md={6} key={draft.claim_id || draft.id}>
<Card
hoverable
bordered
style={{
padding: '16px',
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
borderRadius: 12,
marginBottom: 16,
background: draft.is_legacy ? '#fffbe6' : '#fff',
overflow: 'hidden',
display: 'block', // Вертикальный layout
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
borderRadius: 18,
border: `1px solid ${borderColor}`,
background: bgColor,
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{
padding: 16,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 10,
}}
onClick={() => setSelectedDraft(draft)}
>
<List.Item.Meta
avatar={
<div style={{
width: 40,
height: 40,
borderRadius: '50%',
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
width: 52,
height: 52,
borderRadius: 14,
background: iconBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: draft.is_legacy ? '#faad14' : '#595959',
color: iconColor,
flexShrink: 0,
}}>
{DirectionIcon ? (
<DirectionIcon size={28} strokeWidth={1.8} />
) : (
<span style={{ fontSize: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{config.icon}
</div>
}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
{draft.category && (
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
</span>
)}
</div>
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{/* Заголовок - краткое описание проблемы */}
{draft.problem_title && (
<Text strong style={{
fontSize: 15,
color: '#1a1a1a',
display: 'block',
marginBottom: 4,
}}>
{draft.problem_title}
</Text>
)}
{/* Полное описание проблемы */}
{draft.problem_description && (
<div
<Text
strong
style={{
fontSize: 13,
lineHeight: 1.6,
color: '#262626',
background: '#f5f5f5',
padding: '10px 14px',
borderRadius: 8,
borderLeft: '4px solid #1890ff',
marginTop: 4,
fontSize: 14,
lineHeight: 1.3,
minHeight: 40,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
color: '#111827',
width: '100%',
wordBreak: 'break-word',
}}
title={draft.problem_description}
} as React.CSSProperties}
>
{draft.problem_description.length > 250
? draft.problem_description.substring(0, 250) + '...'
: draft.problem_description
}
</div>
)}
{/* Время обновления */}
<Space size="small">
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Tooltip title={formatDate(draft.updated_at)}>
{tileTitle}
</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{config.label}
{(draft.documents_total != null && draft.documents_total > 0) && (
<span style={{ marginLeft: 4, color: '#1890ff' }}>
{draft.documents_uploaded ?? 0}/{draft.documents_total}
</span>
)}
</Text>
<Tooltip title={formatDate(draft.updated_at)}>
<Text type="secondary" style={{ fontSize: 11 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{getRelativeTime(draft.updated_at)}
</Text>
</Tooltip>
</Space>
{/* Legacy предупреждение */}
{draft.is_legacy && (
<Alert
message="Черновик в старом формате. Нажмите 'Начать заново'."
type="warning"
showIcon
style={{ fontSize: 12, padding: '4px 8px' }}
/>
)}
{/* Список документов со статусами */}
{draft.documents_list && draft.documents_list.length > 0 && (
<div style={{
marginTop: 8,
background: '#fafafa',
borderRadius: 8,
padding: '8px 12px',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}>
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
📄 Документы
</Text>
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
</Text>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{draft.documents_list.map((doc, idx) => (
<div key={idx} style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 12,
}}>
{doc.uploaded ? (
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
) : (
<span style={{
width: 14,
height: 14,
borderRadius: '50%',
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
display: 'inline-block',
}} />
)}
<span style={{
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
textDecoration: doc.uploaded ? 'none' : 'none',
}}>
{doc.name}
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
</span>
</div>
))}
</div>
</div>
)}
{/* Прогрессбар (если нет списка) */}
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
<div style={{ marginTop: 4 }}>
<Progress
percent={docsProgress.percent}
size="small"
showInfo={false}
strokeColor={{
'0%': '#1890ff',
'100%': '#52c41a',
}}
trailColor="#f0f0f0"
/>
</div>
)}
{/* Описание статуса */}
<Text type="secondary" style={{ fontSize: 12 }}>
{config.description}
</Text>
{/* Кнопки действий */}
<div className="draft-actions" style={{
display: 'flex',
gap: 12,
marginTop: 12,
paddingTop: 12,
borderTop: '1px solid #f0f0f0',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', marginTop: 4 }} onClick={(e) => e.stopPropagation()}>
{getActionButton(draft)}
{/* Скрываем кнопку "Удалить" для заявок "В работе" */}
{draft.status_code !== 'in_work' && (
<Popconfirm
title="Удалить заявку?"
@@ -569,6 +581,7 @@ export default function StepDraftSelection({
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
loading={deletingId === (draft.claim_id || draft.id)}
disabled={deletingId === (draft.claim_id || draft.id)}
@@ -578,13 +591,11 @@ export default function StepDraftSelection({
</Popconfirm>
)}
</div>
</Space>
}
/>
</List.Item>
</Card>
</Col>
);
}}
/>
})}
</Row>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { Button, Card, Checkbox, Form, Input, Modal, Radio, Result, Row, Col, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
import { getDocTypeStyle, STATUS_UPLOADED, STATUS_NEEDED, STATUS_NOT_AVAILABLE, STATUS_OPTIONAL } from './documentsScreenMaps';
import AiWorkingIllustration from '../../assets/ai-working.svg';
import type { UploadFile } from 'antd/es/upload/interface';
@@ -1439,7 +1440,6 @@ export default function StepWizardPlan({
})}
<Space style={{ marginTop: 24 }}>
<Button onClick={onPrev}> Назад</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Сохранить и продолжить
</Button>
@@ -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<any[]>([]); // Массив загруженных файлов
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload');
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]);
const [selectedDocIndex, setSelectedDocIndex] = useState<number | null>(null); // Плиточный стиль: какая плитка открыта в модалке
const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы»
// Текущий документ для загрузки
const currentDoc = documentsRequired[currentDocIndex];
@@ -2160,148 +2162,288 @@ export default function StepWizardPlan({
}
};
const showDocumentsOnly = hasNewFlowDocs && documentsRequired.length > 0;
const stepContent = (
<>
{/* ✅ Экран «Загрузка документов» по дизайн-спецификации */}
{hasNewFlowDocs && !allDocsProcessed && documentsRequired.length > 0 ? (
<div style={{ background: '#f5f7fb', margin: '-1px -1px 0', borderRadius: '16px 16px 0 0', overflow: 'hidden', minHeight: 360 }}>
{/* Шапка: градиент синий, заголовок */}
<div style={{ background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', padding: '16px 16px', textAlign: 'center' }}>
<Typography.Text strong style={{ color: '#fff', fontSize: 18 }}>Загрузка документов</Typography.Text>
</div>
<div style={{ padding: '16px 16px 100px' }}>
<Row gutter={[12, 12]} style={{ marginBottom: 80 }}>
{documentsRequired.map((doc: any, index: number) => {
const docId = doc.id || doc.name;
const isUploaded = uploadedDocs.includes(docId);
const isSkipped = skippedDocs.includes(docId);
const fileCount = (formData.documents_uploaded || []).filter((d: any) => (d.type || d.id) === docId).length;
const { Icon: DocIcon, color: docColor } = getDocTypeStyle(docId);
const isSelected = selectedDocIndex === index;
const status = isUploaded ? STATUS_UPLOADED : isSkipped ? STATUS_NOT_AVAILABLE : (doc.required ? STATUS_NEEDED : STATUS_OPTIONAL);
const StatusIcon = status.Icon;
const statusLabel = isUploaded ? (fileCount > 0 ? `${status.label} (${fileCount})` : status.label) : status.label;
const tileBg = isUploaded ? '#ECFDF5' : isSkipped ? '#F3F4F6' : '#FFFBEB';
const tileBorder = isSelected ? '#2563eb' : isUploaded ? '#22C55E' : isSkipped ? '#9ca3af' : '#F59E0B';
return (
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Button onClick={onPrev}> Назад</Button>
{plan && !hasNewFlowDocs && (
<Button type="link" onClick={handleRefreshPlan}>
Обновить рекомендации
</Button>
)}
</div>
<Col xs={12} key={docId}>
<Card
hoverable
bordered
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
borderRadius: 18,
border: `1px solid ${tileBorder}`,
background: tileBg,
boxShadow: isSelected ? '0 0 0 2px rgba(37,99,235,0.25)' : '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
onClick={() => { setCurrentDocIndex(index); setDocChoice(isSkipped ? 'none' : 'upload'); setCurrentUploadedFiles([]); setSelectedDocIndex(index); }}
>
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
{hasNewFlowDocs && !allDocsProcessed && currentDocIndex < documentsRequired.length && currentDoc ? (
<div style={{ padding: '24px 0' }}>
{/* Прогресс */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
<Text type="secondary">{Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}% завершено</Text>
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${docColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: docColor }}>
<DocIcon size={28} strokeWidth={1.8} />
</div>
<Progress
percent={Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}
showInfo={false}
strokeColor="#595959"
/>
</div>
{/* Заголовок документа */}
<Title level={4} style={{ marginBottom: 8 }}>
📄 {currentDoc.name}
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
</Title>
{currentDoc.hints && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
{currentDoc.hints}
</Paragraph>
)}
{/* Радио-кнопки выбора */}
<Radio.Group
value={docChoice}
onChange={(e) => {
setDocChoice(e.target.value);
if (e.target.value === 'none') {
setCurrentUploadedFiles([]);
}
}}
style={{ marginBottom: 16, display: 'block' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="upload" style={{ fontSize: 16 }}>
📎 Загрузить документ
</Radio>
<Radio value="none" style={{ fontSize: 16 }}>
У меня нет этого документа
</Radio>
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{doc.name}</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Space size={6} style={{ fontSize: 12, color: status.color }}>
<StatusIcon size={14} strokeWidth={2} />
<span>{statusLabel}</span>
</Space>
</Radio.Group>
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
{docChoice === 'upload' && (
<Dragger
multiple={true}
beforeUpload={() => false}
fileList={currentUploadedFiles}
onChange={({ fileList }) => handleFilesChange(fileList)}
onRemove={(file) => {
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
return true;
}}
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
disabled={submitting}
style={{ marginBottom: 24 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
</p>
<p className="ant-upload-text">
Перетащите файлы или нажмите для выбора
</p>
<p className="ant-upload-hint">
📌 Можно загрузить несколько файлов (все страницы документа)
<br />
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
</p>
</Dragger>
)}
{/* Предупреждение если "нет документа" для важного */}
{docChoice === 'none' && currentDoc.required && (
<div style={{
padding: 12,
background: '#fff7e6',
border: '1px solid #ffd591',
borderRadius: 8,
marginBottom: 16
}}>
<Text type="warning">
Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
</Text>
{'subLabel' in status && isSkipped && <Text type="secondary" style={{ fontSize: 11 }}>{(status as { subLabel?: string }).subLabel}</Text>}
</div>
)}
{/* Кнопки */}
<Space style={{ marginTop: 16 }}>
<Button onClick={backToDraftsList || onPrev}> К списку заявок</Button>
</Card>
</Col>
);
})}
{/* Плитка: произвольные группы документов (название от пользователя при одной группе) */}
<Col xs={12} key="__custom_docs__">
<Card
hoverable
bordered
style={{
borderRadius: 18,
border: `1px solid #e5e7eb`,
background: '#fff',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
onClick={() => setCustomDocsModalOpen(true)}
>
{(() => {
const { Icon: CustomIcon, color: customColor } = getDocTypeStyle('__custom_docs__');
const StatusIcon = customFileBlocks.length > 0 ? STATUS_UPLOADED.Icon : CustomIcon;
const statusColor = customFileBlocks.length > 0 ? STATUS_UPLOADED.color : '#8c8c8c';
const hasGroups = customFileBlocks.length > 0;
const titleText = hasGroups && customFileBlocks.length === 1 && customFileBlocks[0].description?.trim()
? (customFileBlocks[0].description.trim().length > 25 ? customFileBlocks[0].description.trim().slice(0, 22) + '…' : customFileBlocks[0].description.trim())
: 'Свои документы';
return (
<>
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${customColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: customColor }}>
<CustomIcon size={28} strokeWidth={1.8} />
</div>
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{titleText}</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Space size={6} style={{ fontSize: 12, color: statusColor }}>
<StatusIcon size={14} strokeWidth={2} />
<span>{hasGroups ? `Загружено (${customFileBlocks.length} ${customFileBlocks.length === 1 ? 'группа' : 'группы'})` : 'Добавить'}</span>
</Space>
</div>
</>
);
})()}
</Card>
</Col>
{/* Плитка «Добавить ещё группу» — серая до загрузки, цветная после */}
<Col xs={12} key="__custom_docs_add__">
<Card
hoverable
bordered
style={{
borderRadius: 18,
border: '1px solid #e5e7eb',
background: customFileBlocks.length > 0 ? '#f5f3ff' : '#fafafa',
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 8 }}
onClick={() => setCustomDocsModalOpen(true)}
>
{(() => {
const { Icon: AddIcon, color: addColor } = getDocTypeStyle('__custom_docs__');
const isColored = customFileBlocks.length > 0;
const iconColor = isColored ? addColor : '#9ca3af';
const bgColor = isColored ? `${addColor}18` : '#f3f4f6';
return (
<>
<div style={{ width: 48, height: 48, borderRadius: 14, background: bgColor, display: 'flex', alignItems: 'center', justifyContent: 'center', color: iconColor }}>
<AddIcon size={26} strokeWidth={1.8} />
</div>
<Text style={{ fontSize: 13, color: isColored ? '#374151' : '#9ca3af', lineHeight: 1.3 }}>
Добавить ещё группу
</Text>
</>
);
})()}
</Card>
</Col>
</Row>
{/* Кнопка «Отправить» внизу экрана с плитками (bottom: 90px — выше футера) */}
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '24px 0 0', marginTop: 8 }}>
<Button
type="primary"
onClick={handleDocContinue}
disabled={!canContinue || submitting}
loading={submitting}
size="large"
block
onClick={handleAllDocsComplete}
disabled={!allDocsProcessed}
title={!allDocsProcessed ? `Сначала отметьте все документы (${uploadedDocs.length + skippedDocs.length}/${documentsRequired.length})` : undefined}
style={{
background: allDocsProcessed ? 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' : undefined,
border: 'none',
borderRadius: 28,
height: 52,
fontSize: 16,
fontWeight: 600,
}}
>
{submitting ? 'Загружаем...' : 'Продолжить →'}
Отправить
</Button>
</div>
</div>
<Modal
title={currentDoc ? `📄 ${currentDoc.name}` : 'Документ'}
open={selectedDocIndex !== null && !!documentsRequired[selectedDocIndex]}
onCancel={() => setSelectedDocIndex(null)}
footer={null}
width={520}
destroyOnClose
>
{selectedDocIndex !== null && documentsRequired[selectedDocIndex] && (() => {
const doc = documentsRequired[selectedDocIndex];
return (
<div style={{ padding: '8px 0' }}>
{doc.hints && <Paragraph type="secondary" style={{ marginBottom: 16 }}>{doc.hints}</Paragraph>}
<Radio.Group value={docChoice} onChange={(e) => { setDocChoice(e.target.value); if (e.target.value === 'none') setCurrentUploadedFiles([]); }} style={{ marginBottom: 16, display: 'block' }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Radio value="upload" style={{ fontSize: 15 }}>📎 Загрузить документ</Radio>
<Radio value="none" style={{ fontSize: 15 }}> У меня нет этого документа</Radio>
</Space>
{/* Уже загруженные */}
{uploadedDocs.length > 0 && (
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
<Text strong> Загружено:</Text>
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
{/* Убираем дубликаты и используем уникальные ключи */}
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
const doc = documentsRequired.find((d: any) => d.id === docId);
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
})}
</ul>
</Radio.Group>
{docChoice === 'upload' && (
<Dragger multiple beforeUpload={() => false} fileList={currentUploadedFiles} onChange={({ fileList }) => handleFilesChange(fileList)} onRemove={(file) => { setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid)); return true; }} accept={doc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'} disabled={submitting} style={{ marginBottom: 16 }}>
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 32 }} /></p>
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
<p className="ant-upload-hint">Форматы: {doc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ)</p>
</Dragger>
)}
{docChoice === 'none' && doc.required && (
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, marginBottom: 16 }}>
<Text type="warning"> Документ важен для рассмотрения. Постарайтесь найти его позже.</Text>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button onClick={() => setSelectedDocIndex(null)}>Отмена</Button>
<Button type="primary" onClick={async () => { await handleDocContinue(); setSelectedDocIndex(null); }} disabled={!canContinue || submitting} loading={submitting}>{submitting ? 'Загружаем...' : 'Готово'}</Button>
</div>
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? (
</div>
);
})()}
</Modal>
{/* Модалка «Свои документы» — произвольные группы документов */}
<Modal
title="Дополнительные документы"
open={customDocsModalOpen}
onCancel={() => setCustomDocsModalOpen(false)}
footer={null}
width={560}
destroyOnClose={false}
>
<div style={{ padding: '8px 0' }}>
{customFileBlocks.length === 0 && (
<div style={{ marginBottom: 16, padding: 16, background: '#fafafa', borderRadius: 8 }}>
<Paragraph style={{ marginBottom: 8 }}>
<Text strong>Есть ещё документы, которые могут помочь?</Text>
</Paragraph>
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Добавьте группу документов с названием (например: «Переписка в мессенджере», «Скриншоты»).
В каждой группе своё название и файлы.
</Paragraph>
<Button type="dashed" icon={<PlusOutlined />} onClick={addCustomBlock} block size="large">
Добавить группу документов
</Button>
</div>
)}
<Space direction="vertical" style={{ width: '100%' }}>
{customFileBlocks.map((block, idx) => (
<Card
key={block.id}
size="small"
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
title={<span><FileTextOutlined style={{ color: '#595959', marginRight: 8 }} />Группа документов #{idx + 1}</span>}
extra={<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>Удалить</Button>}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Название группы <Text type="danger">*</Text></Text>
<Input
placeholder="Например: Переписка в WhatsApp с менеджером"
value={block.description}
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
maxLength={500}
showCount
style={{ marginBottom: 12 }}
status={block.files.length > 0 && !block.description?.trim() ? 'error' : ''}
/>
{block.files.length > 0 && !block.description?.trim() && (
<Text type="danger" style={{ fontSize: 12 }}>Укажите название группы</Text>
)}
</div>
<div>
<Text strong style={{ display: 'block', marginBottom: 4 }}>Категория (необязательно)</Text>
<Select
value={block.category}
placeholder="Выберите или оставьте пустым"
onChange={(value) => updateCustomBlock(block.id, { category: value })}
allowClear
style={{ width: '100%' }}
>
{customCategoryOptions.map((opt) => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</Select>
</div>
<Dragger
multiple
beforeUpload={() => false}
fileList={block.files}
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
style={{ marginTop: 8 }}
>
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 24 }} /></p>
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
</Dragger>
</Space>
</Card>
))}
</Space>
{customFileBlocks.length > 0 && (
<Button type="dashed" onClick={addCustomBlock} icon={<PlusOutlined />} block style={{ marginTop: 12 }}>
Добавить ещё группу
</Button>
)}
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Button type="primary" onClick={() => setCustomDocsModalOpen(false)}>Готово</Button>
</div>
</div>
</Modal>
</div>
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length && documentsRequired.length > 0 ? (
<div style={{ padding: '24px 0', textAlign: 'center' }}>
<Text type="warning">
Ошибка: индекс документа ({currentDocIndex}) выходит за границы массива ({documentsRequired.length}).
Ошибка: индекс документа ({currentDocIndex}) выходит за границы ({documentsRequired.length}).
<br />
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
</Text>
@@ -2393,15 +2535,52 @@ export default function StepWizardPlan({
{/* ✅ Дополнительные документы */}
{renderCustomUploads()}
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
Продолжить
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '20px 0', background: '#f5f7fb', marginTop: 24 }}>
<Button
type="primary"
size="large"
block
onClick={handleAllDocsComplete}
style={{
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
border: 'none',
borderRadius: 28,
height: 52,
fontSize: 16,
fontWeight: 600,
}}
>
Отправить
</Button>
</div>
</>
);
})()}
</>
);
return showDocumentsOnly ? (
<div style={{ marginTop: 0 }}>{stepContent}</div>
) : (
<div style={{ marginTop: 24 }}>
{plan && !hasNewFlowDocs && (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<Button type="link" onClick={handleRefreshPlan}>
Обновить рекомендации
</Button>
</div>
)}
<Card
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
}}
>
{stepContent}
{(
<>
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
{!hasNewFlowDocs && isWaiting && !outOfScopeData && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
@@ -2616,6 +2795,8 @@ export default function StepWizardPlan({
{renderQuestions()}
</div>
)}
</>
)}
</Card>
</div>
);

View File

@@ -0,0 +1,44 @@
/**
* Маппинг типов документов и статусов для экрана «Загрузка документов».
* Спецификация: дизайн «Документы кейса», Lucide-иконки.
*/
import {
FileSignature,
Receipt,
ClipboardList,
MessagesSquare,
FileWarning,
FolderOpen,
FolderPlus,
FileText,
CheckCircle2,
AlertTriangle,
Clock3,
Ban,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
export const DOC_TYPE_MAP: Record<string, { Icon: LucideIcon; color: string }> = {
contract: { Icon: FileSignature, color: '#1890ff' },
payment: { Icon: Receipt, color: '#52c41a' },
receipt: { Icon: Receipt, color: '#52c41a' },
cheque: { Icon: Receipt, color: '#52c41a' },
correspondence: { Icon: MessagesSquare, color: '#722ed1' },
acts: { Icon: ClipboardList, color: '#fa8c16' },
claim: { Icon: FileWarning, color: '#ff4d4f' },
other: { Icon: FolderOpen, color: '#595959' },
/** Плитка «Свои документы» — произвольные группы документов */
__custom_docs__: { Icon: FolderPlus, color: '#722ed1' },
};
export function getDocTypeStyle(docId: string): { Icon: LucideIcon; color: string } {
const key = (docId || '').toLowerCase().replace(/\s+/g, '_');
return DOC_TYPE_MAP[key] ?? { Icon: FileText, color: '#1890ff' };
}
/** Цвета и иконки статусов по спецификации */
export const STATUS_UPLOADED = { Icon: CheckCircle2, color: '#22C55E', label: 'Загружено' };
export const STATUS_NEEDED = { Icon: AlertTriangle, color: '#F59E0B', label: 'Нужно' };
export const STATUS_EXPECTED = { Icon: Clock3, color: '#F59E0B', label: 'Ожидаем завтра' };
export const STATUS_NOT_AVAILABLE = { Icon: Ban, color: '#8c8c8c', label: 'Не будет', subLabel: 'Утеряно' };
export const STATUS_OPTIONAL = { Icon: Clock3, color: '#8c8c8c', label: 'По желанию' };

View File

@@ -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 {

View File

@@ -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<string | null>(null);
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(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<string, unknown>;
if (typeof o.value === 'string') return o.value;
if (typeof o.payload === 'string') return o.payload;
if (typeof o.start_param === 'string') return o.start_param;
if (typeof o.data === 'string') return o.data;
return JSON.stringify(o);
}
return String(v);
}, []);
// Извлечь claim_id из строки startapp/start_param (форматы: claim_id=uuid, claim_id_uuid, или голый uuid)
const parseClaimIdFromStartParam = useCallback((startParam: string | Record<string, unknown> | null | undefined): string | null => {
const s = startParamToString(startParam);
if (!s) return null;
const decoded = decodeURIComponent(s.trim());
let m = decoded.match(/(?:^|[?&])claim_id=([^&]+)/i) || decoded.match(/(?:^|[?&])claim_id_([0-9a-f-]{36})/i);
if (!m) m = decoded.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return m ? decodeURIComponent(m[1]) : null;
}, [startParamToString]);
// Автозагрузка черновика из URL или из MAX WebApp start_param после восстановления сессии
useEffect(() => {
if (!sessionRestored) return;
(async () => {
try {
const params = new URLSearchParams(window.location.search);
// claim_id может прийти как UUID или как claim_id_<uuid> (после редиректа из /hello?WebAppStartParam=...)
let claimFromUrl = parseClaimIdFromStartParam(params.get('claim_id') || '') || params.get('claim_id');
// Query: startapp=... или WebAppStartParam=... (MAX подставляет при открытии по диплинку)
if (!claimFromUrl) claimFromUrl = parseClaimIdFromStartParam(params.get('startapp') || params.get('WebAppStartParam') || '');
// Hash (MAX иногда кладёт параметры в #)
if (!claimFromUrl && window.location.hash) {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const fromHash = parseClaimIdFromStartParam(hashParams.get('claim_id') || '') || hashParams.get('claim_id');
claimFromUrl = fromHash || parseClaimIdFromStartParam(hashParams.get('startapp') || hashParams.get('WebAppStartParam') || '');
}
// MAX WebApp: initDataUnsafe.start_param (появляется после загрузки скрипта st.max.ru)
if (!claimFromUrl) {
const wa = (window as any).WebApp;
const startParam = wa?.initDataUnsafe?.start_param;
if (startParam) {
claimFromUrl = parseClaimIdFromStartParam(startParam);
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param:', claimFromUrl);
}
}
// Повторная проверка через 1.2s на случай, если MAX bridge подставил start_param с задержкой
if (!claimFromUrl) {
await new Promise((r) => setTimeout(r, 1200));
const wa = (window as any).WebApp;
const startParam = wa?.initDataUnsafe?.start_param;
if (startParam) {
claimFromUrl = parseClaimIdFromStartParam(startParam);
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param (отложенно):', claimFromUrl);
}
}
if (claimFromUrl) {
if (autoLoadedClaimIdRef.current === claimFromUrl) return;
autoLoadedClaimIdRef.current = claimFromUrl;
// Сразу помечаем черновик как выбранный и скрываем список — чтобы не показывать шаг «Черновики», сразу перейти к документам
setSelectedDraftId(claimFromUrl);
setShowDraftSelection(false);
console.log('🔗 Автозагрузка черновика из URL claim_id=', claimFromUrl, '(сразу на документы)');
await loadDraft(claimFromUrl);
}
} catch (e) {
console.error('❌ Ошибка автозагрузки черновика из URL:', e);
}
})();
}, [sessionRestored, loadDraft, parseClaimIdFromStartParam]);
// Обработчик выбора черновика
const handleSelectDraft = useCallback((claimId: string) => {
loadDraft(claimId);
@@ -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 (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
const isDocumentsStep = steps[currentStep]?.title === 'Документы';
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
<Row gutter={16}>
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
<Row gutter={[0, 16]}>
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
<Card
title="Подать обращение о защите прав потребителя"
className="claim-form-card"
extra={
!isSubmitted && (
<Space>
{/* Кнопка "Выход" - показываем если телефон верифицирован */}
{isPhoneVerified && (
<button
onClick={handleExitToList}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #ff4d4f',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#ff4d4f'
}}
>
🚪 Выход
</button>
)}
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
{isDocumentsStep ? (
<div className="steps-content" style={{ marginTop: 0 }}>
{currentStep > 0 && (
<button
onClick={handleReset}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
🔄 Начать заново
</button>
<div style={{ marginBottom: 12 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={prevStep}>
Назад
</Button>
</div>
)}
{steps[currentStep] ? steps[currentStep].content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
)}
</div>
) : (
<Card title={null} className="claim-form-card" bordered={false}>
{!isSubmitted && currentStep > 0 && (
<div style={{ marginBottom: 8 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={prevStep}>
Назад
</Button>
</div>
)}
</Space>
)
}
>
{isSubmitted ? (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
@@ -1701,16 +1785,6 @@ export default function ClaimForm() {
</p>
</div>
) : (
<>
<Steps current={currentStep} className="steps">
{steps.map((item, index) => (
<Step
key={`step-${index}`}
title={item.title}
description={item.description}
/>
))}
</Steps>
<div className="steps-content">
{steps[currentStep] ? steps[currentStep].content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
@@ -1718,9 +1792,9 @@ export default function ClaimForm() {
</div>
)}
</div>
</>
)}
</Card>
)}
</Col>
{/* Правая часть - Debug консоль (только в dev режиме) */}

View File

@@ -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;
}
}

View File

@@ -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<Status>('idle');
const [greeting, setGreeting] = useState<string>('Привет!');
const [error, setError] = useState<string>('');
@@ -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<string, unknown>;
if (typeof o.value === 'string') return o.value;
if (typeof o.payload === 'string') return o.payload;
if (typeof o.start_param === 'string') return o.start_param;
return JSON.stringify(o);
}
return String(v);
};
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
if (!claimFromUrl && 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,8 +208,10 @@ export default function HelloAuth() {
const data = await res.json();
if (res.ok && data.success) {
setGreeting(data.greeting || 'Привет!');
if (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() {
</div>
</Card>
<Row gutter={[16, 16]} className="hello-grid">
<Row gutter={[16, 16]} className="hello-grid" align="stretch">
{tiles.map((tile) => {
const Icon = tile.icon;
return (
<Col key={tile.title} xs={12} sm={8} md={6}>
<Card className="tile-card" hoverable bordered={false}>
const active = !!tile.href;
const card = (
<Card
className={`tile-card ${active ? '' : 'tile-card--inactive'}`}
hoverable={active}
bordered={false}
onClick={tile.href ? () => {
// В TG при полной перезагрузке теряется initData — переходим без reload (SPA)
if (onNavigate) {
onNavigate(tile.href!);
} else {
window.location.href = tile.href! + (window.location.search || '');
}
} : undefined}
style={tile.href ? { cursor: 'pointer' } : undefined}
>
<div className="tile-icon" style={{ color: tile.color }}>
<Icon size={26} strokeWidth={1.8} />
<Icon size={28} strokeWidth={1.8} />
</div>
<div className="tile-title">{tile.title}</div>
</Card>
);
return (
<Col key={tile.title} xs={12} sm={8} md={6} className="tile-col">
{card}
</Col>
);
})}

View File

@@ -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',