Добавлено логирование для отладки черновиков

- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API
- Добавлены логи в backend (claims.py) для отладки SQL запросов
- Создан лог сессии с описанием проблемы и текущего состояния
- Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
AI Assistant
2025-11-19 18:46:48 +03:00
parent cbab1c0fe6
commit 4c8fda5f55
57 changed files with 6574 additions and 304 deletions

View File

@@ -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 {

View File

@@ -49,3 +49,4 @@
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -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' }}>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 }}>

View File

@@ -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 }}>
Выплата поступит на ваш счет в течение нескольких минут

View File

@@ -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

View File

@@ -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>
)}

View 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>
);
}

View File

@@ -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({
}

View File

@@ -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 {

View File

@@ -225,3 +225,4 @@ export type WizardPlanSample = typeof wizardPlanSample;
export default wizardPlanSample;

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -5,3 +5,4 @@ declare module '*.svg' {
export default content;
}