Draft detail and Back button
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"type-check": "tsc --noEmit",
|
||||
|
||||
18
frontend/scripts/crypto-polyfill.cjs
Normal file
18
frontend/scripts/crypto-polyfill.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Полифилл crypto.getRandomValues для Node 16 (нужен Vite при сборке).
|
||||
* Запуск: node -r ./scripts/crypto-polyfill.cjs node_modules/vite/bin/vite.js build
|
||||
*/
|
||||
const crypto = require('node:crypto');
|
||||
function getRandomValues(buffer) {
|
||||
if (!buffer) return buffer;
|
||||
const bytes = crypto.randomBytes(buffer.length);
|
||||
buffer.set(bytes);
|
||||
return buffer;
|
||||
}
|
||||
if (typeof crypto.getRandomValues !== 'function') {
|
||||
crypto.getRandomValues = getRandomValues;
|
||||
}
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.crypto = globalThis.crypto || {};
|
||||
globalThis.crypto.getRandomValues = getRandomValues;
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
import ClaimForm from './pages/ClaimForm'
|
||||
import HelloAuth from './pages/HelloAuth'
|
||||
import './App.css'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import ClaimForm from './pages/ClaimForm';
|
||||
import HelloAuth from './pages/HelloAuth';
|
||||
import BottomBar from './components/BottomBar';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const pathname = window.location.pathname || '';
|
||||
if (pathname.startsWith('/hello')) {
|
||||
return <HelloAuth />;
|
||||
}
|
||||
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPathname(window.location.pathname || '');
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
|
||||
}, [pathname]);
|
||||
|
||||
const isNewClaimPage = pathname === '/new';
|
||||
|
||||
const navigateTo = useCallback((path: string) => {
|
||||
window.history.pushState({}, '', path);
|
||||
setPathname(path);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<ClaimForm />
|
||||
{pathname.startsWith('/hello') ? (
|
||||
<HelloAuth onAvatarChange={setAvatarUrl} onNavigate={navigateTo} />
|
||||
) : (
|
||||
<ClaimForm forceNewClaim={isNewClaimPage} />
|
||||
)}
|
||||
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
64
frontend/src/components/BottomBar.css
Normal file
64
frontend/src/components/BottomBar.css
Normal file
@@ -0,0 +1,64 @@
|
||||
.app-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
min-height: 64px;
|
||||
height: calc(64px + env(safe-area-inset-bottom, 0));
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
padding-left: env(safe-area-inset-left, 0);
|
||||
padding-right: env(safe-area-inset-right, 0);
|
||||
background: #ffffff;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.app-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.app-bar-item:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.app-bar-item--active {
|
||||
color: #2563EB;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-bar-item--active:hover {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
.app-bar-item--exit:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.app-bar-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
59
frontend/src/components/BottomBar.tsx
Normal file
59
frontend/src/components/BottomBar.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Home, Headphones, User, LogOut } from 'lucide-react';
|
||||
import './BottomBar.css';
|
||||
|
||||
interface BottomBarProps {
|
||||
currentPath: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
const isHome = currentPath.startsWith('/hello');
|
||||
|
||||
const handleExit = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// Telegram Mini App
|
||||
try {
|
||||
const tg = (window as any).Telegram;
|
||||
const webApp = tg?.WebApp;
|
||||
if (webApp && typeof webApp.close === 'function') {
|
||||
webApp.close();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// MAX Mini App
|
||||
try {
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
if (maxWebApp && typeof maxWebApp.close === 'function') {
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: переход на главную
|
||||
window.location.href = '/hello';
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="app-bottom-bar" aria-label="Навигация">
|
||||
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
|
||||
<Home size={24} strokeWidth={1.8} />
|
||||
<span>Домой</span>
|
||||
</a>
|
||||
<a href="/hello" className="app-bar-item">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="app-bar-avatar" />
|
||||
) : (
|
||||
<User size={24} strokeWidth={1.8} />
|
||||
)}
|
||||
<span>Профиль</span>
|
||||
</a>
|
||||
<a href="/hello" className="app-bar-item">
|
||||
<Headphones size={24} strokeWidth={1.8} />
|
||||
<span>Поддержка</span>
|
||||
</a>
|
||||
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">
|
||||
<LogOut size={24} strokeWidth={1.8} />
|
||||
<span>Выход</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Form, Input, Button, Typography, message, Checkbox } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import wizardPlanSample from '../../mocks/wizardPlanSample';
|
||||
|
||||
@@ -135,13 +136,9 @@ export default function StepDescription({
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev} size="large">
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
marginTop: 0,
|
||||
padding: 24,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 8,
|
||||
@@ -213,7 +210,10 @@ export default function StepDescription({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 16 }}>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onPrev}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
|
||||
import { Button, Card, Row, Col, Typography, Space, Empty, Popconfirm, message, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
ClockCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
@@ -26,10 +25,55 @@ import {
|
||||
UploadOutlined,
|
||||
FileSearchOutlined,
|
||||
MobileOutlined,
|
||||
ExclamationCircleOutlined
|
||||
ExclamationCircleOutlined,
|
||||
ArrowLeftOutlined,
|
||||
FolderOpenOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Package,
|
||||
Wrench,
|
||||
Wallet,
|
||||
ShoppingCart,
|
||||
Truck,
|
||||
Plane,
|
||||
GraduationCap,
|
||||
Wifi,
|
||||
Home,
|
||||
Hammer,
|
||||
HeartPulse,
|
||||
Car,
|
||||
Building,
|
||||
Shield,
|
||||
Ticket,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Иконки по направлениям (категориям) для плиток
|
||||
const DIRECTION_ICONS: Record<string, LucideIcon> = {
|
||||
'товары': Package,
|
||||
'услуги': Wrench,
|
||||
'финансы и платежи': Wallet,
|
||||
'интернет-торговля и маркетплейсы': ShoppingCart,
|
||||
'доставка и логистика': Truck,
|
||||
'туризм и путешествия': Plane,
|
||||
'образование и онлайн-курсы': GraduationCap,
|
||||
'связь и интернет': Wifi,
|
||||
'жкх и коммунальные услуги': Home,
|
||||
'строительство и ремонт': Hammer,
|
||||
'медицина и платные клиники': HeartPulse,
|
||||
'транспорт и перевозки': Car,
|
||||
'недвижимость и аренда': Building,
|
||||
'страхование': Shield,
|
||||
'развлечения и мероприятия': Ticket,
|
||||
};
|
||||
|
||||
function getDirectionIcon(directionOrCategory: string | undefined): LucideIcon | null {
|
||||
if (!directionOrCategory || typeof directionOrCategory !== 'string') return null;
|
||||
const key = directionOrCategory.trim().toLowerCase();
|
||||
return DIRECTION_ICONS[key] || null;
|
||||
}
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateStr: string) => {
|
||||
@@ -83,6 +127,8 @@ interface Draft {
|
||||
problem_title?: string; // Краткое описание (заголовок)
|
||||
problem_description?: string;
|
||||
category?: string; // Категория проблемы
|
||||
direction?: string; // Направление (для иконки плитки)
|
||||
facts_short?: string; // Краткие факты от AI — заголовок плитки
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
@@ -184,6 +230,8 @@ export default function StepDraftSelection({
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
/** Черновик, открытый для просмотра полного описания (по клику на карточку) */
|
||||
const [selectedDraft, setSelectedDraft] = useState<Draft | null>(null);
|
||||
|
||||
const loadDrafts = async () => {
|
||||
try {
|
||||
@@ -333,9 +381,80 @@ export default function StepDraftSelection({
|
||||
);
|
||||
};
|
||||
|
||||
// Экран полного описания черновика (по клику на карточку)
|
||||
if (selectedDraft) {
|
||||
const fullText = selectedDraft.problem_description || selectedDraft.facts_short || selectedDraft.problem_title || '—';
|
||||
const draftId = selectedDraft.claim_id || selectedDraft.id;
|
||||
return (
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px 20px' }}
|
||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => setSelectedDraft(null)}
|
||||
style={{ paddingLeft: 0 }}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
<Title level={4} style={{ marginBottom: 8, color: '#111827' }}>
|
||||
Обращение
|
||||
</Title>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e2e8f0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
minHeight: 80,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{fullText}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{selectedDraft.is_legacy && onRestartDraft ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => {
|
||||
onRestartDraft(draftId, selectedDraft.problem_description || '');
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
>
|
||||
Начать заново
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => {
|
||||
onSelectDraft(draftId);
|
||||
setSelectedDraft(null);
|
||||
}}
|
||||
>
|
||||
К документам
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<Card
|
||||
bodyStyle={{ padding: '16px 0' }}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
@@ -344,25 +463,11 @@ export default function StepDraftSelection({
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
|
||||
📋 Ваши заявки
|
||||
<Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}>
|
||||
📋 Мои обращения
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
|
||||
Выберите заявку для продолжения или создайте новую.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания новой заявки - всегда вверху */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewClaim}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Создать новую заявку
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
@@ -374,217 +479,123 @@ export default function StepDraftSelection({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
dataSource={drafts}
|
||||
renderItem={(draft) => {
|
||||
<Row gutter={[16, 16]}>
|
||||
{drafts.map((draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const docsProgress = getDocsProgress(draft);
|
||||
|
||||
const directionOrCategory = draft.direction || draft.category;
|
||||
const DirectionIcon = getDirectionIcon(directionOrCategory);
|
||||
const tileTitle = draft.facts_short
|
||||
|| draft.problem_title
|
||||
|| (draft.problem_description
|
||||
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
|
||||
: 'Обращение');
|
||||
const borderColor = draft.is_legacy ? '#faad14' : '#e8e8e8';
|
||||
const bgColor = draft.is_legacy ? '#fffbe6' : '#fff';
|
||||
const iconBg = draft.is_legacy ? '#fff7e6' : '#f8fafc';
|
||||
const iconColor = draft.is_legacy ? '#faad14' : '#6366f1';
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
display: 'block', // Вертикальный layout
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<div style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: draft.is_legacy ? '#fff7e6' : '#f0f0f0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 20,
|
||||
color: draft.is_legacy ? '#faad14' : '#595959',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{config.icon}
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||
{draft.category && (
|
||||
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Заголовок - краткое описание проблемы */}
|
||||
{draft.problem_title && (
|
||||
<Text strong style={{
|
||||
fontSize: 15,
|
||||
color: '#1a1a1a',
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{draft.problem_title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Полное описание проблемы */}
|
||||
{draft.problem_description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
background: '#f5f5f5',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={draft.problem_description}
|
||||
>
|
||||
{draft.problem_description.length > 250
|
||||
? draft.problem_description.substring(0, 250) + '...'
|
||||
: draft.problem_description
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Время обновления */}
|
||||
<Space size="small">
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Tooltip title={formatDate(draft.updated_at)}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{getRelativeTime(draft.updated_at)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{/* Legacy предупреждение */}
|
||||
{draft.is_legacy && (
|
||||
<Alert
|
||||
message="Черновик в старом формате. Нажмите 'Начать заново'."
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ fontSize: 12, padding: '4px 8px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Список документов со статусами */}
|
||||
{draft.documents_list && draft.documents_list.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
📄 Документы
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
|
||||
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{draft.documents_list.map((doc, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{doc.uploaded ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
|
||||
) : (
|
||||
<span style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
|
||||
textDecoration: doc.uploaded ? 'none' : 'none',
|
||||
}}>
|
||||
{doc.name}
|
||||
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогрессбар (если нет списка) */}
|
||||
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#1890ff',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
<Tooltip title={formatDate(draft.updated_at)}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{getRelativeTime(draft.updated_at)}
|
||||
</Text>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="draft-actions" style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{getActionButton(draft)}
|
||||
{/* Скрываем кнопку "Удалить" для заявок "В работе" */}
|
||||
{draft.status_code !== 'in_work' && (
|
||||
<Popconfirm
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
</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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { Button, Card, Checkbox, Form, Input, Modal, Radio, Result, Row, Col, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import { getDocTypeStyle, STATUS_UPLOADED, STATUS_NEEDED, STATUS_NOT_AVAILABLE, STATUS_OPTIONAL } from './documentsScreenMaps';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
@@ -1439,7 +1440,6 @@ export default function StepWizardPlan({
|
||||
})}
|
||||
|
||||
<Space style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
Сохранить и продолжить →
|
||||
</Button>
|
||||
@@ -1587,8 +1587,10 @@ export default function StepWizardPlan({
|
||||
}
|
||||
}, [currentDocIndex, documentsRequired.length, uploadedDocs, skippedDocs, findFirstUnprocessedDoc, updateFormData]);
|
||||
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
|
||||
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload');
|
||||
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]);
|
||||
const [selectedDocIndex, setSelectedDocIndex] = useState<number | null>(null); // Плиточный стиль: какая плитка открыта в модалке
|
||||
const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы»
|
||||
|
||||
// Текущий документ для загрузки
|
||||
const currentDoc = documentsRequired[currentDocIndex];
|
||||
@@ -2160,148 +2162,288 @@ export default function StepWizardPlan({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && currentDocIndex < documentsRequired.length && currentDoc ? (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
{/* Прогресс */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
|
||||
<Text type="secondary">{Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}% завершено</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(((uploadedDocs.length + skippedDocs.length) / documentsRequired.length) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
const showDocumentsOnly = hasNewFlowDocs && documentsRequired.length > 0;
|
||||
const stepContent = (
|
||||
<>
|
||||
{/* ✅ Экран «Загрузка документов» по дизайн-спецификации */}
|
||||
{hasNewFlowDocs && !allDocsProcessed && documentsRequired.length > 0 ? (
|
||||
<div style={{ background: '#f5f7fb', margin: '-1px -1px 0', borderRadius: '16px 16px 0 0', overflow: 'hidden', minHeight: 360 }}>
|
||||
{/* Шапка: градиент синий, заголовок */}
|
||||
<div style={{ background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', padding: '16px 16px', textAlign: 'center' }}>
|
||||
<Typography.Text strong style={{ color: '#fff', fontSize: 18 }}>Загрузка документов</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* Заголовок документа */}
|
||||
<Title level={4} style={{ marginBottom: 8 }}>
|
||||
📄 {currentDoc.name}
|
||||
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* Радио-кнопки выбора */}
|
||||
<Radio.Group
|
||||
value={docChoice}
|
||||
onChange={(e) => {
|
||||
setDocChoice(e.target.value);
|
||||
if (e.target.value === 'none') {
|
||||
setCurrentUploadedFiles([]);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: 16, display: 'block' }}
|
||||
<div style={{ padding: '16px 16px 100px' }}>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 80 }}>
|
||||
{documentsRequired.map((doc: any, index: number) => {
|
||||
const docId = doc.id || doc.name;
|
||||
const isUploaded = uploadedDocs.includes(docId);
|
||||
const isSkipped = skippedDocs.includes(docId);
|
||||
const fileCount = (formData.documents_uploaded || []).filter((d: any) => (d.type || d.id) === docId).length;
|
||||
const { Icon: DocIcon, color: docColor } = getDocTypeStyle(docId);
|
||||
const isSelected = selectedDocIndex === index;
|
||||
const status = isUploaded ? STATUS_UPLOADED : isSkipped ? STATUS_NOT_AVAILABLE : (doc.required ? STATUS_NEEDED : STATUS_OPTIONAL);
|
||||
const StatusIcon = status.Icon;
|
||||
const statusLabel = isUploaded ? (fileCount > 0 ? `${status.label} (${fileCount})` : status.label) : status.label;
|
||||
const tileBg = isUploaded ? '#ECFDF5' : isSkipped ? '#F3F4F6' : '#FFFBEB';
|
||||
const tileBorder = isSelected ? '#2563eb' : isUploaded ? '#22C55E' : isSkipped ? '#9ca3af' : '#F59E0B';
|
||||
return (
|
||||
<Col xs={12} key={docId}>
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: `1px solid ${tileBorder}`,
|
||||
background: tileBg,
|
||||
boxShadow: isSelected ? '0 0 0 2px rgba(37,99,235,0.25)' : '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
|
||||
onClick={() => { setCurrentDocIndex(index); setDocChoice(isSkipped ? 'none' : 'upload'); setCurrentUploadedFiles([]); setSelectedDocIndex(index); }}
|
||||
>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${docColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: docColor }}>
|
||||
<DocIcon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{doc.name}</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Space size={6} style={{ fontSize: 12, color: status.color }}>
|
||||
<StatusIcon size={14} strokeWidth={2} />
|
||||
<span>{statusLabel}</span>
|
||||
</Space>
|
||||
{'subLabel' in status && isSkipped && <Text type="secondary" style={{ fontSize: 11 }}>{(status as { subLabel?: string }).subLabel}</Text>}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
{/* Плитка: произвольные группы документов (название от пользователя при одной группе) */}
|
||||
<Col xs={12} key="__custom_docs__">
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: `1px solid #e5e7eb`,
|
||||
background: '#fff',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 10 }}
|
||||
onClick={() => setCustomDocsModalOpen(true)}
|
||||
>
|
||||
{(() => {
|
||||
const { Icon: CustomIcon, color: customColor } = getDocTypeStyle('__custom_docs__');
|
||||
const StatusIcon = customFileBlocks.length > 0 ? STATUS_UPLOADED.Icon : CustomIcon;
|
||||
const statusColor = customFileBlocks.length > 0 ? STATUS_UPLOADED.color : '#8c8c8c';
|
||||
const hasGroups = customFileBlocks.length > 0;
|
||||
const titleText = hasGroups && customFileBlocks.length === 1 && customFileBlocks[0].description?.trim()
|
||||
? (customFileBlocks[0].description.trim().length > 25 ? customFileBlocks[0].description.trim().slice(0, 22) + '…' : customFileBlocks[0].description.trim())
|
||||
: 'Свои документы';
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: 52, height: 52, borderRadius: 14, background: `${customColor}18`, display: 'flex', alignItems: 'center', justifyContent: 'center', color: customColor }}>
|
||||
<CustomIcon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text strong style={{ fontSize: 14, lineHeight: 1.3, minHeight: 40, display: 'block', color: '#111827' }}>{titleText}</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||
<Space size={6} style={{ fontSize: 12, color: statusColor }}>
|
||||
<StatusIcon size={14} strokeWidth={2} />
|
||||
<span>{hasGroups ? `Загружено (${customFileBlocks.length} ${customFileBlocks.length === 1 ? 'группа' : 'группы'})` : 'Добавить'}</span>
|
||||
</Space>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Col>
|
||||
{/* Плитка «Добавить ещё группу» — серая до загрузки, цветная после */}
|
||||
<Col xs={12} key="__custom_docs_add__">
|
||||
<Card
|
||||
hoverable
|
||||
bordered
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
border: '1px solid #e5e7eb',
|
||||
background: customFileBlocks.length > 0 ? '#f5f3ff' : '#fafafa',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
height: '100%',
|
||||
}}
|
||||
bodyStyle={{ padding: 16, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', textAlign: 'center', gap: 8 }}
|
||||
onClick={() => setCustomDocsModalOpen(true)}
|
||||
>
|
||||
{(() => {
|
||||
const { Icon: AddIcon, color: addColor } = getDocTypeStyle('__custom_docs__');
|
||||
const isColored = customFileBlocks.length > 0;
|
||||
const iconColor = isColored ? addColor : '#9ca3af';
|
||||
const bgColor = isColored ? `${addColor}18` : '#f3f4f6';
|
||||
return (
|
||||
<>
|
||||
<div style={{ width: 48, height: 48, borderRadius: 14, background: bgColor, display: 'flex', alignItems: 'center', justifyContent: 'center', color: iconColor }}>
|
||||
<AddIcon size={26} strokeWidth={1.8} />
|
||||
</div>
|
||||
<Text style={{ fontSize: 13, color: isColored ? '#374151' : '#9ca3af', lineHeight: 1.3 }}>
|
||||
Добавить ещё группу
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
{/* Кнопка «Отправить» внизу экрана с плитками (bottom: 90px — выше футера) */}
|
||||
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '24px 0 0', marginTop: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleAllDocsComplete}
|
||||
disabled={!allDocsProcessed}
|
||||
title={!allDocsProcessed ? `Сначала отметьте все документы (${uploadedDocs.length + skippedDocs.length}/${documentsRequired.length})` : undefined}
|
||||
style={{
|
||||
background: allDocsProcessed ? 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)' : undefined,
|
||||
border: 'none',
|
||||
borderRadius: 28,
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title={currentDoc ? `📄 ${currentDoc.name}` : 'Документ'}
|
||||
open={selectedDocIndex !== null && !!documentsRequired[selectedDocIndex]}
|
||||
onCancel={() => setSelectedDocIndex(null)}
|
||||
footer={null}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 16 }}>
|
||||
📎 Загрузить документ
|
||||
</Radio>
|
||||
<Radio value="none" style={{ fontSize: 16 }}>
|
||||
❌ У меня нет этого документа
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger
|
||||
multiple={true}
|
||||
beforeUpload={() => false}
|
||||
fileList={currentUploadedFiles}
|
||||
onChange={({ fileList }) => handleFilesChange(fileList)}
|
||||
onRemove={(file) => {
|
||||
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
|
||||
return true;
|
||||
}}
|
||||
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
|
||||
disabled={submitting}
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Перетащите файлы или нажмите для выбора
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
📌 Можно загрузить несколько файлов (все страницы документа)
|
||||
<br />
|
||||
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
|
||||
</p>
|
||||
</Dragger>
|
||||
)}
|
||||
|
||||
{/* Предупреждение если "нет документа" для важного */}
|
||||
{docChoice === 'none' && currentDoc.required && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<Text type="warning">
|
||||
⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
|
||||
</Text>
|
||||
{selectedDocIndex !== null && documentsRequired[selectedDocIndex] && (() => {
|
||||
const doc = documentsRequired[selectedDocIndex];
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{doc.hints && <Paragraph type="secondary" style={{ marginBottom: 16 }}>{doc.hints}</Paragraph>}
|
||||
<Radio.Group value={docChoice} onChange={(e) => { setDocChoice(e.target.value); if (e.target.value === 'none') setCurrentUploadedFiles([]); }} style={{ marginBottom: 16, display: 'block' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Radio value="upload" style={{ fontSize: 15 }}>📎 Загрузить документ</Radio>
|
||||
<Radio value="none" style={{ fontSize: 15 }}>❌ У меня нет этого документа</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
{docChoice === 'upload' && (
|
||||
<Dragger multiple beforeUpload={() => false} fileList={currentUploadedFiles} onChange={({ fileList }) => handleFilesChange(fileList)} onRemove={(file) => { setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid)); return true; }} accept={doc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'} disabled={submitting} style={{ marginBottom: 16 }}>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 32 }} /></p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
|
||||
<p className="ant-upload-hint">Форматы: {doc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ)</p>
|
||||
</Dragger>
|
||||
)}
|
||||
{docChoice === 'none' && doc.required && (
|
||||
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, marginBottom: 16 }}>
|
||||
<Text type="warning">⚠️ Документ важен для рассмотрения. Постарайтесь найти его позже.</Text>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button onClick={() => setSelectedDocIndex(null)}>Отмена</Button>
|
||||
<Button type="primary" onClick={async () => { await handleDocContinue(); setSelectedDocIndex(null); }} disabled={!canContinue || submitting} loading={submitting}>{submitting ? 'Загружаем...' : 'Готово'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Modal>
|
||||
{/* Модалка «Свои документы» — произвольные группы документов */}
|
||||
<Modal
|
||||
title="Дополнительные документы"
|
||||
open={customDocsModalOpen}
|
||||
onCancel={() => setCustomDocsModalOpen(false)}
|
||||
footer={null}
|
||||
width={560}
|
||||
destroyOnClose={false}
|
||||
>
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
{customFileBlocks.length === 0 && (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fafafa', borderRadius: 8 }}>
|
||||
<Paragraph style={{ marginBottom: 8 }}>
|
||||
<Text strong>Есть ещё документы, которые могут помочь?</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
Добавьте группу документов с названием (например: «Переписка в мессенджере», «Скриншоты»).
|
||||
В каждой группе — своё название и файлы.
|
||||
</Paragraph>
|
||||
<Button type="dashed" icon={<PlusOutlined />} onClick={addCustomBlock} block size="large">
|
||||
Добавить группу документов
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{customFileBlocks.map((block, idx) => (
|
||||
<Card
|
||||
key={block.id}
|
||||
size="small"
|
||||
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
|
||||
title={<span><FileTextOutlined style={{ color: '#595959', marginRight: 8 }} />Группа документов #{idx + 1}</span>}
|
||||
extra={<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>Удалить</Button>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>Название группы <Text type="danger">*</Text></Text>
|
||||
<Input
|
||||
placeholder="Например: Переписка в WhatsApp с менеджером"
|
||||
value={block.description}
|
||||
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
|
||||
maxLength={500}
|
||||
showCount
|
||||
style={{ marginBottom: 12 }}
|
||||
status={block.files.length > 0 && !block.description?.trim() ? 'error' : ''}
|
||||
/>
|
||||
{block.files.length > 0 && !block.description?.trim() && (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>Укажите название группы</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>Категория (необязательно)</Text>
|
||||
<Select
|
||||
value={block.category}
|
||||
placeholder="Выберите или оставьте пустым"
|
||||
onChange={(value) => updateCustomBlock(block.id, { category: value })}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{customCategoryOptions.map((opt) => (
|
||||
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Dragger
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
fileList={block.files}
|
||||
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon"><InboxOutlined style={{ color: '#595959', fontSize: 24 }} /></p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для выбора</p>
|
||||
</Dragger>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
{customFileBlocks.length > 0 && (
|
||||
<Button type="dashed" onClick={addCustomBlock} icon={<PlusOutlined />} block style={{ marginTop: 12 }}>
|
||||
Добавить ещё группу
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={() => setCustomDocsModalOpen(false)}>Готово</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={backToDraftsList || onPrev}>← К списку заявок</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
disabled={!canContinue || submitting}
|
||||
loading={submitting}
|
||||
>
|
||||
{submitting ? 'Загружаем...' : 'Продолжить →'}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* Уже загруженные */}
|
||||
{uploadedDocs.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
|
||||
<Text strong>✅ Загружено:</Text>
|
||||
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
{/* Убираем дубликаты и используем уникальные ключи */}
|
||||
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
|
||||
const doc = documentsRequired.find((d: any) => d.id === docId);
|
||||
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length ? (
|
||||
) : hasNewFlowDocs && !allDocsProcessed && currentDocIndex >= documentsRequired.length && documentsRequired.length > 0 ? (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||
<Text type="warning">
|
||||
⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы массива ({documentsRequired.length}).
|
||||
⚠️ Ошибка: индекс документа ({currentDocIndex}) выходит за границы ({documentsRequired.length}).
|
||||
<br />
|
||||
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
|
||||
</Text>
|
||||
@@ -2393,15 +2535,52 @@ export default function StepWizardPlan({
|
||||
{/* ✅ Дополнительные документы */}
|
||||
{renderCustomUploads()}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
<div style={{ position: 'sticky', bottom: 90, left: 0, right: 0, padding: '20px 0', background: '#f5f7fb', marginTop: 24 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
onClick={handleAllDocsComplete}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)',
|
||||
border: 'none',
|
||||
borderRadius: 28,
|
||||
height: 52,
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
</>
|
||||
);
|
||||
|
||||
return showDocumentsOnly ? (
|
||||
<div style={{ marginTop: 0 }}>{stepContent}</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
{plan && !hasNewFlowDocs && (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
|
||||
<Button type="link" onClick={handleRefreshPlan}>
|
||||
Обновить рекомендации
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{stepContent}
|
||||
{(
|
||||
<>
|
||||
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
||||
{!hasNewFlowDocs && isWaiting && !outOfScopeData && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
@@ -2616,6 +2795,8 @@ export default function StepWizardPlan({
|
||||
{renderQuestions()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
44
frontend/src/components/form/documentsScreenMaps.tsx
Normal file
44
frontend/src/components/form/documentsScreenMaps.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Маппинг типов документов и статусов для экрана «Загрузка документов».
|
||||
* Спецификация: дизайн «Документы кейса», Lucide-иконки.
|
||||
*/
|
||||
import {
|
||||
FileSignature,
|
||||
Receipt,
|
||||
ClipboardList,
|
||||
MessagesSquare,
|
||||
FileWarning,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Clock3,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
export const DOC_TYPE_MAP: Record<string, { Icon: LucideIcon; color: string }> = {
|
||||
contract: { Icon: FileSignature, color: '#1890ff' },
|
||||
payment: { Icon: Receipt, color: '#52c41a' },
|
||||
receipt: { Icon: Receipt, color: '#52c41a' },
|
||||
cheque: { Icon: Receipt, color: '#52c41a' },
|
||||
correspondence: { Icon: MessagesSquare, color: '#722ed1' },
|
||||
acts: { Icon: ClipboardList, color: '#fa8c16' },
|
||||
claim: { Icon: FileWarning, color: '#ff4d4f' },
|
||||
other: { Icon: FolderOpen, color: '#595959' },
|
||||
/** Плитка «Свои документы» — произвольные группы документов */
|
||||
__custom_docs__: { Icon: FolderPlus, color: '#722ed1' },
|
||||
};
|
||||
|
||||
export function getDocTypeStyle(docId: string): { Icon: LucideIcon; color: string } {
|
||||
const key = (docId || '').toLowerCase().replace(/\s+/g, '_');
|
||||
return DOC_TYPE_MAP[key] ?? { Icon: FileText, color: '#1890ff' };
|
||||
}
|
||||
|
||||
/** Цвета и иконки статусов по спецификации */
|
||||
export const STATUS_UPLOADED = { Icon: CheckCircle2, color: '#22C55E', label: 'Загружено' };
|
||||
export const STATUS_NEEDED = { Icon: AlertTriangle, color: '#F59E0B', label: 'Нужно' };
|
||||
export const STATUS_EXPECTED = { Icon: Clock3, color: '#F59E0B', label: 'Ожидаем завтра' };
|
||||
export const STATUS_NOT_AVAILABLE = { Icon: Ban, color: '#8c8c8c', label: 'Не будет', subLabel: 'Утеряно' };
|
||||
export const STATUS_OPTIONAL = { Icon: Clock3, color: '#8c8c8c', label: 'По желанию' };
|
||||
@@ -1,7 +1,7 @@
|
||||
/* ========== ВЕБ (дефолт): как в aiform_dev ========== */
|
||||
.claim-form-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
padding: 40px 0;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -9,13 +9,17 @@
|
||||
}
|
||||
|
||||
.claim-form-card {
|
||||
max-width: 800px;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-head {
|
||||
background: #fafafa;
|
||||
color: #000000;
|
||||
@@ -35,12 +39,12 @@
|
||||
|
||||
.steps-content {
|
||||
min-height: 400px;
|
||||
padding: 20px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.claim-form-container {
|
||||
padding: 20px 10px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.claim-form-card {
|
||||
@@ -48,7 +52,7 @@
|
||||
}
|
||||
|
||||
.steps-content {
|
||||
padding: 10px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +60,7 @@
|
||||
.claim-form-container.telegram-mini-app {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 12px 10px max(16px, env(safe-area-inset-bottom));
|
||||
padding: 12px 0 max(16px, env(safe-area-inset-bottom));
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -81,7 +85,7 @@
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-body {
|
||||
padding: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps {
|
||||
@@ -99,7 +103,7 @@
|
||||
|
||||
.claim-form-container.telegram-mini-app .steps-content {
|
||||
min-height: 280px;
|
||||
padding: 8px 4px 12px;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.claim-form-container.telegram-mini-app .ant-btn {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
|
||||
import { Card, message, Row, Col, Spin, Button } from 'antd';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import Step1Phone from '../components/form/Step1Phone';
|
||||
import StepDescription from '../components/form/StepDescription';
|
||||
// Step1Policy убран - старый ERV флоу
|
||||
@@ -14,8 +15,6 @@ import './ClaimForm.css';
|
||||
|
||||
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
/**
|
||||
* Генерация UUID v4
|
||||
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
@@ -81,13 +80,19 @@ interface FormData {
|
||||
accountNumber?: string;
|
||||
}
|
||||
|
||||
export default function ClaimForm() {
|
||||
interface ClaimFormProps {
|
||||
/** Открыта страница «Подать жалобу» (/new) — не показывать список черновиков */
|
||||
forceNewClaim?: boolean;
|
||||
}
|
||||
|
||||
export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
// ✅ claim_id будет создан n8n в Step1Phone после SMS верификации
|
||||
// Не генерируем его локально!
|
||||
|
||||
// session_id будет получен от n8n при создании контакта
|
||||
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
|
||||
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
|
||||
const autoLoadedClaimIdRef = useRef<string | null>(null);
|
||||
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
|
||||
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -112,6 +117,17 @@ export default function ClaimForm() {
|
||||
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
|
||||
/** Заход через MAX Mini App. */
|
||||
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
|
||||
const forceNewClaimRef = useRef(false);
|
||||
|
||||
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
|
||||
useEffect(() => {
|
||||
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
|
||||
if (isNewPage) {
|
||||
forceNewClaimRef.current = true;
|
||||
setShowDraftSelection(false);
|
||||
setHasDrafts(false);
|
||||
}
|
||||
}, [forceNewClaim]);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
@@ -388,6 +404,14 @@ export default function ClaimForm() {
|
||||
// Помечаем телефон как верифицированный
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
// На странице /new («Подать жалобу») не показываем черновики
|
||||
if (forceNewClaimRef.current) {
|
||||
setCurrentStep(1); // сразу к описанию
|
||||
message.success('Добро пожаловать!');
|
||||
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем черновики
|
||||
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
|
||||
|
||||
@@ -1136,6 +1160,81 @@ export default function ClaimForm() {
|
||||
}
|
||||
}, [formData, updateFormData]);
|
||||
|
||||
// Нормализовать start_param: MAX может отдавать строку или объект WebAppStartParam
|
||||
const startParamToString = useCallback((v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.value === 'string') return o.value;
|
||||
if (typeof o.payload === 'string') return o.payload;
|
||||
if (typeof o.start_param === 'string') return o.start_param;
|
||||
if (typeof o.data === 'string') return o.data;
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
return String(v);
|
||||
}, []);
|
||||
|
||||
// Извлечь claim_id из строки startapp/start_param (форматы: claim_id=uuid, claim_id_uuid, или голый uuid)
|
||||
const parseClaimIdFromStartParam = useCallback((startParam: string | Record<string, unknown> | null | undefined): string | null => {
|
||||
const s = startParamToString(startParam);
|
||||
if (!s) return null;
|
||||
const decoded = decodeURIComponent(s.trim());
|
||||
let m = decoded.match(/(?:^|[?&])claim_id=([^&]+)/i) || decoded.match(/(?:^|[?&])claim_id_([0-9a-f-]{36})/i);
|
||||
if (!m) m = decoded.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}, [startParamToString]);
|
||||
|
||||
// Автозагрузка черновика из URL или из MAX WebApp start_param после восстановления сессии
|
||||
useEffect(() => {
|
||||
if (!sessionRestored) return;
|
||||
(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// claim_id может прийти как UUID или как claim_id_<uuid> (после редиректа из /hello?WebAppStartParam=...)
|
||||
let claimFromUrl = parseClaimIdFromStartParam(params.get('claim_id') || '') || params.get('claim_id');
|
||||
// Query: startapp=... или WebAppStartParam=... (MAX подставляет при открытии по диплинку)
|
||||
if (!claimFromUrl) claimFromUrl = parseClaimIdFromStartParam(params.get('startapp') || params.get('WebAppStartParam') || '');
|
||||
// Hash (MAX иногда кладёт параметры в #)
|
||||
if (!claimFromUrl && window.location.hash) {
|
||||
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
const fromHash = parseClaimIdFromStartParam(hashParams.get('claim_id') || '') || hashParams.get('claim_id');
|
||||
claimFromUrl = fromHash || parseClaimIdFromStartParam(hashParams.get('startapp') || hashParams.get('WebAppStartParam') || '');
|
||||
}
|
||||
// MAX WebApp: initDataUnsafe.start_param (появляется после загрузки скрипта st.max.ru)
|
||||
if (!claimFromUrl) {
|
||||
const wa = (window as any).WebApp;
|
||||
const startParam = wa?.initDataUnsafe?.start_param;
|
||||
if (startParam) {
|
||||
claimFromUrl = parseClaimIdFromStartParam(startParam);
|
||||
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param:', claimFromUrl);
|
||||
}
|
||||
}
|
||||
// Повторная проверка через 1.2s на случай, если MAX bridge подставил start_param с задержкой
|
||||
if (!claimFromUrl) {
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
const wa = (window as any).WebApp;
|
||||
const startParam = wa?.initDataUnsafe?.start_param;
|
||||
if (startParam) {
|
||||
claimFromUrl = parseClaimIdFromStartParam(startParam);
|
||||
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param (отложенно):', claimFromUrl);
|
||||
}
|
||||
}
|
||||
if (claimFromUrl) {
|
||||
if (autoLoadedClaimIdRef.current === claimFromUrl) return;
|
||||
autoLoadedClaimIdRef.current = claimFromUrl;
|
||||
// Сразу помечаем черновик как выбранный и скрываем список — чтобы не показывать шаг «Черновики», сразу перейти к документам
|
||||
setSelectedDraftId(claimFromUrl);
|
||||
setShowDraftSelection(false);
|
||||
console.log('🔗 Автозагрузка черновика из URL claim_id=', claimFromUrl, '(сразу на документы)');
|
||||
await loadDraft(claimFromUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка автозагрузки черновика из URL:', e);
|
||||
}
|
||||
})();
|
||||
}, [sessionRestored, loadDraft, parseClaimIdFromStartParam]);
|
||||
|
||||
// Обработчик выбора черновика
|
||||
const handleSelectDraft = useCallback((claimId: string) => {
|
||||
loadDraft(claimId);
|
||||
@@ -1143,6 +1242,10 @@ export default function ClaimForm() {
|
||||
|
||||
// Проверка наличия черновиков
|
||||
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
||||
if (forceNewClaimRef.current) {
|
||||
console.log('🔍 forceNewClaim: пропускаем проверку черновиков');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
console.log('🔍 ========== checkDrafts вызван ==========');
|
||||
console.log('🔍 Параметры:', { unified_id, phone, sessionId });
|
||||
@@ -1362,8 +1465,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 0: Выбор черновика (показывается только если есть черновики)
|
||||
// ✅ unified_id уже означает, что телефон верифицирован
|
||||
// Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts
|
||||
if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
|
||||
// Не показываем черновики на странице «Подать жалобу» (/new)
|
||||
if (!forceNewClaimRef.current && (showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
|
||||
stepsArray.push({
|
||||
title: 'Черновики',
|
||||
description: 'Выбор заявки',
|
||||
@@ -1409,7 +1512,7 @@ export default function ClaimForm() {
|
||||
|
||||
// ✅ Если передан unified_id, значит телефон уже верифицирован (даже если isPhoneVerified ещё false)
|
||||
// Проверяем черновики, если есть unified_id или телефон верифицирован
|
||||
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
|
||||
const shouldCheckDrafts = (finalUnifiedId || (formData.phone && isPhoneVerified)) && !forceNewClaimRef.current;
|
||||
|
||||
if (shouldCheckDrafts && !selectedDraftId) {
|
||||
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone, 'sessionId:', sessionIdRef.current);
|
||||
@@ -1639,60 +1742,41 @@ export default function ClaimForm() {
|
||||
// ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии
|
||||
if (!telegramAuthChecked || !sessionRestored) {
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin size="large" tip="Загрузка..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isDocumentsStep = steps[currentStep]?.title === 'Документы';
|
||||
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
|
||||
<Row gutter={16}>
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
|
||||
<Row gutter={[0, 16]}>
|
||||
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
|
||||
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
|
||||
<Card
|
||||
title="Подать обращение о защите прав потребителя"
|
||||
className="claim-form-card"
|
||||
extra={
|
||||
!isSubmitted && (
|
||||
<Space>
|
||||
{/* Кнопка "Выход" - показываем если телефон верифицирован */}
|
||||
{isPhoneVerified && (
|
||||
<button
|
||||
onClick={handleExitToList}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
background: '#fff',
|
||||
border: '1px solid #ff4d4f',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#ff4d4f'
|
||||
}}
|
||||
>
|
||||
🚪 Выход
|
||||
</button>
|
||||
)}
|
||||
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
|
||||
{currentStep > 0 && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
🔄 Начать заново
|
||||
</button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{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>
|
||||
@@ -1701,26 +1785,16 @@ export default function ClaimForm() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Steps current={currentStep} className="steps">
|
||||
{steps.map((item, index) => (
|
||||
<Step
|
||||
key={`step-${index}`}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
<div className="steps-content">
|
||||
{steps[currentStep] ? steps[currentStep].content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<p>Загрузка шага...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="steps-content">
|
||||
{steps[currentStep] ? steps[currentStep].content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<p>Загрузка шага...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{/* Правая часть - Debug консоль (только в dev режиме) */}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
.hello-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
padding-bottom: 90px;
|
||||
background: #f5f7fb;
|
||||
--tile-h: 160px;
|
||||
}
|
||||
|
||||
.hello-hero {
|
||||
@@ -66,18 +68,31 @@
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.tile-col,
|
||||
.hello-grid .ant-col {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tile-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
min-height: 160px;
|
||||
height: var(--tile-h);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tile-card :where(.ant-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
padding: 24px 16px;
|
||||
background: #ffffff;
|
||||
gap: 10px;
|
||||
padding: 18px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -86,16 +101,41 @@
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tile-card--inactive {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tile-card--inactive .tile-icon {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.tile-card--inactive .tile-title {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.tile-card--inactive:hover {
|
||||
transform: none;
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||
margin-bottom: 12px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.tile-icon svg {
|
||||
display: block; /* убирает baseline */
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
@@ -103,13 +143,69 @@
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
text-align: center;
|
||||
line-height: 18px;
|
||||
min-height: 36px;
|
||||
width: 100%;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Нижний таб-бар */
|
||||
.hello-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
background: #ffffff;
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.hello-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.hello-bar-item:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hello-bar-item--active {
|
||||
color: #2563EB;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hello-bar-item--active:hover {
|
||||
color: #2563EB;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hello-page {
|
||||
padding: 16px;
|
||||
padding-bottom: 90px;
|
||||
--tile-h: 140px;
|
||||
}
|
||||
.tile-card {
|
||||
min-height: 140px;
|
||||
|
||||
.tile-card :where(.ant-card-body) {
|
||||
padding: 16px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Building2,
|
||||
ClipboardList,
|
||||
FileWarning,
|
||||
MessageCircle,
|
||||
} from 'lucide-react';
|
||||
import './HelloAuth.css';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function HelloAuth() {
|
||||
interface HelloAuthProps {
|
||||
onAvatarChange?: (url: string) => void;
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [greeting, setGreeting] = useState<string>('Привет!');
|
||||
const [error, setError] = useState<string>('');
|
||||
@@ -57,8 +65,14 @@ export default function HelloAuth() {
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
let avatarUrl = data.avatar_url;
|
||||
if (!avatarUrl && webApp?.initDataUnsafe?.user?.photo_url) {
|
||||
avatarUrl = webApp.initDataUnsafe.user.photo_url;
|
||||
}
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
@@ -87,6 +101,8 @@ export default function HelloAuth() {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
localStorage.setItem('user_avatar_url', data.avatar_url);
|
||||
onAvatarChange?.(data.avatar_url);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
@@ -97,6 +113,56 @@ export default function HelloAuth() {
|
||||
}
|
||||
|
||||
// Fallback: SMS
|
||||
// If there's a claim_id in URL/hash/MAX start_param, try to load draft and redirect to form
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let claimFromUrl: string | null = params.get('claim_id');
|
||||
const parseStart = (s: string | null) => {
|
||||
if (!s) return null;
|
||||
const d = decodeURIComponent(s.trim());
|
||||
const m = d.match(/claim_id=([^&]+)/i) || d.match(/claim_id_([0-9a-f-]{36})/i) || d.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
};
|
||||
// MAX может отдавать start_param строкой или объектом WebAppStartParam
|
||||
const startParamToStr = (v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'object' && v !== null) {
|
||||
const o = v as Record<string, unknown>;
|
||||
if (typeof o.value === 'string') return o.value;
|
||||
if (typeof o.payload === 'string') return o.payload;
|
||||
if (typeof o.start_param === 'string') return o.start_param;
|
||||
return JSON.stringify(o);
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
|
||||
if (!claimFromUrl && typeof window !== 'undefined' && window.location.hash) {
|
||||
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
|
||||
}
|
||||
const maxStartParam = (window as any).WebApp?.initDataUnsafe?.start_param;
|
||||
if (!claimFromUrl && maxStartParam) claimFromUrl = parseStart(startParamToStr(maxStartParam));
|
||||
if (claimFromUrl) {
|
||||
try {
|
||||
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
|
||||
if (draftRes.ok) {
|
||||
const draftData = await draftRes.json();
|
||||
// If backend provided session_token in draft, store it
|
||||
const st = draftData?.claim?.session_token;
|
||||
if (st) {
|
||||
localStorage.setItem('session_token', st);
|
||||
console.log('HelloAuth: session_token from draft saved', st);
|
||||
}
|
||||
// Redirect to root so ClaimForm can restore session and load the draft
|
||||
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
|
||||
return;
|
||||
} else {
|
||||
console.warn('HelloAuth: draft not found or error', draftRes.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('HelloAuth: error fetching draft by claim_id', e);
|
||||
}
|
||||
}
|
||||
setStatus('idle');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
@@ -142,9 +208,11 @@ export default function HelloAuth() {
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
}
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
localStorage.setItem('user_avatar_url', data.avatar_url);
|
||||
onAvatarChange?.(data.avatar_url);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
@@ -154,8 +222,10 @@ export default function HelloAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const tiles = [
|
||||
{ title: 'Профиль', icon: User, color: '#2563EB' },
|
||||
const tiles: Array<{ title: string; icon: typeof User; color: string; href?: string }> = [
|
||||
{ title: 'Мои обращения', icon: ClipboardList, color: '#6366F1', href: '/' },
|
||||
{ title: 'Подать жалобу', icon: FileWarning, color: '#EA580C', href: '/new' },
|
||||
{ title: 'Консультации', icon: MessageCircle, color: '#8B5CF6' },
|
||||
{ title: 'Членство', icon: IdCard, color: '#10B981' },
|
||||
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },
|
||||
{ title: 'Общественный контроллер', icon: ShieldCheck, color: '#22C55E' },
|
||||
@@ -215,17 +285,34 @@ export default function HelloAuth() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} className="hello-grid">
|
||||
<Row gutter={[16, 16]} className="hello-grid" align="stretch">
|
||||
{tiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
const active = !!tile.href;
|
||||
const card = (
|
||||
<Card
|
||||
className={`tile-card ${active ? '' : 'tile-card--inactive'}`}
|
||||
hoverable={active}
|
||||
bordered={false}
|
||||
onClick={tile.href ? () => {
|
||||
// В TG при полной перезагрузке теряется initData — переходим без reload (SPA)
|
||||
if (onNavigate) {
|
||||
onNavigate(tile.href!);
|
||||
} else {
|
||||
window.location.href = tile.href! + (window.location.search || '');
|
||||
}
|
||||
} : undefined}
|
||||
style={tile.href ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
<div className="tile-icon" style={{ color: tile.color }}>
|
||||
<Icon size={28} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="tile-title">{tile.title}</div>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Col key={tile.title} xs={12} sm={8} md={6}>
|
||||
<Card className="tile-card" hoverable bordered={false}>
|
||||
<div className="tile-icon" style={{ color: tile.color }}>
|
||||
<Icon size={26} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="tile-title">{tile.title}</div>
|
||||
</Card>
|
||||
<Col key={tile.title} xs={12} sm={8} md={6} className="tile-col">
|
||||
{card}
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
// Полифилл crypto.getRandomValues для Node 16 — подключать через: node -r ./scripts/crypto-polyfill.cjs ...
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -10,6 +11,7 @@ export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://host.docker.internal:8201',
|
||||
|
||||
Reference in New Issue
Block a user