Добавлено логирование для отладки черновиков
- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
@@ -5,11 +5,12 @@
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
background: #fafafa;
|
||||
color: #000000;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
@@ -40,8 +41,8 @@
|
||||
|
||||
.card h2 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #667eea;
|
||||
color: #000000;
|
||||
border-bottom: 2px solid #d9d9d9;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -88,8 +89,8 @@
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
color: #000000;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.success {
|
||||
|
||||
@@ -49,3 +49,4 @@
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -50,12 +50,16 @@ export default function DebugPanel({ events, formData }: Props) {
|
||||
color: '#d4d4d4',
|
||||
border: '1px solid #333'
|
||||
}}
|
||||
headStyle={{
|
||||
background: '#252526',
|
||||
color: '#fff',
|
||||
borderBottom: '1px solid #333'
|
||||
styles={{
|
||||
header: {
|
||||
background: '#252526',
|
||||
color: '#fff',
|
||||
borderBottom: '1px solid #333'
|
||||
},
|
||||
body: {
|
||||
padding: 12
|
||||
}
|
||||
}}
|
||||
bodyStyle={{ padding: 12 }}
|
||||
>
|
||||
{/* Текущие данные формы */}
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#2d2d30', borderRadius: 4 }}>
|
||||
@@ -79,18 +83,17 @@ export default function DebugPanel({ events, formData }: Props) {
|
||||
<strong>Events Log:</strong>
|
||||
</div>
|
||||
|
||||
<Timeline style={{ marginTop: 16 }}>
|
||||
{events.length === 0 && (
|
||||
<Timeline.Item color="gray">
|
||||
<span style={{ color: '#888', fontSize: 12 }}>Нет событий...</span>
|
||||
</Timeline.Item>
|
||||
)}
|
||||
|
||||
{events.map((event, index) => (
|
||||
<Timeline.Item
|
||||
key={index}
|
||||
dot={getIcon(event.status)}
|
||||
>
|
||||
<Timeline
|
||||
style={{ marginTop: 16 }}
|
||||
items={events.length === 0 ? [
|
||||
{
|
||||
color: 'gray',
|
||||
children: <span style={{ color: '#888', fontSize: 12 }}>Нет событий...</span>
|
||||
}
|
||||
] : events.map((event, index) => ({
|
||||
key: index,
|
||||
dot: getIcon(event.status),
|
||||
children: (
|
||||
<div style={{ fontSize: 11, fontFamily: 'monospace' }}>
|
||||
<div style={{ color: '#888', marginBottom: 4 }}>
|
||||
{event.timestamp}
|
||||
@@ -251,9 +254,9 @@ export default function DebugPanel({ events, formData }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
|
||||
{events.length > 0 && (
|
||||
<div style={{ marginTop: 16, padding: 8, background: '#2d2d30', borderRadius: 4, textAlign: 'center' }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
interface Props {
|
||||
formData: any;
|
||||
updateFormData: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onNext: (unified_id?: string) => void; // ✅ Может принимать unified_id
|
||||
setIsPhoneVerified: (verified: boolean) => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
@@ -96,7 +96,8 @@ export default function Step1Phone({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
session_id: formData.session_id // ✅ Передаём session_id
|
||||
session_id: formData.session_id, // ✅ Передаём session_id
|
||||
form_id: 'ticket_form' // ✅ Маркируем источник формы
|
||||
})
|
||||
});
|
||||
|
||||
@@ -118,6 +119,7 @@ export default function Step1Phone({
|
||||
phone,
|
||||
contact_id: result.contact_id,
|
||||
claim_id: result.claim_id,
|
||||
unified_id: result.unified_id, // ← Добавляем в лог
|
||||
is_new_contact: result.is_new_contact
|
||||
});
|
||||
|
||||
@@ -126,13 +128,18 @@ export default function Step1Phone({
|
||||
// Сохраняем данные из CRM в форму
|
||||
updateFormData({
|
||||
phone,
|
||||
smsCode: code,
|
||||
contact_id: result.contact_id,
|
||||
unified_id: result.unified_id, // ✅ Unified ID из PostgreSQL (получаем от n8n)
|
||||
claim_id: result.claim_id,
|
||||
is_new_contact: result.is_new_contact
|
||||
});
|
||||
|
||||
message.success(result.is_new_contact ? 'Контакт создан!' : 'Контакт найден!');
|
||||
onNext();
|
||||
|
||||
// ✅ Передаем unified_id напрямую в onNext для проверки черновиков
|
||||
// Это нужно, потому что formData может еще не обновиться
|
||||
onNext(result.unified_id);
|
||||
} else {
|
||||
addDebugEvent?.('crm', 'error', '❌ Ошибка создания контакта в CRM', crmResult);
|
||||
message.error('Ошибка создания контакта в CRM');
|
||||
@@ -173,13 +180,21 @@ export default function Step1Phone({
|
||||
{ pattern: /^\d{10}$/, message: 'Введите 10 цифр без кода страны' }
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<PhoneOutlined />}
|
||||
addonBefore="+7"
|
||||
placeholder="9001234567"
|
||||
maxLength={10}
|
||||
size="large"
|
||||
/>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
readOnly
|
||||
value="+7"
|
||||
size="large"
|
||||
style={{ width: '50px', textAlign: 'center', pointerEvents: 'none', background: '#f5f5f5' }}
|
||||
/>
|
||||
<Input
|
||||
prefix={<PhoneOutlined />}
|
||||
placeholder="9001234567"
|
||||
maxLength={10}
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
|
||||
@@ -470,11 +470,11 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
background: '#fff7e6',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #ffa940'
|
||||
border: '1px solid #d9d9d9'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#d46b08', fontWeight: 500 }}>
|
||||
<p style={{ margin: 0, color: '#000000', fontWeight: 500 }}>
|
||||
⚠️ Полис не найден в базе данных
|
||||
</p>
|
||||
<p style={{ margin: '8px 0 0 0', fontSize: 13, color: '#666' }}>
|
||||
@@ -525,7 +525,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||
Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB)
|
||||
{fileList.length > 0 && (
|
||||
<span style={{ marginLeft: 8, color: '#52c41a' }}>
|
||||
<span style={{ marginLeft: 8, color: '#595959' }}>
|
||||
(автоконвертация в PDF)
|
||||
</span>
|
||||
)}
|
||||
@@ -570,7 +570,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
)}
|
||||
|
||||
{!policyNotFound && (
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#f0f9ff', borderRadius: 8 }}>
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#fafafa', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||
💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически
|
||||
</p>
|
||||
@@ -647,7 +647,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
||||
<div>
|
||||
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
|
||||
<p style={{ marginTop: 16 }}><strong>Полный ответ:</strong></p>
|
||||
<pre style={{ background: '#fff3f3', padding: 12, borderRadius: 4, fontSize: 12, maxHeight: 400, overflow: 'auto' }}>
|
||||
<pre style={{ background: '#fafafa', padding: 12, borderRadius: 4, fontSize: 12, maxHeight: 400, overflow: 'auto' }}>
|
||||
{JSON.stringify(ocrModalContent.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
|
||||
{/* Прогресс обработки документов */}
|
||||
{eventType && currentDocuments.length > 0 && (
|
||||
<Card style={{ marginBottom: 24, background: '#f0f9ff', borderColor: '#91d5ff' }}>
|
||||
<Card style={{ marginBottom: 24, background: '#fafafa', borderColor: '#d9d9d9' }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong>Прогресс обработки документов:</strong>
|
||||
</div>
|
||||
@@ -396,7 +396,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{currentDocuments.map(doc =>
|
||||
processedDocuments[doc.field] ? (
|
||||
<div key={doc.field} style={{ marginBottom: 8, color: '#52c41a' }}>
|
||||
<div key={doc.field} style={{ marginBottom: 8, color: '#595959' }}>
|
||||
<CheckCircleOutlined /> {doc.name} - ✅ Обработан
|
||||
</div>
|
||||
) : null
|
||||
@@ -411,14 +411,14 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
<Card
|
||||
title={`📋 Шаг ${currentDocumentIndex + 1}/${currentDocuments.length}: ${currentDocConfig.name}`}
|
||||
style={{ marginTop: 24 }}
|
||||
headStyle={{ background: currentDocConfig.required ? '#fff7e6' : '#f0f9ff', borderBottom: '2px solid #ffa940' }}
|
||||
headStyle={{ background: currentDocConfig.required ? '#fafafa' : '#fafafa', borderBottom: '2px solid #d9d9d9' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#e6f7ff', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#0050b3' }}>
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#fafafa', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#000000' }}>
|
||||
💡 {currentDocConfig.description}
|
||||
</p>
|
||||
{currentDocConfig.required && (
|
||||
<p style={{ margin: '8px 0 0 0', fontSize: 12, color: '#d46b08' }}>
|
||||
<p style={{ margin: '8px 0 0 0', fontSize: 12, color: '#595959' }}>
|
||||
⚠️ Этот документ обязательный
|
||||
</p>
|
||||
)}
|
||||
@@ -475,11 +475,11 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
{/* Если все документы обработаны или текущий индекс вышел за пределы */}
|
||||
{eventType && currentDocumentIndex >= currentDocuments.length && (
|
||||
<Card
|
||||
style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}
|
||||
style={{ marginTop: 24, background: '#fafafa', borderColor: '#d9d9d9' }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
||||
<h3 style={{ color: '#52c41a' }}>✅ Все документы обработаны!</h3>
|
||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#595959', marginBottom: 16 }} />
|
||||
<h3 style={{ color: '#000000' }}>✅ Все документы обработаны!</h3>
|
||||
<p style={{ color: '#666' }}>
|
||||
Обработано обязательных документов: {processedRequired}/{totalRequired}
|
||||
</p>
|
||||
@@ -533,12 +533,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
</p>
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#f6ffed',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #b7eb8f',
|
||||
border: '1px solid #d9d9d9',
|
||||
marginBottom: 16
|
||||
}}>
|
||||
<p style={{ margin: '0 0 8px 0', color: '#52c41a', fontWeight: 500 }}>
|
||||
<p style={{ margin: '0 0 8px 0', color: '#000000', fontWeight: 500 }}>
|
||||
✅ Документ успешно распознан
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||
@@ -562,7 +562,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
||||
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
|
||||
<p style={{ marginTop: 16 }}><strong>Детали:</strong></p>
|
||||
<pre style={{
|
||||
background: '#fff3f3',
|
||||
background: '#fafafa',
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
|
||||
@@ -92,7 +92,7 @@ const Step2EventType: React.FC<Props> = ({ formData, updateFormData, onNext, onP
|
||||
<Card>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
|
||||
<ThunderboltOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
||||
<ThunderboltOutlined style={{ marginRight: 8, color: '#595959' }} />
|
||||
Выберите тип страхового случая
|
||||
</h2>
|
||||
<p style={{ color: '#666', margin: 0 }}>
|
||||
|
||||
@@ -143,6 +143,14 @@ export default function Step3Payment({
|
||||
initialValues={formData}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
{/* Скрытые технические поля */}
|
||||
<Form.Item name="clientIp" hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<Form.Item name="smsCode" hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Кнопка Назад вверху */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Button onClick={onPrev} size="large">
|
||||
@@ -242,9 +250,9 @@ export default function Step3Payment({
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
background: '#fffbe6',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px dashed #faad14',
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
@@ -271,9 +279,9 @@ export default function Step3Payment({
|
||||
{isPhoneVerified && (
|
||||
<div style={{
|
||||
padding: 12,
|
||||
background: '#f0f9ff',
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #91d5ff'
|
||||
border: '1px solid #d9d9d9'
|
||||
}}>
|
||||
✅ Телефон подтвержден
|
||||
</div>
|
||||
@@ -294,11 +302,11 @@ export default function Step3Payment({
|
||||
>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f0f9ff',
|
||||
background: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #91d5ff'
|
||||
border: '1px solid #d9d9d9'
|
||||
}}>
|
||||
<QrcodeOutlined style={{ fontSize: 20, color: '#1890ff', marginRight: 8 }} />
|
||||
<QrcodeOutlined style={{ fontSize: 20, color: '#595959', marginRight: 8 }} />
|
||||
<strong>СБП (Система быстрых платежей)</strong>
|
||||
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: 13 }}>
|
||||
Выплата поступит на ваш счет в течение нескольких минут
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function StepDescription({
|
||||
marginTop: 24,
|
||||
padding: 24,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e0e6ed',
|
||||
}}
|
||||
>
|
||||
@@ -176,8 +176,8 @@ export default function StepDescription({
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#eef2ff',
|
||||
border: '1px dashed #c7d2fe',
|
||||
background: '#fafafa',
|
||||
border: '1px dashed #d9d9d9',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -222,22 +222,22 @@ const StepDocumentUpload: React.FC<Props> = ({
|
||||
<Progress
|
||||
percent={Math.round(((currentDocNumber - 1) / totalDocs) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#1890ff"
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
|
||||
<FileTextOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
||||
<FileTextOutlined style={{ marginRight: 8, color: '#595959' }} />
|
||||
{documentConfig.name}
|
||||
{documentConfig.required && <span style={{ color: '#ff4d4f', marginLeft: 8 }}>*</span>}
|
||||
{documentConfig.required && <span style={{ color: '#000000', marginLeft: 8 }}>*</span>}
|
||||
</h2>
|
||||
<p style={{ color: '#666', margin: 0 }}>
|
||||
{documentConfig.description}
|
||||
</p>
|
||||
{!documentConfig.required && (
|
||||
<p style={{ color: '#faad14', fontSize: 12, marginTop: 4 }}>
|
||||
<p style={{ color: '#595959', fontSize: 12, marginTop: 4 }}>
|
||||
⚠️ Этот документ необязателен, можно пропустить
|
||||
</p>
|
||||
)}
|
||||
|
||||
255
frontend/src/components/form/StepDraftSelection.tsx
Normal file
255
frontend/src/components/form/StepDraftSelection.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag } from 'antd';
|
||||
import { FileTextOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
// Форматирование даты без date-fns (если библиотека не установлена)
|
||||
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 { Title, Text, Paragraph } = Typography;
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
claim_id: string;
|
||||
session_token: string;
|
||||
status_code: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
problem_description?: string;
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
phone: string;
|
||||
session_id?: string;
|
||||
onSelectDraft: (claimId: string) => void;
|
||||
onNewClaim: () => void;
|
||||
}
|
||||
|
||||
export default function StepDraftSelection({
|
||||
phone,
|
||||
session_id,
|
||||
onSelectDraft,
|
||||
onNewClaim,
|
||||
}: 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 (session_id) {
|
||||
params.append('session_id', session_id);
|
||||
} else if (phone) {
|
||||
params.append('phone', phone);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить черновики');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDrafts(data.drafts || []);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновиков:', error);
|
||||
message.error('Не удалось загрузить список черновиков');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDrafts();
|
||||
}, [phone, session_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 getProgressInfo = (draft: Draft) => {
|
||||
const parts: string[] = [];
|
||||
if (draft.problem_description) parts.push('Описание');
|
||||
if (draft.wizard_plan) parts.push('План вопросов');
|
||||
if (draft.wizard_answers) parts.push('Ответы');
|
||||
if (draft.has_documents) parts.push('Документы');
|
||||
return parts.length > 0 ? parts.join(', ') : 'Начато';
|
||||
};
|
||||
|
||||
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={3} style={{ marginBottom: 8 }}>
|
||||
Продолжить заполнение или создать новую заявку?
|
||||
</Title>
|
||||
<Paragraph type="secondary">
|
||||
У вас есть незавершенные черновики. Вы можете продолжить заполнение или создать новую заявку.
|
||||
</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) => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
background: '#fff',
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="continue"
|
||||
type="primary"
|
||||
onClick={() => onSelectDraft(draft.claim_id!)}
|
||||
icon={<FileTextOutlined />}
|
||||
>
|
||||
Продолжить
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить черновик?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id!)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === draft.claim_id}
|
||||
disabled={deletingId === draft.claim_id}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
||||
title={
|
||||
<Space>
|
||||
<Text strong>Черновик {draft.claim_id}</Text>
|
||||
<Tag color="default">Черновик</Tag>
|
||||
</Space>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Обновлен: {formatDate(draft.updated_at)}
|
||||
</Text>
|
||||
{draft.problem_description && (
|
||||
<Text
|
||||
ellipsis={{ tooltip: draft.problem_description }}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{draft.problem_description}
|
||||
</Text>
|
||||
)}
|
||||
<Space size="small">
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_answers ? 'green' : 'default'}>
|
||||
{draft.wizard_answers ? '✓ Ответы' : 'Ответы'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Прогресс: {getProgressInfo(draft)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
@@ -57,11 +57,16 @@ const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record<s
|
||||
if (!condition) return true;
|
||||
const left = values?.[condition.field];
|
||||
const right = condition.value;
|
||||
|
||||
// Приводим к строкам для более надёжного сравнения (Radio.Group может возвращать строки)
|
||||
const leftStr = left != null ? String(left) : null;
|
||||
const rightStr = right != null ? String(right) : null;
|
||||
|
||||
switch (condition.op) {
|
||||
case '==':
|
||||
return left === right;
|
||||
return leftStr === rightStr;
|
||||
case '!=':
|
||||
return left !== right;
|
||||
return leftStr !== rightStr;
|
||||
case '>':
|
||||
return left > right;
|
||||
case '<':
|
||||
@@ -109,6 +114,7 @@ export default function StepWizardPlan({
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
|
||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
@@ -122,6 +128,10 @@ export default function StepWizardPlan({
|
||||
const [customFileBlocks, setCustomFileBlocks] = useState<FileBlock[]>(
|
||||
formData.wizardUploads?.custom || []
|
||||
);
|
||||
const [skippedDocuments, setSkippedDocuments] = useState<Set<string>>(
|
||||
new Set(formData.wizardSkippedDocuments || [])
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
|
||||
done: 0,
|
||||
total: 0,
|
||||
@@ -131,26 +141,6 @@ export default function StepWizardPlan({
|
||||
if (!progressState.total) return 0;
|
||||
return Math.round((progressState.done / progressState.total) * 100);
|
||||
}, [progressState]);
|
||||
const persistUploads = useCallback(
|
||||
(nextDocuments: Record<string, FileBlock[]>, nextCustom: FileBlock[]) => {
|
||||
updateFormData({
|
||||
wizardUploads: {
|
||||
documents: nextDocuments,
|
||||
custom: nextCustom,
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateFormData]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.wizardUploads?.documents) {
|
||||
setQuestionFileBlocks(formData.wizardUploads.documents);
|
||||
}
|
||||
if (formData.wizardUploads?.custom) {
|
||||
setCustomFileBlocks(formData.wizardUploads.custom);
|
||||
}
|
||||
}, [formData.wizardUploads]);
|
||||
|
||||
useEffect(() => {
|
||||
debugLoggerRef.current = addDebugEvent;
|
||||
@@ -196,32 +186,35 @@ export default function StepWizardPlan({
|
||||
const currentBlocks = nextDocs[docId] || [];
|
||||
const updated = updater(currentBlocks);
|
||||
nextDocs[docId] = updated;
|
||||
persistUploads(nextDocs, customFileBlocks);
|
||||
return nextDocs;
|
||||
});
|
||||
},
|
||||
[customFileBlocks, persistUploads]
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCustomBlocksChange = useCallback(
|
||||
(updater: (blocks: FileBlock[]) => FileBlock[]) => {
|
||||
setCustomFileBlocks((prev) => {
|
||||
const updated = updater(prev);
|
||||
persistUploads(questionFileBlocks, updated);
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[persistUploads, questionFileBlocks]
|
||||
[]
|
||||
);
|
||||
|
||||
const addDocumentBlock = (docId: string, docLabel?: string) => {
|
||||
const addDocumentBlock = (docId: string, docLabel?: string, docList?: WizardDocument[]) => {
|
||||
// Для предопределённых документов используем их ID как категорию
|
||||
const category = docList && docList.length === 1 && docList[0].id && !docList[0].id.includes('_exist')
|
||||
? docList[0].id
|
||||
: docId;
|
||||
|
||||
handleDocumentBlocksChange(docId, (blocks) => [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docId),
|
||||
fieldName: docId,
|
||||
description: '',
|
||||
category: docId,
|
||||
category: category,
|
||||
docLabel: docLabel,
|
||||
files: [],
|
||||
},
|
||||
@@ -304,6 +297,47 @@ export default function StepWizardPlan({
|
||||
setProgressState({ done, total });
|
||||
}, [formValues, questions]);
|
||||
|
||||
// Автоматически создаём блоки для обязательных документов при ответе "Да"
|
||||
useEffect(() => {
|
||||
if (!plan || !formValues) return;
|
||||
|
||||
questions.forEach((question) => {
|
||||
const visible = evaluateCondition(question.ask_if, formValues);
|
||||
if (!visible) return;
|
||||
|
||||
const questionValue = formValues?.[question.name];
|
||||
if (!isAffirmative(questionValue)) return;
|
||||
|
||||
const questionDocs = documentGroups[question.name] || [];
|
||||
questionDocs.forEach((doc) => {
|
||||
if (!doc.required) return;
|
||||
|
||||
const docKey = doc.id || doc.name || `doc_${question.name}`;
|
||||
|
||||
// Не создаём блок, если документ пропущен
|
||||
if (skippedDocuments.has(docKey)) return;
|
||||
|
||||
const existingBlocks = questionFileBlocks[docKey] || [];
|
||||
|
||||
// Если блока ещё нет, создаём его автоматически
|
||||
if (existingBlocks.length === 0) {
|
||||
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
||||
handleDocumentBlocksChange(docKey, (blocks) => [
|
||||
...blocks,
|
||||
{
|
||||
id: generateBlockId(docKey),
|
||||
fieldName: docKey,
|
||||
description: '',
|
||||
category: category,
|
||||
docLabel: doc.name,
|
||||
files: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWaiting || !formData.claim_id || plan) {
|
||||
return;
|
||||
@@ -313,6 +347,16 @@ export default function StepWizardPlan({
|
||||
const source = new EventSource(`/events/${claimId}`);
|
||||
eventSourceRef.current = source;
|
||||
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId });
|
||||
|
||||
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
|
||||
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { claim_id: claimId });
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}, 120000); // 2 минуты для RAG обработки
|
||||
|
||||
source.onopen = () => {
|
||||
setConnectionError(null);
|
||||
@@ -357,6 +401,15 @@ export default function StepWizardPlan({
|
||||
payload?.data?.event_type ||
|
||||
payload?.redis_value?.event_type;
|
||||
|
||||
// Логируем все события для отладки
|
||||
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
|
||||
claim_id: claimId,
|
||||
event_type: eventType,
|
||||
has_wizard_plan: Boolean(extractWizardPayload(payload)),
|
||||
payload_keys: Object.keys(payload),
|
||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||
});
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
|
||||
@@ -384,6 +437,10 @@ export default function StepWizardPlan({
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
@@ -393,6 +450,10 @@ export default function StepWizardPlan({
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
@@ -415,37 +476,55 @@ export default function StepWizardPlan({
|
||||
};
|
||||
|
||||
const validateUploads = (values: Record<string, any>) => {
|
||||
for (const [questionName, docs] of Object.entries(documentGroups)) {
|
||||
if (!docs.length) continue;
|
||||
// Проверяем каждый документ по его ID
|
||||
for (const doc of documents) {
|
||||
// Находим вопрос, к которому привязан документ
|
||||
const questionName = Object.keys(documentGroups).find(key =>
|
||||
documentGroups[key].some(d => d.id === doc.id)
|
||||
);
|
||||
|
||||
if (!questionName) continue;
|
||||
const answer = values?.[questionName];
|
||||
if (!isAffirmative(answer)) continue;
|
||||
const blocks = questionFileBlocks[questionName] || [];
|
||||
for (const doc of docs) {
|
||||
const matched = blocks.some((block) => {
|
||||
if (!block.files.length) return false;
|
||||
if (!block.category) return true;
|
||||
const normalizedCategory = block.category.toLowerCase();
|
||||
const normalizedId = (doc.id || '').toLowerCase();
|
||||
const normalizedName = (doc.name || '').toLowerCase();
|
||||
return (
|
||||
normalizedCategory === normalizedId ||
|
||||
normalizedCategory === normalizedName ||
|
||||
(normalizedCategory.includes('contract') && normalizedId.includes('contract')) ||
|
||||
(normalizedCategory.includes('payment') && normalizedId.includes('payment')) ||
|
||||
(normalizedCategory.includes('correspondence') && normalizedId.includes('correspondence'))
|
||||
);
|
||||
});
|
||||
if (doc.required && !matched) {
|
||||
return `Добавьте файлы для документа "${doc.name}"`;
|
||||
|
||||
// Блоки теперь хранятся по doc.id, а не по questionName
|
||||
const docKey = doc.id || doc.name || `doc_${questionName}`;
|
||||
const blocks = questionFileBlocks[docKey] || [];
|
||||
|
||||
// Проверяем, есть ли файлы для обязательного документа (если он не пропущен)
|
||||
if (doc.required) {
|
||||
if (skippedDocuments.has(docKey)) {
|
||||
continue; // Пропускаем валидацию для пропущенных документов
|
||||
}
|
||||
const hasFiles = blocks.some((block) => block.files.length > 0);
|
||||
if (!hasFiles) {
|
||||
return `Добавьте файлы для документа "${doc.name}" или отметьте, что документа нет`;
|
||||
}
|
||||
}
|
||||
const missingDescription = blocks.some(
|
||||
(block) => block.files.length > 0 && !block.description?.trim()
|
||||
);
|
||||
if (missingDescription) {
|
||||
return 'Заполните описание для каждого блока документов';
|
||||
|
||||
// Проверяем описание только для необязательных документов И только если документ не предопределённый
|
||||
// Предопределённые документы (contract, payment, payment_confirmation, receipt, cheque) не требуют описания
|
||||
const docIdLower = (doc.id || '').toLowerCase();
|
||||
const docNameLower = (doc.name || '').toLowerCase();
|
||||
const isPredefinedDoc = doc.id && !doc.id.includes('_exist') &&
|
||||
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
|
||||
docIdLower.includes('contract') || docIdLower.includes('payment') ||
|
||||
docIdLower.includes('receipt') || docIdLower.includes('cheque') ||
|
||||
docNameLower.includes('договор') || docNameLower.includes('чек') ||
|
||||
docNameLower.includes('оплат') || docNameLower.includes('платеж'));
|
||||
|
||||
// Для обязательных документов описание не требуется
|
||||
// Для предопределённых документов описание не требуется
|
||||
if (!doc.required && !isPredefinedDoc) {
|
||||
const missingDescription = blocks.some(
|
||||
(block) => block.files.length > 0 && !block.description?.trim()
|
||||
);
|
||||
if (missingDescription) {
|
||||
return `Заполните описание для документа "${doc.name}"`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const customMissingDescription = customFileBlocks.some(
|
||||
(block) => block.files.length > 0 && !block.description?.trim()
|
||||
);
|
||||
@@ -455,13 +534,14 @@ export default function StepWizardPlan({
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleFinish = (values: Record<string, any>) => {
|
||||
const handleFinish = async (values: Record<string, any>) => {
|
||||
const uploadError = validateUploads(values);
|
||||
if (uploadError) {
|
||||
message.error(uploadError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем в общий стейт
|
||||
updateFormData({
|
||||
wizardPlan: plan,
|
||||
wizardAnswers: values,
|
||||
@@ -470,14 +550,196 @@ export default function StepWizardPlan({
|
||||
documents: questionFileBlocks,
|
||||
custom: customFileBlocks,
|
||||
},
|
||||
wizardSkippedDocuments: Array.from(skippedDocuments),
|
||||
});
|
||||
|
||||
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
|
||||
answers: values,
|
||||
});
|
||||
|
||||
// Дёргаем вебхук через backend сразу после заполнения визарда (multipart/form-data)
|
||||
try {
|
||||
setSubmitting(true);
|
||||
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
|
||||
claim_id: formData.claim_id,
|
||||
});
|
||||
|
||||
const formPayload = new FormData();
|
||||
formPayload.append('stage', 'wizard');
|
||||
formPayload.append('form_id', 'ticket_form');
|
||||
if (formData.session_id) formPayload.append('session_id', formData.session_id);
|
||||
if (formData.clientIp) formPayload.append('client_ip', formData.clientIp);
|
||||
if (formData.smsCode) formPayload.append('sms_code', formData.smsCode);
|
||||
if (formData.claim_id) formPayload.append('claim_id', formData.claim_id);
|
||||
if (formData.contact_id) formPayload.append('contact_id', String(formData.contact_id));
|
||||
if (formData.project_id) formPayload.append('project_id', String(formData.project_id));
|
||||
if (typeof formData.is_new_contact !== 'undefined') {
|
||||
formPayload.append('is_new_contact', String(formData.is_new_contact));
|
||||
}
|
||||
if (typeof formData.is_new_project !== 'undefined') {
|
||||
formPayload.append('is_new_project', String(formData.is_new_project));
|
||||
}
|
||||
if (formData.phone) formPayload.append('phone', formData.phone);
|
||||
if (formData.email) formPayload.append('email', formData.email);
|
||||
if (formData.eventType) formPayload.append('event_type', formData.eventType);
|
||||
|
||||
// JSON-поля
|
||||
formPayload.append('wizard_plan', JSON.stringify(plan || {}));
|
||||
formPayload.append('wizard_answers', JSON.stringify(values || {}));
|
||||
formPayload.append('wizard_skipped_documents', JSON.stringify(Array.from(skippedDocuments)));
|
||||
|
||||
// --- Группируем блоки в uploads[i][j] + uploads_descriptions[i] + uploads_field_names[i]
|
||||
type UploadGroup = {
|
||||
index: number;
|
||||
question?: string;
|
||||
block: FileBlock;
|
||||
kind: 'question' | 'custom';
|
||||
};
|
||||
|
||||
const groups: UploadGroup[] = [];
|
||||
let groupIndex = 0;
|
||||
|
||||
// Собираем все блоки документов (теперь они хранятся по doc.id)
|
||||
// Сначала ищем блоки, которые привязаны к вопросам через documentGroups
|
||||
const allDocKeys = new Set<string>();
|
||||
Object.values(documentGroups).forEach(docs => {
|
||||
docs.forEach(doc => {
|
||||
const docKey = doc.id || doc.name;
|
||||
if (docKey && questionFileBlocks[docKey]) {
|
||||
allDocKeys.add(docKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Также добавляем блоки по старым ключам (для обратной совместимости)
|
||||
Object.keys(questionFileBlocks).forEach(key => {
|
||||
if (!allDocKeys.has(key) && (key.includes('_exist') || key.startsWith('doc_'))) {
|
||||
allDocKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(allDocKeys).forEach((docKey) => {
|
||||
const blocks = questionFileBlocks[docKey] || [];
|
||||
blocks.forEach((block) => {
|
||||
groups.push({
|
||||
index: groupIndex++,
|
||||
question: docKey, // Используем docKey как идентификатор
|
||||
block,
|
||||
kind: 'question',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Затем кастомные блоки
|
||||
customFileBlocks.forEach((block) => {
|
||||
groups.push({
|
||||
index: groupIndex++,
|
||||
question: 'custom',
|
||||
block,
|
||||
kind: 'custom',
|
||||
});
|
||||
});
|
||||
|
||||
const guessFieldName = (group: UploadGroup): string => {
|
||||
const cat = (group.block.category || group.question || '').toLowerCase();
|
||||
|
||||
// Определяем имя поля на основе категории (которая теперь равна doc.id)
|
||||
if (cat.includes('contract') || cat === 'contract' || cat === 'договор') {
|
||||
return 'upload_contract';
|
||||
}
|
||||
if (cat.includes('payment') || cat.includes('cheque') || cat.includes('receipt') ||
|
||||
cat.includes('подтверждение') || cat === 'payment_proof') {
|
||||
return 'upload_payment';
|
||||
}
|
||||
if (cat.includes('correspondence') || cat.includes('chat') || cat.includes('переписка')) {
|
||||
return 'upload_correspondence';
|
||||
}
|
||||
// Если категория похожа на ID документа, используем её
|
||||
if (cat && !cat.includes('_exist')) {
|
||||
return `upload_${cat.replace(/[^a-z0-9_]/g, '_')}`;
|
||||
}
|
||||
// Fallback на индекс
|
||||
return `upload_${group.index}`;
|
||||
};
|
||||
|
||||
groups.forEach((group) => {
|
||||
const i = group.index;
|
||||
const block = group.block;
|
||||
|
||||
// Описание группы
|
||||
formPayload.append(
|
||||
`uploads_descriptions[${i}]`,
|
||||
block.description || ''
|
||||
);
|
||||
|
||||
// Имя "поля" группы
|
||||
formPayload.append(
|
||||
`uploads_field_names[${i}]`,
|
||||
guessFieldName(group)
|
||||
);
|
||||
|
||||
// Файлы: uploads[i][j]
|
||||
block.files.forEach((file, j) => {
|
||||
const origin: any = (file as any).originFileObj;
|
||||
if (!origin) return;
|
||||
formPayload.append(`uploads[${i}][${j}]`, origin, origin.name);
|
||||
});
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/wizard', {
|
||||
method: 'POST',
|
||||
body: formPayload,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let parsed: any = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
addDebugEvent?.('wizard', 'success', '✅ Визард отправлен в n8n', {
|
||||
response: parsed ?? text,
|
||||
});
|
||||
message.success('Мы изучаем ваш вопрос и документы.');
|
||||
} catch (error) {
|
||||
message.error('Ошибка соединения при отправке визарда.');
|
||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
|
||||
error: String(error),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
onNext();
|
||||
};
|
||||
|
||||
const renderQuestionField = (question: WizardQuestion) => {
|
||||
// Обработка по input_type для более точного определения типа поля
|
||||
if (question.input_type === 'multi_choice' || question.control === 'input[type="checkbox"]') {
|
||||
return (
|
||||
<Checkbox.Group>
|
||||
<Space direction="vertical">
|
||||
{question.options?.map((option) => (
|
||||
<Checkbox key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
|
||||
switch (question.control) {
|
||||
case 'textarea':
|
||||
case 'input[type="textarea"]':
|
||||
@@ -488,6 +750,14 @@ export default function StepWizardPlan({
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
/>
|
||||
);
|
||||
case 'input[type="date"]':
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
size="large"
|
||||
placeholder="Выберите дату"
|
||||
/>
|
||||
);
|
||||
case 'input[type="radio"]':
|
||||
return (
|
||||
<Radio.Group>
|
||||
@@ -510,49 +780,93 @@ export default function StepWizardPlan({
|
||||
const docLabel = docList.map((doc) => doc.name).join(', ');
|
||||
const accept = docList.flatMap((doc) => doc.accept || []);
|
||||
const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png']));
|
||||
|
||||
// Если документ предопределён (конкретный тип, не общий), не показываем лишние поля
|
||||
// Предопределённые документы: contract, payment, payment_confirmation и их вариации
|
||||
const doc = docList[0];
|
||||
const isPredefinedDoc = docList.length === 1 && doc && doc.id &&
|
||||
!doc.id.includes('_exist') &&
|
||||
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
|
||||
doc.id.includes('contract') || doc.id.includes('payment') || doc.id.includes('receipt') ||
|
||||
doc.id.includes('cheque') || doc.id.includes('чек'));
|
||||
const singleDocName = isPredefinedDoc ? doc.name : null;
|
||||
const isRequired = docList.some(doc => doc.required);
|
||||
const isSkipped = skippedDocuments.has(docId);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{currentBlocks.map((block, idx) => (
|
||||
{/* Чекбокс "Пропустить" для обязательных документов */}
|
||||
{isRequired && (
|
||||
<div style={{ marginBottom: 8, padding: 8, background: '#f8f9fa', borderRadius: 8 }}>
|
||||
<Checkbox
|
||||
checked={isSkipped}
|
||||
onChange={(e) => {
|
||||
const newSkipped = new Set(skippedDocuments);
|
||||
if (e.target.checked) {
|
||||
newSkipped.add(docId);
|
||||
} else {
|
||||
newSkipped.delete(docId);
|
||||
}
|
||||
setSkippedDocuments(newSkipped);
|
||||
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
||||
}}
|
||||
>
|
||||
У меня нет этого документа
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSkipped && currentBlocks.map((block, idx) => (
|
||||
<Card
|
||||
key={block.id}
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e0e7ff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff',
|
||||
}}
|
||||
title={`${docLabel} — группа #${idx + 1}`}
|
||||
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => removeDocumentBlock(docId, block.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
currentBlocks.length > 1 && (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
size="small"
|
||||
onClick={() => removeDocumentBlock(docId, block.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Описание документов (например: договор от 12.05, платёжка №123)"
|
||||
value={block.description}
|
||||
onChange={(e) =>
|
||||
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={block.category || docId}
|
||||
onChange={(value) => updateDocumentBlock(docId, block.id, { category: value })}
|
||||
placeholder="Категория блока"
|
||||
>
|
||||
{documentCategoryOptions.map((option) => (
|
||||
<Option key={`${docId}-${option.value}`} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
{/* Поле описания только для необязательных/кастомных документов */}
|
||||
{/* Для обязательных документов (contract, payment) описание не требуется */}
|
||||
{!isPredefinedDoc && !isRequired && (
|
||||
<Input
|
||||
placeholder="Описание документов (например: договор от 12.05, платёжка №123)"
|
||||
value={block.description}
|
||||
onChange={(e) =>
|
||||
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Выпадашка категорий только для общих вопросов (docs_exist, correspondence_exist) */}
|
||||
{!isPredefinedDoc && (
|
||||
<Select
|
||||
value={block.category || docId}
|
||||
onChange={(value) => updateDocumentBlock(docId, block.id, { category: value })}
|
||||
placeholder="Категория блока"
|
||||
>
|
||||
{documentCategoryOptions.map((option) => (
|
||||
<Option key={`${docId}-${option.value}`} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<Dragger
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
@@ -561,10 +875,10 @@ export default function StepWizardPlan({
|
||||
updateDocumentBlock(docId, block.id, { files: fileList })
|
||||
}
|
||||
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
|
||||
style={{ background: '#f8f9ff' }}
|
||||
style={{ background: '#fafafa' }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<LoadingOutlined style={{ color: '#6366f1' }} />
|
||||
<LoadingOutlined style={{ color: '#595959' }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
||||
<p className="ant-upload-hint">
|
||||
@@ -574,13 +888,18 @@ export default function StepWizardPlan({
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => addDocumentBlock(docId, docLabel)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Добавить документы ({docLabel})
|
||||
</Button>
|
||||
{/* Кнопка "Добавить" только если документ не пропущен */}
|
||||
{!isSkipped && (!isPredefinedDoc || currentBlocks.length === 0) && (
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => addDocumentBlock(docId, docLabel, docList)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isPredefinedDoc && currentBlocks.length === 0
|
||||
? `Загрузить ${singleDocName || docLabel}`
|
||||
: `Добавить документы (${docLabel})`}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
@@ -588,8 +907,8 @@ export default function StepWizardPlan({
|
||||
const renderCustomUploads = () => (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginTop: 24, borderRadius: 12, border: '1px solid #e0e7ff' }}
|
||||
title="Дополнительные документы"
|
||||
style={{ marginTop: 24, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
||||
title="Документы"
|
||||
extra={
|
||||
<Button type="link" onClick={addCustomBlock} icon={<PlusOutlined />}>
|
||||
Добавить блок
|
||||
@@ -642,7 +961,7 @@ export default function StepWizardPlan({
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<LoadingOutlined style={{ color: '#6366f1' }} />
|
||||
<LoadingOutlined style={{ color: '#595959' }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
||||
<p className="ant-upload-hint">Максимум 10 файлов, до 20 МБ каждый.</p>
|
||||
@@ -663,7 +982,7 @@ export default function StepWizardPlan({
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 16, borderRadius: 12, border: '1px solid #e0e7ff' }}
|
||||
style={{ marginBottom: 16, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
@@ -681,44 +1000,94 @@ export default function StepWizardPlan({
|
||||
onFinish={handleFinish}
|
||||
initialValues={{ ...prefillMap, ...formData.wizardAnswers }}
|
||||
>
|
||||
{questions.map((question) => (
|
||||
<Form.Item shouldUpdate key={question.name}>
|
||||
{() => {
|
||||
const values = form.getFieldsValue(true);
|
||||
if (!evaluateCondition(question.ask_if, values)) {
|
||||
return null;
|
||||
}
|
||||
const questionDocs = documentGroups[question.name] || [];
|
||||
const questionValue = values?.[question.name];
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={question.label}
|
||||
name={question.name}
|
||||
rules={[
|
||||
{
|
||||
required: question.required,
|
||||
message: 'Поле обязательно для заполнения',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderQuestionField(question)}
|
||||
</Form.Item>
|
||||
{questionDocs.length > 0 && isAffirmative(questionValue) && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Text strong>Загрузите документы:</Text>
|
||||
{renderDocumentBlocks(question.name, questionDocs)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
))}
|
||||
{questions.map((question) => {
|
||||
// Для условных полей используем dependencies для отслеживания изменений
|
||||
const dependencies = question.ask_if ? [question.ask_if.field] : undefined;
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={question.name}
|
||||
dependencies={dependencies}
|
||||
shouldUpdate={dependencies ? (prev, curr) => {
|
||||
// Обновляем только если изменилось значение поля, от которого зависит вопрос
|
||||
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
|
||||
} : undefined}
|
||||
>
|
||||
{() => {
|
||||
const values = form.getFieldsValue(true);
|
||||
if (!evaluateCondition(question.ask_if, values)) {
|
||||
return null;
|
||||
}
|
||||
const questionDocs = documentGroups[question.name] || [];
|
||||
const questionValue = values?.[question.name];
|
||||
|
||||
// Скрываем вопросы, которые связаны с загрузкой документов
|
||||
// Если в плане визарда есть документы, не показываем поля про загрузку (text/textarea/file)
|
||||
const questionLabelLower = (question.label || '').toLowerCase();
|
||||
const questionNameLower = (question.name || '').toLowerCase();
|
||||
const isDocumentUploadQuestion =
|
||||
(question.input_type === 'text' ||
|
||||
question.input_type === 'textarea' ||
|
||||
question.input_type === 'file') &&
|
||||
(questionLabelLower.includes('загрузите') ||
|
||||
questionLabelLower.includes('фото') ||
|
||||
questionLabelLower.includes('сканы') ||
|
||||
questionLabelLower.includes('документ') ||
|
||||
questionLabelLower.includes('договор') ||
|
||||
questionLabelLower.includes('чек') ||
|
||||
questionLabelLower.includes('платеж') ||
|
||||
questionLabelLower.includes('копии') ||
|
||||
questionLabelLower.includes('переписк') ||
|
||||
questionNameLower.includes('upload') ||
|
||||
questionNameLower.includes('document'));
|
||||
|
||||
// Если это вопрос про загрузку документов И в плане есть документы, не показываем поле
|
||||
// (даже если вопрос не связан с documentGroups)
|
||||
// Загрузка файлов уже реализована через блоки документов (documents)
|
||||
if (isDocumentUploadQuestion && documents.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={question.label}
|
||||
name={question.name}
|
||||
rules={[
|
||||
{
|
||||
required: question.required,
|
||||
message: 'Поле обязательно для заполнения',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderQuestionField(question)}
|
||||
</Form.Item>
|
||||
{questionDocs.length > 0 && isAffirmative(questionValue) && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Text strong>Загрузите документы:</Text>
|
||||
<Space direction="vertical" style={{ width: '100%', marginTop: 16 }}>
|
||||
{questionDocs.map((doc) => {
|
||||
// Используем doc.id как ключ для отдельного хранения блоков каждого документа
|
||||
const docKey = doc.id || doc.name || `doc_${question.name}`;
|
||||
return (
|
||||
<div key={doc.id}>
|
||||
{renderDocumentBlocks(docKey, [doc])}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
<Space style={{ marginTop: 24 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
Сохранить и продолжить →
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -751,9 +1120,9 @@ export default function StepWizardPlan({
|
||||
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: '1px solid #dbeafe',
|
||||
background: '#f8fbff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{isWaiting && (
|
||||
@@ -791,7 +1160,7 @@ export default function StepWizardPlan({
|
||||
{!isWaiting && plan && (
|
||||
<div>
|
||||
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ThunderboltOutlined style={{ color: '#6366f1' }} /> План действий
|
||||
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
|
||||
@@ -801,9 +1170,9 @@ export default function StepWizardPlan({
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
borderRadius: 8,
|
||||
background: '#fff',
|
||||
border: '1px solid #e0e7ff',
|
||||
border: '1px solid #d9d9d9',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
title="Документы, которые понадобятся"
|
||||
@@ -844,3 +1213,4 @@ export default function StepWizardPlan({
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f5f5f5;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
@@ -225,3 +225,4 @@ export type WizardPlanSample = typeof wizardPlanSample;
|
||||
|
||||
export default wizardPlanSample;
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.claim-form-container {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -10,18 +10,20 @@
|
||||
.claim-form-card {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-head {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: #fafafa;
|
||||
color: #000000;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.claim-form-card .ant-card-head-title {
|
||||
color: white;
|
||||
color: #000000;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Steps, Card, message, Row, Col } from 'antd';
|
||||
import Step1Phone from '../components/form/Step1Phone';
|
||||
import StepDescription from '../components/form/StepDescription';
|
||||
import Step1Policy from '../components/form/Step1Policy';
|
||||
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||
import Step2EventType from '../components/form/Step2EventType';
|
||||
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
||||
@@ -11,7 +12,7 @@ import DebugPanel from '../components/DebugPanel';
|
||||
import { getDocumentsForEventType } from '../constants/documentConfigs';
|
||||
import './ClaimForm.css';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
|
||||
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
@@ -19,7 +20,11 @@ interface FormData {
|
||||
// Шаг 1: Phone
|
||||
phone?: string;
|
||||
contact_id?: string;
|
||||
unified_id?: string; // ✅ Unified ID пользователя из PostgreSQL
|
||||
is_new_contact?: boolean;
|
||||
smsCode?: string;
|
||||
clientIp?: string;
|
||||
smsDebugCode?: string;
|
||||
|
||||
// Шаг 2: Policy
|
||||
voucher: string;
|
||||
@@ -35,6 +40,7 @@ interface FormData {
|
||||
wizardPrefillArray?: Array<{ name: string; value: any }>;
|
||||
wizardCoverageReport?: any;
|
||||
wizardUploads?: Record<string, any>;
|
||||
wizardSkippedDocuments?: string[];
|
||||
|
||||
// Шаг 3: Event Type
|
||||
eventType?: string;
|
||||
@@ -81,12 +87,33 @@ export default function ClaimForm() {
|
||||
});
|
||||
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
|
||||
const [debugEvents, setDebugEvents] = useState<any[]>([]);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
||||
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
|
||||
const [hasDrafts, setHasDrafts] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
console.log('🔥 ClaimForm v2.0 - claim_id НЕ генерируется на фронте!');
|
||||
}, []);
|
||||
|
||||
// Получаем IP клиента один раз при монтировании
|
||||
useEffect(() => {
|
||||
const fetchClientIp = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/utils/client-ip');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data?.ip) {
|
||||
setFormData((prev) => ({ ...prev, clientIp: data.ip }));
|
||||
}
|
||||
} catch {
|
||||
// Тихо игнорируем, IP всегда можно взять на бэке из request
|
||||
}
|
||||
};
|
||||
fetchClientIp();
|
||||
}, []);
|
||||
|
||||
// Динамически определяем список шагов на основе выбранного eventType
|
||||
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
|
||||
const totalDocumentSteps = documentConfigs.length;
|
||||
@@ -127,51 +154,196 @@ export default function ClaimForm() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Загрузка черновика
|
||||
const loadDraft = useCallback(async (claimId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/claims/drafts/${claimId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Не удалось загрузить черновик');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const claim = data.claim;
|
||||
const payload = claim.payload || {};
|
||||
|
||||
// Восстанавливаем данные формы из черновика
|
||||
updateFormData({
|
||||
claim_id: claim.claim_id,
|
||||
session_id: claim.session_token || sessionId,
|
||||
phone: payload.phone || formData.phone,
|
||||
email: payload.email || formData.email,
|
||||
problemDescription: payload.problem_description || formData.problemDescription,
|
||||
wizardPlan: payload.wizard_plan || formData.wizardPlan,
|
||||
wizardAnswers: payload.answers || formData.wizardAnswers,
|
||||
wizardPrefill: payload.answers_prefill ?
|
||||
payload.answers_prefill.reduce((acc: any, item: any) => {
|
||||
acc[item.name] = item.value;
|
||||
return acc;
|
||||
}, {}) : formData.wizardPrefill,
|
||||
wizardPrefillArray: payload.answers_prefill || formData.wizardPrefillArray,
|
||||
wizardCoverageReport: payload.coverage_report || formData.wizardCoverageReport,
|
||||
wizardUploads: {
|
||||
documents: payload.documents_meta ? {} : formData.wizardUploads?.documents,
|
||||
custom: formData.wizardUploads?.custom || [],
|
||||
},
|
||||
wizardSkippedDocuments: payload.wizard_skipped_documents || formData.wizardSkippedDocuments,
|
||||
eventType: payload.event_type || formData.eventType,
|
||||
contact_id: payload.contact_id || formData.contact_id,
|
||||
project_id: payload.project_id || formData.project_id,
|
||||
});
|
||||
|
||||
setSelectedDraftId(claimId);
|
||||
setShowDraftSelection(false);
|
||||
|
||||
// Переходим к шагу с описанием, если оно есть, иначе к шагу с рекомендациями
|
||||
if (payload.problem_description) {
|
||||
// Если есть описание, переходим к шагу с рекомендациями
|
||||
setCurrentStep(2); // StepWizardPlan
|
||||
} else {
|
||||
// Если нет описания, переходим к шагу с описанием
|
||||
setCurrentStep(1); // StepDescription
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки черновика:', error);
|
||||
message.error('Не удалось загрузить черновик');
|
||||
}
|
||||
}, [formData, sessionId, updateFormData]);
|
||||
|
||||
// Обработчик выбора черновика
|
||||
const handleSelectDraft = useCallback((claimId: string) => {
|
||||
loadDraft(claimId);
|
||||
}, [loadDraft]);
|
||||
|
||||
// Проверка наличия черновиков
|
||||
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
// Приоритет: unified_id > phone > session_id
|
||||
if (unified_id) {
|
||||
params.append('unified_id', unified_id);
|
||||
} else if (phone) {
|
||||
params.append('phone', phone);
|
||||
} else if (sessionId) {
|
||||
params.append('session_id', sessionId);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
|
||||
console.log('🔍 Запрос черновиков:', url);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('🔍 Ответ API черновиков:', data);
|
||||
const count = data.count || 0;
|
||||
console.log('🔍 Количество черновиков:', count);
|
||||
|
||||
setHasDrafts(count > 0);
|
||||
setShowDraftSelection(count > 0);
|
||||
return count > 0;
|
||||
} catch (error) {
|
||||
console.error('Ошибка проверки черновиков:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Обработчик создания новой заявки
|
||||
const handleNewClaim = useCallback(() => {
|
||||
setShowDraftSelection(false);
|
||||
setSelectedDraftId(null);
|
||||
// Очищаем данные формы, кроме телефона и session_id
|
||||
updateFormData({
|
||||
claim_id: undefined,
|
||||
problemDescription: undefined,
|
||||
wizardPlan: undefined,
|
||||
wizardAnswers: undefined,
|
||||
wizardPrefill: undefined,
|
||||
wizardPrefillArray: undefined,
|
||||
wizardCoverageReport: undefined,
|
||||
wizardUploads: undefined,
|
||||
wizardSkippedDocuments: undefined,
|
||||
eventType: undefined,
|
||||
});
|
||||
// Переходим к шагу с описанием
|
||||
setCurrentStep(1);
|
||||
}, [updateFormData]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
addDebugEvent('form', 'info', '📤 Отправка заявки на сервер');
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/claims/create`, {
|
||||
addDebugEvent('form', 'info', '📤 Отправка заявки в n8n через backend');
|
||||
|
||||
const payload = {
|
||||
stage: 'final',
|
||||
form_id: 'ticket_form',
|
||||
session_id: formData.session_id ?? sessionId,
|
||||
client_ip: formData.clientIp,
|
||||
sms_code: formData.smsCode,
|
||||
|
||||
// Базовые идентификаторы
|
||||
claim_id: formData.claim_id,
|
||||
contact_id: formData.contact_id,
|
||||
project_id: formData.project_id,
|
||||
ticket_id: formData.ticket_id,
|
||||
is_new_contact: formData.is_new_contact,
|
||||
is_new_project: formData.is_new_project,
|
||||
|
||||
// Основные поля формы (для удобства в n8n)
|
||||
voucher: formData.voucher,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
event_type: formData.eventType,
|
||||
payment_method: formData.paymentMethod,
|
||||
bank_name: formData.bankName,
|
||||
card_number: formData.cardNumber,
|
||||
account_number: formData.accountNumber,
|
||||
|
||||
// Старый блок документов + новые загрузки визарда (пока как есть)
|
||||
documents: formData.documents || {},
|
||||
wizard_uploads: formData.wizardUploads || {},
|
||||
|
||||
// Всё состояние формы целиком — на всякий случай
|
||||
form: formData,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/v1/claims/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
claim_id: formData.claim_id, // ✅ Используем claim_id от n8n
|
||||
voucher: formData.voucher,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
event_type: formData.eventType,
|
||||
payment_method: formData.paymentMethod,
|
||||
bank_name: formData.bankName,
|
||||
card_number: formData.cardNumber,
|
||||
account_number: formData.accountNumber,
|
||||
documents: formData.documents || {},
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.success(`Заявка ${result.claim_number} успешно создана!`);
|
||||
addDebugEvent('form', 'success', `✅ Заявка ${result.claim_number} создана`);
|
||||
|
||||
// Сброс формы (создаём новую заявку, claim_id будет сгенерирован при следующем SMS)
|
||||
setFormData({
|
||||
voucher: '',
|
||||
claim_id: undefined, // ✅ Очищаем для новой заявки
|
||||
session_id: sessionId,
|
||||
paymentMethod: 'sbp',
|
||||
});
|
||||
setCurrentStep(0);
|
||||
setIsPhoneVerified(false);
|
||||
} else {
|
||||
message.error('Ошибка при создании заявки');
|
||||
addDebugEvent('form', 'error', '❌ Ошибка создания заявки');
|
||||
const text = await response.text();
|
||||
let parsed: any = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
message.error('Ошибка при создании заявки (n8n)');
|
||||
addDebugEvent('form', 'error', '❌ Ошибка создания заявки в n8n', {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
addDebugEvent('form', 'success', '✅ Финальный webhook в n8n отработал', {
|
||||
response: parsed ?? text,
|
||||
});
|
||||
// Помечаем, что заявка отправлена, и показываем заглушку.
|
||||
setIsSubmitted(true);
|
||||
message.success('Данные отправлены, заявка принята в обработку.');
|
||||
} catch (error) {
|
||||
message.error('Ошибка соединения с сервером');
|
||||
addDebugEvent('form', 'error', '❌ Ошибка соединения');
|
||||
addDebugEvent('form', 'error', '❌ Ошибка соединения', { error: String(error) });
|
||||
console.error(error);
|
||||
}
|
||||
}, [formData, sessionId, addDebugEvent]);
|
||||
@@ -180,6 +352,22 @@ export default function ClaimForm() {
|
||||
const steps = useMemo(() => {
|
||||
const stepsArray: any[] = [];
|
||||
|
||||
// Шаг 0: Выбор черновика (показывается только если есть черновики и телефон верифицирован)
|
||||
if (showDraftSelection && isPhoneVerified && !selectedDraftId && hasDrafts) {
|
||||
stepsArray.push({
|
||||
title: 'Черновики',
|
||||
description: 'Выбор заявки',
|
||||
content: (
|
||||
<StepDraftSelection
|
||||
phone={formData.phone || ''}
|
||||
session_id={sessionId}
|
||||
onSelectDraft={handleSelectDraft}
|
||||
onNewClaim={handleNewClaim}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 1: Phone (телефон + SMS верификация)
|
||||
stepsArray.push({
|
||||
title: 'Телефон',
|
||||
@@ -187,11 +375,52 @@ export default function ClaimForm() {
|
||||
content: (
|
||||
<Step1Phone
|
||||
formData={{ ...formData, session_id: sessionId }} // ✅ claim_id будет создан n8n
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
updateFormData={(data: any) => {
|
||||
updateFormData(data);
|
||||
// После верификации телефона проверяем черновики
|
||||
if (data.phone && isPhoneVerified && !selectedDraftId && !showDraftSelection) {
|
||||
setShowDraftSelection(true);
|
||||
}
|
||||
}}
|
||||
onNext={async (unified_id?: string) => {
|
||||
console.log('🔥 onNext вызван с unified_id:', unified_id);
|
||||
console.log('🔥 formData.unified_id:', formData.unified_id);
|
||||
console.log('🔥 isPhoneVerified:', isPhoneVerified);
|
||||
console.log('🔥 selectedDraftId:', selectedDraftId);
|
||||
|
||||
// После верификации проверяем черновики
|
||||
// Используем unified_id из параметра (если передан) или из formData
|
||||
const finalUnifiedId = unified_id || formData.unified_id;
|
||||
console.log('🔥 finalUnifiedId:', finalUnifiedId);
|
||||
|
||||
if (formData.phone && isPhoneVerified && !selectedDraftId) {
|
||||
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone);
|
||||
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionId);
|
||||
console.log('🔍 Результат checkDrafts:', hasDraftsResult);
|
||||
if (hasDraftsResult) {
|
||||
console.log('✅ Есть черновики, переходим к шагу 0');
|
||||
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||
} else {
|
||||
console.log('❌ Нет черновиков, идем дальше');
|
||||
nextStep(); // Нет черновиков, идем дальше
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ Условие не выполнено, идем дальше');
|
||||
nextStep();
|
||||
}
|
||||
}}
|
||||
onPrev={prevStep}
|
||||
isPhoneVerified={isPhoneVerified}
|
||||
setIsPhoneVerified={setIsPhoneVerified}
|
||||
setIsPhoneVerified={async (verified: boolean) => {
|
||||
setIsPhoneVerified(verified);
|
||||
// После верификации проверяем черновики
|
||||
if (verified && formData.phone && !selectedDraftId) {
|
||||
const hasDraftsResult = await checkDrafts(formData.unified_id, formData.phone, sessionId);
|
||||
if (hasDraftsResult) {
|
||||
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||
}
|
||||
}
|
||||
}}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
@@ -296,9 +525,10 @@ export default function ClaimForm() {
|
||||
});
|
||||
|
||||
return stepsArray;
|
||||
}, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent]);
|
||||
}, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||
|
||||
const handleReset = () => {
|
||||
setIsSubmitted(false);
|
||||
setFormData({
|
||||
voucher: '',
|
||||
claim_id: undefined, // ✅ Очищаем для новой заявки
|
||||
@@ -312,7 +542,7 @@ export default function ClaimForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="claim-form-container" style={{ padding: '20px', background: '#f0f2f5' }}>
|
||||
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}>
|
||||
<Row gutter={16}>
|
||||
{/* Левая часть - Форма */}
|
||||
<Col xs={24} lg={14}>
|
||||
@@ -320,7 +550,7 @@ export default function ClaimForm() {
|
||||
title="Подать заявку на выплату"
|
||||
className="claim-form-card"
|
||||
extra={
|
||||
currentStep > 0 && (
|
||||
!isSubmitted && currentStep > 0 && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
@@ -337,16 +567,27 @@ export default function ClaimForm() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<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].content}</div>
|
||||
{isSubmitted ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Мы изучаем ваш вопрос и документы</h3>
|
||||
<p style={{ color: '#666666', maxWidth: 480, margin: '0 auto 24px' }}>
|
||||
Заявка отправлена в работу. Юристы проверят информацию и свяжутся с вами по указанным контактам.
|
||||
</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].content}</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
|
||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -5,3 +5,4 @@ declare module '*.svg' {
|
||||
export default content;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user