Files
aiform_dev/frontend/src/components/form/StepWizardPlan.tsx

1366 lines
53 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
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';
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;
// Приводим к строкам для более надёжного сравнения (Radio.Group может возвращать строки)
const leftStr = left != null ? String(left) : null;
const rightStr = right != null ? String(right) : null;
switch (condition.op) {
case '==':
return leftStr === rightStr;
case '!=':
return leftStr !== rightStr;
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) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
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);
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 [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,
});
const formValues = Form.useWatch([], form);
const progressPercent = useMemo(() => {
if (!progressState.total) return 0;
return Math.round((progressState.done / progressState.total) * 100);
}, [progressState]);
useEffect(() => {
debugLoggerRef.current = addDebugEvent;
}, [addDebugEvent]);
// ✅ Автосохранение прогресса заполнения (debounce 3 секунды)
useEffect(() => {
if (!formData.claim_id || !formValues) return;
const timeoutId = setTimeout(() => {
const answers = form.getFieldsValue(true);
// Сохраняем только если есть хоть какие-то ответы
const hasAnswers = Object.keys(answers).some(key => answers[key] !== undefined && answers[key] !== '');
if (hasAnswers) {
console.log('💾 Автосохранение прогресса:', { claim_id: formData.claim_id, answersCount: Object.keys(answers).length });
// Обновляем formData с текущими ответами
updateFormData({
wizardAnswers: answers,
wizardUploads: {
documents: questionFileBlocks,
custom: customFileBlocks,
},
wizardSkippedDocuments: Array.from(skippedDocuments),
});
addDebugEvent?.('wizard', 'info', '💾 Автосохранение прогресса');
}
}, 3000); // 3 секунды debounce
return () => clearTimeout(timeoutId);
}, [formValues, formData.claim_id]); // Зависимость от formValues, но БЕЗ questionFileBlocks/customFileBlocks/skippedDocuments (они обновляются отдельно)
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;
return nextDocs;
});
},
[]
);
const handleCustomBlocksChange = useCallback(
(updater: (blocks: FileBlock[]) => FileBlock[]) => {
setCustomFileBlocks((prev) => {
const updated = updater(prev);
return updated;
});
},
[]
);
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: category,
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 (!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.session_id || plan) {
return;
}
const sessionId = formData.session_id;
const source = new EventSource(`/events/${sessionId}`);
eventSourceRef.current = source;
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
timeoutRef.current = setTimeout(() => {
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}, 120000); // 2 минуты для RAG обработки
source.onopen = () => {
setConnectionError(null);
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { session_id: sessionId });
};
source.onerror = (error) => {
console.error('❌ Wizard SSE error:', error);
setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.');
source.close();
eventSourceRef.current = null;
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { session_id: sessionId });
};
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;
// Логируем все события для отладки
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
session_id: sessionId,
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);
if (eventType?.includes('wizard') || hasWizardPlan) {
const wizardPlan = wizardPayload?.wizard_plan;
const answersPrefill = wizardPayload?.answers_prefill;
const coverageReport = wizardPayload?.coverage_report;
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
session_id: sessionId,
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',
});
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
source.close();
eventSourceRef.current = null;
}
} catch (err) {
console.error('❌ Ошибка разбора события wizard:', err);
}
};
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [isWaiting, formData.session_id, plan, updateFormData]);
const handleRefreshPlan = () => {
if (!formData.session_id) {
message.error('Не найден session_id для подписки на события.');
return;
}
setIsWaiting(true);
setPlan(null);
setConnectionError(null);
updateFormData({
wizardPlan: null,
wizardPlanStatus: 'pending',
});
};
const validateUploads = (values: Record<string, any>) => {
// Проверяем каждый документ по его 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;
// Блоки теперь хранятся по 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}" или отметьте, что документа нет`;
}
}
// Проверяем описание только для необязательных документов И только если документ не предопределённый
// Предопределённые документы (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()
);
if (customMissingDescription) {
return 'Заполните описание для дополнительных документов';
}
return null;
};
const handleFinish = async (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,
},
wizardSkippedDocuments: Array.from(skippedDocuments),
});
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
answers: values,
});
// Дёргаем вебхук через backend сразу после заполнения визарда (multipart/form-data)
try {
setSubmitting(true);
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
session_id: formData.session_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);
// Добавляем unified_id и claim_id (если есть)
if (formData.unified_id) formPayload.append('unified_id', formData.unified_id);
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 || ''
);
feat: Add claim plan confirmation flow via Redis SSE Problem: - After wizard form submission, need to wait for claim data from n8n - Claim data comes via Redis channel claim:plan:{session_token} - Need to display confirmation form with claim data Solution: 1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token} - Subscribes to Redis channel claim:plan:{session_token} - Streams claim data from n8n to frontend - Handles timeouts and errors gracefully 2. Frontend: Added subscription to claim:plan channel - StepWizardPlan: After form submission, subscribes to SSE - Waits for claim_plan_ready event - Shows loading message while waiting - On success: saves claimPlanData and shows confirmation step 3. New component: StepClaimConfirmation - Displays claim confirmation form in iframe - Receives claimPlanData from parent - Generates HTML form (placeholder - should call n8n for real HTML) - Handles confirmation/cancellation via postMessage 4. ClaimForm: Added conditional step for confirmation - Shows StepClaimConfirmation when showClaimConfirmation=true - Step appears after StepWizardPlan - Only visible when claimPlanData is available Flow: 1. User fills wizard form → submits 2. Form data sent to n8n via /api/v1/claims/wizard 3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token} 4. n8n processes data → publishes to Redis claim:plan:{session_token} 5. Backend receives → streams to frontend via SSE 6. Frontend receives → shows StepClaimConfirmation 7. User confirms → proceeds to next step Files: - backend/app/api/events.py: Added stream_claim_plan endpoint - frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan - frontend/src/components/form/StepClaimConfirmation.tsx: New component - frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
2025-11-24 13:36:14 +03:00
// Имя "поля" группы (используем docLabel если есть, иначе guessFieldName)
const fieldLabel = block.docLabel || block.fieldName || guessFieldName(group);
formPayload.append(
`uploads_field_names[${i}]`,
feat: Add claim plan confirmation flow via Redis SSE Problem: - After wizard form submission, need to wait for claim data from n8n - Claim data comes via Redis channel claim:plan:{session_token} - Need to display confirmation form with claim data Solution: 1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token} - Subscribes to Redis channel claim:plan:{session_token} - Streams claim data from n8n to frontend - Handles timeouts and errors gracefully 2. Frontend: Added subscription to claim:plan channel - StepWizardPlan: After form submission, subscribes to SSE - Waits for claim_plan_ready event - Shows loading message while waiting - On success: saves claimPlanData and shows confirmation step 3. New component: StepClaimConfirmation - Displays claim confirmation form in iframe - Receives claimPlanData from parent - Generates HTML form (placeholder - should call n8n for real HTML) - Handles confirmation/cancellation via postMessage 4. ClaimForm: Added conditional step for confirmation - Shows StepClaimConfirmation when showClaimConfirmation=true - Step appears after StepWizardPlan - Only visible when claimPlanData is available Flow: 1. User fills wizard form → submits 2. Form data sent to n8n via /api/v1/claims/wizard 3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token} 4. n8n processes data → publishes to Redis claim:plan:{session_token} 5. Backend receives → streams to frontend via SSE 6. Frontend receives → shows StepClaimConfirmation 7. User confirms → proceeds to next step Files: - backend/app/api/events.py: Added stream_claim_plan endpoint - frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan - frontend/src/components/form/StepClaimConfirmation.tsx: New component - frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
2025-11-24 13:36:14 +03:00
fieldLabel
);
// ✅ Добавляем реальное название поля (label) для использования в n8n
formPayload.append(
`uploads_field_labels[${i}]`,
block.docLabel || block.description || fieldLabel
);
// Файлы: 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);
});
});
// Логируем ключевые поля перед отправкой
console.log('📤 Отправка в n8n:', {
session_id: formData.session_id,
unified_id: formData.unified_id,
claim_id: formData.claim_id,
contact_id: formData.contact_id,
phone: formData.phone,
});
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('Мы изучаем ваш вопрос и документы.');
feat: Add claim plan confirmation flow via Redis SSE Problem: - After wizard form submission, need to wait for claim data from n8n - Claim data comes via Redis channel claim:plan:{session_token} - Need to display confirmation form with claim data Solution: 1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token} - Subscribes to Redis channel claim:plan:{session_token} - Streams claim data from n8n to frontend - Handles timeouts and errors gracefully 2. Frontend: Added subscription to claim:plan channel - StepWizardPlan: After form submission, subscribes to SSE - Waits for claim_plan_ready event - Shows loading message while waiting - On success: saves claimPlanData and shows confirmation step 3. New component: StepClaimConfirmation - Displays claim confirmation form in iframe - Receives claimPlanData from parent - Generates HTML form (placeholder - should call n8n for real HTML) - Handles confirmation/cancellation via postMessage 4. ClaimForm: Added conditional step for confirmation - Shows StepClaimConfirmation when showClaimConfirmation=true - Step appears after StepWizardPlan - Only visible when claimPlanData is available Flow: 1. User fills wizard form → submits 2. Form data sent to n8n via /api/v1/claims/wizard 3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token} 4. n8n processes data → publishes to Redis claim:plan:{session_token} 5. Backend receives → streams to frontend via SSE 6. Frontend receives → shows StepClaimConfirmation 7. User confirms → proceeds to next step Files: - backend/app/api/events.py: Added stream_claim_plan endpoint - frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan - frontend/src/components/form/StepClaimConfirmation.tsx: New component - frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
2025-11-24 13:36:14 +03:00
// Подписываемся на канал claim:plan для получения данных заявления
if (formData.session_id) {
subscribeToClaimPlan(formData.session_id);
} else {
console.warn('⚠️ session_id отсутствует, не можем подписаться на claim:plan');
onNext();
}
} catch (error) {
message.error('Ошибка соединения при отправке визарда.');
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
error: String(error),
});
feat: Add claim plan confirmation flow via Redis SSE Problem: - After wizard form submission, need to wait for claim data from n8n - Claim data comes via Redis channel claim:plan:{session_token} - Need to display confirmation form with claim data Solution: 1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token} - Subscribes to Redis channel claim:plan:{session_token} - Streams claim data from n8n to frontend - Handles timeouts and errors gracefully 2. Frontend: Added subscription to claim:plan channel - StepWizardPlan: After form submission, subscribes to SSE - Waits for claim_plan_ready event - Shows loading message while waiting - On success: saves claimPlanData and shows confirmation step 3. New component: StepClaimConfirmation - Displays claim confirmation form in iframe - Receives claimPlanData from parent - Generates HTML form (placeholder - should call n8n for real HTML) - Handles confirmation/cancellation via postMessage 4. ClaimForm: Added conditional step for confirmation - Shows StepClaimConfirmation when showClaimConfirmation=true - Step appears after StepWizardPlan - Only visible when claimPlanData is available Flow: 1. User fills wizard form → submits 2. Form data sent to n8n via /api/v1/claims/wizard 3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token} 4. n8n processes data → publishes to Redis claim:plan:{session_token} 5. Backend receives → streams to frontend via SSE 6. Frontend receives → shows StepClaimConfirmation 7. User confirms → proceeds to next step Files: - backend/app/api/events.py: Added stream_claim_plan endpoint - frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan - frontend/src/components/form/StepClaimConfirmation.tsx: New component - frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
2025-11-24 13:36:14 +03:00
onNext();
} finally {
setSubmitting(false);
}
};
feat: Add claim plan confirmation flow via Redis SSE Problem: - After wizard form submission, need to wait for claim data from n8n - Claim data comes via Redis channel claim:plan:{session_token} - Need to display confirmation form with claim data Solution: 1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token} - Subscribes to Redis channel claim:plan:{session_token} - Streams claim data from n8n to frontend - Handles timeouts and errors gracefully 2. Frontend: Added subscription to claim:plan channel - StepWizardPlan: After form submission, subscribes to SSE - Waits for claim_plan_ready event - Shows loading message while waiting - On success: saves claimPlanData and shows confirmation step 3. New component: StepClaimConfirmation - Displays claim confirmation form in iframe - Receives claimPlanData from parent - Generates HTML form (placeholder - should call n8n for real HTML) - Handles confirmation/cancellation via postMessage 4. ClaimForm: Added conditional step for confirmation - Shows StepClaimConfirmation when showClaimConfirmation=true - Step appears after StepWizardPlan - Only visible when claimPlanData is available Flow: 1. User fills wizard form → submits 2. Form data sent to n8n via /api/v1/claims/wizard 3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token} 4. n8n processes data → publishes to Redis claim:plan:{session_token} 5. Backend receives → streams to frontend via SSE 6. Frontend receives → shows StepClaimConfirmation 7. User confirms → proceeds to next step Files: - backend/app/api/events.py: Added stream_claim_plan endpoint - frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan - frontend/src/components/form/StepClaimConfirmation.tsx: New component - frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
2025-11-24 13:36:14 +03:00
// Функция подписки на канал claim:plan
const subscribeToClaimPlan = useCallback((sessionToken: string) => {
console.log('📡 Подписка на канал claim:plan для session:', sessionToken);
// Закрываем предыдущее соединение, если есть
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// Создаём новое SSE соединение
const eventSource = new EventSource(`/api/v1/claim-plan/${sessionToken}`);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('✅ Подключено к каналу claim:plan');
addDebugEvent?.('claim-plan', 'info', '📡 Ожидание данных заявления...');
message.loading('Ожидание данных заявления...', 0);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📥 Получены данные из claim:plan:', data);
if (data.event_type === 'claim_plan_ready' && data.status === 'ready') {
// Данные заявления получены!
message.destroy(); // Убираем loading сообщение
message.success('Данные заявления готовы!');
// Сохраняем данные заявления в formData
updateFormData({
claimPlanData: data.data, // Данные от n8n
showClaimConfirmation: true, // Флаг для показа формы подтверждения
});
// Закрываем SSE соединение
eventSource.close();
eventSourceRef.current = null;
// Переходим к следующему шагу (форма подтверждения)
onNext();
} else if (data.event_type === 'claim_plan_error' || data.status === 'error') {
message.destroy();
message.error(data.message || 'Ошибка получения данных заявления');
eventSource.close();
eventSourceRef.current = null;
onNext(); // Переходим дальше даже при ошибке
} else if (data.event_type === 'claim_plan_timeout' || data.status === 'timeout') {
message.destroy();
message.warning('Превышено время ожидания. Попробуйте обновить страницу.');
eventSource.close();
eventSourceRef.current = null;
onNext();
}
} catch (error) {
console.error('❌ Ошибка парсинга данных из claim:plan:', error);
message.destroy();
message.error('Ошибка обработки данных заявления');
}
};
eventSource.onerror = (error) => {
console.error('❌ Ошибка SSE соединения claim:plan:', error);
message.destroy();
message.error('Ошибка подключения к серверу');
eventSource.close();
eventSourceRef.current = null;
onNext(); // Переходим дальше даже при ошибке
};
// Таймаут на 5 минут
timeoutRef.current = setTimeout(() => {
console.warn('⏰ Таймаут ожидания данных заявления');
message.destroy();
message.warning('Превышено время ожидания данных заявления');
eventSource.close();
eventSourceRef.current = null;
onNext();
}, 300000); // 5 минут
}, [addDebugEvent, updateFormData, 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"]':
return (
<TextArea
rows={4}
placeholder="Ответ"
autoSize={{ minRows: 3, maxRows: 6 }}
/>
);
case 'input[type="date"]':
return (
<Input
type="date"
size="large"
placeholder="Выберите дату"
/>
);
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']));
// Если документ предопределён (конкретный тип, не общий), не показываем лишние поля
// Предопределённые документы: 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%' }}>
{/* Чекбокс "Пропустить" для обязательных документов */}
{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: 8,
border: '1px solid #d9d9d9',
background: '#fff',
}}
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
extra={
currentBlocks.length > 1 && (
<Button
type="link"
danger
size="small"
onClick={() => removeDocumentBlock(docId, block.id)}
>
Удалить
</Button>
)
}
>
<Space direction="vertical" style={{ width: '100%' }}>
{/* Поле описания только для необязательных/кастомных документов */}
{/* Для обязательных документов (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}
fileList={block.files}
onChange={({ fileList }) =>
updateDocumentBlock(docId, block.id, { files: fileList })
}
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
style={{ background: '#fafafa' }}
>
<p className="ant-upload-drag-icon">
<LoadingOutlined style={{ color: '#595959' }} />
</p>
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
<p className="ant-upload-hint">
Допустимые форматы: {uniqueAccept.join(', ')}. До 5 файлов, максимум 20 МБ каждый.
</p>
</Dragger>
</Space>
</Card>
))}
{/* Кнопка "Добавить" только если документ не пропущен */}
{!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>
);
};
const renderCustomUploads = () => (
<Card
size="small"
style={{ marginTop: 24, borderRadius: 8, border: '1px solid #d9d9d9' }}
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: '#595959' }} />
</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 = () => {
console.log('🔍 StepWizardPlan renderQuestions:', {
questionsCount: questions.length,
documentsCount: documents.length,
questions: questions.map(q => ({ name: q.name, label: q.label, input_type: q.input_type, required: q.required }))
});
return (
<>
<Card
size="small"
style={{ marginBottom: 16, borderRadius: 8, border: '1px solid #d9d9d9' }}
>
<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) => {
// Для условных полей используем shouldUpdate для отслеживания изменений
const hasCondition = !!question.ask_if;
return (
<Form.Item
key={question.name}
shouldUpdate={hasCondition ? (prev, curr) => {
// Обновляем только если изменилось значение поля, от которого зависит вопрос
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
} : true} // ✅ Для безусловных полей shouldUpdate=true, чтобы render function работала
>
{() => {
const values = form.getFieldsValue(true);
if (!evaluateCondition(question.ask_if, values)) {
console.log(`⏭️ Question ${question.name} skipped: condition not met`, 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) {
console.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
return null;
}
console.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
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" loading={submitting}>
Сохранить и продолжить
</Button>
</Space>
</Form>
{renderCustomUploads()}
</>
);
};
if (!formData.session_id) {
return (
<Result
status="warning"
title="Нет session_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: 8,
border: '1px solid #d9d9d9',
background: '#fafafa',
}}
>
{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: '#595959' }} /> План действий
</Title>
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
</Paragraph>
{documents.length > 0 && (
<Card
size="small"
style={{
borderRadius: 8,
background: '#fff',
border: '1px solid #d9d9d9',
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>
);
}