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