Files
aiform_dev/frontend/src/components/form/StepDraftSelection.tsx
AI Assistant 02689e65db fix: Исправление загрузки документов и SQL запросов
- Исправлена потеря документов при обновлении черновика (SQL объединяет вместо перезаписи)
- Исправлено определение типа документа (приоритет field_label над field_name)
- Исправлены дубликаты в documents_meta и documents_uploaded
- Добавлена передача group_index с фронтенда для правильного field_name
- Исправлены все документы в таблице clpr_claim_documents с правильными field_name
- Обновлены SQL запросы: claimsave и claimsave_final для нового флоу
- Добавлена поддержка multi-file upload для одного документа
- Исправлены дубликаты в списке загруженных документов на фронтенде

Файлы:
- SQL: SQL_CLAIMSAVE_FIXED_NEW_FLOW.sql, SQL_CLAIMSAVE_FINAL_FIXED_NEW_FLOW_WITH_UPLOADED.sql
- n8n: N8N_CODE_PROCESS_UPLOADED_FILES_FIXED.js (поддержка group_index)
- Backend: documents.py (передача group_index в n8n)
- Frontend: StepWizardPlan.tsx (передача group_index, исправление дубликатов)
- Скрипты: fix_claim_documents_field_names.py, fix_documents_meta_duplicates.py

Результат: документы больше не теряются, имеют правильные типы и field_name
2025-11-26 19:54:51 +03:00

511 lines
19 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, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag, Alert, Progress, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
LoadingOutlined,
UploadOutlined,
FileSearchOutlined,
MobileOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons';
const { Title, Text, Paragraph } = Typography;
// Форматирование даты
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 Draft {
id: string;
claim_id: string;
session_token: string;
status_code: string;
channel: string;
created_at: string;
updated_at: string;
problem_description?: string;
wizard_plan: boolean;
wizard_answers: boolean;
has_documents: boolean;
// Новые поля для нового флоу
documents_total?: number;
documents_uploaded?: number;
documents_skipped?: number;
wizard_ready?: boolean;
claim_ready?: boolean;
is_legacy?: boolean; // Старый формат без documents_required
}
interface Props {
phone?: string;
session_id?: string;
unified_id?: string;
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: <LoadingOutlined />,
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,
onSelectDraft,
onNewClaim,
onRestartDraft,
}: Props) {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | 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)
const 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,
};
});
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 if (draft.status_code === 'draft_docs_complete') {
// Всё ещё обрабатывается - показываем сообщение
message.info('Заявление формируется. Пожалуйста, подождите.');
} else {
// Обычный переход
onSelectDraft(draftId);
}
};
// Кнопка действия
const getActionButton = (draft: Draft) => {
const config = getStatusConfig(draft);
const isProcessing = draft.status_code === 'draft_docs_complete';
return (
<Button
type={isProcessing ? 'default' : 'primary'}
onClick={() => handleDraftAction(draft)}
icon={config.icon}
disabled={isProcessing}
loading={isProcessing}
>
{config.action}
</Button>
);
};
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
<Card
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fff',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
📋 Ваши заявки
</Title>
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
Выберите заявку для продолжения или создайте новую.
</Paragraph>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : drafts.length === 0 ? (
<Empty
description="У вас нет незавершенных заявок"
image={Empty.PRESENTED_IMAGE_SIMPLE}
>
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
Создать новую заявку
</Button>
</Empty>
) : (
<>
<List
dataSource={drafts}
renderItem={(draft) => {
const config = getStatusConfig(draft);
const docsProgress = getDocsProgress(draft);
return (
<List.Item
style={{
padding: '16px',
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
borderRadius: 8,
marginBottom: 12,
background: draft.is_legacy ? '#fffbe6' : '#fff',
overflow: 'hidden',
}}
actions={[
getActionButton(draft),
<Popconfirm
key="delete"
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>,
]}
>
<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>
</div>
}
description={
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{/* Описание проблемы */}
{draft.problem_description && (
<Text
style={{
fontSize: 14,
display: 'block',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
}}
title={draft.problem_description}
>
{draft.problem_description.length > 60
? draft.problem_description.substring(0, 60) + '...'
: draft.problem_description
}
</Text>
)}
{/* Время обновления */}
<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' }}
/>
)}
{/* Прогресс документов */}
{docsProgress && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
</Text>
<Progress
percent={docsProgress.percent}
size="small"
showInfo={false}
strokeColor="#52c41a"
/>
</div>
)}
{/* Старые теги прогресса (для обратной совместимости) */}
{!docsProgress && !draft.is_legacy && (
<Space size="small" wrap>
<Tag color={draft.problem_description ? 'green' : 'default'}>
{draft.problem_description ? '✓ Описание' : 'Описание'}
</Tag>
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
{draft.wizard_plan ? '✓ План' : 'План'}
</Tag>
<Tag color={draft.has_documents ? 'green' : 'default'}>
{draft.has_documents ? '✓ Документы' : 'Документы'}
</Tag>
</Space>
)}
{/* Описание статуса */}
<Text type="secondary" style={{ fontSize: 12 }}>
{config.description}
</Text>
</Space>
}
/>
</List.Item>
);
}}
/>
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={onNewClaim}
size="large"
style={{ width: '100%' }}
>
Создать новую заявку
</Button>
</div>
<div style={{ textAlign: 'center' }}>
<Button
type="link"
icon={<ReloadOutlined />}
onClick={loadDrafts}
loading={loading}
>
Обновить список
</Button>
</div>
</>
)}
</Space>
</Card>
</div>
);
}