Mini-app updates: UI TG MAX session nav logs
This commit is contained in:
29
CHANGELOG_MINIAPP.md
Normal file
29
CHANGELOG_MINIAPP.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Доработки мини-приложения Clientright (TG/MAX и веб)
|
||||
|
||||
## UI и навигация
|
||||
- **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей».
|
||||
- Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок.
|
||||
- Список обращений по категориям в виде карточек с hover; фильтр по выбранной категории.
|
||||
- Кнопка **«Назад»** перенесена в нижний бар; убраны дублирующие кнопки «Назад» из контента (описание, документы).
|
||||
|
||||
## Telegram и MAX
|
||||
- **Выход**: корректное закрытие приложения — в TG вызывается `Telegram.WebApp.close()`, в MAX — `window.WebApp.close()` / `postEvent('web_app_close')`. Определение платформы по initData/URL.
|
||||
- Подключение скриптов по платформе: при наличии `tgWebAppData`/`tgWebAppVersion` в URL грузится только `telegram-web-app.js`, иначе — только `max-web-app.js` (устранены ошибки UnsupportedEvent в MAX).
|
||||
- В TG/MAX **не показывается экран ввода телефона** — шаг «Вход» только для обычного веба; раннее определение платформы (опрос `WebApp.initData`), флаг `platformChecked` чтобы не мелькал телефон до определения.
|
||||
|
||||
## Сессия и авторизация
|
||||
- Сессию не сбрасывать при сетевых/временных ошибках `session/verify` — удалять `session_token` только при явном ответе `valid: false`.
|
||||
- При нажатии «Назад» с авторизованного пользователя не вести на шаг «Вход» — переход на дашборд «Мои обращения» или на `/hello`.
|
||||
- Переход на «Подать обращение» через роут `/new` и `pushState` для стабильного флоу без возврата на телефон.
|
||||
|
||||
## Исправления
|
||||
- **TDZ-ошибка** (пустой экран после перехода с /hello): `useEffect` для `miniapp:goBack` перенесён после объявления `prevStep` (useCallback).
|
||||
- Тостер **«Добро пожаловать!»** показывается только в вебе (не в TG/MAX), проверка по `Telegram.WebApp.initData` и `WebApp.initData`.
|
||||
|
||||
## Отладка и логи
|
||||
- Клиентский логгер `miniappLogger`: сбор событий, ошибок, отправка на `POST /api/v1/utils/client-log`; идентификация бандла (build/moduleUrl); очистка логов при смене сборки.
|
||||
- Бэкенд: приём логов в `main.py`, запись в `logs/cursor-debug-*.log` (NDJSON), без PII.
|
||||
|
||||
## Файлы
|
||||
- Новые: `StepComplaintsDashboard.tsx/.css`, `StepDraftSelection.css`, `miniappLogger.ts`.
|
||||
- Правки: `ClaimForm.tsx`, `HelloAuth.tsx`, `BottomBar.tsx`, `StepDescription.tsx`, `StepWizardPlan.tsx`, `StepDraftSelection.tsx`, `App.tsx`, `main.tsx`, `index.html`, `main.py`, `api/claims.py`, `ClaimForm.css`, `BottomBar.css`, `Dockerfile.prod`.
|
||||
@@ -383,8 +383,13 @@ async def list_drafts(
|
||||
if facts_short and len(facts_short) > 200:
|
||||
facts_short = facts_short[:200].rstrip() + '…'
|
||||
|
||||
# Подробное описание (для превью)
|
||||
problem_text = payload.get('problem_description', '')
|
||||
# Подробное описание (для превью); n8n может сохранять в description/chatInput
|
||||
problem_text = (
|
||||
payload.get('problem_description')
|
||||
or payload.get('description')
|
||||
or payload.get('chatInput')
|
||||
or ''
|
||||
)
|
||||
|
||||
# Считаем документы
|
||||
documents_meta = payload.get('documents_meta') or []
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
Ticket Form Intake Platform - FastAPI Backend
|
||||
"""
|
||||
from fastapi import FastAPI, Request
|
||||
import json
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .config import settings, get_cors_origins_live, get_settings
|
||||
from .services.database import db
|
||||
@@ -23,6 +27,82 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEBUG_SESSION_ID = "2a4d38"
|
||||
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)
|
||||
DEBUG_LOG_PATH = "/app/logs/cursor-debug-2a4d38.log"
|
||||
|
||||
|
||||
def _debug_write(
|
||||
*,
|
||||
hypothesis_id: str,
|
||||
run_id: str,
|
||||
location: str,
|
||||
message: str,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
"""
|
||||
NDJSON debug log for Cursor Debug Mode.
|
||||
IMPORTANT: do not log secrets/PII (tokens, tg hash, full init_data, phone, etc).
|
||||
"""
|
||||
try:
|
||||
ts = int(time.time() * 1000)
|
||||
entry = {
|
||||
"sessionId": DEBUG_SESSION_ID,
|
||||
"id": f"log_{ts}_{uuid.uuid4().hex[:8]}",
|
||||
"timestamp": ts,
|
||||
"location": location,
|
||||
"message": message,
|
||||
"data": data,
|
||||
"runId": run_id,
|
||||
"hypothesisId": hypothesis_id,
|
||||
}
|
||||
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
# Never break prod request handling due to debug logging
|
||||
return
|
||||
|
||||
|
||||
def _extract_client_bundle_info(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""
|
||||
Returns (moduleUrl, scriptSrc, build) from the last 'boot' entry if present.
|
||||
"""
|
||||
logs = payload.get("logs") or []
|
||||
if not isinstance(logs, list):
|
||||
return (None, None, None)
|
||||
for entry in reversed(logs):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("event") != "boot":
|
||||
continue
|
||||
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
||||
module_url = data.get("moduleUrl") if isinstance(data.get("moduleUrl"), str) else None
|
||||
script_src = data.get("scriptSrc") if isinstance(data.get("scriptSrc"), str) else None
|
||||
build = data.get("build") if isinstance(data.get("build"), str) else None
|
||||
return (module_url, script_src, build)
|
||||
return (None, None, None)
|
||||
|
||||
|
||||
def _extract_last_window_error(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
logs = payload.get("logs") or []
|
||||
if not isinstance(logs, list):
|
||||
return {}
|
||||
for entry in reversed(logs):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("event") != "window_error":
|
||||
continue
|
||||
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
|
||||
# Keep only safe fields
|
||||
return {
|
||||
"message": data.get("message"),
|
||||
"filename": data.get("filename"),
|
||||
"lineno": data.get("lineno"),
|
||||
"colno": data.get("colno"),
|
||||
"hasStack": bool(data.get("stack")),
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
@@ -246,6 +326,71 @@ async def get_client_ip(request: Request):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/utils/client-log")
|
||||
async def client_log(request: Request):
|
||||
"""
|
||||
Принимает клиентские логи (для отладки webview/miniapp) и пишет в backend-логи.
|
||||
Формат: { reason, client: {...}, logs: [...] }
|
||||
"""
|
||||
client_host = request.client.host if request.client else None
|
||||
ua = request.headers.get("user-agent", "")
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {"error": "invalid_json"}
|
||||
|
||||
# Cursor debug-mode evidence (sanitized)
|
||||
try:
|
||||
if isinstance(payload, dict):
|
||||
reason = payload.get("reason")
|
||||
client = payload.get("client") if isinstance(payload.get("client"), dict) else {}
|
||||
pathname = client.get("pathname") if isinstance(client.get("pathname"), str) else None
|
||||
origin = client.get("origin") if isinstance(client.get("origin"), str) else None
|
||||
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
|
||||
|
||||
module_url, script_src, build = _extract_client_bundle_info(payload)
|
||||
last_err = _extract_last_window_error(payload)
|
||||
first_err_file = None
|
||||
last_err_file = None
|
||||
if isinstance(logs, list):
|
||||
for e in logs:
|
||||
if isinstance(e, dict) and e.get("event") == "window_error":
|
||||
d = e.get("data") if isinstance(e.get("data"), dict) else {}
|
||||
fn = d.get("filename")
|
||||
if isinstance(fn, str):
|
||||
if first_err_file is None:
|
||||
first_err_file = fn
|
||||
last_err_file = fn
|
||||
|
||||
_debug_write(
|
||||
hypothesis_id="H1",
|
||||
run_id="pre-fix",
|
||||
location="backend/app/main.py:client_log",
|
||||
message="client_log_received",
|
||||
data={
|
||||
"ip": client_host,
|
||||
"uaPrefix": ua[:80] if isinstance(ua, str) else "",
|
||||
"reason": reason,
|
||||
"origin": origin,
|
||||
"pathname": pathname,
|
||||
"logsCount": len(logs) if isinstance(logs, list) else None,
|
||||
"boot": {"moduleUrl": module_url, "scriptSrc": script_src, "build": build},
|
||||
"windowErrorLast": last_err,
|
||||
"windowErrorFiles": {"first": first_err_file, "last": last_err_file},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ограничим размер вывода, но оставим самое важное
|
||||
try:
|
||||
s = json.dumps(payload, ensure_ascii=False)[:20000]
|
||||
except Exception:
|
||||
s = str(payload)[:20000]
|
||||
logger.warning(f"📱 CLIENT_LOG ip={client_host} ua={ua} payload={s}")
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.get("/api/v1/info")
|
||||
async def info():
|
||||
"""Информация о платформе"""
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
# React Frontend Dockerfile (PRODUCTION BUILD)
|
||||
# Продакшен: сборка + отдача dist (без dev-сервера).
|
||||
# После правок в коде: docker compose build frontend && docker compose up -d frontend
|
||||
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем package.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Устанавливаем зависимости
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
RUN node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build
|
||||
|
||||
# Собираем production build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:18-alpine
|
||||
|
||||
# Устанавливаем serve глобально
|
||||
RUN npm install -g serve
|
||||
|
||||
# Копируем собранное приложение из builder stage
|
||||
COPY --from=builder /app/dist /app/dist
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Открываем порт
|
||||
RUN npm install -g serve
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3000
|
||||
|
||||
# Запускаем serve для раздачи статических файлов
|
||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
<!-- MAX Bridge: нужен для window.WebApp и initData при заходе из MAX -->
|
||||
<script src="https://st.max.ru/js/max-web-app.js"></script>
|
||||
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
|
||||
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
|
||||
<script>
|
||||
(function() {
|
||||
var u = window.location.href || '';
|
||||
if (u.indexOf('tgWebAppData') !== -1 || u.indexOf('tgWebAppVersion') !== -1) {
|
||||
var s = document.createElement('script'); s.src = 'https://telegram.org/js/telegram-web-app.js'; document.head.appendChild(s);
|
||||
} else {
|
||||
var s = document.createElement('script'); s.src = 'https://st.max.ru/js/max-web-app.js'; document.head.appendChild(s);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ClaimForm from './pages/ClaimForm';
|
||||
import HelloAuth from './pages/HelloAuth';
|
||||
import BottomBar from './components/BottomBar';
|
||||
import './App.css';
|
||||
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
|
||||
|
||||
function App() {
|
||||
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||
const lastRouteTsRef = useRef<number>(Date.now());
|
||||
const lastPathRef = useRef<string>(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPathname(window.location.pathname || '');
|
||||
@@ -14,6 +17,41 @@ function App() {
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
// Логируем смену маршрута + ловим быстрый возврат на /hello (симптом бага)
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const prev = lastPathRef.current;
|
||||
lastPathRef.current = pathname;
|
||||
lastRouteTsRef.current = now;
|
||||
miniappLog('route', { prev, next: pathname });
|
||||
|
||||
if (pathname.startsWith('/hello') && !prev.startsWith('/hello')) {
|
||||
// Вернулись на /hello: отправим дамп, чтобы поймать “ложится”
|
||||
void miniappSendLogs('returned_to_hello');
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
// Ловим клики в первые 2с после смены маршрута (ghost click / попадание в бар)
|
||||
useEffect(() => {
|
||||
const onClickCapture = (e: MouseEvent) => {
|
||||
const dt = Date.now() - lastRouteTsRef.current;
|
||||
if (dt > 2000) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
const inBar = !!t?.closest?.('.app-bottom-bar');
|
||||
miniappLog('click_capture', {
|
||||
dtFromRouteMs: dt,
|
||||
inBottomBar: inBar,
|
||||
tag: t?.tagName,
|
||||
id: t?.id,
|
||||
class: t?.className,
|
||||
x: (e as MouseEvent).clientX,
|
||||
y: (e as MouseEvent).clientY,
|
||||
});
|
||||
};
|
||||
window.addEventListener('click', onClickCapture, true);
|
||||
return () => window.removeEventListener('click', onClickCapture, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
|
||||
}, [pathname]);
|
||||
|
||||
@@ -43,6 +43,15 @@
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.app-bar-item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.app-bar-item:disabled:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.app-bar-item--active {
|
||||
color: #2563EB;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Home, Headphones, User, LogOut } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Home, Headphones, User, LogOut, ArrowLeft } from 'lucide-react';
|
||||
import './BottomBar.css';
|
||||
import { miniappLog } from '../utils/miniappLogger';
|
||||
|
||||
interface BottomBarProps {
|
||||
currentPath: string;
|
||||
@@ -8,32 +10,100 @@ interface BottomBarProps {
|
||||
|
||||
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
const isHome = currentPath.startsWith('/hello');
|
||||
const [backEnabled, setBackEnabled] = useState(false);
|
||||
|
||||
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться
|
||||
useEffect(() => {
|
||||
if (isHome) {
|
||||
setBackEnabled(false);
|
||||
return;
|
||||
}
|
||||
setBackEnabled(false);
|
||||
const t = window.setTimeout(() => setBackEnabled(true), 1200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [isHome, currentPath]);
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
miniappLog('bottom_bar_back_click', { backEnabled, currentPath });
|
||||
if (!backEnabled) return;
|
||||
window.dispatchEvent(new CustomEvent('miniapp:goBack'));
|
||||
};
|
||||
|
||||
const handleExit = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// 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 (_) {}
|
||||
const tgWebApp = (window as any).Telegram?.WebApp;
|
||||
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
||||
const isTg =
|
||||
tgInitData.length > 0 ||
|
||||
window.location.href.includes('tgWebAppData') ||
|
||||
navigator.userAgent.includes('Telegram');
|
||||
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
||||
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
||||
const isMax =
|
||||
maxInitData.length > 0 ||
|
||||
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
|
||||
|
||||
miniappLog('bottom_bar_exit_click', {
|
||||
currentPath,
|
||||
isTg,
|
||||
isMax,
|
||||
tgInitDataLen: tgInitData.length,
|
||||
maxInitDataLen: maxInitData.length,
|
||||
hasTgClose: typeof tgWebApp?.close === 'function',
|
||||
hasMaxClose: typeof maxWebApp?.close === 'function',
|
||||
hasMaxPostEvent: typeof maxWebApp?.postEvent === 'function',
|
||||
});
|
||||
|
||||
// ВАЖНО: telegram-web-app.js может объявлять Telegram.WebApp.close() вне Telegram.
|
||||
// Поэтому выбираем платформу по реальному initData, иначе в MAX будем вызывать TG close и рано выходить.
|
||||
if (isTg) {
|
||||
try {
|
||||
if (typeof tgWebApp?.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (isMax) {
|
||||
try {
|
||||
if (typeof maxWebApp?.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp?.postEvent === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Fallback: переход на главную
|
||||
miniappLog('bottom_bar_exit_fallback', {});
|
||||
window.location.href = '/hello';
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="app-bottom-bar" aria-label="Навигация">
|
||||
{!isHome && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-bar-item"
|
||||
onClick={handleBack}
|
||||
disabled={!backEnabled}
|
||||
aria-label="Назад"
|
||||
>
|
||||
<ArrowLeft size={24} strokeWidth={1.8} />
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
)}
|
||||
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
|
||||
<Home size={24} strokeWidth={1.8} />
|
||||
<span>Домой</span>
|
||||
|
||||
37
frontend/src/components/form/StepComplaintsDashboard.css
Normal file
37
frontend/src/components/form/StepComplaintsDashboard.css
Normal file
@@ -0,0 +1,37 @@
|
||||
/* Карточки дашборда — в стиле экрана hello: тень и подъём при наведении, одинаковая высота */
|
||||
.dashboard-tile {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
min-height: 88px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dashboard-tile:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-tile .ant-card-body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* чтобы все плитки в ряду были одной высоты */
|
||||
.dashboard-tile-row .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
.dashboard-tile-row .ant-col .dashboard-tile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* заголовок плитки — фиксированная высота под 2 строки, чтобы «Приняты к работе» не делал карточку выше */
|
||||
.dashboard-tile-title {
|
||||
min-height: 2.5em;
|
||||
line-height: 1.25;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
249
frontend/src/components/form/StepComplaintsDashboard.tsx
Normal file
249
frontend/src/components/form/StepComplaintsDashboard.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* StepComplaintsDashboard.tsx
|
||||
*
|
||||
* Экран «Мои обращения»: плитки по статусам + кнопка «Подать жалобу».
|
||||
* Показывается после нажатия «Мои обращения» на приветственном экране.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Row, Col, Typography, Spin } from 'antd';
|
||||
import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle } from 'lucide-react';
|
||||
|
||||
import './StepComplaintsDashboard.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Статусы для плиток (маппинг status_code → категория дашборда)
|
||||
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
|
||||
const IN_WORK_CODE = 'in_work';
|
||||
const RESOLVED_CODES = ['completed', 'submitted'];
|
||||
const REJECTED_CODE = 'rejected';
|
||||
|
||||
interface DraftItem {
|
||||
claim_id?: string;
|
||||
id?: string;
|
||||
status_code?: string;
|
||||
}
|
||||
|
||||
interface Counts {
|
||||
pending: number;
|
||||
inWork: number;
|
||||
resolved: number;
|
||||
rejected: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function countByStatus(drafts: DraftItem[]): Counts {
|
||||
let pending = 0;
|
||||
let inWork = 0;
|
||||
let resolved = 0;
|
||||
let rejected = 0;
|
||||
for (const d of drafts) {
|
||||
const code = (d.status_code || '').toLowerCase();
|
||||
if (code === IN_WORK_CODE) inWork += 1;
|
||||
else if (code === REJECTED_CODE) rejected += 1;
|
||||
else if (RESOLVED_CODES.includes(code)) resolved += 1;
|
||||
else if (PENDING_CODES.includes(code) || code === 'draft') pending += 1;
|
||||
else pending += 1; // неизвестный — в «ожидании»
|
||||
}
|
||||
return {
|
||||
pending,
|
||||
inWork,
|
||||
resolved,
|
||||
rejected,
|
||||
total: drafts.length,
|
||||
};
|
||||
}
|
||||
|
||||
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||
|
||||
interface StepComplaintsDashboardProps {
|
||||
unified_id?: string;
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
onGoToList: (filter: DraftsListFilter) => void;
|
||||
onNewClaim: () => void;
|
||||
}
|
||||
|
||||
export default function StepComplaintsDashboard({
|
||||
unified_id,
|
||||
phone,
|
||||
session_id,
|
||||
onGoToList,
|
||||
onNewClaim,
|
||||
}: StepComplaintsDashboardProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [counts, setCounts] = useState<Counts>({ pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const params = new URLSearchParams();
|
||||
if (unified_id) params.append('unified_id', unified_id);
|
||||
else if (phone) params.append('phone', phone);
|
||||
else if (session_id) params.append('session_id', session_id);
|
||||
if (!unified_id && !phone && !session_id) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
fetch(`/api/v1/claims/drafts/list?${params.toString()}`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить список'))))
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const drafts = data.drafts || [];
|
||||
setCounts(countByStatus(drafts));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCounts((c) => ({ ...c, total: 0 }));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [unified_id, phone, session_id]);
|
||||
|
||||
const tiles = [
|
||||
{
|
||||
key: 'pending' as const,
|
||||
title: 'В ожидании',
|
||||
count: counts.pending,
|
||||
label: counts.pending === 1 ? '1 дело' : counts.pending < 5 ? `${counts.pending} дела` : `${counts.pending} дел`,
|
||||
color: '#3B82F6',
|
||||
bg: '#EFF6FF',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
key: 'in_work' as const,
|
||||
title: 'Приняты к работе',
|
||||
count: counts.inWork,
|
||||
label: counts.inWork === 1 ? '1 дело' : counts.inWork < 5 ? `${counts.inWork} дела` : `${counts.inWork} дел`,
|
||||
color: '#EA580C',
|
||||
bg: '#FFF7ED',
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
key: 'resolved' as const,
|
||||
title: 'Решены',
|
||||
count: counts.resolved,
|
||||
label: counts.resolved === 1 ? '1 дело' : counts.resolved < 5 ? `${counts.resolved} дела` : `${counts.resolved} дел`,
|
||||
color: '#16A34A',
|
||||
bg: '#F0FDF4',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
key: 'rejected' as const,
|
||||
title: 'Отклонены',
|
||||
count: counts.rejected,
|
||||
label: counts.rejected === 1 ? '1 дело' : counts.rejected < 5 ? `${counts.rejected} дела` : `${counts.rejected} дел`,
|
||||
color: '#DC2626',
|
||||
bg: '#FEF2F2',
|
||||
icon: XCircle,
|
||||
},
|
||||
];
|
||||
|
||||
const handleTileClick = (key: DraftsListFilter) => {
|
||||
onGoToList(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', paddingBottom: 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 4, color: '#111827', fontSize: 22 }}>
|
||||
Мои обращения
|
||||
</Title>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 20 }}>
|
||||
Выберите категорию
|
||||
</Text>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }} className="dashboard-tile-row">
|
||||
{tiles.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Col xs={12} key={t.key}>
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: t.bg }}
|
||||
onClick={() => handleTileClick(t.key)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: t.color,
|
||||
}}
|
||||
>
|
||||
<Icon size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }} className="dashboard-tile-title">
|
||||
{t.title}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
className="dashboard-tile"
|
||||
style={{ background: '#F9FAFB', marginBottom: 20 }}
|
||||
onClick={() => handleTileClick('all' as const)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#6366F1',
|
||||
}}
|
||||
>
|
||||
<FileSearch size={24} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
|
||||
Все обращения
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{counts.total === 1 ? '1 дело всего' : counts.total < 5 ? `${counts.total} дела всего` : `${counts.total} дел всего`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
icon={<PlusCircle size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />}
|
||||
onClick={onNewClaim}
|
||||
style={{ height: 48, fontSize: 16, borderRadius: 12 }}
|
||||
>
|
||||
Подать жалобу
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
|
||||
@@ -16,7 +15,7 @@ interface Props {
|
||||
export default function StepDescription({
|
||||
formData,
|
||||
updateFormData,
|
||||
onPrev,
|
||||
onPrev: _onPrev,
|
||||
onNext,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
@@ -210,10 +209,7 @@ export default function StepDescription({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 16 }}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onPrev}>
|
||||
Назад
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
|
||||
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
|
||||
12
frontend/src/components/form/StepDraftSelection.css
Normal file
12
frontend/src/components/form/StepDraftSelection.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Карточки списка обращений — как на hello: тень и подъём при наведении */
|
||||
.draft-list-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.draft-list-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Row, Col, Typography, Space, Empty, Popconfirm, message, Spin, Tooltip } from 'antd';
|
||||
import { Button, Card, Typography, Space, Empty, message, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
FileSearchOutlined,
|
||||
MobileOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
FolderOpenOutlined
|
||||
} from '@ant-design/icons';
|
||||
import './StepDraftSelection.css';
|
||||
import {
|
||||
Package,
|
||||
Wrench,
|
||||
@@ -90,6 +90,41 @@ const formatDate = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Короткая дата для карточек списка: "12 апреля 2024"
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
return `${day} ${month} ${year}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
// Маппинг status_code → категория дашборда (как в StepComplaintsDashboard)
|
||||
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
|
||||
const IN_WORK_CODE = 'in_work';
|
||||
const RESOLVED_CODES = ['completed', 'submitted'];
|
||||
const REJECTED_CODE = 'rejected';
|
||||
|
||||
function getDraftCategory(statusCode: string): 'pending' | 'in_work' | 'resolved' | 'rejected' {
|
||||
const code = (statusCode || '').toLowerCase();
|
||||
if (code === IN_WORK_CODE) return 'in_work';
|
||||
if (code === REJECTED_CODE) return 'rejected';
|
||||
if (RESOLVED_CODES.includes(code)) return 'resolved';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected', string> = {
|
||||
all: 'Все обращения',
|
||||
pending: 'В ожидании',
|
||||
in_work: 'Приняты к работе',
|
||||
resolved: 'Решены',
|
||||
rejected: 'Отклонены',
|
||||
};
|
||||
|
||||
// Относительное время
|
||||
const getRelativeTime = (dateStr: string) => {
|
||||
try {
|
||||
@@ -142,14 +177,23 @@ interface Draft {
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
}
|
||||
|
||||
/** Фильтр списка по категории (с дашборда) */
|
||||
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||
|
||||
interface Props {
|
||||
phone?: string;
|
||||
session_id?: string;
|
||||
unified_id?: string;
|
||||
isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App
|
||||
isTelegramMiniApp?: boolean;
|
||||
/** ID черновика, открытого для просмотра описания (управляется из ClaimForm, чтобы не терять при пересчёте steps) */
|
||||
draftDetailClaimId?: string | null;
|
||||
/** Показывать только обращения этой категории (с дашборда) */
|
||||
categoryFilter?: DraftsListFilter;
|
||||
onOpenDraftDetail?: (claimId: string) => void;
|
||||
onCloseDraftDetail?: () => void;
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
|
||||
onRestartDraft?: (claimId: string, description: string) => void;
|
||||
}
|
||||
|
||||
// === Конфиг статусов ===
|
||||
@@ -223,15 +267,31 @@ export default function StepDraftSelection({
|
||||
session_id,
|
||||
unified_id,
|
||||
isTelegramMiniApp,
|
||||
draftDetailClaimId = null,
|
||||
categoryFilter = 'all',
|
||||
onOpenDraftDetail,
|
||||
onCloseDraftDetail,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
onRestartDraft,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
|
||||
/** Список отфильтрован по категории с дашборда */
|
||||
const filteredDrafts =
|
||||
categoryFilter === 'all'
|
||||
? drafts
|
||||
: drafts.filter((d) => getDraftCategory(d.status_code) === categoryFilter);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
/** Черновик, открытый для просмотра полного описания (по клику на карточку) */
|
||||
const [selectedDraft, setSelectedDraft] = useState<Draft | null>(null);
|
||||
/** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */
|
||||
const [detailDraftPayload, setDetailDraftPayload] = useState<{ claimId: string; payload: Record<string, unknown> } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
/** Черновик для экрана описания: из пропа draftDetailClaimId + список drafts */
|
||||
const selectedDraft = draftDetailClaimId
|
||||
? (drafts.find((d) => (d.claim_id || d.id) === draftDetailClaimId) ?? null)
|
||||
: null;
|
||||
|
||||
const loadDrafts = async () => {
|
||||
try {
|
||||
@@ -332,6 +392,38 @@ export default function StepDraftSelection({
|
||||
return { uploaded, skipped, total, percent };
|
||||
};
|
||||
|
||||
// Открыть экран полного описания (загрузка payload — в useEffect по draftDetailClaimId)
|
||||
const openDraftDetail = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
onOpenDraftDetail?.(draftId);
|
||||
setDetailDraftPayload(null);
|
||||
setDetailLoading(true);
|
||||
};
|
||||
|
||||
const closeDraftDetail = () => {
|
||||
onCloseDraftDetail?.();
|
||||
setDetailDraftPayload(null);
|
||||
};
|
||||
|
||||
// Загрузка payload при открытии по draftDetailClaimId (клик по карточке или восстановление после пересчёта steps)
|
||||
useEffect(() => {
|
||||
if (!draftDetailClaimId) return;
|
||||
if (detailDraftPayload?.claimId === draftDetailClaimId) return;
|
||||
setDetailLoading(true);
|
||||
setDetailDraftPayload(null);
|
||||
const claimId = draftDetailClaimId;
|
||||
fetch(`/api/v1/claims/drafts/${claimId}`)
|
||||
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить черновик'))))
|
||||
.then((data) => {
|
||||
const payload = data?.claim?.payload;
|
||||
if (payload && typeof payload === 'object') {
|
||||
setDetailDraftPayload({ claimId, payload });
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setDetailLoading(false));
|
||||
}, [draftDetailClaimId]);
|
||||
|
||||
// Обработка клика на черновик
|
||||
const handleDraftAction = (draft: Draft) => {
|
||||
const draftId = draft.claim_id || draft.id;
|
||||
@@ -381,25 +473,28 @@ export default function StepDraftSelection({
|
||||
);
|
||||
};
|
||||
|
||||
// Экран полного описания черновика (по клику на карточку)
|
||||
if (selectedDraft) {
|
||||
const fullText = selectedDraft.problem_description || selectedDraft.facts_short || selectedDraft.problem_title || '—';
|
||||
const draftId = selectedDraft.claim_id || selectedDraft.id;
|
||||
// Экран полного описания черновика (draftDetailClaimId открыт; selectedDraft может быть null пока список не подгрузился)
|
||||
if (draftDetailClaimId) {
|
||||
const draftId = draftDetailClaimId;
|
||||
const payload = detailDraftPayload?.claimId === draftId ? detailDraftPayload.payload : null;
|
||||
const fromPayload =
|
||||
(payload && (payload.problem_description ?? payload.description ?? payload.chatInput)) ?? '';
|
||||
const fromDraft = selectedDraft
|
||||
? (selectedDraft.problem_description ||
|
||||
selectedDraft.facts_short ||
|
||||
selectedDraft.problem_title ||
|
||||
'')
|
||||
: '';
|
||||
const fullText = String(fromPayload || fromDraft || '').trim();
|
||||
const displayText = fullText || 'Описание не сохранено';
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}>
|
||||
<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>
|
||||
@@ -416,17 +511,17 @@ export default function StepDraftSelection({
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{fullText}
|
||||
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{selectedDraft.is_legacy && onRestartDraft ? (
|
||||
{selectedDraft?.is_legacy && onRestartDraft ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
onRestartDraft(draftId, selectedDraft.problem_description || '');
|
||||
setSelectedDraft(null);
|
||||
closeDraftDetail();
|
||||
}}
|
||||
>
|
||||
Начать заново
|
||||
@@ -438,7 +533,7 @@ export default function StepDraftSelection({
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => {
|
||||
onSelectDraft(draftId);
|
||||
setSelectedDraft(null);
|
||||
closeDraftDetail();
|
||||
}}
|
||||
>
|
||||
К документам
|
||||
@@ -451,166 +546,95 @@ export default function StepDraftSelection({
|
||||
);
|
||||
}
|
||||
|
||||
// Цвет точки статуса по категории (как на макете — зелёный для «Приняты к работе»)
|
||||
const statusDotColor: Record<string, string> = {
|
||||
pending: '#1890ff',
|
||||
in_work: '#52c41a',
|
||||
resolved: '#52c41a',
|
||||
rejected: '#ff4d4f',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px 0' }}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}>
|
||||
📋 Мои обращения
|
||||
</Title>
|
||||
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}>
|
||||
{/* Шапка: заголовок + подзаголовок категории */}
|
||||
<div style={{ marginBottom: 16, padding: '16px 0 8px' }}>
|
||||
<Title level={3} style={{ margin: 0, color: '#111827', fontWeight: 700 }}>
|
||||
Мои обращения
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14, marginTop: 4, display: 'block' }}>
|
||||
{CATEGORY_LABELS[categoryFilter]}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : filteredDrafts.length === 0 ? (
|
||||
<Empty
|
||||
description={categoryFilter === 'all' ? 'У вас пока нет обращений' : `Нет обращений в категории «${CATEGORY_LABELS[categoryFilter]}»`}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{filteredDrafts.map((draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const tileTitle = draft.facts_short
|
||||
|| draft.problem_title
|
||||
|| (draft.problem_description
|
||||
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
|
||||
: 'Обращение');
|
||||
const category = getDraftCategory(draft.status_code);
|
||||
const dotColor = statusDotColor[category] || '#8c8c8c';
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={draft.claim_id || draft.id}
|
||||
className="draft-list-card"
|
||||
hoverable
|
||||
style={{ background: '#fff', cursor: 'pointer' }}
|
||||
bodyStyle={{ padding: '14px 16px' }}
|
||||
onClick={() => openDraftDetail(draft)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Text strong style={{ fontSize: 15, color: '#111827', lineHeight: 1.35 }}>
|
||||
{tileTitle}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: dotColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 13, color: dotColor }}>{config.label}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.4 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateShort(draft.updated_at)}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ textAlign: 'center', padding: '8px 0' }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadDrafts}
|
||||
loading={loading}
|
||||
>
|
||||
Обновить список
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<Empty
|
||||
description="У вас пока нет незавершенных заявок"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
{drafts.map((draft) => {
|
||||
const config = getStatusConfig(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 (
|
||||
<Col xs={12} sm={8} md={6} key={draft.claim_id || draft.id}>
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
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)}
|
||||
>
|
||||
<div style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
background: iconBg,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: iconColor,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{DirectionIcon ? (
|
||||
<DirectionIcon size={28} strokeWidth={1.8} />
|
||||
) : (
|
||||
<span style={{ fontSize: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{config.icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 1.3,
|
||||
minHeight: 40,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
color: '#111827',
|
||||
width: '100%',
|
||||
wordBreak: 'break-word',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
<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="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadDrafts}
|
||||
loading={loading}
|
||||
>
|
||||
Обновить список
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1456,7 +1456,6 @@ export default function StepWizardPlan({
|
||||
status="warning"
|
||||
title="Нет session_id"
|
||||
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
|
||||
extra={<Button onClick={onPrev}>Вернуться</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2706,9 +2705,6 @@ export default function StepWizardPlan({
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev} style={{ marginRight: 12 }}>
|
||||
← Изменить описание
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => {
|
||||
// Сбрасываем состояние и возвращаемся на первый экран
|
||||
updateFormData({
|
||||
|
||||
@@ -2,6 +2,71 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { miniappLog, miniappSendLogs } from './utils/miniappLogger'
|
||||
|
||||
// #region agent log (build tag)
|
||||
// В прод-сборке это будет URL текущего JS-бандла (/assets/index-XXXX.js)
|
||||
(window as any).__MINIAPP_BUILD__ = (import.meta as any).url;
|
||||
// #endregion agent log
|
||||
|
||||
// Логирование при загрузке — по нему видно, какой фронт отдаётся и куда идут запросы
|
||||
const bootLog = {
|
||||
ts: new Date().toISOString(),
|
||||
href: window.location.href,
|
||||
origin: window.location.origin,
|
||||
pathname: window.location.pathname,
|
||||
host: window.location.host,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
marker: 'MINIAPP_AIFORM_PROD',
|
||||
// #region agent log (bundle identity)
|
||||
moduleUrl: (import.meta as any).url,
|
||||
scriptSrc:
|
||||
document
|
||||
.querySelector('script[type="module"][src*="/assets/index-"]')
|
||||
?.getAttribute('src') || undefined,
|
||||
build: (window as any).__MINIAPP_BUILD__,
|
||||
// #endregion agent log
|
||||
};
|
||||
console.log('[MINIAPP] Boot', bootLog);
|
||||
miniappLog('boot', bootLog);
|
||||
|
||||
// Логирование всех запросов к /api — куда реально уходят запросы (относительный URL = текущий origin)
|
||||
const _fetch = window.fetch;
|
||||
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api') || url.startsWith('/api')) {
|
||||
const full = url.startsWith('http') ? url : window.location.origin + (url.startsWith('/') ? url : '/' + url);
|
||||
console.log('[MINIAPP] API request', { url, full, method: init?.method || 'GET' });
|
||||
miniappLog('api_request', { url, full, method: init?.method || 'GET' });
|
||||
}
|
||||
return _fetch.apply(this, arguments as any);
|
||||
};
|
||||
|
||||
// Ловим JS-ошибки и отправляем дамп на бэк
|
||||
window.addEventListener('error', (e) => {
|
||||
const ev = e as ErrorEvent;
|
||||
miniappLog('window_error', {
|
||||
message: ev.message,
|
||||
filename: ev.filename,
|
||||
lineno: ev.lineno,
|
||||
colno: ev.colno,
|
||||
name: (ev.error && (ev.error as any).name) || undefined,
|
||||
stack: (ev.error && (ev.error as any).stack) || undefined,
|
||||
});
|
||||
void miniappSendLogs('window_error');
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const ev = e as PromiseRejectionEvent;
|
||||
const reason = ev.reason;
|
||||
miniappLog('unhandledrejection', {
|
||||
reason: reason ? String(reason) : '(empty)',
|
||||
name: reason && (reason as any).name ? String((reason as any).name) : undefined,
|
||||
stack: reason && (reason as any).stack ? String((reason as any).stack) : undefined,
|
||||
});
|
||||
void miniappSendLogs('unhandledrejection');
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
.claim-form-card {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-body {
|
||||
@@ -40,6 +40,7 @@
|
||||
.steps-content {
|
||||
min-height: 400px;
|
||||
padding: 20px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -68,8 +69,8 @@
|
||||
.claim-form-container.telegram-mini-app .claim-form-card {
|
||||
max-width: 100%;
|
||||
box-shadow: none;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
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 флоу
|
||||
import StepComplaintsDashboard from '../components/form/StepComplaintsDashboard';
|
||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||
@@ -12,6 +12,7 @@ import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||
import DebugPanel from '../components/DebugPanel';
|
||||
// getDocumentsForEventType убран - старый ERV флоу
|
||||
import './ClaimForm.css';
|
||||
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
|
||||
|
||||
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||
|
||||
@@ -95,6 +96,8 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
const autoLoadedClaimIdRef = useRef<string | null>(null);
|
||||
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
|
||||
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Защита от «ghost click» после навигации с /hello: игнорируем back первые ~1500мс
|
||||
const barBackIgnoreUntilRef = useRef<number>(Date.now() + 1500);
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
|
||||
@@ -109,6 +112,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
|
||||
/** ID черновика, открытого для просмотра описания (состояние в родителе, чтобы не терять при пересчёте steps) */
|
||||
const [draftDetailClaimId, setDraftDetailClaimId] = useState<string | null>(null);
|
||||
/** Фильтр списка обращений при переходе с дашборда: по какой категории показывать (all = все) */
|
||||
const [draftsListFilter, setDraftsListFilter] = useState<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected'>('all');
|
||||
const [hasDrafts, setHasDrafts] = useState(false);
|
||||
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
|
||||
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
|
||||
@@ -117,8 +124,41 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
|
||||
/** Заход через MAX Mini App. */
|
||||
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
|
||||
/** Платформа определена (TG/MAX/веб) — до этого шаг «Вход» не показываем, чтобы в MAX не мелькал экран телефона. */
|
||||
const [platformChecked, setPlatformChecked] = useState(false);
|
||||
const forceNewClaimRef = useRef(false);
|
||||
|
||||
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении (иначе до 2.5 с оба флага false)
|
||||
useEffect(() => {
|
||||
const detect = () => {
|
||||
const tg = (window as any).Telegram?.WebApp?.initData;
|
||||
const max = (window as any).WebApp?.initData;
|
||||
if (tg && typeof tg === 'string' && tg.length > 0) {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
if (max && typeof max === 'string' && max.length > 0) {
|
||||
setIsMaxMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (detect()) return;
|
||||
const interval = setInterval(() => {
|
||||
if (detect()) clearInterval(interval);
|
||||
}, 50);
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
setPlatformChecked(true);
|
||||
}, 3000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
|
||||
useEffect(() => {
|
||||
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
|
||||
@@ -246,13 +286,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
session_id: maxData.session_token,
|
||||
}));
|
||||
setIsPhoneVerified(true);
|
||||
if (maxData.has_drafts) {
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
setCurrentStep(0);
|
||||
} else {
|
||||
setCurrentStep(1);
|
||||
}
|
||||
setShowDraftSelection(!!maxData.has_drafts);
|
||||
setHasDrafts(!!maxData.has_drafts);
|
||||
setCurrentStep(0); // дашборд «Мои обращения» при заходе из MAX
|
||||
} else {
|
||||
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
|
||||
}
|
||||
@@ -321,23 +357,16 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
|
||||
if (data.has_drafts) {
|
||||
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
setCurrentStep(0);
|
||||
} else {
|
||||
// Иначе переходим сразу к описанию проблемы
|
||||
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
|
||||
setCurrentStep(1);
|
||||
}
|
||||
setShowDraftSelection(!!data.has_drafts);
|
||||
setHasDrafts(!!data.has_drafts);
|
||||
setCurrentStep(0); // дашборд «Мои обращения» при заходе из TG
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setTgDebug(`TG: ошибка: ${msg}`);
|
||||
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
|
||||
} finally {
|
||||
setTelegramAuthChecked(true);
|
||||
setPlatformChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -375,14 +404,15 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
body: JSON.stringify({ session_token: savedSessionToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
let data: any = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (_) {
|
||||
data = null;
|
||||
}
|
||||
console.log('🔑 Session verify response:', { ok: response.ok, status: response.status, data });
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔑 Session verify response:', data);
|
||||
|
||||
if (data.success && data.valid) {
|
||||
if (response.ok && data?.success && data?.valid) {
|
||||
// Сессия валидна! Восстанавливаем состояние
|
||||
console.log('✅ Session valid! Restoring user data:', {
|
||||
unified_id: data.unified_id,
|
||||
@@ -406,8 +436,11 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
|
||||
// На странице /new («Подать жалобу») не показываем черновики
|
||||
if (forceNewClaimRef.current) {
|
||||
setCurrentStep(1); // сразу к описанию
|
||||
message.success('Добро пожаловать!');
|
||||
// Если сессия валидна — не возвращаем на экран телефона
|
||||
setCurrentStep(1); // сразу к описанию (индекс зависит от step-структуры; ниже goBack не даст попасть на «Вход»)
|
||||
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||
message.success('Добро пожаловать!');
|
||||
}
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||
return;
|
||||
}
|
||||
@@ -415,36 +448,32 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
// Проверяем черновики
|
||||
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
|
||||
|
||||
if (hasDraftsResult) {
|
||||
// Есть черновики - показываем список
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
|
||||
// Переходим к шагу выбора черновика
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentStep(0);
|
||||
});
|
||||
});
|
||||
|
||||
setShowDraftSelection(!!hasDraftsResult);
|
||||
setHasDrafts(!!hasDraftsResult);
|
||||
setCurrentStep(0); // дашборд «Мои обращения»
|
||||
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
|
||||
} else {
|
||||
// Нет черновиков - переходим к описанию
|
||||
setCurrentStep(1);
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||
}
|
||||
} else {
|
||||
// Сессия невалидна - удаляем из localStorage
|
||||
addDebugEvent('session', 'success', hasDraftsResult ? '✅ Сессия восстановлена, найдены черновики' : '✅ Сессия восстановлена');
|
||||
}
|
||||
|
||||
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
|
||||
if (response.ok && data?.success && data?.valid === false) {
|
||||
console.log('❌ Session invalid or expired, removing from localStorage');
|
||||
localStorage.removeItem('session_token');
|
||||
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
|
||||
return;
|
||||
}
|
||||
|
||||
// Сетевые/серверные проблемы — токен не трогаем (иначе “порой разлогин”).
|
||||
if (!response.ok || !data?.success) {
|
||||
console.warn('⚠️ Session verify failed (token kept)', { ok: response.ok, status: response.status });
|
||||
addDebugEvent('session', 'warning', '⚠️ Не удалось проверить сессию (токен сохранён)');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error verifying session:', error);
|
||||
localStorage.removeItem('session_token');
|
||||
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
|
||||
// Не удаляем session_token на сетевых ошибках — это вызывает “рандомный разлогин”
|
||||
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии (токен сохранён)');
|
||||
} finally {
|
||||
setSessionRestored(true);
|
||||
}
|
||||
@@ -1463,10 +1492,25 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
const steps = useMemo(() => {
|
||||
const stepsArray: any[] = [];
|
||||
|
||||
// Шаг 0: Выбор черновика (показывается только если есть черновики)
|
||||
// ✅ unified_id уже означает, что телефон верифицирован
|
||||
// Не показываем черновики на странице «Подать жалобу» (/new)
|
||||
if (!forceNewClaimRef.current && (showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
|
||||
// Шаги «Мои обращения»: дашборд с плитками + список черновиков — для любого авторизованного (unified_id) на главной
|
||||
// Не показываем на странице «Подать жалобу» (/new)
|
||||
if (!forceNewClaimRef.current && formData.unified_id && !selectedDraftId) {
|
||||
stepsArray.push({
|
||||
title: 'Мои обращения',
|
||||
description: 'Ваши обращения',
|
||||
content: (
|
||||
<StepComplaintsDashboard
|
||||
unified_id={formData.unified_id}
|
||||
phone={formData.phone || ''}
|
||||
session_id={sessionIdRef.current}
|
||||
onGoToList={(filter) => {
|
||||
setDraftsListFilter(filter);
|
||||
nextStep();
|
||||
}}
|
||||
onNewClaim={handleNewClaim}
|
||||
/>
|
||||
),
|
||||
});
|
||||
stepsArray.push({
|
||||
title: 'Черновики',
|
||||
description: 'Выбор заявки',
|
||||
@@ -1474,8 +1518,12 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
<StepDraftSelection
|
||||
phone={formData.phone || ''}
|
||||
session_id={sessionIdRef.current}
|
||||
unified_id={formData.unified_id} // ✅ Передаём unified_id
|
||||
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
|
||||
unified_id={formData.unified_id}
|
||||
isTelegramMiniApp={isTelegramMiniApp}
|
||||
draftDetailClaimId={draftDetailClaimId}
|
||||
categoryFilter={draftsListFilter}
|
||||
onOpenDraftDetail={setDraftDetailClaimId}
|
||||
onCloseDraftDetail={() => setDraftDetailClaimId(null)}
|
||||
onSelectDraft={handleSelectDraft}
|
||||
onNewClaim={handleNewClaim}
|
||||
/>
|
||||
@@ -1483,12 +1531,13 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 1: Phone (телефон + SMS верификация)
|
||||
stepsArray.push({
|
||||
title: 'Вход',
|
||||
description: 'Подтверждение телефона',
|
||||
content: (
|
||||
<Step1Phone
|
||||
// Шаг «Вход» (телефон + SMS) только для обычного веба и только после определения платформы (в MAX/TG не показываем, и пока не проверили — тоже не показываем).
|
||||
if (platformChecked && !isTelegramMiniApp && !isMaxMiniApp) {
|
||||
stepsArray.push({
|
||||
title: 'Вход',
|
||||
description: 'Подтверждение телефона',
|
||||
content: (
|
||||
<Step1Phone
|
||||
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
||||
updateFormData={(data: any) => {
|
||||
updateFormData(data);
|
||||
@@ -1596,6 +1645,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
||||
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
|
||||
@@ -1617,7 +1667,66 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
// Step3Payment убран - не используется
|
||||
|
||||
return stepsArray;
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked]);
|
||||
|
||||
// Кнопка «Назад» в нижнем баре: обработка через событие (вместо кнопок в контенте)
|
||||
// ВАЖНО: держим effect ниже prevStep и steps (иначе TDZ/стейл шаги).
|
||||
useEffect(() => {
|
||||
const onGoBack = () => {
|
||||
const now = Date.now();
|
||||
const currentTitle = steps[currentStep]?.title;
|
||||
const prevTitle = currentStep > 0 ? steps[currentStep - 1]?.title : null;
|
||||
const isAuthed = !!formData.unified_id || isPhoneVerified || !!localStorage.getItem('session_token');
|
||||
|
||||
miniappLog('claim_form_go_back_event', {
|
||||
currentStep,
|
||||
currentTitle,
|
||||
prevTitle,
|
||||
isAuthed,
|
||||
hasUnifiedId: !!formData.unified_id,
|
||||
isPhoneVerified,
|
||||
forceNewClaim: forceNewClaimRef.current,
|
||||
ignoreUntil: barBackIgnoreUntilRef.current,
|
||||
now,
|
||||
});
|
||||
if (now < barBackIgnoreUntilRef.current) return;
|
||||
|
||||
// Если открыта деталка черновика — закрываем её
|
||||
if (draftDetailClaimId) {
|
||||
miniappLog('claim_form_go_back_action', { action: 'close_draft_detail' });
|
||||
setDraftDetailClaimId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Если “назад” ведёт на шаг «Вход», но мы уже авторизованы — не показываем телефон (это выглядит как разлогин)
|
||||
if (isAuthed && prevTitle === 'Вход') {
|
||||
const hasDashboard = steps[0]?.title === 'Мои обращения';
|
||||
if (hasDashboard) {
|
||||
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_dashboard' });
|
||||
setCurrentStep(0);
|
||||
return;
|
||||
}
|
||||
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_hello' });
|
||||
void miniappSendLogs('go_back_skip_phone');
|
||||
window.history.pushState({}, '', '/hello');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 0) {
|
||||
miniappLog('claim_form_go_back_action', { action: 'to_hello' });
|
||||
void miniappSendLogs('go_back_to_hello');
|
||||
window.history.pushState({}, '', '/hello');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
return;
|
||||
}
|
||||
|
||||
miniappLog('claim_form_go_back_action', { action: 'prev_step' });
|
||||
prevStep();
|
||||
};
|
||||
window.addEventListener('miniapp:goBack', onGoBack);
|
||||
return () => window.removeEventListener('miniapp:goBack', onGoBack);
|
||||
}, [currentStep, draftDetailClaimId, formData.unified_id, isPhoneVerified, prevStep, steps]);
|
||||
|
||||
const handleReset = () => {
|
||||
console.log('🔄 Начать заново - возврат к списку черновиков');
|
||||
@@ -1757,26 +1866,12 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
||||
{isDocumentsStep ? (
|
||||
<div className="steps-content" style={{ marginTop: 0 }}>
|
||||
{currentStep > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{isSubmitted ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
|
||||
|
||||
@@ -47,6 +47,34 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
const tryAuth = async () => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
// Сначала проверяем сохранённую сессию — при возврате «Домой» не показывать форму входа
|
||||
const savedToken = localStorage.getItem('session_token');
|
||||
if (savedToken) {
|
||||
try {
|
||||
const verifyRes = await fetch('/api/v1/session/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_token: savedToken }),
|
||||
});
|
||||
const verifyData = await verifyRes.json();
|
||||
if (verifyRes.ok && verifyData.success && verifyData.valid) {
|
||||
setGreeting(verifyData.greeting || 'Привет!');
|
||||
// В Telegram подставляем имя и аватар из WebApp (или из localStorage)
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
if (tgUser?.first_name) {
|
||||
setGreeting(`Привет, ${tgUser.first_name}!`);
|
||||
}
|
||||
let avatarUrl = tgUser?.photo_url || localStorage.getItem('user_avatar_url') || '';
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Telegram Mini App
|
||||
if (isTelegramContext()) {
|
||||
const script = document.createElement('script');
|
||||
|
||||
112
frontend/src/utils/miniappLogger.ts
Normal file
112
frontend/src/utils/miniappLogger.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
type MiniappLogEntry = {
|
||||
ts: number;
|
||||
iso: string;
|
||||
event: string;
|
||||
build?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__MINIAPP_LOGS__?: MiniappLogEntry[];
|
||||
__MINIAPP_BUILD__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const LS_KEY = 'miniapp_debug_logs_v1';
|
||||
const LS_BUILD_KEY = 'miniapp_debug_build_v1';
|
||||
const MAX_ENTRIES = 250;
|
||||
|
||||
function safeJsonParse<T>(s: string | null): T | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getBuffer(): MiniappLogEntry[] {
|
||||
if (!window.__MINIAPP_LOGS__) {
|
||||
window.__MINIAPP_LOGS__ = safeJsonParse<MiniappLogEntry[]>(localStorage.getItem(LS_KEY)) || [];
|
||||
}
|
||||
return window.__MINIAPP_LOGS__;
|
||||
}
|
||||
|
||||
function getBuildTag(): string | undefined {
|
||||
const b = window.__MINIAPP_BUILD__;
|
||||
return typeof b === 'string' && b.length ? b : undefined;
|
||||
}
|
||||
|
||||
function persist(buf: MiniappLogEntry[]) {
|
||||
try {
|
||||
const trimmed = buf.slice(-MAX_ENTRIES);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(trimmed));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function miniappLog(event: string, data?: unknown) {
|
||||
const entry: MiniappLogEntry = {
|
||||
ts: Date.now(),
|
||||
iso: new Date().toISOString(),
|
||||
event,
|
||||
build: getBuildTag(),
|
||||
data,
|
||||
};
|
||||
const buf = getBuffer();
|
||||
buf.push(entry);
|
||||
if (buf.length > MAX_ENTRIES * 2) {
|
||||
window.__MINIAPP_LOGS__ = buf.slice(-MAX_ENTRIES);
|
||||
}
|
||||
persist(window.__MINIAPP_LOGS__ || buf);
|
||||
// В TG/MAX консоль не всегда доступна, но пусть будет
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[MINIAPP][LOG]', entry.event, entry.data || '');
|
||||
}
|
||||
|
||||
export function miniappDumpLogs() {
|
||||
return getBuffer().slice(-MAX_ENTRIES);
|
||||
}
|
||||
|
||||
export async function miniappSendLogs(reason: string) {
|
||||
// Если билд сменился — не мешаем старые логи с новыми.
|
||||
// Это не “фикс бага”, а гарантия чистой диагностики.
|
||||
try {
|
||||
const cur = getBuildTag();
|
||||
const prev = localStorage.getItem(LS_BUILD_KEY) || '';
|
||||
if (cur && prev && cur !== prev) {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
window.__MINIAPP_LOGS__ = [];
|
||||
}
|
||||
if (cur) localStorage.setItem(LS_BUILD_KEY, cur);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const payload = {
|
||||
reason,
|
||||
client: {
|
||||
href: window.location.href,
|
||||
origin: window.location.origin,
|
||||
pathname: window.location.pathname,
|
||||
search: window.location.search,
|
||||
hash: window.location.hash,
|
||||
ua: navigator.userAgent,
|
||||
referrer: document.referrer,
|
||||
ts: Date.now(),
|
||||
},
|
||||
logs: miniappDumpLogs(),
|
||||
};
|
||||
try {
|
||||
await fetch('/api/v1/utils/client-log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user