Добавлено логирование для отладки черновиков
- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
@@ -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({
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user