2025-11-15 18:48:15 +03:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
2025-11-19 18:46:48 +03:00
|
|
|
|
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
2025-11-26 19:54:51 +03:00
|
|
|
|
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined } from '@ant-design/icons';
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Приводим к строкам для более надёжного сравнения (Radio.Group может возвращать строки)
|
|
|
|
|
|
const leftStr = left != null ? String(left) : null;
|
|
|
|
|
|
const rightStr = right != null ? String(right) : null;
|
|
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
switch (condition.op) {
|
|
|
|
|
|
case '==':
|
2025-11-19 18:46:48 +03:00
|
|
|
|
return leftStr === rightStr;
|
2025-11-15 18:48:15 +03:00
|
|
|
|
case '!=':
|
2025-11-19 18:46:48 +03:00
|
|
|
|
return leftStr !== rightStr;
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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) {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
2025-11-15 18:48:15 +03:00
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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 || []
|
|
|
|
|
|
);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
const [skippedDocuments, setSkippedDocuments] = useState<Set<string>>(
|
|
|
|
|
|
new Set(formData.wizardSkippedDocuments || [])
|
|
|
|
|
|
);
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
// ✅ Автосохранение прогресса заполнения (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 (они обновляются отдельно)
|
|
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2025-11-19 18:46:48 +03:00
|
|
|
|
[]
|
2025-11-15 18:48:15 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleCustomBlocksChange = useCallback(
|
|
|
|
|
|
(updater: (blocks: FileBlock[]) => FileBlock[]) => {
|
|
|
|
|
|
setCustomFileBlocks((prev) => {
|
|
|
|
|
|
const updated = updater(prev);
|
|
|
|
|
|
return updated;
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2025-11-19 18:46:48 +03:00
|
|
|
|
[]
|
2025-11-15 18:48:15 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
handleDocumentBlocksChange(docId, (blocks) => {
|
|
|
|
|
|
// ✅ Автогенерация уникального описания:
|
|
|
|
|
|
// - Первый блок: пустое (будет использоваться docLabel)
|
|
|
|
|
|
// - Второй и далее: "docLabel #N"
|
|
|
|
|
|
const blockNumber = blocks.length + 1;
|
|
|
|
|
|
const autoDescription = blockNumber > 1
|
|
|
|
|
|
? `${docLabel || docId} #${blockNumber}`
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
...blocks,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: generateBlockId(docId),
|
|
|
|
|
|
fieldName: docId,
|
|
|
|
|
|
description: autoDescription,
|
|
|
|
|
|
category: category,
|
|
|
|
|
|
docLabel: docLabel,
|
|
|
|
|
|
files: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
});
|
2025-11-15 18:48:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// Автоматически создаём блоки для ВСЕХ документов из плана при загрузке
|
|
|
|
|
|
// Используем ref чтобы отслеживать какие блоки уже созданы
|
|
|
|
|
|
const createdDocBlocksRef = useRef<Set<string>>(new Set());
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
useEffect(() => {
|
2025-11-26 19:54:51 +03:00
|
|
|
|
if (!plan || !documents || documents.length === 0) return;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
documents.forEach((doc) => {
|
|
|
|
|
|
const docKey = doc.id || doc.name || `doc_unknown`;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// Не создаём блок, если уже создавали
|
|
|
|
|
|
if (createdDocBlocksRef.current.has(docKey)) return;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// Не создаём блок, если документ пропущен
|
|
|
|
|
|
if (skippedDocuments.has(docKey)) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Помечаем как созданный
|
|
|
|
|
|
createdDocBlocksRef.current.add(docKey);
|
|
|
|
|
|
|
|
|
|
|
|
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
|
|
|
|
|
handleDocumentBlocksChange(docKey, (blocks) => {
|
|
|
|
|
|
// Проверяем ещё раз внутри callback
|
|
|
|
|
|
if (blocks.length > 0) return blocks;
|
|
|
|
|
|
return [
|
|
|
|
|
|
...blocks,
|
|
|
|
|
|
{
|
|
|
|
|
|
id: generateBlockId(docKey),
|
|
|
|
|
|
fieldName: docKey,
|
|
|
|
|
|
description: '',
|
|
|
|
|
|
category: category,
|
|
|
|
|
|
docLabel: doc.name,
|
|
|
|
|
|
files: [],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2025-11-19 18:46:48 +03:00
|
|
|
|
});
|
|
|
|
|
|
});
|
2025-11-26 19:54:51 +03:00
|
|
|
|
}, [plan, documents, handleDocumentBlocksChange, skippedDocuments]);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
useEffect(() => {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
if (!isWaiting || !formData.session_id || plan) {
|
2025-11-26 19:54:51 +03:00
|
|
|
|
console.log('⏭️ StepWizardPlan: пропускаем подписку SSE', {
|
|
|
|
|
|
isWaiting,
|
|
|
|
|
|
hasSessionId: !!formData.session_id,
|
|
|
|
|
|
hasPlan: !!plan,
|
|
|
|
|
|
});
|
2025-11-15 18:48:15 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
const sessionId = formData.session_id;
|
2025-11-26 19:54:51 +03:00
|
|
|
|
console.log('🔌 StepWizardPlan: подписываемся на SSE канал для получения wizard_plan', {
|
|
|
|
|
|
session_id: sessionId,
|
|
|
|
|
|
sse_url: `/events/${sessionId}`,
|
|
|
|
|
|
redis_channel: `ocr_events:${sessionId}`,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
const source = new EventSource(`/events/${sessionId}`);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
eventSourceRef.current = source;
|
2025-11-20 18:31:42 +03:00
|
|
|
|
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
|
|
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
|
|
|
|
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
|
2025-11-20 18:31:42 +03:00
|
|
|
|
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
|
2025-11-19 18:46:48 +03:00
|
|
|
|
if (eventSourceRef.current) {
|
|
|
|
|
|
eventSourceRef.current.close();
|
|
|
|
|
|
eventSourceRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 120000); // 2 минуты для RAG обработки
|
2025-11-15 18:48:15 +03:00
|
|
|
|
|
|
|
|
|
|
source.onopen = () => {
|
|
|
|
|
|
setConnectionError(null);
|
2025-11-20 18:31:42 +03:00
|
|
|
|
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { session_id: sessionId });
|
2025-11-15 18:48:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
source.onerror = (error) => {
|
|
|
|
|
|
console.error('❌ Wizard SSE error:', error);
|
|
|
|
|
|
setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.');
|
|
|
|
|
|
source.close();
|
|
|
|
|
|
eventSourceRef.current = null;
|
2025-11-20 18:31:42 +03:00
|
|
|
|
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { session_id: sessionId });
|
2025-11-15 18:48:15 +03:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
// Логируем все события для отладки
|
|
|
|
|
|
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
session_id: sessionId,
|
2025-11-19 18:46:48 +03:00
|
|
|
|
event_type: eventType,
|
|
|
|
|
|
has_wizard_plan: Boolean(extractWizardPayload(payload)),
|
|
|
|
|
|
payload_keys: Object.keys(payload),
|
|
|
|
|
|
payload_preview: JSON.stringify(payload).substring(0, 200),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
|
|
|
|
|
if (eventType === 'documents_list_ready') {
|
|
|
|
|
|
const documentsRequired = payload.documents_required || [];
|
|
|
|
|
|
|
|
|
|
|
|
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
|
|
|
|
|
session_id: sessionId,
|
|
|
|
|
|
documents_count: documentsRequired.length,
|
|
|
|
|
|
documents: documentsRequired.map((d: any) => d.name),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
console.log('📋 documents_list_ready:', {
|
|
|
|
|
|
claim_id: payload.claim_id,
|
|
|
|
|
|
documents_required: documentsRequired,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Сохраняем в formData для нового флоу
|
|
|
|
|
|
updateFormData({
|
|
|
|
|
|
documents_required: documentsRequired,
|
|
|
|
|
|
claim_id: payload.claim_id,
|
|
|
|
|
|
wizardPlanStatus: 'documents_ready', // Новый статус
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
setIsWaiting(false);
|
|
|
|
|
|
setConnectionError(null);
|
|
|
|
|
|
|
|
|
|
|
|
if (timeoutRef.current) {
|
|
|
|
|
|
clearTimeout(timeoutRef.current);
|
|
|
|
|
|
timeoutRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
|
|
|
|
|
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
|
|
|
|
|
|
|
|
|
|
|
// TODO: onNext() для перехода к StepDocumentsNew
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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', '✨ Получен план вопросов', {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
session_id: sessionId,
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
if (timeoutRef.current) {
|
|
|
|
|
|
clearTimeout(timeoutRef.current);
|
|
|
|
|
|
timeoutRef.current = null;
|
|
|
|
|
|
}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
source.close();
|
|
|
|
|
|
eventSourceRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('❌ Ошибка разбора события wizard:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
2025-11-19 18:46:48 +03:00
|
|
|
|
if (timeoutRef.current) {
|
|
|
|
|
|
clearTimeout(timeoutRef.current);
|
|
|
|
|
|
timeoutRef.current = null;
|
|
|
|
|
|
}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
if (eventSourceRef.current) {
|
|
|
|
|
|
eventSourceRef.current.close();
|
|
|
|
|
|
eventSourceRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-11-20 18:31:42 +03:00
|
|
|
|
}, [isWaiting, formData.session_id, plan, updateFormData]);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
|
|
|
|
|
|
const handleRefreshPlan = () => {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
if (!formData.session_id) {
|
|
|
|
|
|
message.error('Не найден session_id для подписки на события.');
|
2025-11-15 18:48:15 +03:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsWaiting(true);
|
|
|
|
|
|
setPlan(null);
|
|
|
|
|
|
setConnectionError(null);
|
|
|
|
|
|
updateFormData({
|
|
|
|
|
|
wizardPlan: null,
|
|
|
|
|
|
wizardPlanStatus: 'pending',
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const validateUploads = (values: Record<string, any>) => {
|
2025-11-19 18:46:48 +03:00
|
|
|
|
// Проверяем каждый документ по его ID
|
|
|
|
|
|
for (const doc of documents) {
|
|
|
|
|
|
// Находим вопрос, к которому привязан документ
|
|
|
|
|
|
const questionName = Object.keys(documentGroups).find(key =>
|
|
|
|
|
|
documentGroups[key].some(d => d.id === doc.id)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (!questionName) continue;
|
2025-11-15 18:48:15 +03:00
|
|
|
|
const answer = values?.[questionName];
|
|
|
|
|
|
if (!isAffirmative(answer)) continue;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Блоки теперь хранятся по 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}" или отметьте, что документа нет`;
|
2025-11-15 18:48:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Проверяем описание только для необязательных документов И только если документ не предопределённый
|
|
|
|
|
|
// Предопределённые документы (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}"`;
|
|
|
|
|
|
}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
const customMissingDescription = customFileBlocks.some(
|
|
|
|
|
|
(block) => block.files.length > 0 && !block.description?.trim()
|
|
|
|
|
|
);
|
|
|
|
|
|
if (customMissingDescription) {
|
|
|
|
|
|
return 'Заполните описание для дополнительных документов';
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
const handleFinish = async (values: Record<string, any>) => {
|
2025-11-15 18:48:15 +03:00
|
|
|
|
const uploadError = validateUploads(values);
|
|
|
|
|
|
if (uploadError) {
|
|
|
|
|
|
message.error(uploadError);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
// Сохраняем в общий стейт
|
2025-11-15 18:48:15 +03:00
|
|
|
|
updateFormData({
|
|
|
|
|
|
wizardPlan: plan,
|
|
|
|
|
|
wizardAnswers: values,
|
|
|
|
|
|
wizardPlanStatus: 'answered',
|
|
|
|
|
|
wizardUploads: {
|
|
|
|
|
|
documents: questionFileBlocks,
|
|
|
|
|
|
custom: customFileBlocks,
|
|
|
|
|
|
},
|
2025-11-19 18:46:48 +03:00
|
|
|
|
wizardSkippedDocuments: Array.from(skippedDocuments),
|
2025-11-15 18:48:15 +03:00
|
|
|
|
});
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
|
|
|
|
|
|
answers: values,
|
|
|
|
|
|
});
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Дёргаем вебхук через backend сразу после заполнения визарда (multipart/form-data)
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
session_id: formData.session_id,
|
2025-11-19 18:46:48 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2025-11-20 18:31:42 +03:00
|
|
|
|
// Добавляем unified_id и claim_id (если есть)
|
|
|
|
|
|
if (formData.unified_id) formPayload.append('unified_id', formData.unified_id);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// ✅ Подсчитываем дубликаты labels для автоматической нумерации
|
|
|
|
|
|
const labelCounts: Record<string, number> = {};
|
|
|
|
|
|
const labelIndexes: Record<string, number> = {};
|
|
|
|
|
|
|
|
|
|
|
|
// Первый проход - считаем сколько раз встречается каждый label
|
|
|
|
|
|
groups.forEach((group) => {
|
|
|
|
|
|
const block = group.block;
|
|
|
|
|
|
const baseLabel = (block.description?.trim()) || block.docLabel || block.fieldName || guessFieldName(group);
|
|
|
|
|
|
labelCounts[baseLabel] = (labelCounts[baseLabel] || 0) + 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
groups.forEach((group) => {
|
|
|
|
|
|
const i = group.index;
|
|
|
|
|
|
const block = group.block;
|
|
|
|
|
|
|
|
|
|
|
|
// Описание группы
|
|
|
|
|
|
formPayload.append(
|
|
|
|
|
|
`uploads_descriptions[${i}]`,
|
|
|
|
|
|
block.description || ''
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-24 13:36:14 +03:00
|
|
|
|
// Имя "поля" группы (используем docLabel если есть, иначе guessFieldName)
|
|
|
|
|
|
const fieldLabel = block.docLabel || block.fieldName || guessFieldName(group);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
formPayload.append(
|
|
|
|
|
|
`uploads_field_names[${i}]`,
|
2025-11-24 13:36:14 +03:00
|
|
|
|
fieldLabel
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ Добавляем реальное название поля (label) для использования в n8n
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// Приоритет: description (если заполнено) > docLabel > fieldLabel
|
|
|
|
|
|
const baseLabel = (block.description?.trim()) || block.docLabel || fieldLabel;
|
|
|
|
|
|
|
|
|
|
|
|
// ✅ Автоматическая нумерация для дубликатов
|
|
|
|
|
|
let finalFieldLabel = baseLabel;
|
|
|
|
|
|
if (labelCounts[baseLabel] > 1) {
|
|
|
|
|
|
labelIndexes[baseLabel] = (labelIndexes[baseLabel] || 0) + 1;
|
|
|
|
|
|
finalFieldLabel = `${baseLabel} #${labelIndexes[baseLabel]}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-24 13:36:14 +03:00
|
|
|
|
formPayload.append(
|
|
|
|
|
|
`uploads_field_labels[${i}]`,
|
2025-11-26 19:54:51 +03:00
|
|
|
|
finalFieldLabel
|
2025-11-19 18:46:48 +03:00
|
|
|
|
);
|
2025-11-26 19:54:51 +03:00
|
|
|
|
|
|
|
|
|
|
// 🔍 Логируем отправляемые метаданные документов
|
|
|
|
|
|
console.log(`📁 Группа ${i}:`, {
|
|
|
|
|
|
field_name: fieldLabel,
|
|
|
|
|
|
field_label: finalFieldLabel,
|
|
|
|
|
|
description: block.description,
|
|
|
|
|
|
docLabel: block.docLabel,
|
|
|
|
|
|
filesCount: block.files.length,
|
|
|
|
|
|
});
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
// Файлы: 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
// Логируем ключевые поля перед отправкой
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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('Мы изучаем ваш вопрос и документы.');
|
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();
|
|
|
|
|
|
}
|
2025-11-19 18:46:48 +03:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error('Ошибка соединения при отправке визарда.');
|
|
|
|
|
|
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
|
|
|
|
|
|
error: String(error),
|
|
|
|
|
|
});
|
2025-11-24 13:36:14 +03:00
|
|
|
|
onNext();
|
2025-11-19 18:46:48 +03:00
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
};
|
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]);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
|
|
|
|
|
|
const renderQuestionField = (question: WizardQuestion) => {
|
2025-11-19 18:46:48 +03:00
|
|
|
|
// Обработка по 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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
switch (question.control) {
|
|
|
|
|
|
case 'textarea':
|
|
|
|
|
|
case 'input[type="textarea"]':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<TextArea
|
|
|
|
|
|
rows={4}
|
|
|
|
|
|
placeholder="Ответ"
|
|
|
|
|
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
case 'input[type="date"]':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="date"
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
placeholder="Выберите дату"
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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']));
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// Документ предопределён если у него есть id и он НЕ общий (не содержит _exist)
|
|
|
|
|
|
// Для предустановленных документов НЕ показываем поле описания и кнопку "Удалить"
|
2025-11-19 18:46:48 +03:00
|
|
|
|
const doc = docList[0];
|
2025-11-26 19:54:51 +03:00
|
|
|
|
const isPredefinedDoc = docList.length === 1 && doc && doc.id && !doc.id.includes('_exist');
|
|
|
|
|
|
const singleDocName = doc?.name || docLabel;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
const isRequired = docList.some(doc => doc.required);
|
|
|
|
|
|
const isSkipped = skippedDocuments.has(docId);
|
2025-11-15 18:48:15 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
{/* Если документ пропущен - показываем только сообщение */}
|
|
|
|
|
|
{isSkipped && (
|
|
|
|
|
|
<div style={{ padding: 12, background: '#fff7e6', borderRadius: 8, border: '1px solid #ffd591' }}>
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<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) });
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
<Text type="warning">У меня нет документа: {docLabel}</Text>
|
2025-11-19 18:46:48 +03:00
|
|
|
|
</Checkbox>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!isSkipped && currentBlocks.map((block, idx) => (
|
2025-11-15 18:48:15 +03:00
|
|
|
|
<Card
|
|
|
|
|
|
key={block.id}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style={{
|
2025-11-19 18:46:48 +03:00
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
border: '1px solid #d9d9d9',
|
2025-11-15 18:48:15 +03:00
|
|
|
|
background: '#fff',
|
|
|
|
|
|
}}
|
2025-11-19 18:46:48 +03:00
|
|
|
|
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
extra={
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// Кнопка "Удалить" только если это дополнительный блок (idx > 0)
|
|
|
|
|
|
// Первый блок предустановленного документа удалять нельзя
|
|
|
|
|
|
(currentBlocks.length > 1 && idx > 0) && (
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<Button
|
|
|
|
|
|
type="link"
|
|
|
|
|
|
danger
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
onClick={() => removeDocumentBlock(docId, block.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Удалить
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)
|
2025-11-15 18:48:15 +03:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
{/* Поле описания показываем только для дополнительных блоков (idx > 0)
|
|
|
|
|
|
или для общих документов (docs_exist) */}
|
|
|
|
|
|
{(idx > 0 || !isPredefinedDoc) && (
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<Input
|
2025-11-26 19:54:51 +03:00
|
|
|
|
placeholder="Уточните тип документа (например: Претензия от 12.05)"
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
<Dragger
|
|
|
|
|
|
multiple
|
|
|
|
|
|
beforeUpload={() => false}
|
|
|
|
|
|
fileList={block.files}
|
|
|
|
|
|
onChange={({ fileList }) =>
|
|
|
|
|
|
updateDocumentBlock(docId, block.id, { files: fileList })
|
|
|
|
|
|
}
|
|
|
|
|
|
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
|
2025-11-19 18:46:48 +03:00
|
|
|
|
style={{ background: '#fafafa' }}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
>
|
|
|
|
|
|
<p className="ant-upload-drag-icon">
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<LoadingOutlined style={{ color: '#595959' }} />
|
2025-11-15 18:48:15 +03:00
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
|
|
|
|
|
<p className="ant-upload-hint">
|
|
|
|
|
|
Допустимые форматы: {uniqueAccept.join(', ')}. До 5 файлов, максимум 20 МБ каждый.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Dragger>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
|
|
|
|
|
|
{/* Чекбокс "Нет документа" под загрузкой - только для обязательных и только в первом блоке */}
|
|
|
|
|
|
{isRequired && idx === 0 && block.files.length === 0 && (
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
checked={false}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
if (e.target.checked) {
|
|
|
|
|
|
const newSkipped = new Set(skippedDocuments);
|
|
|
|
|
|
newSkipped.add(docId);
|
|
|
|
|
|
setSkippedDocuments(newSkipped);
|
|
|
|
|
|
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ marginTop: 8 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Text type="secondary">У меня нет этого документа</Text>
|
|
|
|
|
|
</Checkbox>
|
|
|
|
|
|
)}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
))}
|
2025-11-19 18:46:48 +03:00
|
|
|
|
{/* Кнопка "Добавить" только если документ не пропущен */}
|
|
|
|
|
|
{!isSkipped && (!isPredefinedDoc || currentBlocks.length === 0) && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<PlusOutlined />}
|
|
|
|
|
|
onClick={() => addDocumentBlock(docId, docLabel, docList)}
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isPredefinedDoc && currentBlocks.length === 0
|
|
|
|
|
|
? `Загрузить ${singleDocName || docLabel}`
|
|
|
|
|
|
: `Добавить документы (${docLabel})`}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
</Space>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const renderCustomUploads = () => (
|
|
|
|
|
|
<Card
|
|
|
|
|
|
size="small"
|
2025-11-19 18:46:48 +03:00
|
|
|
|
style={{ marginTop: 24, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
|
|
|
|
|
title="Документы"
|
2025-11-15 18:48:15 +03:00
|
|
|
|
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">
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<LoadingOutlined style={{ color: '#595959' }} />
|
2025-11-15 18:48:15 +03:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
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 (
|
2025-11-15 18:48:15 +03:00
|
|
|
|
<>
|
|
|
|
|
|
<Card
|
|
|
|
|
|
size="small"
|
2025-11-19 18:46:48 +03:00
|
|
|
|
style={{ marginBottom: 16, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
>
|
|
|
|
|
|
<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 }}
|
|
|
|
|
|
>
|
2025-11-19 18:46:48 +03:00
|
|
|
|
{questions.map((question) => {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
// Для условных полей используем shouldUpdate для отслеживания изменений
|
|
|
|
|
|
const hasCondition = !!question.ask_if;
|
2025-11-19 18:46:48 +03:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
key={question.name}
|
2025-11-20 18:31:42 +03:00
|
|
|
|
shouldUpdate={hasCondition ? (prev, curr) => {
|
2025-11-19 18:46:48 +03:00
|
|
|
|
// Обновляем только если изменилось значение поля, от которого зависит вопрос
|
|
|
|
|
|
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
|
2025-11-20 18:31:42 +03:00
|
|
|
|
} : true} // ✅ Для безусловных полей shouldUpdate=true, чтобы render function работала
|
2025-11-19 18:46:48 +03:00
|
|
|
|
>
|
|
|
|
|
|
{() => {
|
|
|
|
|
|
const values = form.getFieldsValue(true);
|
|
|
|
|
|
if (!evaluateCondition(question.ask_if, values)) {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
console.log(`⏭️ Question ${question.name} skipped: condition not met`, question.ask_if, values);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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();
|
2025-11-26 19:54:51 +03:00
|
|
|
|
|
|
|
|
|
|
// Скрываем вопрос docs_exist (чекбоксы "какие документы есть") если есть документы
|
|
|
|
|
|
// Загрузка документов реализована через отдельные блоки под информационной карточкой
|
|
|
|
|
|
const isDocsExistQuestion = questionNameLower === 'docs_exist' ||
|
|
|
|
|
|
questionNameLower === 'correspondence_exist' ||
|
|
|
|
|
|
questionNameLower.includes('docs_exist');
|
|
|
|
|
|
if (isDocsExistQuestion && documents.length > 0) {
|
|
|
|
|
|
console.log(`🚫 Question ${question.name} hidden: docs_exist with documents`);
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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) {
|
2025-11-20 18:31:42 +03:00
|
|
|
|
console.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
|
2025-11-19 18:46:48 +03:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
console.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
|
|
|
|
|
|
|
2025-11-19 18:46:48 +03:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-11-15 18:48:15 +03:00
|
|
|
|
|
|
|
|
|
|
<Space style={{ marginTop: 24 }}>
|
|
|
|
|
|
<Button onClick={onPrev}>← Назад</Button>
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<Button type="primary" htmlType="submit" loading={submitting}>
|
2025-11-15 18:48:15 +03:00
|
|
|
|
Сохранить и продолжить →
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
{renderCustomUploads()}
|
|
|
|
|
|
</>
|
2025-11-20 18:31:42 +03:00
|
|
|
|
);
|
|
|
|
|
|
};
|
2025-11-15 18:48:15 +03:00
|
|
|
|
|
2025-11-20 18:31:42 +03:00
|
|
|
|
if (!formData.session_id) {
|
2025-11-15 18:48:15 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<Result
|
|
|
|
|
|
status="warning"
|
2025-11-20 18:31:42 +03:00
|
|
|
|
title="Нет session_id"
|
|
|
|
|
|
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
|
2025-11-15 18:48:15 +03:00
|
|
|
|
extra={<Button onClick={onPrev}>Вернуться</Button>}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
// ✅ НОВЫЙ ФЛОУ: Если есть documents_required, показываем загрузку документов
|
|
|
|
|
|
const documentsRequired = formData.documents_required || [];
|
|
|
|
|
|
const hasNewFlowDocs = documentsRequired.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 🔍 ОТЛАДКА: Логируем состояние для диагностики
|
|
|
|
|
|
console.log('🔍 StepWizardPlan - определение флоу:', {
|
|
|
|
|
|
documentsRequiredCount: documentsRequired.length,
|
|
|
|
|
|
documentsRequired: documentsRequired,
|
|
|
|
|
|
hasNewFlowDocs,
|
|
|
|
|
|
hasPlan: !!plan,
|
|
|
|
|
|
isWaiting,
|
|
|
|
|
|
formDataKeys: Object.keys(formData),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Состояние для поэкранной загрузки документов (новый флоу)
|
|
|
|
|
|
const [currentDocIndex, setCurrentDocIndex] = useState(formData.current_doc_index || 0);
|
|
|
|
|
|
// Убираем дубликаты при инициализации
|
|
|
|
|
|
const initialUploadedDocs = formData.documents_uploaded?.map((d: any) => d.type || d.id) || [];
|
|
|
|
|
|
const [uploadedDocs, setUploadedDocs] = useState<string[]>(Array.from(new Set(initialUploadedDocs)));
|
|
|
|
|
|
const [skippedDocs, setSkippedDocs] = useState<string[]>(formData.documents_skipped || []);
|
|
|
|
|
|
const [docChoice, setDocChoice] = useState<'upload' | 'none'>('upload'); // Выбор: загрузить или нет документа (по умолчанию - загрузить)
|
|
|
|
|
|
const [currentUploadedFiles, setCurrentUploadedFiles] = useState<any[]>([]); // Массив загруженных файлов
|
|
|
|
|
|
|
|
|
|
|
|
// Текущий документ для загрузки
|
|
|
|
|
|
const currentDoc = documentsRequired[currentDocIndex];
|
|
|
|
|
|
const isLastDoc = currentDocIndex >= documentsRequired.length - 1;
|
|
|
|
|
|
const allDocsProcessed = currentDocIndex >= documentsRequired.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Обработчик выбора файлов (НЕ отправляем сразу, только сохраняем)
|
|
|
|
|
|
const handleFilesChange = (fileList: any[]) => {
|
|
|
|
|
|
console.log('📁 handleFilesChange:', fileList.length, 'файлов', fileList.map(f => f.name));
|
|
|
|
|
|
setCurrentUploadedFiles(fileList);
|
|
|
|
|
|
if (fileList.length > 0) {
|
|
|
|
|
|
setDocChoice('upload');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Обработчик "Продолжить" — отправляем файл или пропускаем
|
|
|
|
|
|
const handleDocContinue = async () => {
|
|
|
|
|
|
if (!currentDoc) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Если выбрано "Нет документа" — пропускаем
|
|
|
|
|
|
if (docChoice === 'none') {
|
|
|
|
|
|
if (currentDoc.required) {
|
|
|
|
|
|
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки. Постарайтесь найти его позже.`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const newSkipped = [...skippedDocs, currentDoc.id];
|
|
|
|
|
|
setSkippedDocs(newSkipped);
|
|
|
|
|
|
|
|
|
|
|
|
updateFormData({
|
|
|
|
|
|
documents_skipped: newSkipped,
|
|
|
|
|
|
current_doc_index: currentDocIndex + 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Переход к следующему (сброс состояния в useEffect)
|
|
|
|
|
|
setCurrentDocIndex(prev => prev + 1);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Если выбрано "Загрузить" — отправляем все файлы ОДНИМ запросом
|
|
|
|
|
|
if (docChoice === 'upload' && currentUploadedFiles.length > 0) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setSubmitting(true);
|
|
|
|
|
|
|
|
|
|
|
|
console.log('📤 Загружаем все файлы одним запросом:', {
|
|
|
|
|
|
totalFiles: currentUploadedFiles.length,
|
|
|
|
|
|
files: currentUploadedFiles.map(f => ({ name: f.name, uid: f.uid, size: f.size }))
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const formDataToSend = new FormData();
|
|
|
|
|
|
formDataToSend.append('claim_id', formData.claim_id || '');
|
|
|
|
|
|
formDataToSend.append('session_id', formData.session_id || '');
|
|
|
|
|
|
formDataToSend.append('unified_id', formData.unified_id || '');
|
|
|
|
|
|
formDataToSend.append('contact_id', formData.contact_id || '');
|
|
|
|
|
|
formDataToSend.append('phone', formData.phone || '');
|
|
|
|
|
|
formDataToSend.append('document_type', currentDoc.id);
|
|
|
|
|
|
formDataToSend.append('document_name', currentDoc.name || currentDoc.id);
|
|
|
|
|
|
formDataToSend.append('document_description', currentDoc.hints || '');
|
|
|
|
|
|
formDataToSend.append('group_index', String(currentDocIndex)); // ✅ Передаём индекс документа для правильного field_name
|
|
|
|
|
|
|
|
|
|
|
|
// Добавляем все файлы в один запрос
|
|
|
|
|
|
currentUploadedFiles.forEach((file) => {
|
|
|
|
|
|
formDataToSend.append('files', file.originFileObj, file.name);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/api/v1/documents/upload-multiple', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
body: formDataToSend,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
const errorData = await response.json().catch(() => ({}));
|
|
|
|
|
|
throw new Error(errorData.detail || 'Ошибка загрузки файлов');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
|
console.log('✅ Все файлы загружены:', result);
|
|
|
|
|
|
|
|
|
|
|
|
// Обновляем состояние
|
|
|
|
|
|
const uploadedDocsData = [...(formData.documents_uploaded || [])];
|
|
|
|
|
|
|
|
|
|
|
|
// Добавляем информацию о каждом загруженном файле
|
|
|
|
|
|
result.file_ids.forEach((fileId: string, i: number) => {
|
|
|
|
|
|
uploadedDocsData.push({
|
|
|
|
|
|
type: currentDoc.id,
|
|
|
|
|
|
file_id: fileId,
|
|
|
|
|
|
filename: currentUploadedFiles[i]?.name || `file_${i}`,
|
|
|
|
|
|
ocr_status: 'processing',
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
message.success(`${currentDoc.name}: загружено ${result.files_count} файл(ов)!`);
|
|
|
|
|
|
|
|
|
|
|
|
// Убираем дубликаты при добавлении
|
|
|
|
|
|
const newUploaded = uploadedDocs.includes(currentDoc.id)
|
|
|
|
|
|
? uploadedDocs
|
|
|
|
|
|
: [...uploadedDocs, currentDoc.id];
|
|
|
|
|
|
setUploadedDocs(newUploaded);
|
|
|
|
|
|
|
|
|
|
|
|
updateFormData({
|
|
|
|
|
|
documents_uploaded: uploadedDocsData,
|
|
|
|
|
|
current_doc_index: currentDocIndex + 1,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Переход к следующему (сброс состояния в useEffect)
|
|
|
|
|
|
setCurrentDocIndex(prev => prev + 1);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
|
message.error(`Ошибка загрузки: ${error.message}`);
|
|
|
|
|
|
console.error('Upload error:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Можно ли нажать "Продолжить"
|
|
|
|
|
|
const canContinue = docChoice === 'none' || (docChoice === 'upload' && currentUploadedFiles.length > 0);
|
|
|
|
|
|
|
|
|
|
|
|
// Сброс состояния при переходе к следующему документу
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setDocChoice('upload');
|
|
|
|
|
|
setCurrentUploadedFiles([]);
|
|
|
|
|
|
}, [currentDocIndex]);
|
|
|
|
|
|
|
|
|
|
|
|
// Все документы загружены — переход к ожиданию заявления
|
|
|
|
|
|
const handleAllDocsComplete = () => {
|
|
|
|
|
|
message.loading('Формируем заявление...', 0);
|
|
|
|
|
|
// TODO: Переход к StepWaitingClaim или показ loader
|
|
|
|
|
|
onNext();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-15 18:48:15 +03:00
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ marginTop: 24 }}>
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
|
|
|
|
|
<Button onClick={onPrev}>← Назад</Button>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
{plan && !hasNewFlowDocs && (
|
2025-11-15 18:48:15 +03:00
|
|
|
|
<Button type="link" onClick={handleRefreshPlan}>
|
|
|
|
|
|
Обновить рекомендации
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
|
style={{
|
2025-11-19 18:46:48 +03:00
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
border: '1px solid #d9d9d9',
|
|
|
|
|
|
background: '#fafafa',
|
2025-11-15 18:48:15 +03:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
{/* ✅ НОВЫЙ ФЛОУ: Поэкранная загрузка документов */}
|
|
|
|
|
|
{hasNewFlowDocs && !allDocsProcessed && currentDoc && (
|
|
|
|
|
|
<div style={{ padding: '24px 0' }}>
|
|
|
|
|
|
{/* Прогресс */}
|
|
|
|
|
|
<div style={{ marginBottom: 24 }}>
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
|
|
|
|
<Text type="secondary">Документ {currentDocIndex + 1} из {documentsRequired.length}</Text>
|
|
|
|
|
|
<Text type="secondary">{Math.round((currentDocIndex / documentsRequired.length) * 100)}% завершено</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={Math.round((currentDocIndex / documentsRequired.length) * 100)}
|
|
|
|
|
|
showInfo={false}
|
|
|
|
|
|
strokeColor="#595959"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Заголовок документа */}
|
|
|
|
|
|
<Title level={4} style={{ marginBottom: 8 }}>
|
|
|
|
|
|
📄 {currentDoc.name}
|
|
|
|
|
|
{currentDoc.required && <Tag color="volcano" style={{ marginLeft: 8 }}>Важный</Tag>}
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
|
|
|
|
|
|
{currentDoc.hints && (
|
|
|
|
|
|
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
|
|
|
|
|
{currentDoc.hints}
|
|
|
|
|
|
</Paragraph>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Радио-кнопки выбора */}
|
|
|
|
|
|
<Radio.Group
|
|
|
|
|
|
value={docChoice}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
setDocChoice(e.target.value);
|
|
|
|
|
|
if (e.target.value === 'none') {
|
|
|
|
|
|
setCurrentUploadedFiles([]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ marginBottom: 16, display: 'block' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
|
|
|
|
<Radio value="upload" style={{ fontSize: 16 }}>
|
|
|
|
|
|
📎 Загрузить документ
|
|
|
|
|
|
</Radio>
|
|
|
|
|
|
<Radio value="none" style={{ fontSize: 16 }}>
|
|
|
|
|
|
❌ У меня нет этого документа
|
|
|
|
|
|
</Radio>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Radio.Group>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Загрузка файлов — показываем только если выбрано "Загрузить" */}
|
|
|
|
|
|
{docChoice === 'upload' && (
|
|
|
|
|
|
<Dragger
|
|
|
|
|
|
multiple={true}
|
|
|
|
|
|
beforeUpload={() => false}
|
|
|
|
|
|
fileList={currentUploadedFiles}
|
|
|
|
|
|
onChange={({ fileList }) => handleFilesChange(fileList)}
|
|
|
|
|
|
onRemove={(file) => {
|
|
|
|
|
|
setCurrentUploadedFiles(prev => prev.filter(f => f.uid !== file.uid));
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}}
|
|
|
|
|
|
accept={currentDoc.accept?.map((ext: string) => `.${ext}`).join(',') || '.pdf,.jpg,.jpeg,.png'}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
style={{ marginBottom: 24 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<p className="ant-upload-drag-icon">
|
|
|
|
|
|
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-text">
|
|
|
|
|
|
Перетащите файлы или нажмите для выбора
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p className="ant-upload-hint">
|
|
|
|
|
|
📌 Можно загрузить несколько файлов (все страницы документа)
|
|
|
|
|
|
<br />
|
|
|
|
|
|
Форматы: {currentDoc.accept?.join(', ') || 'PDF, JPG, PNG'} (до 20 МБ каждый)
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</Dragger>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Предупреждение если "нет документа" для важного */}
|
|
|
|
|
|
{docChoice === 'none' && currentDoc.required && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
background: '#fff7e6',
|
|
|
|
|
|
border: '1px solid #ffd591',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
marginBottom: 16
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<Text type="warning">
|
|
|
|
|
|
⚠️ Этот документ важен для рассмотрения заявки. Постарайтесь найти его позже.
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Кнопки */}
|
|
|
|
|
|
<Space style={{ marginTop: 16 }}>
|
|
|
|
|
|
<Button onClick={onPrev}>← Назад</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
onClick={handleDocContinue}
|
|
|
|
|
|
disabled={!canContinue || submitting}
|
|
|
|
|
|
loading={submitting}
|
|
|
|
|
|
>
|
|
|
|
|
|
{submitting ? 'Загружаем...' : 'Продолжить →'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Уже загруженные */}
|
|
|
|
|
|
{uploadedDocs.length > 0 && (
|
|
|
|
|
|
<div style={{ marginTop: 24, padding: 12, background: '#f6ffed', borderRadius: 8 }}>
|
|
|
|
|
|
<Text strong>✅ Загружено:</Text>
|
|
|
|
|
|
<ul style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
|
|
|
|
|
{/* Убираем дубликаты и используем уникальные ключи */}
|
|
|
|
|
|
{Array.from(new Set(uploadedDocs)).map((docId, idx) => {
|
|
|
|
|
|
const doc = documentsRequired.find((d: any) => d.id === docId);
|
|
|
|
|
|
return <li key={`${docId}_${idx}`}>{doc?.name || docId}</li>;
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* ✅ НОВЫЙ ФЛОУ: Все документы загружены */}
|
|
|
|
|
|
{hasNewFlowDocs && allDocsProcessed && (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
|
|
|
|
|
<Title level={4}>✅ Все документы загружены!</Title>
|
|
|
|
|
|
<Paragraph type="secondary">
|
|
|
|
|
|
Загружено: {uploadedDocs.length}, пропущено: {skippedDocs.length}
|
|
|
|
|
|
</Paragraph>
|
|
|
|
|
|
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
|
|
|
|
|
Продолжить →
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* СТАРЫЙ ФЛОУ: Ожидание визарда */}
|
|
|
|
|
|
{!hasNewFlowDocs && isWaiting && (
|
2025-11-15 18:48:15 +03:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-11-26 19:54:51 +03:00
|
|
|
|
{/* СТАРЫЙ ФЛОУ: Визард готов */}
|
|
|
|
|
|
{!hasNewFlowDocs && !isWaiting && plan && (
|
2025-11-15 18:48:15 +03:00
|
|
|
|
<div>
|
|
|
|
|
|
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
2025-11-19 18:46:48 +03:00
|
|
|
|
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
|
2025-11-15 18:48:15 +03:00
|
|
|
|
</Title>
|
|
|
|
|
|
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
|
|
|
|
|
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
|
|
|
|
|
|
</Paragraph>
|
|
|
|
|
|
|
|
|
|
|
|
{documents.length > 0 && (
|
2025-11-26 19:54:51 +03:00
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
2025-11-15 18:48:15 +03:00
|
|
|
|
</div>
|
2025-11-26 19:54:51 +03:00
|
|
|
|
))}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Блоки загрузки для каждого документа из плана */}
|
|
|
|
|
|
<div style={{ marginTop: 16, marginBottom: 24 }}>
|
|
|
|
|
|
<Text strong style={{ fontSize: 16, marginBottom: 16, display: 'block' }}>
|
|
|
|
|
|
Загрузите документы
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
|
|
|
|
{documents.map((doc: any) => {
|
|
|
|
|
|
const docKey = doc.id || doc.name || `doc_${Math.random()}`;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={docKey}>
|
|
|
|
|
|
{renderDocumentBlocks(docKey, [doc])}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2025-11-15 18:48:15 +03:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{renderQuestions()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|