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:
|
if facts_short and len(facts_short) > 200:
|
||||||
facts_short = facts_short[:200].rstrip() + '…'
|
facts_short = facts_short[:200].rstrip() + '…'
|
||||||
|
|
||||||
# Подробное описание (для превью)
|
# Подробное описание (для превью); n8n может сохранять в description/chatInput
|
||||||
problem_text = payload.get('problem_description', '')
|
problem_text = (
|
||||||
|
payload.get('problem_description')
|
||||||
|
or payload.get('description')
|
||||||
|
or payload.get('chatInput')
|
||||||
|
or ''
|
||||||
|
)
|
||||||
|
|
||||||
# Считаем документы
|
# Считаем документы
|
||||||
documents_meta = payload.get('documents_meta') or []
|
documents_meta = payload.get('documents_meta') or []
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
Ticket Form Intake Platform - FastAPI Backend
|
Ticket Form Intake Platform - FastAPI Backend
|
||||||
"""
|
"""
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
|
import json
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from .config import settings, get_cors_origins_live, get_settings
|
from .config import settings, get_cors_origins_live, get_settings
|
||||||
from .services.database import db
|
from .services.database import db
|
||||||
@@ -23,6 +27,82 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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")
|
@app.get("/api/v1/info")
|
||||||
async def 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
|
FROM node:18-alpine AS builder
|
||||||
|
|
||||||
# Устанавливаем рабочую директорию
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
# Копируем package.json
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Устанавливаем зависимости
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Копируем исходный код
|
|
||||||
COPY . .
|
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
|
FROM node:18-alpine
|
||||||
|
|
||||||
# Устанавливаем serve глобально
|
|
||||||
RUN npm install -g serve
|
|
||||||
|
|
||||||
# Копируем собранное приложение из builder stage
|
|
||||||
COPY --from=builder /app/dist /app/dist
|
|
||||||
|
|
||||||
# Устанавливаем рабочую директорию
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
RUN npm install -g serve
|
||||||
# Открываем порт
|
COPY --from=builder /app/dist ./dist
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Запускаем serve для раздачи статических файлов
|
|
||||||
CMD ["serve", "-s", "dist", "-l", "3000"]
|
CMD ["serve", "-s", "dist", "-l", "3000"]
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,17 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Clientright — защита прав потребителей</title>
|
<title>Clientright — защита прав потребителей</title>
|
||||||
<!-- MAX Bridge: нужен для window.WebApp и initData при заходе из MAX -->
|
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
|
||||||
<script src="https://st.max.ru/js/max-web-app.js"></script>
|
<script>
|
||||||
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
|
(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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<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 ClaimForm from './pages/ClaimForm';
|
||||||
import HelloAuth from './pages/HelloAuth';
|
import HelloAuth from './pages/HelloAuth';
|
||||||
import BottomBar from './components/BottomBar';
|
import BottomBar from './components/BottomBar';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
|
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||||
|
const lastRouteTsRef = useRef<number>(Date.now());
|
||||||
|
const lastPathRef = useRef<string>(pathname);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPopState = () => setPathname(window.location.pathname || '');
|
const onPopState = () => setPathname(window.location.pathname || '');
|
||||||
@@ -14,6 +17,41 @@ function App() {
|
|||||||
return () => window.removeEventListener('popstate', onPopState);
|
return () => window.removeEventListener('popstate', onPopState);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Логируем смену маршрута + ловим быстрый возврат на /hello (симптом бага)
|
||||||
|
useEffect(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = lastPathRef.current;
|
||||||
|
lastPathRef.current = pathname;
|
||||||
|
lastRouteTsRef.current = now;
|
||||||
|
miniappLog('route', { prev, next: pathname });
|
||||||
|
|
||||||
|
if (pathname.startsWith('/hello') && !prev.startsWith('/hello')) {
|
||||||
|
// Вернулись на /hello: отправим дамп, чтобы поймать “ложится”
|
||||||
|
void miniappSendLogs('returned_to_hello');
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Ловим клики в первые 2с после смены маршрута (ghost click / попадание в бар)
|
||||||
|
useEffect(() => {
|
||||||
|
const onClickCapture = (e: MouseEvent) => {
|
||||||
|
const dt = Date.now() - lastRouteTsRef.current;
|
||||||
|
if (dt > 2000) return;
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
const inBar = !!t?.closest?.('.app-bottom-bar');
|
||||||
|
miniappLog('click_capture', {
|
||||||
|
dtFromRouteMs: dt,
|
||||||
|
inBottomBar: inBar,
|
||||||
|
tag: t?.tagName,
|
||||||
|
id: t?.id,
|
||||||
|
class: t?.className,
|
||||||
|
x: (e as MouseEvent).clientX,
|
||||||
|
y: (e as MouseEvent).clientY,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener('click', onClickCapture, true);
|
||||||
|
return () => window.removeEventListener('click', onClickCapture, true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
|
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|||||||
@@ -43,6 +43,15 @@
|
|||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-bar-item:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bar-item:disabled:hover {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.app-bar-item--active {
|
.app-bar-item--active {
|
||||||
color: #2563EB;
|
color: #2563EB;
|
||||||
font-weight: 600;
|
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 './BottomBar.css';
|
||||||
|
import { miniappLog } from '../utils/miniappLogger';
|
||||||
|
|
||||||
interface BottomBarProps {
|
interface BottomBarProps {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
@@ -8,32 +10,100 @@ interface BottomBarProps {
|
|||||||
|
|
||||||
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||||
const isHome = currentPath.startsWith('/hello');
|
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) => {
|
const handleExit = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Telegram Mini App
|
const tgWebApp = (window as any).Telegram?.WebApp;
|
||||||
try {
|
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
||||||
const tg = (window as any).Telegram;
|
const isTg =
|
||||||
const webApp = tg?.WebApp;
|
tgInitData.length > 0 ||
|
||||||
if (webApp && typeof webApp.close === 'function') {
|
window.location.href.includes('tgWebAppData') ||
|
||||||
webApp.close();
|
navigator.userAgent.includes('Telegram');
|
||||||
return;
|
|
||||||
}
|
const maxWebApp = (window as any).WebApp;
|
||||||
} catch (_) {}
|
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
||||||
// MAX Mini App
|
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
||||||
try {
|
const isMax =
|
||||||
const maxWebApp = (window as any).WebApp;
|
maxInitData.length > 0 ||
|
||||||
if (maxWebApp && typeof maxWebApp.close === 'function') {
|
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
|
||||||
maxWebApp.close();
|
|
||||||
return;
|
miniappLog('bottom_bar_exit_click', {
|
||||||
}
|
currentPath,
|
||||||
} catch (_) {}
|
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: переход на главную
|
// Fallback: переход на главную
|
||||||
|
miniappLog('bottom_bar_exit_fallback', {});
|
||||||
window.location.href = '/hello';
|
window.location.href = '/hello';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="app-bottom-bar" aria-label="Навигация">
|
<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' : ''}`}>
|
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
|
||||||
<Home size={24} strokeWidth={1.8} />
|
<Home size={24} strokeWidth={1.8} />
|
||||||
<span>Домой</span>
|
<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 { Form, Input, Button, Typography, message, Checkbox } from 'antd';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import wizardPlanSample from '../../mocks/wizardPlanSample';
|
import wizardPlanSample from '../../mocks/wizardPlanSample';
|
||||||
|
|
||||||
@@ -16,7 +15,7 @@ interface Props {
|
|||||||
export default function StepDescription({
|
export default function StepDescription({
|
||||||
formData,
|
formData,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
onPrev,
|
onPrev: _onPrev,
|
||||||
onNext,
|
onNext,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -210,10 +209,7 @@ export default function StepDescription({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
|
||||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onPrev}>
|
|
||||||
Назад
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
|
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
|
||||||
Продолжить →
|
Продолжить →
|
||||||
</Button>
|
</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 { 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 {
|
import {
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
@@ -26,9 +26,9 @@ import {
|
|||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
ArrowLeftOutlined,
|
|
||||||
FolderOpenOutlined
|
FolderOpenOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import './StepDraftSelection.css';
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Wrench,
|
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) => {
|
const getRelativeTime = (dateStr: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -142,14 +177,23 @@ interface Draft {
|
|||||||
is_legacy?: boolean; // Старый формат без documents_required
|
is_legacy?: boolean; // Старый формат без documents_required
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Фильтр списка по категории (с дашборда) */
|
||||||
|
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
phone?: string;
|
phone?: string;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
unified_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;
|
onSelectDraft: (claimId: string) => void;
|
||||||
onNewClaim: () => 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,
|
session_id,
|
||||||
unified_id,
|
unified_id,
|
||||||
isTelegramMiniApp,
|
isTelegramMiniApp,
|
||||||
|
draftDetailClaimId = null,
|
||||||
|
categoryFilter = 'all',
|
||||||
|
onOpenDraftDetail,
|
||||||
|
onCloseDraftDetail,
|
||||||
onSelectDraft,
|
onSelectDraft,
|
||||||
onNewClaim,
|
onNewClaim,
|
||||||
onRestartDraft,
|
onRestartDraft,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||||
|
|
||||||
|
/** Список отфильтрован по категории с дашборда */
|
||||||
|
const filteredDrafts =
|
||||||
|
categoryFilter === 'all'
|
||||||
|
? drafts
|
||||||
|
: drafts.filter((d) => getDraftCategory(d.status_code) === categoryFilter);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
/** Черновик, открытый для просмотра полного описания (по клику на карточку) */
|
/** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */
|
||||||
const [selectedDraft, setSelectedDraft] = useState<Draft | null>(null);
|
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 () => {
|
const loadDrafts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -332,6 +392,38 @@ export default function StepDraftSelection({
|
|||||||
return { uploaded, skipped, total, percent };
|
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 handleDraftAction = (draft: Draft) => {
|
||||||
const draftId = draft.claim_id || draft.id;
|
const draftId = draft.claim_id || draft.id;
|
||||||
@@ -381,25 +473,28 @@ export default function StepDraftSelection({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Экран полного описания черновика (по клику на карточку)
|
// Экран полного описания черновика (draftDetailClaimId открыт; selectedDraft может быть null пока список не подгрузился)
|
||||||
if (selectedDraft) {
|
if (draftDetailClaimId) {
|
||||||
const fullText = selectedDraft.problem_description || selectedDraft.facts_short || selectedDraft.problem_title || '—';
|
const draftId = draftDetailClaimId;
|
||||||
const draftId = selectedDraft.claim_id || selectedDraft.id;
|
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 (
|
return (
|
||||||
<div style={{ padding: '12px 16px' }}>
|
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}>
|
||||||
<Card
|
<Card
|
||||||
bodyStyle={{ padding: '16px 20px' }}
|
bodyStyle={{ padding: '16px 20px' }}
|
||||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
|
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
<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 level={4} style={{ marginBottom: 8, color: '#111827' }}>
|
||||||
Обращение
|
Обращение
|
||||||
</Title>
|
</Title>
|
||||||
@@ -416,17 +511,17 @@ export default function StepDraftSelection({
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{fullText}
|
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{selectedDraft.is_legacy && onRestartDraft ? (
|
{selectedDraft?.is_legacy && onRestartDraft ? (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRestartDraft(draftId, selectedDraft.problem_description || '');
|
onRestartDraft(draftId, selectedDraft.problem_description || '');
|
||||||
setSelectedDraft(null);
|
closeDraftDetail();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Начать заново
|
Начать заново
|
||||||
@@ -438,7 +533,7 @@ export default function StepDraftSelection({
|
|||||||
icon={<FolderOpenOutlined />}
|
icon={<FolderOpenOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSelectDraft(draftId);
|
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 (
|
return (
|
||||||
<div style={{ padding: '12px 16px' }}>
|
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}>
|
||||||
<Card
|
{/* Шапка: заголовок + подзаголовок категории */}
|
||||||
bodyStyle={{ padding: '16px 0' }}
|
<div style={{ marginBottom: 16, padding: '16px 0 8px' }}>
|
||||||
style={{
|
<Title level={3} style={{ margin: 0, color: '#111827', fontWeight: 700 }}>
|
||||||
borderRadius: 8,
|
Мои обращения
|
||||||
border: '1px solid #d9d9d9',
|
</Title>
|
||||||
background: '#fff',
|
<Text type="secondary" style={{ fontSize: 14, marginTop: 4, display: 'block' }}>
|
||||||
}}
|
{CATEGORY_LABELS[categoryFilter]}
|
||||||
>
|
</Text>
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
</div>
|
||||||
<div>
|
|
||||||
<Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}>
|
{loading ? (
|
||||||
📋 Мои обращения
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
</Title>
|
<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>
|
</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>
|
</Space>
|
||||||
</Card>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1456,7 +1456,6 @@ export default function StepWizardPlan({
|
|||||||
status="warning"
|
status="warning"
|
||||||
title="Нет session_id"
|
title="Нет session_id"
|
||||||
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
|
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
|
||||||
extra={<Button onClick={onPrev}>Вернуться</Button>}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2706,9 +2705,6 @@ export default function StepWizardPlan({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 24 }}>
|
<div style={{ marginTop: 24 }}>
|
||||||
<Button onClick={onPrev} style={{ marginRight: 12 }}>
|
|
||||||
← Изменить описание
|
|
||||||
</Button>
|
|
||||||
<Button type="primary" onClick={() => {
|
<Button type="primary" onClick={() => {
|
||||||
// Сбрасываем состояние и возвращаемся на первый экран
|
// Сбрасываем состояние и возвращаемся на первый экран
|
||||||
updateFormData({
|
updateFormData({
|
||||||
|
|||||||
@@ -2,6 +2,71 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
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(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -11,9 +11,9 @@
|
|||||||
.claim-form-card {
|
.claim-form-card {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: none;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
border: 1px solid #d9d9d9;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-form-card .ant-card-body {
|
.claim-form-card .ant-card-body {
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
.steps-content {
|
.steps-content {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -68,8 +69,8 @@
|
|||||||
.claim-form-container.telegram-mini-app .claim-form-card {
|
.claim-form-container.telegram-mini-app .claim-form-card {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {
|
.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 { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import { Card, message, Row, Col, Spin, Button } from 'antd';
|
import { Card, message, Row, Col, Spin, Button } from 'antd';
|
||||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
|
||||||
import Step1Phone from '../components/form/Step1Phone';
|
import Step1Phone from '../components/form/Step1Phone';
|
||||||
import StepDescription from '../components/form/StepDescription';
|
import StepDescription from '../components/form/StepDescription';
|
||||||
// Step1Policy убран - старый ERV флоу
|
// Step1Policy убран - старый ERV флоу
|
||||||
|
import StepComplaintsDashboard from '../components/form/StepComplaintsDashboard';
|
||||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||||
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
||||||
@@ -12,6 +12,7 @@ import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
|
|||||||
import DebugPanel from '../components/DebugPanel';
|
import DebugPanel from '../components/DebugPanel';
|
||||||
// getDocumentsForEventType убран - старый ERV флоу
|
// getDocumentsForEventType убран - старый ERV флоу
|
||||||
import './ClaimForm.css';
|
import './ClaimForm.css';
|
||||||
|
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
|
||||||
|
|
||||||
// Используем относительные пути - Vite proxy перенаправит на backend
|
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
const autoLoadedClaimIdRef = useRef<string | null>(null);
|
const autoLoadedClaimIdRef = useRef<string | null>(null);
|
||||||
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
|
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
|
||||||
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | 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 [currentStep, setCurrentStep] = useState(0);
|
||||||
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
|
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
|
||||||
@@ -109,6 +112,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
||||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
|
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 [hasDrafts, setHasDrafts] = useState(false);
|
||||||
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
|
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
|
||||||
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
|
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
|
||||||
@@ -117,8 +124,41 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
|
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
|
||||||
/** Заход через MAX Mini App. */
|
/** Заход через MAX Mini App. */
|
||||||
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
|
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
|
||||||
|
/** Платформа определена (TG/MAX/веб) — до этого шаг «Вход» не показываем, чтобы в MAX не мелькал экран телефона. */
|
||||||
|
const [platformChecked, setPlatformChecked] = useState(false);
|
||||||
const forceNewClaimRef = useRef(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 — сразу форма новой жалобы, без экрана черновиков
|
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
|
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,
|
session_id: maxData.session_token,
|
||||||
}));
|
}));
|
||||||
setIsPhoneVerified(true);
|
setIsPhoneVerified(true);
|
||||||
if (maxData.has_drafts) {
|
setShowDraftSelection(!!maxData.has_drafts);
|
||||||
setShowDraftSelection(true);
|
setHasDrafts(!!maxData.has_drafts);
|
||||||
setHasDrafts(true);
|
setCurrentStep(0); // дашборд «Мои обращения» при заходе из MAX
|
||||||
setCurrentStep(0);
|
|
||||||
} else {
|
|
||||||
setCurrentStep(1);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
|
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
|
||||||
}
|
}
|
||||||
@@ -321,23 +357,16 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
|
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
|
||||||
setIsPhoneVerified(true);
|
setIsPhoneVerified(true);
|
||||||
|
|
||||||
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
|
setShowDraftSelection(!!data.has_drafts);
|
||||||
if (data.has_drafts) {
|
setHasDrafts(!!data.has_drafts);
|
||||||
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
|
setCurrentStep(0); // дашборд «Мои обращения» при заходе из TG
|
||||||
setShowDraftSelection(true);
|
|
||||||
setHasDrafts(true);
|
|
||||||
setCurrentStep(0);
|
|
||||||
} else {
|
|
||||||
// Иначе переходим сразу к описанию проблемы
|
|
||||||
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
|
|
||||||
setCurrentStep(1);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
setTgDebug(`TG: ошибка: ${msg}`);
|
setTgDebug(`TG: ошибка: ${msg}`);
|
||||||
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
|
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
|
||||||
} finally {
|
} finally {
|
||||||
setTelegramAuthChecked(true);
|
setTelegramAuthChecked(true);
|
||||||
|
setPlatformChecked(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -375,14 +404,15 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
body: JSON.stringify({ session_token: savedSessionToken })
|
body: JSON.stringify({ session_token: savedSessionToken })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
let data: any = null;
|
||||||
throw new Error(`HTTP ${response.status}`);
|
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();
|
if (response.ok && data?.success && data?.valid) {
|
||||||
console.log('🔑 Session verify response:', data);
|
|
||||||
|
|
||||||
if (data.success && data.valid) {
|
|
||||||
// Сессия валидна! Восстанавливаем состояние
|
// Сессия валидна! Восстанавливаем состояние
|
||||||
console.log('✅ Session valid! Restoring user data:', {
|
console.log('✅ Session valid! Restoring user data:', {
|
||||||
unified_id: data.unified_id,
|
unified_id: data.unified_id,
|
||||||
@@ -406,8 +436,11 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
|
|
||||||
// На странице /new («Подать жалобу») не показываем черновики
|
// На странице /new («Подать жалобу») не показываем черновики
|
||||||
if (forceNewClaimRef.current) {
|
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', '✅ Сессия восстановлена');
|
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -415,36 +448,32 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
// Проверяем черновики
|
// Проверяем черновики
|
||||||
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
|
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
|
||||||
|
|
||||||
if (hasDraftsResult) {
|
setShowDraftSelection(!!hasDraftsResult);
|
||||||
// Есть черновики - показываем список
|
setHasDrafts(!!hasDraftsResult);
|
||||||
setShowDraftSelection(true);
|
setCurrentStep(0); // дашборд «Мои обращения»
|
||||||
setHasDrafts(true);
|
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||||
|
|
||||||
// Переходим к шагу выбора черновика
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setCurrentStep(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
message.success('Добро пожаловать!');
|
message.success('Добро пожаловать!');
|
||||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
|
|
||||||
} else {
|
|
||||||
// Нет черновиков - переходим к описанию
|
|
||||||
setCurrentStep(1);
|
|
||||||
message.success('Добро пожаловать!');
|
|
||||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
|
||||||
}
|
}
|
||||||
} else {
|
addDebugEvent('session', 'success', hasDraftsResult ? '✅ Сессия восстановлена, найдены черновики' : '✅ Сессия восстановлена');
|
||||||
// Сессия невалидна - удаляем из localStorage
|
}
|
||||||
|
|
||||||
|
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
|
||||||
|
if (response.ok && data?.success && data?.valid === false) {
|
||||||
console.log('❌ Session invalid or expired, removing from localStorage');
|
console.log('❌ Session invalid or expired, removing from localStorage');
|
||||||
localStorage.removeItem('session_token');
|
localStorage.removeItem('session_token');
|
||||||
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
|
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) {
|
} catch (error) {
|
||||||
console.error('❌ Error verifying session:', error);
|
console.error('❌ Error verifying session:', error);
|
||||||
localStorage.removeItem('session_token');
|
// Не удаляем session_token на сетевых ошибках — это вызывает “рандомный разлогин”
|
||||||
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
|
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии (токен сохранён)');
|
||||||
} finally {
|
} finally {
|
||||||
setSessionRestored(true);
|
setSessionRestored(true);
|
||||||
}
|
}
|
||||||
@@ -1463,10 +1492,25 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
const steps = useMemo(() => {
|
const steps = useMemo(() => {
|
||||||
const stepsArray: any[] = [];
|
const stepsArray: any[] = [];
|
||||||
|
|
||||||
// Шаг 0: Выбор черновика (показывается только если есть черновики)
|
// Шаги «Мои обращения»: дашборд с плитками + список черновиков — для любого авторизованного (unified_id) на главной
|
||||||
// ✅ unified_id уже означает, что телефон верифицирован
|
// Не показываем на странице «Подать жалобу» (/new)
|
||||||
// Не показываем черновики на странице «Подать жалобу» (/new)
|
if (!forceNewClaimRef.current && formData.unified_id && !selectedDraftId) {
|
||||||
if (!forceNewClaimRef.current && (showDraftSelection || (formData.unified_id && hasDrafts)) && !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({
|
stepsArray.push({
|
||||||
title: 'Черновики',
|
title: 'Черновики',
|
||||||
description: 'Выбор заявки',
|
description: 'Выбор заявки',
|
||||||
@@ -1474,8 +1518,12 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
<StepDraftSelection
|
<StepDraftSelection
|
||||||
phone={formData.phone || ''}
|
phone={formData.phone || ''}
|
||||||
session_id={sessionIdRef.current}
|
session_id={sessionIdRef.current}
|
||||||
unified_id={formData.unified_id} // ✅ Передаём unified_id
|
unified_id={formData.unified_id}
|
||||||
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
|
isTelegramMiniApp={isTelegramMiniApp}
|
||||||
|
draftDetailClaimId={draftDetailClaimId}
|
||||||
|
categoryFilter={draftsListFilter}
|
||||||
|
onOpenDraftDetail={setDraftDetailClaimId}
|
||||||
|
onCloseDraftDetail={() => setDraftDetailClaimId(null)}
|
||||||
onSelectDraft={handleSelectDraft}
|
onSelectDraft={handleSelectDraft}
|
||||||
onNewClaim={handleNewClaim}
|
onNewClaim={handleNewClaim}
|
||||||
/>
|
/>
|
||||||
@@ -1483,12 +1531,13 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 1: Phone (телефон + SMS верификация)
|
// Шаг «Вход» (телефон + SMS) только для обычного веба и только после определения платформы (в MAX/TG не показываем, и пока не проверили — тоже не показываем).
|
||||||
stepsArray.push({
|
if (platformChecked && !isTelegramMiniApp && !isMaxMiniApp) {
|
||||||
title: 'Вход',
|
stepsArray.push({
|
||||||
description: 'Подтверждение телефона',
|
title: 'Вход',
|
||||||
content: (
|
description: 'Подтверждение телефона',
|
||||||
<Step1Phone
|
content: (
|
||||||
|
<Step1Phone
|
||||||
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
||||||
updateFormData={(data: any) => {
|
updateFormData={(data: any) => {
|
||||||
updateFormData(data);
|
updateFormData(data);
|
||||||
@@ -1596,6 +1645,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
||||||
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
|
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
|
||||||
@@ -1617,7 +1667,66 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
// Step3Payment убран - не используется
|
// Step3Payment убран - не используется
|
||||||
|
|
||||||
return stepsArray;
|
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 = () => {
|
const handleReset = () => {
|
||||||
console.log('🔄 Начать заново - возврат к списку черновиков');
|
console.log('🔄 Начать заново - возврат к списку черновиков');
|
||||||
@@ -1757,26 +1866,12 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
|||||||
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
||||||
{isDocumentsStep ? (
|
{isDocumentsStep ? (
|
||||||
<div className="steps-content" style={{ marginTop: 0 }}>
|
<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 : (
|
{steps[currentStep] ? steps[currentStep].content : (
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
|
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card title={null} className="claim-form-card" bordered={false}>
|
<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 ? (
|
{isSubmitted ? (
|
||||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||||
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
|
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
|
||||||
|
|||||||
@@ -47,6 +47,34 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
|||||||
const tryAuth = async () => {
|
const tryAuth = async () => {
|
||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
try {
|
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
|
// Telegram Mini App
|
||||||
if (isTelegramContext()) {
|
if (isTelegramContext()) {
|
||||||
const script = document.createElement('script');
|
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