feat(ticket_form): add wizard plan step and dev cache

This commit is contained in:
AI Assistant
2025-11-15 18:48:15 +03:00
parent 3306d01e0d
commit cbab1c0fe6
7 changed files with 1285 additions and 12 deletions

View File

@@ -188,3 +188,38 @@ Unit-тестов почти нет, поэтому проверяем сцен
Upd 14.11.2025, автор: GPT-5.1 Codex.
---
## 11. Ticket Form — доработки 15.11.2025
### 11.1. SSE + Wizard Plan
- Новая стадия формы `StepWizardPlan` между описанием и выбором услуги:
- подключается к `/events/{claim_id}`, выбирает payload даже если `wizard_plan` лежит в `data`, `redis_value` или `event`.
- отображает иллюстрацию/спиннер, пишет события в DebugPanel.
- при Success сохраняет `wizardPlan`, `answers_prefill`, `coverage_report`, `wizardPrefillMap` в состоянии.
- На случай отладки добавлен чекбокс в `StepDescription`: «Использовать сохранённые рекомендации (DEV)».
- По умолчанию включен; берёт мок `wizardPlanSample` (лежит в `frontend/src/mocks`), пропускает вызов AI и блокирует textarea.
- При снятом чекбоксе описание снова обязательное и реально отправляется на `/api/v1/claims/description`.
### 11.2. Динамическая анкета
- `StepWizardPlan` строит форму исключительно из `wizard_plan.questions`: текст, textarea, радио.
- Въелся прогресс-бар с подсчётом обязательных полей (done / total).
- `wizardPlanStatus` принимает значения `pending | ready | answered`, чтобы следующие шаги понимали, прошёл ли пользователь анкету.
### 11.3. Документы прямо в анкете
- Под вопросами «Есть ли документы?» и «Есть ли переписка?» появляются мультилоадеры:
- группы файлов с описанием, категорией (select), списком допустимых форматов, лимитом 20 МБ.
- для каждого документа из `plan.documents` можно создать несколько блоков; храним их в `wizardUploads.documents`.
- кастомная секция «Дополнительные документы» позволяет добавить произвольные блоки (категория + описание + файлы), лежат в `wizardUploads.custom`.
- Валидация: если ответ «Да», но файлы не добавлены или нет описаний — показываем ошибку, не пускаем дальше.
- До отправки (переход на следующий шаг) сохраняем `wizardUploads` для дальнейшего api/n8n.
### 11.4. Прочее
- `ClaimForm` логи перенесены в `useEffect`, чтобы StrictMode не писал дубль.
- Кнопка «Обновить рекомендации» сбрасывает `wizardPlan` и пересоздаёт SSE.
- Docker: каждый раз после правок фронт пересобирали `docker compose build ticket_form_frontend && docker compose up -d ticket_form_frontend`.
### TODO (перенесено в бэклог)
- На backend обезопасить хранение `wizard_plan` в Redis (по ключу `wizard_plan:{claim_id}`) и отдавать кеш при DEV-галке.
- Передать `wizardUploads` в следующий шаг & далее в n8n, чтобы фактически загрузить файлы/метаданные.

View File

@@ -0,0 +1,51 @@
<svg width="360" height="220" viewBox="0 0 360 220" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#5c6ac4"/>
<stop offset="100%" stop-color="#7dd3fc"/>
</linearGradient>
<linearGradient id="bubble" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.6"/>
</linearGradient>
</defs>
<rect width="360" height="220" rx="24" fill="#f5f7fb"/>
<circle cx="60" cy="60" r="50" fill="url(#grad)" opacity="0.2"/>
<circle cx="300" cy="40" r="30" fill="url(#grad)" opacity="0.15"/>
<circle cx="320" cy="160" r="40" fill="url(#grad)" opacity="0.15"/>
<g>
<rect x="70" y="90" width="220" height="100" rx="20" fill="#fff" stroke="#dbeafe" stroke-width="2"/>
<rect x="90" y="110" width="180" height="60" rx="12" fill="#eef2ff"/>
<rect x="100" y="120" width="120" height="12" rx="6" fill="#c7d2fe"/>
<rect x="100" y="140" width="150" height="12" rx="6" fill="#c7d2fe" opacity="0.6"/>
<rect x="100" y="160" width="90" height="12" rx="6" fill="#c7d2fe" opacity="0.4"/>
</g>
<g>
<path d="M180 50 C210 20, 260 20, 280 60" stroke="#38bdf8" stroke-width="4" fill="none" stroke-linecap="round"/>
<circle cx="280" cy="62" r="6" fill="#38bdf8"/>
<path d="M220 40 L235 35 L232 50 Z" fill="#38bdf8"/>
</g>
<g>
<rect x="125" y="30" width="110" height="36" rx="18" fill="#fff" stroke="#e0e7ff"/>
<circle cx="145" cy="48" r="10" fill="#c7d2fe"/>
<rect x="165" y="42" width="60" height="12" rx="6" fill="#e0e7ff"/>
</g>
<g>
<ellipse cx="180" cy="190" rx="90" ry="14" fill="#dbeafe"/>
<circle cx="150" cy="150" r="26" fill="url(#bubble)"/>
<circle cx="210" cy="150" r="26" fill="url(#bubble)"/>
<rect x="145" y="138" width="70" height="8" rx="4" fill="#c7d2fe"/>
<rect x="145" y="154" width="70" height="8" rx="4" fill="#c7d2fe" opacity="0.6"/>
</g>
<g>
<circle cx="110" cy="185" r="10" fill="#38bdf8" opacity="0.8">
<animate attributeName="r" values="8;12;8" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;0.9;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="250" cy="185" r="10" fill="#5c6ac4" opacity="0.8">
<animate attributeName="r" values="12;8;12" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.9;0.5;0.9" dur="2s" repeatCount="indefinite"/>
</circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,5 +1,6 @@
import { Form, Input, Button, Typography, message } from 'antd';
import { Form, Input, Button, Typography, message, Checkbox } from 'antd';
import { useEffect, useState } from 'react';
import wizardPlanSample from '../../mocks/wizardPlanSample';
const { TextArea } = Input;
const { Paragraph } = Typography;
@@ -19,6 +20,19 @@ export default function StepDescription({
}: Props) {
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const [useMockWizard, setUseMockWizard] = useState(true);
const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
if (!prefill) {
return {};
}
return prefill.reduce<Record<string, any>>((acc, item) => {
if (item?.name) {
acc[item.name] = item.value;
}
return acc;
}, {});
};
useEffect(() => {
form.setFieldsValue({
@@ -28,15 +42,44 @@ export default function StepDescription({
const handleContinue = async () => {
try {
let problemDescription = form.getFieldValue('problemDescription');
if (!useMockWizard) {
const values = await form.validateFields();
problemDescription = values.problemDescription;
}
const safeDescription = problemDescription || '';
if (!formData.session_id) {
message.error('Не найден session_id. Попробуйте обновить страницу.');
return;
}
if (!formData.claim_id) {
message.error('Не удалось определить номер обращения. Вернитесь на шаг с телефоном.');
return;
}
setSubmitting(true);
if (useMockWizard && wizardPlanSample?.wizard_plan) {
const mockPrefill = buildPrefillMap(wizardPlanSample.answers_prefill);
const mockClaimId = wizardPlanSample.claim_id || formData.claim_id;
updateFormData({
problemDescription: safeDescription,
claim_id: mockClaimId,
wizardPlan: wizardPlanSample.wizard_plan,
wizardPlanStatus: 'ready',
wizardPrefill: mockPrefill,
wizardPrefillArray: wizardPlanSample.answers_prefill,
wizardCoverageReport: wizardPlanSample.coverage_report,
wizardAnswers: undefined,
});
message.success('Загружены сохранённые рекомендации (DEV).');
onNext();
return;
}
const response = await fetch('/api/v1/claims/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -45,7 +88,7 @@ export default function StepDescription({
claim_id: formData.claim_id,
phone: formData.phone,
email: formData.email,
problem_description: values.problemDescription,
problem_description: safeDescription,
}),
});
@@ -53,8 +96,15 @@ export default function StepDescription({
throw new Error(`Ошибка API: ${response.status}`);
}
message.success('Описание отправлено, продолжаем заполнение');
updateFormData(values);
message.success('Описание отправлено, подбираем рекомендации...');
updateFormData({
problemDescription: safeDescription,
wizardPlan: undefined,
wizardPlanStatus: 'pending',
wizardAnswers: undefined,
wizardPrefill: undefined,
wizardPrefillArray: undefined,
});
onNext();
} catch (error) {
console.error(error);
@@ -93,22 +143,54 @@ export default function StepDescription({
label="Описание ситуации"
name="problemDescription"
rules={[
{ required: true, message: 'Поле обязательно' },
{
min: 20,
message: 'Опишите, пожалуйста, минимум в пару предложений',
validator: (_, value) => {
if (useMockWizard) {
return Promise.resolve();
}
if (!value) {
return Promise.reject(new Error('Поле обязательно'));
}
if (value.length < 20) {
return Promise.reject(
new Error('Опишите, пожалуйста, минимум в пару предложений')
);
}
return Promise.resolve();
},
},
]}
>
<TextArea
disabled={useMockWizard}
autoSize={{ minRows: 6 }}
maxLength={3000}
showCount
showCount={!useMockWizard}
placeholder="Например: заключил договор на оказание услуг..., деньги списали..., услугу не выполнили..."
/>
</Form.Item>
</Form>
<div
style={{
marginTop: 12,
padding: 12,
borderRadius: 8,
background: '#eef2ff',
border: '1px dashed #c7d2fe',
}}
>
<Checkbox
checked={useMockWizard}
onChange={(e) => setUseMockWizard(e.target.checked)}
>
Использовать сохранённые рекомендации (DEV)
</Checkbox>
<Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 4 }}>
Если включено, план вопросов берётся из локального файла и не запускает модель.
</Paragraph>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
Продолжить

View File

@@ -0,0 +1,846 @@
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 { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
import AiWorkingIllustration from '../../assets/ai-working.svg';
import type { UploadFile } from 'antd/es/upload/interface';
const { Paragraph, Title, Text } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
const { Option } = Select;
interface WizardQuestion {
order: number;
name: string;
label: string;
control: string;
input_type: string;
required: boolean;
priority?: number;
rationale?: string;
ask_if?: {
field: string;
op: '==' | '!=' | '>' | '<' | '>=' | '<=';
value: any;
} | null;
options?: { label: string; value: string }[];
}
interface WizardDocument {
id: string;
name: string;
required: boolean;
priority?: number;
hints?: string;
accept?: string[];
}
interface FileBlock {
id: string;
fieldName: string;
description: string;
category?: string;
files: UploadFile[];
required?: boolean;
docLabel?: string;
}
interface Props {
formData: any;
updateFormData: (data: any) => void;
onNext: () => void;
onPrev: () => void;
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
}
const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record<string, any>) => {
if (!condition) return true;
const left = values?.[condition.field];
const right = condition.value;
switch (condition.op) {
case '==':
return left === right;
case '!=':
return left !== right;
case '>':
return left > right;
case '<':
return left < right;
case '>=':
return left >= right;
case '<=':
return left <= right;
default:
return true;
}
};
const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
if (!prefill || prefill.length === 0) return {};
return prefill.reduce<Record<string, any>>((acc, item) => {
if (item.name) {
acc[item.name] = item.value;
}
return acc;
}, {});
};
const YES_VALUES = ['да', 'yes', 'true', '1'];
const isAffirmative = (value: any) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
return YES_VALUES.includes(value.toLowerCase());
}
return false;
};
const generateBlockId = (prefix: string) =>
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
export default function StepWizardPlan({
formData,
updateFormData,
onNext,
onPrev,
addDebugEvent,
}: Props) {
const [form] = Form.useForm();
const eventSourceRef = useRef<EventSource | null>(null);
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
);
const [questionFileBlocks, setQuestionFileBlocks] = useState<Record<string, FileBlock[]>>(
formData.wizardUploads?.documents || {}
);
const [customFileBlocks, setCustomFileBlocks] = useState<FileBlock[]>(
formData.wizardUploads?.custom || []
);
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
done: 0,
total: 0,
});
const formValues = Form.useWatch([], form);
const progressPercent = useMemo(() => {
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;
}, [addDebugEvent]);
const questions: WizardQuestion[] = useMemo(() => plan?.questions || [], [plan]);
const documents: WizardDocument[] = plan?.documents || [];
const documentGroups = useMemo(() => {
const groups: Record<string, WizardDocument[]> = {};
documents.forEach((doc) => {
const id = doc.id?.toLowerCase() || '';
let key = 'docs_exist';
if (id.includes('correspondence') || id.includes('chat')) {
key = 'correspondence_exist';
}
if (!groups[key]) {
groups[key] = [];
}
groups[key].push(doc);
});
return groups;
}, [documents]);
const documentCategoryOptions = useMemo(() => {
const map = new Map<string, string>();
documents.forEach((doc) => {
const key = doc.id || doc.name || '';
const label = doc.name || doc.id || '';
if (key) {
map.set(key, label);
}
});
map.set('other', 'Другое');
return Array.from(map.entries()).map(([value, label]) => ({ value, label }));
}, [documents]);
const customCategoryOptions = useMemo(() => documentCategoryOptions, [documentCategoryOptions]);
const handleDocumentBlocksChange = useCallback(
(docId: string, updater: (blocks: FileBlock[]) => FileBlock[]) => {
setQuestionFileBlocks((prev) => {
const nextDocs = { ...prev };
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) => {
handleDocumentBlocksChange(docId, (blocks) => [
...blocks,
{
id: generateBlockId(docId),
fieldName: docId,
description: '',
category: docId,
docLabel: docLabel,
files: [],
},
]);
};
const updateDocumentBlock = (
docId: string,
blockId: string,
patch: Partial<Omit<FileBlock, 'id' | 'fieldName'>>
) => {
handleDocumentBlocksChange(docId, (blocks) =>
blocks.map((block) => (block.id === blockId ? { ...block, ...patch } : block))
);
};
const removeDocumentBlock = (docId: string, blockId: string) => {
handleDocumentBlocksChange(docId, (blocks) => blocks.filter((block) => block.id !== blockId));
};
const addCustomBlock = () => {
handleCustomBlocksChange((blocks) => [
...blocks,
{
id: generateBlockId('custom'),
fieldName: 'custom',
description: '',
category: undefined,
files: [],
},
]);
};
const updateCustomBlock = (blockId: string, patch: Partial<FileBlock>) => {
handleCustomBlocksChange((blocks) =>
blocks.map((block) => (block.id === blockId ? { ...block, ...patch } : block))
);
};
const removeCustomBlock = (blockId: string) => {
handleCustomBlocksChange((blocks) => blocks.filter((block) => block.id !== blockId));
};
useEffect(() => {
if (plan) {
const existingAnswers = formData.wizardAnswers || {};
const initialValues = { ...prefillMap, ...existingAnswers };
form.setFieldsValue(initialValues);
}
}, [plan, prefillMap, formData.wizardAnswers, form]);
useEffect(() => {
if (!questions.length) {
setProgressState({ done: 0, total: 0 });
return;
}
const values = formValues || {};
let total = 0;
let done = 0;
questions.forEach((question) => {
const visible = evaluateCondition(question.ask_if, values);
if (question.required && visible) {
total += 1;
const value = values?.[question.name];
let filled = false;
if (Array.isArray(value)) {
filled = value.length > 0;
} else if (typeof value === 'boolean') {
filled = value;
} else {
filled = value !== undefined && value !== null && String(value).trim().length > 0;
}
if (filled) {
done += 1;
}
}
});
setProgressState({ done, total });
}, [formValues, questions]);
useEffect(() => {
if (!isWaiting || !formData.claim_id || plan) {
return;
}
const claimId = formData.claim_id;
const source = new EventSource(`/events/${claimId}`);
eventSourceRef.current = source;
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId });
source.onopen = () => {
setConnectionError(null);
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { claim_id: claimId });
};
source.onerror = (error) => {
console.error('❌ Wizard SSE error:', error);
setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.');
source.close();
eventSourceRef.current = null;
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { claim_id: claimId });
};
const extractWizardPayload = (incoming: any): any => {
if (!incoming || typeof incoming !== 'object') return null;
if (incoming.wizard_plan) return incoming;
const candidates = [
incoming.data,
incoming.redis_value,
incoming.event,
incoming.payload,
];
for (const candidate of candidates) {
if (!candidate) continue;
const unwrapped = extractWizardPayload(candidate);
if (unwrapped) return unwrapped;
}
return null;
};
source.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
const eventType =
payload.event_type ||
payload.type ||
payload?.event?.event_type ||
payload?.data?.event_type ||
payload?.redis_value?.event_type;
const wizardPayload = extractWizardPayload(payload);
const hasWizardPlan = Boolean(wizardPayload);
if (eventType?.includes('wizard') || hasWizardPlan) {
const wizardPlan = wizardPayload?.wizard_plan;
const answersPrefill = wizardPayload?.answers_prefill;
const coverageReport = wizardPayload?.coverage_report;
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
claim_id: claimId,
questions: wizardPlan?.questions?.length || 0,
});
const prefill = buildPrefillMap(answersPrefill);
setPlan(wizardPlan);
setPrefillMap(prefill);
setIsWaiting(false);
setConnectionError(null);
updateFormData({
wizardPlan: wizardPlan,
wizardPrefill: prefill,
wizardPrefillArray: answersPrefill,
wizardCoverageReport: coverageReport,
wizardPlanStatus: 'ready',
});
source.close();
eventSourceRef.current = null;
}
} catch (err) {
console.error('❌ Ошибка разбора события wizard:', err);
}
};
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [isWaiting, formData.claim_id, plan, updateFormData]);
const handleRefreshPlan = () => {
if (!formData.claim_id) {
message.error('Не найден claim_id для подписки на события.');
return;
}
setIsWaiting(true);
setPlan(null);
setConnectionError(null);
updateFormData({
wizardPlan: null,
wizardPlanStatus: 'pending',
});
};
const validateUploads = (values: Record<string, any>) => {
for (const [questionName, docs] of Object.entries(documentGroups)) {
if (!docs.length) 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}"`;
}
}
const missingDescription = blocks.some(
(block) => block.files.length > 0 && !block.description?.trim()
);
if (missingDescription) {
return 'Заполните описание для каждого блока документов';
}
}
const customMissingDescription = customFileBlocks.some(
(block) => block.files.length > 0 && !block.description?.trim()
);
if (customMissingDescription) {
return 'Заполните описание для дополнительных документов';
}
return null;
};
const handleFinish = (values: Record<string, any>) => {
const uploadError = validateUploads(values);
if (uploadError) {
message.error(uploadError);
return;
}
updateFormData({
wizardPlan: plan,
wizardAnswers: values,
wizardPlanStatus: 'answered',
wizardUploads: {
documents: questionFileBlocks,
custom: customFileBlocks,
},
});
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
answers: values,
});
onNext();
};
const renderQuestionField = (question: WizardQuestion) => {
switch (question.control) {
case 'textarea':
case 'input[type="textarea"]':
return (
<TextArea
rows={4}
placeholder="Ответ"
autoSize={{ minRows: 3, maxRows: 6 }}
/>
);
case 'input[type="radio"]':
return (
<Radio.Group>
<Space direction="vertical">
{question.options?.map((option) => (
<Radio key={option.value} value={option.value}>
{option.label}
</Radio>
))}
</Space>
</Radio.Group>
);
default:
return <Input size="large" placeholder="Ответ" />;
}
};
const renderDocumentBlocks = (docId: string, docList: WizardDocument[]) => {
const currentBlocks = questionFileBlocks[docId] || [];
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']));
return (
<Space direction="vertical" style={{ width: '100%' }}>
{currentBlocks.map((block, idx) => (
<Card
key={block.id}
size="small"
style={{
borderRadius: 12,
border: '1px solid #e0e7ff',
background: '#fff',
}}
title={`${docLabel} — группа #${idx + 1}`}
extra={
<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>
<Dragger
multiple
beforeUpload={() => false}
fileList={block.files}
onChange={({ fileList }) =>
updateDocumentBlock(docId, block.id, { files: fileList })
}
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
style={{ background: '#f8f9ff' }}
>
<p className="ant-upload-drag-icon">
<LoadingOutlined style={{ color: '#6366f1' }} />
</p>
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
<p className="ant-upload-hint">
Допустимые форматы: {uniqueAccept.join(', ')}. До 5 файлов, максимум 20 МБ каждый.
</p>
</Dragger>
</Space>
</Card>
))}
<Button
icon={<PlusOutlined />}
onClick={() => addDocumentBlock(docId, docLabel)}
style={{ width: '100%' }}
>
Добавить документы ({docLabel})
</Button>
</Space>
);
};
const renderCustomUploads = () => (
<Card
size="small"
style={{ marginTop: 24, borderRadius: 12, border: '1px solid #e0e7ff' }}
title="Дополнительные документы"
extra={
<Button type="link" onClick={addCustomBlock} icon={<PlusOutlined />}>
Добавить блок
</Button>
}
>
{customFileBlocks.length === 0 && (
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
Можно добавить произвольные группы документов например, переписку, дополнительные акты
или фото.
</Paragraph>
)}
<Space direction="vertical" style={{ width: '100%' }}>
{customFileBlocks.map((block, idx) => (
<Card
key={block.id}
size="small"
type="inner"
title={`Группа #${idx + 1}`}
extra={
<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>
Удалить
</Button>
}
>
<Space direction="vertical" style={{ width: '100%' }}>
<Select
value={block.category}
placeholder="Категория"
onChange={(value) => updateCustomBlock(block.id, { category: value })}
allowClear
>
{customCategoryOptions.map((option) => (
<Option key={`custom-${option.value}`} value={option.value}>
{option.label}
</Option>
))}
</Select>
<TextArea
placeholder="Описание (например: переписка в WhatsApp с менеджером)"
autoSize={{ minRows: 2, maxRows: 4 }}
value={block.description}
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
/>
<Dragger
multiple
beforeUpload={() => false}
fileList={block.files}
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
>
<p className="ant-upload-drag-icon">
<LoadingOutlined style={{ color: '#6366f1' }} />
</p>
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
<p className="ant-upload-hint">Максимум 10 файлов, до 20 МБ каждый.</p>
</Dragger>
</Space>
</Card>
))}
{customFileBlocks.length > 0 && (
<Button onClick={addCustomBlock} icon={<PlusOutlined />}>
Добавить ещё документы
</Button>
)}
</Space>
</Card>
);
const renderQuestions = () => (
<>
<Card
size="small"
style={{ marginBottom: 16, borderRadius: 12, border: '1px solid #e0e7ff' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text strong>Прогресс заполнения</Text>
<Text type="secondary">
{progressState.done}/{progressState.total} обязательных ответов
</Text>
</div>
<Progress percent={progressPercent} showInfo={false} />
</Space>
</Card>
<Form
form={form}
layout="vertical"
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>
))}
<Space style={{ marginTop: 24 }}>
<Button onClick={onPrev}> Назад</Button>
<Button type="primary" htmlType="submit">
Сохранить и продолжить
</Button>
</Space>
</Form>
{renderCustomUploads()}
</>
);
if (!formData.claim_id) {
return (
<Result
status="warning"
title="Нет claim_id"
subTitle="Не удалось определить идентификатор заявки. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);
}
return (
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Button onClick={onPrev}> Назад</Button>
{plan && (
<Button type="link" onClick={handleRefreshPlan}>
Обновить рекомендации
</Button>
)}
</div>
<Card
style={{
borderRadius: 16,
border: '1px solid #dbeafe',
background: '#f8fbff',
}}
>
{isWaiting && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<img
src={AiWorkingIllustration}
alt="AI работает"
style={{ maxWidth: 320, width: '100%', marginBottom: 24 }}
/>
<Title level={4}>Мы собираем рекомендации для вашего случая</Title>
<Paragraph type="secondary" style={{ maxWidth: 420, margin: '0 auto 24px' }}>
Наш AI-ассистент анализирует ваше описание и подбирает вопросы и список документов,
которые помогут быстро решить проблему.
</Paragraph>
<Space direction="vertical">
<Skeleton.Button active size="large" style={{ width: 220 }} />
<Skeleton.Input active size="large" style={{ width: 260 }} />
</Space>
<div style={{ marginTop: 32, color: '#94a3b8' }}>
<LoadingOutlined style={{ fontSize: 28 }} spin /> Подождите несколько секунд
</div>
{connectionError && (
<div style={{ marginTop: 16 }}>
<Text type="danger">{connectionError}</Text>
<div>
<Button onClick={handleRefreshPlan} style={{ marginTop: 12 }}>
Попробовать снова
</Button>
</div>
</div>
)}
</div>
)}
{!isWaiting && plan && (
<div>
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#6366f1' }} /> План действий
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
</Paragraph>
{documents.length > 0 && (
<Card
size="small"
style={{
borderRadius: 12,
background: '#fff',
border: '1px solid #e0e7ff',
marginBottom: 24,
}}
title="Документы, которые понадобятся"
>
<Space direction="vertical" style={{ width: '100%' }}>
{documents.map((doc: any) => (
<div
key={doc.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<div>
<Text strong>{doc.name}</Text>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
{doc.hints}
</Paragraph>
</div>
<Tag color={doc.required ? 'volcano' : 'geekblue'}>
{doc.required ? 'Обязательно' : 'Опционально'}
</Tag>
</div>
))}
</Space>
</Card>
)}
{renderQuestions()}
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,227 @@
const wizardPlanSample = {
claim_id: 'CLM-2025-11-15-QIVHLZ',
wizard_plan: {
version: '1.0',
case_type: 'consumer',
goals: [
'Собрать детали по услуге обучения и проблемам с учебными материалами',
'Подтвердить наличие документов и переписки для доказательства нарушения',
'Определить ожидания пользователя по решению спора',
'Подготовить базу для претензии и возможного иска',
],
questions: [
{
order: 1,
name: 'item',
label: 'Как называется услуга или курс, по которому возникла проблема?',
control: 'input[type="text"]',
input_type: 'text',
required: true,
priority: 1,
rationale: 'Нужно точно определить учебную услугу для претензии',
ask_if: null,
options: [],
},
{
order: 2,
name: 'place_date',
label: 'Где и когда вы приобрели или заказали курс (сайт, дата)?',
control: 'input[type="text"]',
input_type: 'text',
required: true,
priority: 1,
rationale: 'Для фиксации условий и времени заключения договора',
ask_if: null,
options: [],
},
{
order: 3,
name: 'problem',
label: 'Кратко опишите проблему с учебными материалами и заданием',
control: 'textarea',
input_type: 'textarea',
required: true,
priority: 1,
rationale: 'Для фиксации сути нарушения в материалах и заданиях',
ask_if: null,
options: [],
},
{
order: 4,
name: 'steps_taken',
label: 'Какие шаги вы уже предпринимали для решения проблемы?',
control: 'textarea',
input_type: 'textarea',
required: true,
priority: 1,
rationale: 'Для понимания предпринятых действий и ссылок на коммуникацию',
ask_if: null,
options: [],
},
{
order: 5,
name: 'docs_exist',
label: 'Есть ли у вас договор, чеки или иные документы по курсу?',
control: 'input[type="radio"]',
input_type: 'choice',
required: true,
priority: 1,
rationale: 'Доказательства оплаты и условий обучения важны',
ask_if: null,
options: [
{ label: 'Да', value: 'Да' },
{ label: 'Нет', value: 'Нет' },
],
},
{
order: 6,
name: 'correspondence_exist',
label:
'Есть ли у вас переписка (скриншоты, письма) с организаторами по проблемам с материалами?',
control: 'input[type="radio"]',
input_type: 'choice',
required: true,
priority: 1,
rationale:
'Переписка поможет подтвердить факт обращения и реакцию организаторов',
ask_if: null,
options: [
{ label: 'Да', value: 'Да' },
{ label: 'Нет', value: 'Нет' },
],
},
{
order: 7,
name: 'expectation',
label: 'Что вы хотите получить в результате решения спора?',
control: 'input[type="radio"]',
input_type: 'choice',
required: true,
priority: 1,
rationale: 'Уточнение желаемого решения для корректного требования',
ask_if: null,
options: [
{ label: 'Возврат денег', value: 'Возврат денег' },
{ label: 'Замена услуги', value: 'Замена услуги' },
{ label: 'Компенсация морального вреда', value: 'Компенсация морального вреда' },
{ label: 'Другое', value: 'Другое' },
],
},
{
order: 8,
name: 'other_expectation',
label: 'Опишите, пожалуйста, ваше требование, если выбрали «Другое»',
control: 'textarea',
input_type: 'textarea',
required: true,
priority: 2,
rationale: 'Для уточнения нетипичных требований',
ask_if: { field: 'expectation', op: '==', value: 'Другое' },
options: [],
},
],
documents: [
{
id: 'contract',
name: 'Договор или подтверждение заказа курса',
required: true,
priority: 1,
accept: ['pdf', 'jpg', 'png'],
hints: 'Фото или скан подписанного договора или подтверждения заказа',
},
{
id: 'payment',
name: 'Чеки или подтверждения оплаты курса',
required: true,
priority: 1,
accept: ['pdf', 'jpg', 'png'],
hints: 'Фото или сканы квитанций, банковских выписок или платёжных поручений',
},
{
id: 'correspondence',
name: 'Переписка с организаторами курса',
required: false,
priority: 1,
accept: ['pdf', 'jpg', 'png'],
hints: 'Скриншоты, копии писем, чаты, подтверждающие ваше обращение',
},
],
ask_order: [
'item',
'place_date',
'problem',
'steps_taken',
'docs_exist',
'upload_docs',
'correspondence_exist',
'upload_correspondence',
'expectation',
'other_expectation',
],
user_text:
'Для составления претензии нам понадобятся данные о вашем курсе, дате и месте покупки, описание проблемы с учебными материалами, а также документы и переписка с организаторами. Это поможет подтвердить нарушения и добиться компенсации или возврата денег.',
notes:
'Вопросы подобраны для сбора ключевых фактов и документов по спору с обучающей компанией.',
risks: ['DOCS_STATUS_UNKNOWN', 'EXPECTATION_UNSET', 'DATE_AMBIGUOUS'],
deadlines: [
{ type: 'USER_UPLOAD_TTL', duration_hours: 48 },
{ type: 'USER_APPROVAL_TTL', duration_hours: 24 },
],
},
answers_prefill: [
{
name: 'problem',
value:
'Отсутствуют материалы для домашнего задания, инструкция по установке ПО недоступна, видео материалы отсутствуют, вместо них только презентации. Куратор сообщил, что модули на переработке.',
confidence: 1,
needs_confirm: false,
source: 'user_message',
evidence:
'В процессе обучения отсутствуют материалы и инструкция, ссылка не работает, видео нет.',
},
{
name: 'steps_taken',
value: 'Озвучил претензии куратору, получил устный ответ о переработке материала.',
confidence: 1,
needs_confirm: false,
source: 'user_message',
evidence: 'После претензий куратору сообщили о переработке модулей.',
},
],
coverage_report: {
questions: [
{ name: 'item', status: 'missing', confidence: 0, source: null, value: null },
{ name: 'place_date', status: 'missing', confidence: 0, source: null, value: null },
{
name: 'problem',
status: 'covered',
confidence: 1,
source: 'user_message',
value: 'Отсутствие материалов и инструкций',
},
{
name: 'steps_taken',
status: 'covered',
confidence: 1,
source: 'user_message',
value: 'Обращение к куратору, ответ о переработке',
},
{ name: 'docs_exist', status: 'missing', confidence: 0, source: null, value: null },
{
name: 'correspondence_exist',
status: 'missing',
confidence: 0,
source: null,
value: null,
},
{ name: 'expectation', status: 'missing', confidence: 0, source: null, value: null },
],
docs_received: [],
docs_missing: ['contract', 'payment', 'correspondence'],
},
};
export type WizardPlanSample = typeof wizardPlanSample;
export default wizardPlanSample;

View File

@@ -1,8 +1,9 @@
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
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 StepWizardPlan from '../components/form/StepWizardPlan';
import Step2EventType from '../components/form/Step2EventType';
import StepDocumentUpload from '../components/form/StepDocumentUpload';
import Step3Payment from '../components/form/Step3Payment';
@@ -27,6 +28,13 @@ interface FormData {
project_id?: string; // ✅ ID проекта в vTiger (полис)
is_new_project?: boolean; // ✅ Флаг: создан новый проект
problemDescription?: string;
wizardPlan?: any;
wizardPlanStatus?: 'pending' | 'ready' | 'answered';
wizardAnswers?: Record<string, any>;
wizardPrefill?: Record<string, any>;
wizardPrefillArray?: Array<{ name: string; value: any }>;
wizardCoverageReport?: any;
wizardUploads?: Record<string, any>;
// Шаг 3: Event Type
eventType?: string;
@@ -74,8 +82,10 @@ export default function ClaimForm() {
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
const [debugEvents, setDebugEvents] = useState<any[]>([]);
useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
console.log('🔥 ClaimForm v2.0 - claim_id НЕ генерируется на фронте!');
}, []);
// Динамически определяем список шагов на основе выбранного eventType
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
@@ -201,6 +211,21 @@ export default function ClaimForm() {
),
});
// Шаг 3: AI Рекомендации
stepsArray.push({
title: 'Рекомендации',
description: 'AI ассистент',
content: (
<StepWizardPlan
formData={formData}
updateFormData={updateFormData}
onPrev={prevStep}
onNext={nextStep}
addDebugEvent={addDebugEvent}
/>
),
});
// Шаг 3: Policy (всегда)
stepsArray.push({
title: 'Проверка полиса',

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.svg' {
const content: string;
export default content;
}