Draft detail and Back button

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

View File

@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"type-check": "tsc --noEmit",

View File

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

View File

@@ -1,17 +1,40 @@
import ClaimForm from './pages/ClaimForm'
import HelloAuth from './pages/HelloAuth'
import './App.css'
import { useState, useEffect, useCallback } from 'react';
import ClaimForm from './pages/ClaimForm';
import HelloAuth from './pages/HelloAuth';
import BottomBar from './components/BottomBar';
import './App.css';
function App() {
const pathname = window.location.pathname || '';
if (pathname.startsWith('/hello')) {
return <HelloAuth />;
}
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
useEffect(() => {
const onPopState = () => setPathname(window.location.pathname || '');
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
useEffect(() => {
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
}, [pathname]);
const isNewClaimPage = pathname === '/new';
const navigateTo = useCallback((path: string) => {
window.history.pushState({}, '', path);
setPathname(path);
}, []);
return (
<div className="App">
<ClaimForm />
{pathname.startsWith('/hello') ? (
<HelloAuth onAvatarChange={setAvatarUrl} onNavigate={navigateTo} />
) : (
<ClaimForm forceNewClaim={isNewClaimPage} />
)}
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} />
</div>
)
);
}
export default App
export default App;

View File

@@ -0,0 +1,64 @@
.app-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 100%;
max-width: 100vw;
box-sizing: border-box;
min-height: 64px;
height: calc(64px + env(safe-area-inset-bottom, 0));
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
padding-right: env(safe-area-inset-right, 0);
background: #ffffff;
border-top: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
display: flex;
align-items: center;
justify-content: space-around;
z-index: 100;
}
.app-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
color: #6b7280;
text-decoration: none;
font-size: 12px;
font-weight: 500;
transition: color 0.2s ease;
background: none;
border: none;
cursor: pointer;
font-family: inherit;
}
.app-bar-item:hover {
color: #111827;
}
.app-bar-item--active {
color: #2563EB;
font-weight: 600;
}
.app-bar-item--active:hover {
color: #2563EB;
}
.app-bar-item--exit:hover {
color: #dc2626;
}
.app-bar-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}

View File

@@ -0,0 +1,59 @@
import { Home, Headphones, User, LogOut } from 'lucide-react';
import './BottomBar.css';
interface BottomBarProps {
currentPath: string;
avatarUrl?: string;
}
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
const isHome = currentPath.startsWith('/hello');
const handleExit = (e: React.MouseEvent) => {
e.preventDefault();
// Telegram Mini App
try {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
if (webApp && typeof webApp.close === 'function') {
webApp.close();
return;
}
} catch (_) {}
// MAX Mini App
try {
const maxWebApp = (window as any).WebApp;
if (maxWebApp && typeof maxWebApp.close === 'function') {
maxWebApp.close();
return;
}
} catch (_) {}
// Fallback: переход на главную
window.location.href = '/hello';
};
return (
<nav className="app-bottom-bar" aria-label="Навигация">
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
<Home size={24} strokeWidth={1.8} />
<span>Домой</span>
</a>
<a href="/hello" className="app-bar-item">
{avatarUrl ? (
<img src={avatarUrl} alt="" className="app-bar-avatar" />
) : (
<User size={24} strokeWidth={1.8} />
)}
<span>Профиль</span>
</a>
<a href="/hello" className="app-bar-item">
<Headphones size={24} strokeWidth={1.8} />
<span>Поддержка</span>
</a>
<button type="button" className="app-bar-item app-bar-item--exit" onClick={handleExit} aria-label="Выход">
<LogOut size={24} strokeWidth={1.8} />
<span>Выход</span>
</button>
</nav>
);
}

View File

@@ -1,4 +1,5 @@
import { Form, Input, Button, Typography, message, Checkbox } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import wizardPlanSample from '../../mocks/wizardPlanSample';
@@ -135,13 +136,9 @@ export default function StepDescription({
return (
<div style={{ marginTop: 24 }}>
<Button onClick={onPrev} size="large">
Назад
</Button>
<div
style={{
marginTop: 24,
marginTop: 0,
padding: 24,
background: '#f6f8fa',
borderRadius: 8,
@@ -213,7 +210,10 @@ export default function StepDescription({
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 16 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onPrev}>
Назад
</Button>
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
Продолжить
</Button>

View File

@@ -14,11 +14,10 @@
*/
import { useEffect, useState } from 'react';
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
import { Button, Card, Row, Col, Typography, Space, Empty, Popconfirm, message, Spin, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
@@ -26,10 +25,55 @@ import {
UploadOutlined,
FileSearchOutlined,
MobileOutlined,
ExclamationCircleOutlined
ExclamationCircleOutlined,
ArrowLeftOutlined,
FolderOpenOutlined
} from '@ant-design/icons';
import {
Package,
Wrench,
Wallet,
ShoppingCart,
Truck,
Plane,
GraduationCap,
Wifi,
Home,
Hammer,
HeartPulse,
Car,
Building,
Shield,
Ticket,
type LucideIcon,
} from 'lucide-react';
const { Title, Text, Paragraph } = Typography;
const { Title, Text } = Typography;
// Иконки по направлениям (категориям) для плиток
const DIRECTION_ICONS: Record<string, LucideIcon> = {
'товары': Package,
'услуги': Wrench,
'финансы и платежи': Wallet,
'интернет-торговля и маркетплейсы': ShoppingCart,
'доставка и логистика': Truck,
'туризм и путешествия': Plane,
'образование и онлайн-курсы': GraduationCap,
'связь и интернет': Wifi,
'жкх и коммунальные услуги': Home,
'строительство и ремонт': Hammer,
'медицина и платные клиники': HeartPulse,
'транспорт и перевозки': Car,
'недвижимость и аренда': Building,
'страхование': Shield,
'развлечения и мероприятия': Ticket,
};
function getDirectionIcon(directionOrCategory: string | undefined): LucideIcon | null {
if (!directionOrCategory || typeof directionOrCategory !== 'string') return null;
const key = directionOrCategory.trim().toLowerCase();
return DIRECTION_ICONS[key] || null;
}
// Форматирование даты
const formatDate = (dateStr: string) => {
@@ -83,6 +127,8 @@ interface Draft {
problem_title?: string; // Краткое описание (заголовок)
problem_description?: string;
category?: string; // Категория проблемы
direction?: string; // Направление (для иконки плитки)
facts_short?: string; // Краткие факты от AI — заголовок плитки
wizard_plan: boolean;
wizard_answers: boolean;
has_documents: boolean;
@@ -184,6 +230,8 @@ export default function StepDraftSelection({
const [drafts, setDrafts] = useState<Draft[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
/** Черновик, открытый для просмотра полного описания (по клику на карточку) */
const [selectedDraft, setSelectedDraft] = useState<Draft | null>(null);
const loadDrafts = async () => {
try {
@@ -333,9 +381,80 @@ export default function StepDraftSelection({
);
};
// Экран полного описания черновика (по клику на карточку)
if (selectedDraft) {
const fullText = selectedDraft.problem_description || selectedDraft.facts_short || selectedDraft.problem_title || '—';
const draftId = selectedDraft.claim_id || selectedDraft.id;
return (
<div style={{ 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

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { Button, Card, Checkbox, Form, Input, Modal, Radio, Result, Row, Col, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
import { getDocTypeStyle, STATUS_UPLOADED, STATUS_NEEDED, STATUS_NOT_AVAILABLE, STATUS_OPTIONAL } from './documentsScreenMaps';
import AiWorkingIllustration from '../../assets/ai-working.svg';
import type { UploadFile } from 'antd/es/upload/interface';
@@ -1439,7 +1440,6 @@ export default function StepWizardPlan({
})}
<Space style={{ marginTop: 24 }}>
<Button onClick={onPrev}> Назад</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Сохранить и продолжить
</Button>
@@ -1587,8 +1587,10 @@ export default function StepWizardPlan({
}
}, [currentDocIndex, documentsRequired.length, uploadedDocs, skippedDocs, findFirstUnprocessedDoc, updateFormData]);
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload');
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]);
const [selectedDocIndex, setSelectedDocIndex] = useState<number | null>(null); // Плиточный стиль: какая плитка открыта в модалке
const [customDocsModalOpen, setCustomDocsModalOpen] = useState(false); // Модалка «Свои документы»
// Текущий документ для загрузки
const currentDoc = documentsRequired[currentDocIndex];
@@ -2160,148 +2162,288 @@ export default function StepWizardPlan({
}
};
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>
);

View File

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

View File

@@ -1,7 +1,7 @@
/* ========== ВЕБ (дефолт): как в aiform_dev ========== */
.claim-form-container {
min-height: 100vh;
padding: 40px 20px;
padding: 40px 0;
background: #ffffff;
display: flex;
justify-content: center;
@@ -9,13 +9,17 @@
}
.claim-form-card {
max-width: 800px;
max-width: 100%;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
border: 1px solid #d9d9d9;
}
.claim-form-card .ant-card-body {
padding: 16px 0;
}
.claim-form-card .ant-card-head {
background: #fafafa;
color: #000000;
@@ -35,12 +39,12 @@
.steps-content {
min-height: 400px;
padding: 20px;
padding: 20px 0;
}
@media (max-width: 768px) {
.claim-form-container {
padding: 20px 10px;
padding: 20px 0;
}
.claim-form-card {
@@ -48,7 +52,7 @@
}
.steps-content {
padding: 10px;
padding: 10px 0;
}
}
@@ -56,7 +60,7 @@
.claim-form-container.telegram-mini-app {
min-height: 100vh;
min-height: 100dvh;
padding: 12px 10px max(16px, env(safe-area-inset-bottom));
padding: 12px 0 max(16px, env(safe-area-inset-bottom));
align-items: flex-start;
justify-content: flex-start;
}
@@ -81,7 +85,7 @@
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-body {
padding: 12px;
padding: 12px 0;
}
.claim-form-container.telegram-mini-app .steps {
@@ -99,7 +103,7 @@
.claim-form-container.telegram-mini-app .steps-content {
min-height: 280px;
padding: 8px 4px 12px;
padding: 8px 0 12px;
}
.claim-form-container.telegram-mini-app .ant-btn {

View File

@@ -1,5 +1,6 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Steps, Card, message, Row, Col, Space, Spin } from 'antd';
import { Card, message, Row, Col, Spin, Button } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription';
// Step1Policy убран - старый ERV флоу
@@ -14,8 +15,6 @@ import './ClaimForm.css';
// Используем относительные пути - Vite proxy перенаправит на backend
const { Step } = Steps;
/**
* Генерация UUID v4
* Формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
@@ -81,13 +80,19 @@ interface FormData {
accountNumber?: string;
}
export default function ClaimForm() {
interface ClaimFormProps {
/** Открыта страница «Подать жалобу» (/new) — не показывать список черновиков */
forceNewClaim?: boolean;
}
export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
// ✅ claim_id будет создан n8n в Step1Phone после SMS верификации
// Не генерируем его локально!
// session_id будет получен от n8n при создании контакта
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
const autoLoadedClaimIdRef = useRef<string | null>(null);
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -112,6 +117,17 @@ export default function ClaimForm() {
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
/** Заход через MAX Mini App. */
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
const forceNewClaimRef = useRef(false);
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
useEffect(() => {
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
if (isNewPage) {
forceNewClaimRef.current = true;
setShowDraftSelection(false);
setHasDrafts(false);
}
}, [forceNewClaim]);
useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
@@ -388,6 +404,14 @@ export default function ClaimForm() {
// Помечаем телефон как верифицированный
setIsPhoneVerified(true);
// На странице /new («Подать жалобу») не показываем черновики
if (forceNewClaimRef.current) {
setCurrentStep(1); // сразу к описанию
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
return;
}
// Проверяем черновики
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
@@ -1136,6 +1160,81 @@ export default function ClaimForm() {
}
}, [formData, updateFormData]);
// Нормализовать start_param: MAX может отдавать строку или объект WebAppStartParam
const startParamToString = useCallback((v: unknown): string | null => {
if (v == null) return null;
if (typeof v === 'string') return v;
if (typeof v === 'object' && v !== null) {
const o = v as Record<string, unknown>;
if (typeof o.value === 'string') return o.value;
if (typeof o.payload === 'string') return o.payload;
if (typeof o.start_param === 'string') return o.start_param;
if (typeof o.data === 'string') return o.data;
return JSON.stringify(o);
}
return String(v);
}, []);
// Извлечь claim_id из строки startapp/start_param (форматы: claim_id=uuid, claim_id_uuid, или голый uuid)
const parseClaimIdFromStartParam = useCallback((startParam: string | Record<string, unknown> | null | undefined): string | null => {
const s = startParamToString(startParam);
if (!s) return null;
const decoded = decodeURIComponent(s.trim());
let m = decoded.match(/(?:^|[?&])claim_id=([^&]+)/i) || decoded.match(/(?:^|[?&])claim_id_([0-9a-f-]{36})/i);
if (!m) m = decoded.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return m ? decodeURIComponent(m[1]) : null;
}, [startParamToString]);
// Автозагрузка черновика из URL или из MAX WebApp start_param после восстановления сессии
useEffect(() => {
if (!sessionRestored) return;
(async () => {
try {
const params = new URLSearchParams(window.location.search);
// claim_id может прийти как UUID или как claim_id_<uuid> (после редиректа из /hello?WebAppStartParam=...)
let claimFromUrl = parseClaimIdFromStartParam(params.get('claim_id') || '') || params.get('claim_id');
// Query: startapp=... или WebAppStartParam=... (MAX подставляет при открытии по диплинку)
if (!claimFromUrl) claimFromUrl = parseClaimIdFromStartParam(params.get('startapp') || params.get('WebAppStartParam') || '');
// Hash (MAX иногда кладёт параметры в #)
if (!claimFromUrl && window.location.hash) {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const fromHash = parseClaimIdFromStartParam(hashParams.get('claim_id') || '') || hashParams.get('claim_id');
claimFromUrl = fromHash || parseClaimIdFromStartParam(hashParams.get('startapp') || hashParams.get('WebAppStartParam') || '');
}
// MAX WebApp: initDataUnsafe.start_param (появляется после загрузки скрипта st.max.ru)
if (!claimFromUrl) {
const wa = (window as any).WebApp;
const startParam = wa?.initDataUnsafe?.start_param;
if (startParam) {
claimFromUrl = parseClaimIdFromStartParam(startParam);
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param:', claimFromUrl);
}
}
// Повторная проверка через 1.2s на случай, если MAX bridge подставил start_param с задержкой
if (!claimFromUrl) {
await new Promise((r) => setTimeout(r, 1200));
const wa = (window as any).WebApp;
const startParam = wa?.initDataUnsafe?.start_param;
if (startParam) {
claimFromUrl = parseClaimIdFromStartParam(startParam);
if (claimFromUrl) console.log('🔗 claim_id из MAX WebApp.start_param (отложенно):', claimFromUrl);
}
}
if (claimFromUrl) {
if (autoLoadedClaimIdRef.current === claimFromUrl) return;
autoLoadedClaimIdRef.current = claimFromUrl;
// Сразу помечаем черновик как выбранный и скрываем список — чтобы не показывать шаг «Черновики», сразу перейти к документам
setSelectedDraftId(claimFromUrl);
setShowDraftSelection(false);
console.log('🔗 Автозагрузка черновика из URL claim_id=', claimFromUrl, '(сразу на документы)');
await loadDraft(claimFromUrl);
}
} catch (e) {
console.error('❌ Ошибка автозагрузки черновика из URL:', e);
}
})();
}, [sessionRestored, loadDraft, parseClaimIdFromStartParam]);
// Обработчик выбора черновика
const handleSelectDraft = useCallback((claimId: string) => {
loadDraft(claimId);
@@ -1143,6 +1242,10 @@ export default function ClaimForm() {
// Проверка наличия черновиков
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
if (forceNewClaimRef.current) {
console.log('🔍 forceNewClaim: пропускаем проверку черновиков');
return false;
}
try {
console.log('🔍 ========== checkDrafts вызван ==========');
console.log('🔍 Параметры:', { unified_id, phone, sessionId });
@@ -1362,8 +1465,8 @@ export default function ClaimForm() {
// Шаг 0: Выбор черновика (показывается только если есть черновики)
// ✅ unified_id уже означает, что телефон верифицирован
// Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts
if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
// Не показываем черновики на странице «Подать жалобу» (/new)
if (!forceNewClaimRef.current && (showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
stepsArray.push({
title: 'Черновики',
description: 'Выбор заявки',
@@ -1409,7 +1512,7 @@ export default function ClaimForm() {
// ✅ Если передан unified_id, значит телефон уже верифицирован (даже если isPhoneVerified ещё false)
// Проверяем черновики, если есть unified_id или телефон верифицирован
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
const shouldCheckDrafts = (finalUnifiedId || (formData.phone && isPhoneVerified)) && !forceNewClaimRef.current;
if (shouldCheckDrafts && !selectedDraftId) {
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone, 'sessionId:', sessionIdRef.current);
@@ -1639,60 +1742,41 @@ export default function ClaimForm() {
// ✅ Показываем loader пока идёт проверка Telegram auth и восстановление сессии
if (!telegramAuthChecked || !sessionRestored) {
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Spin size="large" tip="Загрузка..." />
</div>
);
}
const isDocumentsStep = steps[currentStep]?.title === 'Документы';
return (
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px', background: '#ffffff' }}>
<Row gutter={16}>
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
<Row gutter={[0, 16]}>
{/* Левая часть - Форма (в проде на всю ширину, в деве 14 из 24) */}
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
<Card
title="Подать обращение о защите прав потребителя"
className="claim-form-card"
extra={
!isSubmitted && (
<Space>
{/* Кнопка "Выход" - показываем если телефон верифицирован */}
{isPhoneVerified && (
<button
onClick={handleExitToList}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #ff4d4f',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#ff4d4f'
}}
>
🚪 Выход
</button>
)}
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
{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 режиме) */}

View File

@@ -1,7 +1,9 @@
.hello-page {
min-height: 100vh;
padding: 32px;
padding-bottom: 90px;
background: #f5f7fb;
--tile-h: 160px;
}
.hello-hero {
@@ -66,18 +68,31 @@
margin-top: 32px;
}
.tile-col,
.hello-grid .ant-col {
display: flex;
}
.tile-card {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
min-height: 160px;
height: var(--tile-h);
width: 100%;
box-sizing: border-box;
transition: transform 0.2s ease, box-shadow 0.2s ease;
background: #ffffff;
text-align: center;
}
.tile-card :where(.ant-card-body) {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: center;
flex-direction: column;
transition: transform 0.2s ease, box-shadow 0.2s ease;
padding: 24px 16px;
background: #ffffff;
gap: 10px;
padding: 18px 16px;
text-align: center;
}
@@ -86,16 +101,41 @@
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}
.tile-card--inactive {
cursor: default;
pointer-events: none;
}
.tile-card--inactive .tile-icon {
color: #9ca3af !important;
}
.tile-card--inactive .tile-title {
color: #9ca3af;
}
.tile-card--inactive:hover {
transform: none;
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
}
.tile-icon {
width: 56px;
height: 56px;
width: 44px;
height: 44px;
border-radius: 16px;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
margin-bottom: 12px;
margin-left: 0;
margin-right: 0;
}
.tile-icon svg {
display: block; /* убирает baseline */
width: 28px;
height: 28px;
}
.tile-title {
@@ -103,13 +143,69 @@
font-weight: 600;
color: #111827;
text-align: center;
line-height: 18px;
min-height: 36px;
width: 100%;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Нижний таб-бар */
.hello-bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
padding-bottom: env(safe-area-inset-bottom, 0);
background: #ffffff;
border-top: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
display: flex;
align-items: center;
justify-content: space-around;
z-index: 100;
}
.hello-bar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 16px;
color: #6b7280;
text-decoration: none;
font-size: 12px;
font-weight: 500;
transition: color 0.2s ease;
}
.hello-bar-item:hover {
color: #111827;
}
.hello-bar-item--active {
color: #2563EB;
font-weight: 600;
}
.hello-bar-item--active:hover {
color: #2563EB;
}
@media (max-width: 768px) {
.hello-page {
padding: 16px;
padding-bottom: 90px;
--tile-h: 140px;
}
.tile-card {
min-height: 140px;
.tile-card :where(.ant-card-body) {
padding: 16px 12px;
gap: 8px;
}
}

View File

@@ -9,12 +9,20 @@ import {
FileText,
HelpCircle,
Building2,
ClipboardList,
FileWarning,
MessageCircle,
} from 'lucide-react';
import './HelloAuth.css';
type Status = 'idle' | 'loading' | 'success' | 'error';
export default function HelloAuth() {
interface HelloAuthProps {
onAvatarChange?: (url: string) => void;
onNavigate?: (path: string) => void;
}
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
const [status, setStatus] = useState<Status>('idle');
const [greeting, setGreeting] = useState<string>('Привет!');
const [error, setError] = useState<string>('');
@@ -57,8 +65,14 @@ export default function HelloAuth() {
const data = await res.json();
if (res.ok && data.success) {
setGreeting(data.greeting || 'Привет!');
if (data.avatar_url) {
setAvatar(data.avatar_url);
let avatarUrl = data.avatar_url;
if (!avatarUrl && webApp?.initDataUnsafe?.user?.photo_url) {
avatarUrl = webApp.initDataUnsafe.user.photo_url;
}
if (avatarUrl) {
setAvatar(avatarUrl);
localStorage.setItem('user_avatar_url', avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return;
@@ -87,6 +101,8 @@ export default function HelloAuth() {
setGreeting(data.greeting || 'Привет!');
if (data.avatar_url) {
setAvatar(data.avatar_url);
localStorage.setItem('user_avatar_url', data.avatar_url);
onAvatarChange?.(data.avatar_url);
}
setStatus('success');
return;
@@ -97,6 +113,56 @@ export default function HelloAuth() {
}
// Fallback: SMS
// If there's a claim_id in URL/hash/MAX start_param, try to load draft and redirect to form
const params = new URLSearchParams(window.location.search);
let claimFromUrl: string | null = params.get('claim_id');
const parseStart = (s: string | null) => {
if (!s) return null;
const d = decodeURIComponent(s.trim());
const m = d.match(/claim_id=([^&]+)/i) || d.match(/claim_id_([0-9a-f-]{36})/i) || d.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return m ? decodeURIComponent(m[1]) : null;
};
// MAX может отдавать start_param строкой или объектом WebAppStartParam
const startParamToStr = (v: unknown): string | null => {
if (v == null) return null;
if (typeof v === 'string') return v;
if (typeof v === 'object' && v !== null) {
const o = v as Record<string, unknown>;
if (typeof o.value === 'string') return o.value;
if (typeof o.payload === 'string') return o.payload;
if (typeof o.start_param === 'string') return o.start_param;
return JSON.stringify(o);
}
return String(v);
};
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
if (!claimFromUrl && typeof window !== 'undefined' && window.location.hash) {
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
}
const maxStartParam = (window as any).WebApp?.initDataUnsafe?.start_param;
if (!claimFromUrl && maxStartParam) claimFromUrl = parseStart(startParamToStr(maxStartParam));
if (claimFromUrl) {
try {
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
if (draftRes.ok) {
const draftData = await draftRes.json();
// If backend provided session_token in draft, store it
const st = draftData?.claim?.session_token;
if (st) {
localStorage.setItem('session_token', st);
console.log('HelloAuth: session_token from draft saved', st);
}
// Redirect to root so ClaimForm can restore session and load the draft
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
return;
} else {
console.warn('HelloAuth: draft not found or error', draftRes.status);
}
} catch (e) {
console.error('HelloAuth: error fetching draft by claim_id', e);
}
}
setStatus('idle');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -142,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>
);
})}

View File

@@ -1,5 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Полифилл crypto.getRandomValues для Node 16 — подключать через: node -r ./scripts/crypto-polyfill.cjs ...
export default defineConfig({
plugins: [react()],
@@ -10,6 +11,7 @@ export default defineConfig({
server: {
host: '0.0.0.0',
port: 3000,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://host.docker.internal:8201',