Files
aiform_prod/frontend/src/components/form/StepDraftSelection.tsx
2026-02-21 22:08:30 +03:00

617 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* StepDraftSelection.tsx
*
* Выбор черновика с поддержкой разных статусов:
* - draft_new: только описание
* - draft_docs_progress: часть документов загружена
* - draft_docs_complete: все документы, ждём заявление
* - draft_claim_ready: заявление готово
* - awaiting_sms: ждёт SMS подтверждения
* - legacy: старый формат (без documents_required)
*
* @version 2.0
* @date 2025-11-26
*/
import { useEffect, useState } from 'react';
import { Button, Card, Row, Col, Typography, Space, Empty, Popconfirm, message, Spin, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
ReloadOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
LoadingOutlined,
UploadOutlined,
FileSearchOutlined,
MobileOutlined,
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 } = 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) => {
try {
const date = new Date(dateStr);
const day = date.getDate();
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day} ${month} ${year}, ${hours}:${minutes}`;
} catch {
return dateStr;
}
};
// Относительное время
const getRelativeTime = (dateStr: string) => {
try {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин. назад`;
if (diffHours < 24) return `${diffHours} ч. назад`;
if (diffDays < 7) return `${diffDays} дн. назад`;
return formatDate(dateStr);
} catch {
return dateStr;
}
};
interface DocumentStatus {
name: string;
required: boolean;
uploaded: boolean;
}
interface Draft {
id: string;
claim_id: string;
session_token: string;
status_code: string;
channel: string;
created_at: string;
updated_at: string;
problem_title?: string; // Краткое описание (заголовок)
problem_description?: string;
category?: string; // Категория проблемы
direction?: string; // Направление (для иконки плитки)
facts_short?: string; // Краткие факты от AI — заголовок плитки
wizard_plan: boolean;
wizard_answers: boolean;
has_documents: boolean;
// Новые поля для нового флоу
documents_total?: number;
documents_uploaded?: number;
documents_skipped?: number;
documents_list?: DocumentStatus[]; // Список документов со статусами
wizard_ready?: boolean;
claim_ready?: boolean;
is_legacy?: boolean; // Старый формат без documents_required
}
interface Props {
phone?: string;
session_id?: string;
unified_id?: string;
isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App
onSelectDraft: (claimId: string) => void;
onNewClaim: () => void;
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
}
// === Конфиг статусов ===
const STATUS_CONFIG: Record<string, {
color: string;
icon: React.ReactNode;
label: string;
description: string;
action: string;
}> = {
draft: {
color: 'default',
icon: <FileTextOutlined />,
label: 'Черновик',
description: 'Начато заполнение',
action: 'Продолжить',
},
draft_new: {
color: 'blue',
icon: <FileTextOutlined />,
label: 'Новый',
description: 'Только описание проблемы',
action: 'Загрузить документы',
},
draft_docs_progress: {
color: 'processing',
icon: <UploadOutlined />,
label: 'Загрузка документов',
description: 'Часть документов загружена',
action: 'Продолжить загрузку',
},
draft_docs_complete: {
color: 'orange',
icon: <CheckCircleOutlined />,
label: 'Документы загружены',
description: 'Все документы обработаны',
action: 'Продолжить',
},
draft_claim_ready: {
color: 'green',
icon: <CheckCircleOutlined />,
label: 'Готово к отправке',
description: 'Заявление готово',
action: 'Просмотреть и отправить',
},
awaiting_sms: {
color: 'volcano',
icon: <MobileOutlined />,
label: 'Ожидает подтверждения',
description: 'Введите SMS код',
action: 'Подтвердить',
},
in_work: {
color: 'cyan',
icon: <FileSearchOutlined />,
label: 'В работе',
description: 'Заявка на рассмотрении',
action: 'Просмотреть',
},
legacy: {
color: 'warning',
icon: <ExclamationCircleOutlined />,
label: 'Устаревший формат',
description: 'Требуется обновление',
action: 'Начать заново',
},
};
export default function StepDraftSelection({
phone,
session_id,
unified_id,
isTelegramMiniApp,
onSelectDraft,
onNewClaim,
onRestartDraft,
}: Props) {
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 {
setLoading(true);
const params = new URLSearchParams();
if (unified_id) {
params.append('unified_id', unified_id);
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
} else if (phone) {
params.append('phone', phone);
console.log('🔍 StepDraftSelection: загружаем черновики по phone:', phone);
} else if (session_id) {
params.append('session_id', session_id);
console.log('🔍 StepDraftSelection: загружаем черновики по session_id:', session_id);
}
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
console.log('🔍 StepDraftSelection: запрос:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Не удалось загрузить черновики');
}
const data = await response.json();
console.log('🔍 StepDraftSelection: ответ API:', data);
// Определяем legacy черновики (без documents_required в payload)
let processedDrafts = (data.drafts || []).map((draft: Draft) => {
// Legacy только если:
// 1. Статус 'draft' (старый формат) ИЛИ
// 2. Нет новых статусов (draft_new, draft_docs_progress, draft_docs_complete, draft_claim_ready)
// И есть wizard_plan (старый формат)
const isNewFlowStatus = ['draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready'].includes(draft.status_code || '');
const isLegacy = !isNewFlowStatus && draft.wizard_plan && draft.status_code === 'draft';
return {
...draft,
is_legacy: isLegacy,
};
});
// ✅ В Telegram Mini App скрываем заявки "В работе"
if (isTelegramMiniApp) {
processedDrafts = processedDrafts.filter((draft: Draft) => draft.status_code !== 'in_work');
console.log('🔍 Telegram Mini App: заявки "В работе" скрыты');
}
setDrafts(processedDrafts);
} catch (error) {
console.error('Ошибка загрузки черновиков:', error);
message.error('Не удалось загрузить список черновиков');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDrafts();
}, [phone, session_id, unified_id]);
const handleDelete = async (claimId: string) => {
try {
setDeletingId(claimId);
const response = await fetch(`/api/v1/claims/drafts/${claimId}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Не удалось удалить черновик');
}
message.success('Черновик удален');
await loadDrafts();
} catch (error) {
console.error('Ошибка удаления черновика:', error);
message.error('Не удалось удалить черновик');
} finally {
setDeletingId(null);
}
};
// Получение конфига статуса
const getStatusConfig = (draft: Draft) => {
if (draft.is_legacy) {
return STATUS_CONFIG.legacy;
}
return STATUS_CONFIG[draft.status_code] || STATUS_CONFIG.draft;
};
// Прогресс документов
const getDocsProgress = (draft: Draft) => {
if (!draft.documents_total) return null;
const uploaded = draft.documents_uploaded || 0;
const skipped = draft.documents_skipped || 0;
const total = draft.documents_total;
const percent = Math.round(((uploaded + skipped) / total) * 100);
return { uploaded, skipped, total, percent };
};
// Обработка клика на черновик
const handleDraftAction = (draft: Draft) => {
const draftId = draft.claim_id || draft.id;
if (draft.is_legacy && onRestartDraft) {
// Legacy черновик - предлагаем начать заново с тем же описанием
onRestartDraft(draftId, draft.problem_description || '');
} else {
// ✅ Разрешаем переход на любом этапе до апрува по SMS
onSelectDraft(draftId);
}
};
// Кнопка действия
const getActionButton = (draft: Draft) => {
// Для заявок "В работе"
if (draft.status_code === 'in_work') {
// ✅ В веб-версии показываем кнопку "Просмотреть в Telegram"
if (!isTelegramMiniApp) {
return (
<Button
type="primary"
icon={<FileSearchOutlined />}
onClick={() => {
// Открываем Telegram бота
window.open('https://t.me/klientprav_bot', '_blank');
}}
>
Просмотреть в Telegram
</Button>
);
}
// ✅ В Telegram Mini App не показываем (но этот код не выполнится, т.к. заявки отфильтрованы)
return null;
}
const config = getStatusConfig(draft);
return (
<Button
type="primary"
onClick={() => handleDraftAction(draft)}
icon={config.icon}
>
{config.action}
</Button>
);
};
// Экран полного описания черновика (по клику на карточку)
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={{ padding: '12px 16px' }}>
<Card
bodyStyle={{ padding: '16px 0' }}
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fff',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}>
📋 Мои обращения
</Title>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : drafts.length === 0 ? (
<Empty
description="У вас пока нет незавершенных заявок"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<>
<Row gutter={[16, 16]}>
{drafts.map((draft) => {
const config = getStatusConfig(draft);
const directionOrCategory = draft.direction || draft.category;
const DirectionIcon = getDirectionIcon(directionOrCategory);
const tileTitle = draft.facts_short
|| draft.problem_title
|| (draft.problem_description
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
: 'Обращение');
const borderColor = draft.is_legacy ? '#faad14' : '#e8e8e8';
const bgColor = draft.is_legacy ? '#fffbe6' : '#fff';
const iconBg = draft.is_legacy ? '#fff7e6' : '#f8fafc';
const iconColor = draft.is_legacy ? '#faad14' : '#6366f1';
return (
<Col xs={12} sm={8} md={6} key={draft.claim_id || draft.id}>
<Card
hoverable
bordered
style={{
borderRadius: 18,
border: `1px solid ${borderColor}`,
background: bgColor,
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{
padding: 16,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 10,
}}
onClick={() => setSelectedDraft(draft)}
>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: iconBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: iconColor,
flexShrink: 0,
}}>
{DirectionIcon ? (
<DirectionIcon size={28} strokeWidth={1.8} />
) : (
<span style={{ fontSize: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{config.icon}
</span>
)}
</div>
<Text
strong
style={{
fontSize: 14,
lineHeight: 1.3,
minHeight: 40,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
color: '#111827',
width: '100%',
wordBreak: 'break-word',
} as React.CSSProperties}
>
{tileTitle}
</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{config.label}
{(draft.documents_total != null && draft.documents_total > 0) && (
<span style={{ marginLeft: 4, color: '#1890ff' }}>
{draft.documents_uploaded ?? 0}/{draft.documents_total}
</span>
)}
</Text>
<Tooltip title={formatDate(draft.updated_at)}>
<Text type="secondary" style={{ fontSize: 11 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{getRelativeTime(draft.updated_at)}
</Text>
</Tooltip>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', marginTop: 4 }} onClick={(e) => e.stopPropagation()}>
{getActionButton(draft)}
{draft.status_code !== 'in_work' && (
<Popconfirm
title="Удалить заявку?"
description="Это действие нельзя отменить"
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
okText="Да, удалить"
cancelText="Отмена"
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
loading={deletingId === (draft.claim_id || draft.id)}
disabled={deletingId === (draft.claim_id || draft.id)}
>
Удалить
</Button>
</Popconfirm>
)}
</div>
</Card>
</Col>
);
})}
</Row>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button
type="link"
icon={<ReloadOutlined />}
onClick={loadDrafts}
loading={loading}
>
Обновить список
</Button>
</div>
</>
)}
</Space>
</Card>
</div>
);
}