import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Card, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd'; import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'; import AiWorkingIllustration from '../../assets/ai-working.svg'; import type { UploadFile } from 'antd/es/upload/interface'; const { Paragraph, Title, Text } = Typography; const { TextArea } = Input; const { Dragger } = Upload; const { Option } = Select; interface WizardQuestion { order: number; name: string; label: string; control: string; input_type: string; required: boolean; priority?: number; rationale?: string; ask_if?: { field: string; op: '==' | '!=' | '>' | '<' | '>=' | '<='; value: any; } | null; options?: { label: string; value: string }[]; } interface WizardDocument { id: string; name: string; required: boolean; priority?: number; hints?: string; accept?: string[]; } interface FileBlock { id: string; fieldName: string; description: string; category?: string; files: UploadFile[]; required?: boolean; docLabel?: string; } interface Props { formData: any; updateFormData: (data: any) => void; onNext: () => void; onPrev: () => void; addDebugEvent?: (type: string, status: string, message: string, data?: any) => void; } const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record) => { if (!condition) return true; const left = values?.[condition.field]; const right = condition.value; switch (condition.op) { case '==': return left === right; case '!=': return left !== right; case '>': return left > right; case '<': return left < right; case '>=': return left >= right; case '<=': return left <= right; default: return true; } }; const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => { if (!prefill || prefill.length === 0) return {}; return prefill.reduce>((acc, item) => { if (item.name) { acc[item.name] = item.value; } return acc; }, {}); }; const YES_VALUES = ['да', 'yes', 'true', '1']; const isAffirmative = (value: any) => { if (typeof value === 'boolean') { return value; } if (typeof value === 'string') { return YES_VALUES.includes(value.toLowerCase()); } return false; }; const generateBlockId = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; export default function StepWizardPlan({ formData, updateFormData, onNext, onPrev, addDebugEvent, }: Props) { const [form] = Form.useForm(); const eventSourceRef = useRef(null); const debugLoggerRef = useRef(addDebugEvent); const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan); const [connectionError, setConnectionError] = useState(null); const [plan, setPlan] = useState(formData.wizardPlan || null); const [prefillMap, setPrefillMap] = useState>( formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray) ); const [questionFileBlocks, setQuestionFileBlocks] = useState>( formData.wizardUploads?.documents || {} ); const [customFileBlocks, setCustomFileBlocks] = useState( formData.wizardUploads?.custom || [] ); const [progressState, setProgressState] = useState<{ done: number; total: number }>({ done: 0, total: 0, }); const formValues = Form.useWatch([], form); const progressPercent = useMemo(() => { if (!progressState.total) return 0; return Math.round((progressState.done / progressState.total) * 100); }, [progressState]); const persistUploads = useCallback( (nextDocuments: Record, nextCustom: FileBlock[]) => { updateFormData({ wizardUploads: { documents: nextDocuments, custom: nextCustom, }, }); }, [updateFormData] ); useEffect(() => { if (formData.wizardUploads?.documents) { setQuestionFileBlocks(formData.wizardUploads.documents); } if (formData.wizardUploads?.custom) { setCustomFileBlocks(formData.wizardUploads.custom); } }, [formData.wizardUploads]); useEffect(() => { debugLoggerRef.current = addDebugEvent; }, [addDebugEvent]); const questions: WizardQuestion[] = useMemo(() => plan?.questions || [], [plan]); const documents: WizardDocument[] = plan?.documents || []; const documentGroups = useMemo(() => { const groups: Record = {}; 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(); documents.forEach((doc) => { const key = doc.id || doc.name || ''; const label = doc.name || doc.id || ''; if (key) { map.set(key, label); } }); map.set('other', 'Другое'); return Array.from(map.entries()).map(([value, label]) => ({ value, label })); }, [documents]); const customCategoryOptions = useMemo(() => documentCategoryOptions, [documentCategoryOptions]); const handleDocumentBlocksChange = useCallback( (docId: string, updater: (blocks: FileBlock[]) => FileBlock[]) => { setQuestionFileBlocks((prev) => { const nextDocs = { ...prev }; const currentBlocks = nextDocs[docId] || []; const updated = updater(currentBlocks); nextDocs[docId] = updated; persistUploads(nextDocs, customFileBlocks); return nextDocs; }); }, [customFileBlocks, persistUploads] ); const handleCustomBlocksChange = useCallback( (updater: (blocks: FileBlock[]) => FileBlock[]) => { setCustomFileBlocks((prev) => { const updated = updater(prev); persistUploads(questionFileBlocks, updated); return updated; }); }, [persistUploads, questionFileBlocks] ); const addDocumentBlock = (docId: string, docLabel?: string) => { handleDocumentBlocksChange(docId, (blocks) => [ ...blocks, { id: generateBlockId(docId), fieldName: docId, description: '', category: docId, docLabel: docLabel, files: [], }, ]); }; const updateDocumentBlock = ( docId: string, blockId: string, patch: Partial> ) => { 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) => { handleCustomBlocksChange((blocks) => blocks.map((block) => (block.id === blockId ? { ...block, ...patch } : block)) ); }; const removeCustomBlock = (blockId: string) => { handleCustomBlocksChange((blocks) => blocks.filter((block) => block.id !== blockId)); }; useEffect(() => { if (plan) { const existingAnswers = formData.wizardAnswers || {}; const initialValues = { ...prefillMap, ...existingAnswers }; form.setFieldsValue(initialValues); } }, [plan, prefillMap, formData.wizardAnswers, form]); useEffect(() => { if (!questions.length) { setProgressState({ done: 0, total: 0 }); return; } const values = formValues || {}; let total = 0; let done = 0; questions.forEach((question) => { const visible = evaluateCondition(question.ask_if, values); if (question.required && visible) { total += 1; const value = values?.[question.name]; let filled = false; if (Array.isArray(value)) { filled = value.length > 0; } else if (typeof value === 'boolean') { filled = value; } else { filled = value !== undefined && value !== null && String(value).trim().length > 0; } if (filled) { done += 1; } } }); setProgressState({ done, total }); }, [formValues, questions]); useEffect(() => { if (!isWaiting || !formData.claim_id || plan) { return; } const claimId = formData.claim_id; const source = new EventSource(`/events/${claimId}`); eventSourceRef.current = source; debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId }); source.onopen = () => { setConnectionError(null); debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { claim_id: claimId }); }; source.onerror = (error) => { console.error('❌ Wizard SSE error:', error); setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.'); source.close(); eventSourceRef.current = null; debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { claim_id: claimId }); }; const extractWizardPayload = (incoming: any): any => { if (!incoming || typeof incoming !== 'object') return null; if (incoming.wizard_plan) return incoming; const candidates = [ incoming.data, incoming.redis_value, incoming.event, incoming.payload, ]; for (const candidate of candidates) { if (!candidate) continue; const unwrapped = extractWizardPayload(candidate); if (unwrapped) return unwrapped; } return null; }; source.onmessage = (event) => { try { const payload = JSON.parse(event.data); const eventType = payload.event_type || payload.type || payload?.event?.event_type || payload?.data?.event_type || payload?.redis_value?.event_type; const wizardPayload = extractWizardPayload(payload); const hasWizardPlan = Boolean(wizardPayload); if (eventType?.includes('wizard') || hasWizardPlan) { const wizardPlan = wizardPayload?.wizard_plan; const answersPrefill = wizardPayload?.answers_prefill; const coverageReport = wizardPayload?.coverage_report; debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', { claim_id: claimId, questions: wizardPlan?.questions?.length || 0, }); const prefill = buildPrefillMap(answersPrefill); setPlan(wizardPlan); setPrefillMap(prefill); setIsWaiting(false); setConnectionError(null); updateFormData({ wizardPlan: wizardPlan, wizardPrefill: prefill, wizardPrefillArray: answersPrefill, wizardCoverageReport: coverageReport, wizardPlanStatus: 'ready', }); source.close(); eventSourceRef.current = null; } } catch (err) { console.error('❌ Ошибка разбора события wizard:', err); } }; return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; }, [isWaiting, formData.claim_id, plan, updateFormData]); const handleRefreshPlan = () => { if (!formData.claim_id) { message.error('Не найден claim_id для подписки на события.'); return; } setIsWaiting(true); setPlan(null); setConnectionError(null); updateFormData({ wizardPlan: null, wizardPlanStatus: 'pending', }); }; const validateUploads = (values: Record) => { for (const [questionName, docs] of Object.entries(documentGroups)) { if (!docs.length) continue; const answer = values?.[questionName]; if (!isAffirmative(answer)) continue; const blocks = questionFileBlocks[questionName] || []; for (const doc of docs) { const matched = blocks.some((block) => { if (!block.files.length) return false; if (!block.category) return true; const normalizedCategory = block.category.toLowerCase(); const normalizedId = (doc.id || '').toLowerCase(); const normalizedName = (doc.name || '').toLowerCase(); return ( normalizedCategory === normalizedId || normalizedCategory === normalizedName || (normalizedCategory.includes('contract') && normalizedId.includes('contract')) || (normalizedCategory.includes('payment') && normalizedId.includes('payment')) || (normalizedCategory.includes('correspondence') && normalizedId.includes('correspondence')) ); }); if (doc.required && !matched) { return `Добавьте файлы для документа "${doc.name}"`; } } const missingDescription = blocks.some( (block) => block.files.length > 0 && !block.description?.trim() ); if (missingDescription) { return 'Заполните описание для каждого блока документов'; } } const customMissingDescription = customFileBlocks.some( (block) => block.files.length > 0 && !block.description?.trim() ); if (customMissingDescription) { return 'Заполните описание для дополнительных документов'; } return null; }; const handleFinish = (values: Record) => { const uploadError = validateUploads(values); if (uploadError) { message.error(uploadError); return; } updateFormData({ wizardPlan: plan, wizardAnswers: values, wizardPlanStatus: 'answered', wizardUploads: { documents: questionFileBlocks, custom: customFileBlocks, }, }); addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', { answers: values, }); onNext(); }; const renderQuestionField = (question: WizardQuestion) => { switch (question.control) { case 'textarea': case 'input[type="textarea"]': return (