feat: Получение cf_2624 из MySQL и блокировка полей при подтверждении данных
- Добавлен сервис CrmMySQLService для прямого подключения к MySQL CRM - Обновлён метод get_draft() для получения cf_2624 напрямую из БД - Реализована блокировка полей (readonly) при contact_data_confirmed = true - Добавлен выбор банка для СБП выплат с динамической загрузкой из API - Обновлена документация по работе с cf_2624 и MySQL - Добавлен network_mode: host в docker-compose для доступа к MySQL - Обновлены компоненты формы для поддержки блокировки полей
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ERV Insurance Platform</title>
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
8927
frontend/package-lock.json
generated
Normal file
8927
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ERV Insurance Platform</title>
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -278,6 +278,37 @@ export default function Step1Phone({
|
||||
maxLength={10}
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
onPaste={(e) => {
|
||||
// Обработка вставки: очищаем от +7, пробелов и других символов
|
||||
e.preventDefault();
|
||||
const pastedText = (e.clipboardData || (window as any).clipboardData).getData('text');
|
||||
// Убираем все нецифровые символы
|
||||
let cleanText = pastedText.replace(/\D/g, '');
|
||||
// Если начинается с 7 или 8, убираем первую цифру (код страны)
|
||||
if (cleanText.length === 11 && (cleanText.startsWith('7') || cleanText.startsWith('8'))) {
|
||||
cleanText = cleanText.substring(1);
|
||||
}
|
||||
// Оставляем только первые 10 цифр
|
||||
cleanText = cleanText.substring(0, 10);
|
||||
|
||||
// ✅ Устанавливаем значение напрямую в input, затем синхронизируем с формой
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target) {
|
||||
target.value = cleanText;
|
||||
// Триггерим событие input для синхронизации с формой
|
||||
const inputEvent = new Event('input', { bubbles: true });
|
||||
target.dispatchEvent(inputEvent);
|
||||
}
|
||||
|
||||
// ✅ Синхронизируем с формой через requestAnimationFrame для избежания циклических ссылок
|
||||
requestAnimationFrame(() => {
|
||||
form.setFieldValue('phone', cleanText);
|
||||
// Показываем предупреждение, если номер был обрезан
|
||||
if (pastedText.replace(/\D/g, '').length > 10) {
|
||||
message.warning('Номер автоматически обрезан до 10 цифр');
|
||||
}
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Button, Select, message, Space, Divider } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Form, Input, Button, AutoComplete, message, Space, Divider } from 'antd';
|
||||
import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
|
||||
const NSPK_BANKS_API = 'http://212.193.27.93/api/payouts/dictionaries/nspk-banks';
|
||||
|
||||
const { Option } = Select;
|
||||
interface Bank {
|
||||
bankid: string;
|
||||
bankname: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
@@ -31,6 +35,72 @@ export default function Step3Payment({
|
||||
const [verifyLoading, setVerifyLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [debugCode, setDebugCode] = useState<string | null>(formData.smsDebugCode ?? null);
|
||||
const [banks, setBanks] = useState<Bank[]>([]);
|
||||
const [banksLoading, setBanksLoading] = useState(false);
|
||||
|
||||
// Загрузка списка банков при монтировании компонента
|
||||
useEffect(() => {
|
||||
const loadBanks = async () => {
|
||||
try {
|
||||
setBanksLoading(true);
|
||||
addDebugEvent?.('banks', 'pending', '📋 Загружаю список банков СБП...');
|
||||
|
||||
const response = await fetch(NSPK_BANKS_API);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const banksData: Bank[] = await response.json();
|
||||
|
||||
// Сортируем по названию для удобства
|
||||
banksData.sort((a, b) => a.bankname.localeCompare(b.bankname, 'ru'));
|
||||
|
||||
setBanks(banksData);
|
||||
addDebugEvent?.('banks', 'success', `✅ Загружено ${banksData.length} банков`, { count: banksData.length });
|
||||
|
||||
// Если есть сохранённый bankName или bankId - восстанавливаем значения
|
||||
if (formData.bankName) {
|
||||
const foundBank = banksData.find(b =>
|
||||
b.bankname.toLowerCase() === formData.bankName.toLowerCase() ||
|
||||
b.bankname.toLowerCase().includes(formData.bankName.toLowerCase())
|
||||
);
|
||||
if (foundBank) {
|
||||
updateFormData({
|
||||
bankId: foundBank.bankid,
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
form.setFieldsValue({
|
||||
bankId: foundBank.bankid,
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
}
|
||||
} else if (formData.bankId) {
|
||||
// Если есть только bankId, находим по ID
|
||||
const foundBank = banksData.find(b => b.bankid === formData.bankId);
|
||||
if (foundBank) {
|
||||
updateFormData({
|
||||
bankId: foundBank.bankid,
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
form.setFieldsValue({
|
||||
bankId: foundBank.bankid,
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Ошибка загрузки банков:', error);
|
||||
addDebugEvent?.('banks', 'error', `❌ Ошибка загрузки банков: ${error.message}`, { error: error.message });
|
||||
message.error('Не удалось загрузить список банков. Попробуйте обновить страницу.');
|
||||
} finally {
|
||||
setBanksLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadBanks();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Загружаем банки только при монтировании
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
@@ -136,11 +206,25 @@ export default function Step3Payment({
|
||||
}
|
||||
};
|
||||
|
||||
// Инициализация формы с bankId и bankName если есть
|
||||
useEffect(() => {
|
||||
if (formData.bankId || formData.bankName) {
|
||||
form.setFieldsValue({
|
||||
bankId: formData.bankId,
|
||||
bankName: formData.bankName
|
||||
});
|
||||
}
|
||||
}, [formData.bankId, formData.bankName, form]);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={formData}
|
||||
initialValues={{
|
||||
...formData,
|
||||
bankId: formData.bankId,
|
||||
bankName: formData.bankName,
|
||||
}}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
{/* Скрытые технические поля */}
|
||||
@@ -314,34 +398,78 @@ export default function Step3Payment({
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* Скрытое поле для bankId */}
|
||||
<Form.Item name="bankId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Выберите ваш банк"
|
||||
label="Банк для получения выплаты"
|
||||
name="bankName"
|
||||
rules={[{ required: true, message: 'Выберите банк для получения выплаты' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Выберите банк"
|
||||
size="large"
|
||||
showSearch
|
||||
filterOption={(input: string, option: any) => {
|
||||
const children = option?.children;
|
||||
if (typeof children === 'string') {
|
||||
return children.toLowerCase().includes(input.toLowerCase());
|
||||
rules={[
|
||||
{ required: true, message: 'Выберите банк для получения выплаты' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const foundBank = banks.find(b =>
|
||||
b.bankname.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
if (!foundBank) {
|
||||
return Promise.reject(new Error('Выберите банк из списка'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
]}
|
||||
>
|
||||
<AutoComplete
|
||||
placeholder={banksLoading ? "Загрузка списка банков..." : "Начните вводить название банка"}
|
||||
size="large"
|
||||
loading={banksLoading}
|
||||
notFoundContent={banksLoading ? "Загрузка..." : "Банк не найден. Попробуйте ввести другое название"}
|
||||
options={banks.map((bank) => ({
|
||||
value: bank.bankname,
|
||||
label: bank.bankname,
|
||||
}))}
|
||||
filterOption={(inputValue, option) => {
|
||||
if (!option?.label) return false;
|
||||
return option.label.toLowerCase().includes(inputValue.toLowerCase());
|
||||
}}
|
||||
>
|
||||
<Option value="sberbank">🟢 Сбербанк</Option>
|
||||
<Option value="tinkoff">🟡 Тинькофф</Option>
|
||||
<Option value="vtb">🔵 ВТБ</Option>
|
||||
<Option value="alfabank">🔴 Альфа-Банк</Option>
|
||||
<Option value="raiffeisen">🟡 Райффайзенбанк</Option>
|
||||
<Option value="gazprombank">🔵 Газпромбанк</Option>
|
||||
<Option value="rosbank">🔴 Росбанк</Option>
|
||||
<Option value="sovcombank">🟢 Совкомбанк</Option>
|
||||
<Option value="otkritie">🔵 Открытие</Option>
|
||||
<Option value="other">💳 Другой банк</Option>
|
||||
</Select>
|
||||
onSelect={(value) => {
|
||||
// При выборе из списка находим банк и сохраняем оба поля
|
||||
const selectedBank = banks.find(b => b.bankname === value);
|
||||
if (selectedBank) {
|
||||
updateFormData({
|
||||
bankId: selectedBank.bankid,
|
||||
bankName: selectedBank.bankname
|
||||
});
|
||||
// Устанавливаем bankId в скрытое поле
|
||||
form.setFieldsValue({ bankId: selectedBank.bankid });
|
||||
}
|
||||
}}
|
||||
onChange={(value) => {
|
||||
// При вводе текста ищем точное совпадение по названию
|
||||
if (typeof value === 'string') {
|
||||
const foundBank = banks.find(b =>
|
||||
b.bankname.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
if (foundBank) {
|
||||
updateFormData({
|
||||
bankId: foundBank.bankid,
|
||||
bankName: foundBank.bankname
|
||||
});
|
||||
form.setFieldsValue({ bankId: foundBank.bankid });
|
||||
} else if (value === '') {
|
||||
// Если поле очищено, очищаем и bankId
|
||||
updateFormData({ bankId: undefined, bankName: undefined });
|
||||
form.setFieldsValue({ bankId: undefined });
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
@@ -387,7 +515,8 @@ export default function Step3Payment({
|
||||
email: 'test@test.ru',
|
||||
phone: '+79991234567',
|
||||
paymentMethod: 'sbp',
|
||||
bankName: 'sberbank',
|
||||
bankId: banks.length > 0 ? banks[0].bankid : '100000000111', // Сбербанк по умолчанию
|
||||
bankName: banks.length > 0 ? banks[0].bankname : 'Сбербанк',
|
||||
};
|
||||
updateFormData(devData);
|
||||
message.success('DEV: Телефон автоматически подтверждён');
|
||||
@@ -407,7 +536,8 @@ export default function Step3Payment({
|
||||
email: 'test@test.ru',
|
||||
phone: '+79991234567',
|
||||
paymentMethod: 'sbp',
|
||||
bankName: 'sberbank',
|
||||
bankId: banks.length > 0 ? banks[0].bankid : '100000000111', // Сбербанк по умолчанию
|
||||
bankName: banks.length > 0 ? banks[0].bankname : 'Сбербанк',
|
||||
};
|
||||
updateFormData(devData);
|
||||
onSubmit();
|
||||
|
||||
@@ -4,14 +4,18 @@ import { generateConfirmationFormHTML } from './generateConfirmationFormHTML';
|
||||
|
||||
interface Props {
|
||||
claimPlanData: any; // Данные заявления от n8n
|
||||
contact_data_confirmed?: boolean; // ✅ Флаг подтверждения данных контакта
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onSubmitted?: () => void; // ✅ Callback после успешной отправки
|
||||
}
|
||||
|
||||
export default function StepClaimConfirmation({
|
||||
claimPlanData,
|
||||
contact_data_confirmed: prop_contact_data_confirmed,
|
||||
onNext,
|
||||
onPrev,
|
||||
onSubmitted,
|
||||
}: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
@@ -86,8 +90,15 @@ export default function StepClaimConfirmation({
|
||||
console.log('📋 formData.propertyName:', formData.propertyName);
|
||||
console.log('📋 formData.propertyName?.meta:', formData.propertyName?.meta);
|
||||
|
||||
// ✅ Получаем флаги подтверждения данных из props, claimPlanData или formData
|
||||
const contact_data_confirmed =
|
||||
prop_contact_data_confirmed !== undefined ? prop_contact_data_confirmed :
|
||||
claimPlanData?.contact_data_confirmed ||
|
||||
claimPlanData?.propertyName?.meta?.contact_data_confirmed ||
|
||||
false;
|
||||
|
||||
// Генерируем HTML форму здесь, на нашей стороне
|
||||
const html = generateConfirmationFormHTML(formData);
|
||||
const html = generateConfirmationFormHTML(formData, contact_data_confirmed);
|
||||
setHtmlContent(html);
|
||||
setLoading(false);
|
||||
}, [claimPlanData]);
|
||||
@@ -114,6 +125,17 @@ export default function StepClaimConfirmation({
|
||||
claimPlanData?.propertyName?.user?.mobile ||
|
||||
claimPlanData?.phone || '';
|
||||
|
||||
// ✅ Получаем флаг подтверждения данных контакта
|
||||
const contact_data_confirmed =
|
||||
prop_contact_data_confirmed !== undefined ? prop_contact_data_confirmed :
|
||||
claimPlanData?.contact_data_confirmed ||
|
||||
claimPlanData?.propertyName?.meta?.contact_data_confirmed ||
|
||||
false;
|
||||
|
||||
// ✅ Получаем данные банка (ID и название)
|
||||
const bankId = formData?.user?.bank_id || '';
|
||||
const bankName = formData?.user?.bank_name || '';
|
||||
|
||||
// Формируем payload для Redis канала
|
||||
const payload = {
|
||||
claim_id: claimId,
|
||||
@@ -124,6 +146,14 @@ export default function StepClaimConfirmation({
|
||||
phone: phone,
|
||||
sms_code: smsCode || '', // SMS код для верификации
|
||||
|
||||
// ✅ Флаг редактирования перс данных (cf_2624)
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
cf_2624: contact_data_confirmed ? "1" : "0", // Значение для CRM
|
||||
|
||||
// ✅ Данные банка для СБП выплаты
|
||||
bank_id: bankId,
|
||||
bank_name: bankName,
|
||||
|
||||
// Данные формы подтверждения
|
||||
form_data: formData,
|
||||
user: formData?.user || {},
|
||||
@@ -214,10 +244,15 @@ export default function StepClaimConfirmation({
|
||||
saveFormData(pendingFormData, code);
|
||||
|
||||
// Показываем сообщение об успешной отправке
|
||||
message.success('Ваше заявление отправлено!');
|
||||
message.success('Поздравляем! Ваше обращение направлено в Клиентправ.');
|
||||
|
||||
// Переходим дальше
|
||||
onNext();
|
||||
// ✅ Вызываем callback для показа сообщения об успехе вместо формы
|
||||
if (onSubmitted) {
|
||||
onSubmitted();
|
||||
} else {
|
||||
// Fallback: переходим дальше
|
||||
onNext();
|
||||
}
|
||||
} else {
|
||||
message.error(result.detail || 'Неверный код');
|
||||
}
|
||||
|
||||
@@ -359,367 +359,3 @@ export default function StepDocumentsNew({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
* StepDocumentsNew.tsx
|
||||
*
|
||||
* Поэкранная загрузка документов.
|
||||
* Один документ на экран с возможностью пропуска.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Upload,
|
||||
Progress,
|
||||
Alert,
|
||||
Typography,
|
||||
Space,
|
||||
Spin,
|
||||
message,
|
||||
Result
|
||||
} from 'antd';
|
||||
import {
|
||||
UploadOutlined,
|
||||
FileTextOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
LoadingOutlined,
|
||||
InboxOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { UploadFile, UploadProps } from 'antd/es/upload/interface';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
// === Типы ===
|
||||
export interface DocumentConfig {
|
||||
type: string; // Идентификатор: contract, payment, correspondence
|
||||
name: string; // Название: "Договор или оферта"
|
||||
critical: boolean; // Обязательный документ?
|
||||
hints?: string; // Подсказка: "Скриншот или PDF договора"
|
||||
accept?: string[]; // Допустимые форматы: ['pdf', 'jpg', 'png']
|
||||
}
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
updateFormData: (data: any) => void;
|
||||
documents: DocumentConfig[];
|
||||
currentIndex: number;
|
||||
onDocumentUploaded: (docType: string, fileData: any) => void;
|
||||
onDocumentSkipped: (docType: string) => void;
|
||||
onAllDocumentsComplete: () => void;
|
||||
onPrev: () => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
// === Компонент ===
|
||||
export default function StepDocumentsNew({
|
||||
formData,
|
||||
updateFormData,
|
||||
documents,
|
||||
currentIndex,
|
||||
onDocumentUploaded,
|
||||
onDocumentSkipped,
|
||||
onAllDocumentsComplete,
|
||||
onPrev,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
// Текущий документ
|
||||
const currentDoc = documents[currentIndex];
|
||||
const isLastDocument = currentIndex === documents.length - 1;
|
||||
const totalDocs = documents.length;
|
||||
|
||||
// Сбрасываем файлы при смене документа
|
||||
useEffect(() => {
|
||||
setFileList([]);
|
||||
setUploadProgress(0);
|
||||
}, [currentIndex]);
|
||||
|
||||
// === Handlers ===
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('Выберите файл для загрузки');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileList[0];
|
||||
if (!file.originFileObj) {
|
||||
message.error('Ошибка: файл не найден');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
addDebugEvent?.('documents', 'info', `📤 Загрузка документа: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
});
|
||||
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id || '');
|
||||
formDataToSend.append('session_id', formData.session_id || '');
|
||||
formDataToSend.append('document_type', currentDoc.type);
|
||||
formDataToSend.append('file', file.originFileObj, file.name);
|
||||
|
||||
// Симуляция прогресса (реальный прогресс будет через XHR)
|
||||
const progressInterval = setInterval(() => {
|
||||
setUploadProgress(prev => Math.min(prev + 10, 90));
|
||||
}, 200);
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setUploadProgress(100);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Ошибка загрузки: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
addDebugEvent?.('documents', 'success', `✅ Документ загружен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
});
|
||||
|
||||
message.success(`${currentDoc.name} загружен!`);
|
||||
|
||||
// Сохраняем в formData
|
||||
const uploadedDocs = formData.documents_uploaded || [];
|
||||
uploadedDocs.push({
|
||||
type: currentDoc.type,
|
||||
file_id: result.file_id,
|
||||
file_name: file.name,
|
||||
ocr_status: 'processing',
|
||||
});
|
||||
|
||||
updateFormData({
|
||||
documents_uploaded: uploadedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentUploaded(currentDoc.type, result);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Upload error:', error);
|
||||
message.error('Ошибка загрузки файла. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('documents', 'error', `❌ Ошибка загрузки: ${currentDoc.name}`, {
|
||||
error: String(error),
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [fileList, currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentUploaded, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (currentDoc.critical) {
|
||||
// Показываем предупреждение, но всё равно разрешаем пропустить
|
||||
message.warning(`⚠️ Документ "${currentDoc.name}" важен для рассмотрения заявки`);
|
||||
}
|
||||
|
||||
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type,
|
||||
was_critical: currentDoc.critical,
|
||||
});
|
||||
|
||||
// Сохраняем в список пропущенных
|
||||
const skippedDocs = formData.documents_skipped || [];
|
||||
if (!skippedDocs.includes(currentDoc.type)) {
|
||||
skippedDocs.push(currentDoc.type);
|
||||
}
|
||||
|
||||
updateFormData({
|
||||
documents_skipped: skippedDocs,
|
||||
current_doc_index: currentIndex + 1,
|
||||
});
|
||||
|
||||
// Callback
|
||||
onDocumentSkipped(currentDoc.type);
|
||||
|
||||
// Переходим к следующему или завершаем
|
||||
if (isLastDocument) {
|
||||
onAllDocumentsComplete();
|
||||
}
|
||||
}, [currentDoc, formData, updateFormData, currentIndex, isLastDocument, onDocumentSkipped, onAllDocumentsComplete, addDebugEvent]);
|
||||
|
||||
// === Upload Props ===
|
||||
const uploadProps: UploadProps = {
|
||||
fileList,
|
||||
onChange: ({ fileList: newFileList }) => setFileList(newFileList.slice(-1)), // Только один файл
|
||||
beforeUpload: () => false, // Не загружаем автоматически
|
||||
maxCount: 1,
|
||||
accept: currentDoc?.accept
|
||||
? currentDoc.accept.map(ext => `.${ext}`).join(',')
|
||||
: '.pdf,.jpg,.jpeg,.png,.heic,.doc,.docx',
|
||||
disabled: uploading,
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (!currentDoc) {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Все документы обработаны"
|
||||
subTitle="Переходим к формированию заявления..."
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700, margin: '0 auto' }}>
|
||||
<Card>
|
||||
{/* === Прогресс === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text type="secondary">
|
||||
Документ {currentIndex + 1} из {totalDocs}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{Math.round((currentIndex / totalDocs) * 100)}% завершено
|
||||
</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round((currentIndex / totalDocs) * 100)}
|
||||
showInfo={false}
|
||||
strokeColor="#595959"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileTextOutlined style={{ color: '#595959' }} />
|
||||
{currentDoc.name}
|
||||
{currentDoc.critical && (
|
||||
<ExclamationCircleOutlined
|
||||
style={{ color: '#fa8c16', fontSize: 20 }}
|
||||
title="Важный документ"
|
||||
/>
|
||||
)}
|
||||
</Title>
|
||||
|
||||
{currentDoc.hints && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{currentDoc.hints}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* === Алерт для критичных документов === */}
|
||||
{currentDoc.critical && (
|
||||
<Alert
|
||||
message="Важный документ"
|
||||
description="Этот документ значительно повысит шансы на успешное рассмотрение заявки. Если документа нет — можно пропустить, но мы рекомендуем загрузить."
|
||||
type="warning"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Загрузка файла === */}
|
||||
<Dragger {...uploadProps} style={{ marginBottom: 24 }}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
{uploading ? (
|
||||
<LoadingOutlined style={{ fontSize: 48, color: '#595959' }} spin />
|
||||
) : (
|
||||
<InboxOutlined style={{ fontSize: 48, color: '#595959' }} />
|
||||
)}
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{uploading
|
||||
? 'Загружаем документ...'
|
||||
: 'Перетащите файл сюда или нажмите для выбора'
|
||||
}
|
||||
</p>
|
||||
<p className="ant-upload-hint">
|
||||
Поддерживаются: PDF, JPG, PNG, HEIC, DOC (до 20 МБ)
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* === Прогресс загрузки === */}
|
||||
{uploading && (
|
||||
<Progress
|
||||
percent={uploadProgress}
|
||||
status="active"
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Кнопки === */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={onPrev}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
← Назад
|
||||
</Button>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
onClick={handleSkip}
|
||||
disabled={uploading}
|
||||
size="large"
|
||||
>
|
||||
Пропустить
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
loading={uploading}
|
||||
disabled={fileList.length === 0}
|
||||
size="large"
|
||||
icon={<UploadOutlined />}
|
||||
>
|
||||
{isLastDocument ? 'Загрузить и продолжить' : 'Загрузить'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* === Уже загруженные документы === */}
|
||||
{formData.documents_uploaded && formData.documents_uploaded.length > 0 && (
|
||||
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<Text strong>Загруженные документы:</Text>
|
||||
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
|
||||
{formData.documents_uploaded.map((doc: any, idx: number) => (
|
||||
<li key={idx}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
|
||||
{documents.find(d => d.type === doc.type)?.name || doc.type}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ const getRelativeTime = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
interface DocumentStatus {
|
||||
name: string;
|
||||
required: boolean;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
claim_id: string;
|
||||
@@ -74,7 +80,9 @@ interface Draft {
|
||||
channel: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
problem_title?: string; // Краткое описание (заголовок)
|
||||
problem_description?: string;
|
||||
category?: string; // Категория проблемы
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
@@ -82,6 +90,7 @@ interface Draft {
|
||||
documents_total?: number;
|
||||
documents_uploaded?: number;
|
||||
documents_skipped?: number;
|
||||
documents_list?: DocumentStatus[]; // Список документов со статусами
|
||||
wizard_ready?: boolean;
|
||||
claim_ready?: boolean;
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
@@ -127,10 +136,10 @@ const STATUS_CONFIG: Record<string, {
|
||||
},
|
||||
draft_docs_complete: {
|
||||
color: 'orange',
|
||||
icon: <LoadingOutlined />,
|
||||
label: 'Обработка',
|
||||
description: 'Формируется заявление...',
|
||||
action: 'Ожидайте',
|
||||
icon: <CheckCircleOutlined />,
|
||||
label: 'Документы загружены',
|
||||
description: 'Все документы обработаны',
|
||||
action: 'Продолжить',
|
||||
},
|
||||
draft_claim_ready: {
|
||||
color: 'green',
|
||||
@@ -274,11 +283,8 @@ export default function StepDraftSelection({
|
||||
if (draft.is_legacy && onRestartDraft) {
|
||||
// Legacy черновик - предлагаем начать заново с тем же описанием
|
||||
onRestartDraft(draftId, draft.problem_description || '');
|
||||
} else if (draft.status_code === 'draft_docs_complete') {
|
||||
// Всё ещё обрабатывается - показываем сообщение
|
||||
message.info('Заявление формируется. Пожалуйста, подождите.');
|
||||
} else {
|
||||
// Обычный переход
|
||||
// ✅ Разрешаем переход на любом этапе до апрува по SMS
|
||||
onSelectDraft(draftId);
|
||||
}
|
||||
};
|
||||
@@ -286,15 +292,12 @@ export default function StepDraftSelection({
|
||||
// Кнопка действия
|
||||
const getActionButton = (draft: Draft) => {
|
||||
const config = getStatusConfig(draft);
|
||||
const isProcessing = draft.status_code === 'draft_docs_complete';
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={isProcessing ? 'default' : 'primary'}
|
||||
type="primary"
|
||||
onClick={() => handleDraftAction(draft)}
|
||||
icon={config.icon}
|
||||
disabled={isProcessing}
|
||||
loading={isProcessing}
|
||||
>
|
||||
{config.action}
|
||||
</Button>
|
||||
@@ -320,19 +323,26 @@ export default function StepDraftSelection({
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания новой заявки - всегда вверху */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewClaim}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Создать новую заявку
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<Empty
|
||||
description="У вас нет незавершенных заявок"
|
||||
description="У вас пока нет незавершенных заявок"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
|
||||
Создать новую заявку
|
||||
</Button>
|
||||
</Empty>
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
@@ -342,35 +352,17 @@ export default function StepDraftSelection({
|
||||
const docsProgress = getDocsProgress(draft);
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
display: 'block', // Вертикальный layout
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
actions={[
|
||||
getActionButton(draft),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
@@ -392,28 +384,46 @@ export default function StepDraftSelection({
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||
{draft.category && (
|
||||
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Описание проблемы */}
|
||||
{/* Заголовок - краткое описание проблемы */}
|
||||
{draft.problem_title && (
|
||||
<Text strong style={{
|
||||
fontSize: 15,
|
||||
color: '#1a1a1a',
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{draft.problem_title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Полное описание проблемы */}
|
||||
{draft.problem_description && (
|
||||
<Text
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
background: '#f5f5f5',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={draft.problem_description}
|
||||
>
|
||||
{draft.problem_description.length > 60
|
||||
? draft.problem_description.substring(0, 60) + '...'
|
||||
{draft.problem_description.length > 250
|
||||
? draft.problem_description.substring(0, 250) + '...'
|
||||
: draft.problem_description
|
||||
}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Время обновления */}
|
||||
@@ -436,62 +446,115 @@ export default function StepDraftSelection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Прогресс документов */}
|
||||
{docsProgress && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
|
||||
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
|
||||
</Text>
|
||||
{/* Список документов со статусами */}
|
||||
{draft.documents_list && draft.documents_list.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
📄 Документы
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
|
||||
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{draft.documents_list.map((doc, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{doc.uploaded ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
|
||||
) : (
|
||||
<span style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
|
||||
textDecoration: doc.uploaded ? 'none' : 'none',
|
||||
}}>
|
||||
{doc.name}
|
||||
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогрессбар (если нет списка) */}
|
||||
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#52c41a"
|
||||
strokeColor={{
|
||||
'0%': '#1890ff',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Старые теги прогресса (для обратной совместимости) */}
|
||||
{!docsProgress && !draft.is_legacy && (
|
||||
<Space size="small" wrap>
|
||||
<Tag color={draft.problem_description ? 'green' : 'default'}>
|
||||
{draft.problem_description ? '✓ Описание' : 'Описание'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{getActionButton(draft)}
|
||||
<Popconfirm
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onNewClaim}
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Создать новую заявку
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ReloadOutlined />}
|
||||
|
||||
@@ -336,344 +336,3 @@ export default function StepWaitingClaim({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
* StepWaitingClaim.tsx
|
||||
*
|
||||
* Экран ожидания формирования заявления.
|
||||
* Показывает прогресс: OCR → Анализ → Формирование заявления.
|
||||
* Подписывается на SSE для получения claim_ready.
|
||||
*
|
||||
* @version 1.0
|
||||
* @date 2025-11-26
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, Typography, Progress, Space, Button, Spin, Result, Steps } from 'antd';
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
FileSearchOutlined,
|
||||
RobotOutlined,
|
||||
FileTextOutlined,
|
||||
ClockCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
claimId?: string;
|
||||
documentsCount: number;
|
||||
onClaimReady: (claimData: any) => void;
|
||||
onTimeout: () => void;
|
||||
onError: (error: string) => void;
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
type ProcessingStep = 'ocr' | 'analysis' | 'generation' | 'ready';
|
||||
|
||||
interface ProcessingState {
|
||||
currentStep: ProcessingStep;
|
||||
ocrCompleted: number;
|
||||
ocrTotal: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default function StepWaitingClaim({
|
||||
sessionId,
|
||||
claimId,
|
||||
documentsCount,
|
||||
onClaimReady,
|
||||
onTimeout,
|
||||
onError,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [state, setState] = useState<ProcessingState>({
|
||||
currentStep: 'ocr',
|
||||
ocrCompleted: 0,
|
||||
ocrTotal: documentsCount,
|
||||
message: 'Распознаём документы...',
|
||||
});
|
||||
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Таймер для отображения времени
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsedTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// SSE подписка
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
setError('Отсутствует session_id');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔌 StepWaitingClaim: подписываемся на SSE', { sessionId, claimId });
|
||||
|
||||
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
addDebugEvent?.('waiting', 'info', '🔌 Подписка на SSE для ожидания заявления', {
|
||||
session_id: sessionId,
|
||||
claim_id: claimId,
|
||||
});
|
||||
|
||||
// Таймаут 5 минут
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
console.warn('⏰ Timeout ожидания заявления');
|
||||
setError('Превышено время ожидания. Попробуйте обновить страницу.');
|
||||
addDebugEvent?.('waiting', 'warning', '⏰ Таймаут ожидания заявления');
|
||||
eventSource.close();
|
||||
onTimeout();
|
||||
}, 300000); // 5 минут
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE соединение открыто (waiting)');
|
||||
addDebugEvent?.('waiting', 'info', '✅ SSE соединение открыто');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📥 SSE event (waiting):', data);
|
||||
|
||||
const eventType = data.event_type || data.type;
|
||||
|
||||
// OCR документа завершён
|
||||
if (eventType === 'document_ocr_completed') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
ocrCompleted: prev.ocrCompleted + 1,
|
||||
message: `Распознано ${prev.ocrCompleted + 1} из ${prev.ocrTotal} документов`,
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', `📄 OCR завершён: ${data.document_type}`);
|
||||
}
|
||||
|
||||
// Все документы распознаны, начинаем анализ
|
||||
if (eventType === 'ocr_all_completed' || eventType === 'analysis_started') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'analysis',
|
||||
message: 'Анализируем данные...',
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', '🔍 Начат анализ данных');
|
||||
}
|
||||
|
||||
// Генерация заявления
|
||||
if (eventType === 'claim_generation_started') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'generation',
|
||||
message: 'Формируем заявление...',
|
||||
}));
|
||||
addDebugEvent?.('waiting', 'info', '📝 Начато формирование заявления');
|
||||
}
|
||||
|
||||
// Заявление готово!
|
||||
if (eventType === 'claim_ready' || eventType === 'claim_plan_ready') {
|
||||
console.log('🎉 Заявление готово!', data);
|
||||
|
||||
// Очищаем таймаут
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStep: 'ready',
|
||||
message: 'Заявление готово!',
|
||||
}));
|
||||
|
||||
addDebugEvent?.('waiting', 'success', '✅ Заявление готово');
|
||||
|
||||
// Закрываем SSE
|
||||
eventSource.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
// Callback с данными
|
||||
setTimeout(() => {
|
||||
onClaimReady(data.data || data.claim_data || data);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Ошибка
|
||||
if (eventType === 'claim_error' || data.status === 'error') {
|
||||
setError(data.message || 'Произошла ошибка при формировании заявления');
|
||||
addDebugEvent?.('waiting', 'error', `❌ Ошибка: ${data.message}`);
|
||||
eventSource.close();
|
||||
onError(data.message);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Ошибка парсинга SSE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('❌ SSE error (waiting):', err);
|
||||
// Не показываем ошибку сразу — SSE может переподключиться
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, claimId, onClaimReady, onTimeout, onError, addDebugEvent]);
|
||||
|
||||
// Форматирование времени
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Вычисляем процент прогресса
|
||||
const getProgress = (): number => {
|
||||
switch (state.currentStep) {
|
||||
case 'ocr':
|
||||
// OCR: 0-50%
|
||||
return state.ocrTotal > 0
|
||||
? Math.round((state.ocrCompleted / state.ocrTotal) * 50)
|
||||
: 25;
|
||||
case 'analysis':
|
||||
return 60;
|
||||
case 'generation':
|
||||
return 85;
|
||||
case 'ready':
|
||||
return 100;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Индекс текущего шага для Steps
|
||||
const getStepIndex = (): number => {
|
||||
switch (state.currentStep) {
|
||||
case 'ocr': return 0;
|
||||
case 'analysis': return 1;
|
||||
case 'generation': return 2;
|
||||
case 'ready': return 3;
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// === Render ===
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Ошибка"
|
||||
subTitle={error}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Обновить страницу
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.currentStep === 'ready') {
|
||||
return (
|
||||
<Result
|
||||
status="success"
|
||||
title="Заявление готово!"
|
||||
subTitle="Переходим к просмотру..."
|
||||
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
extra={<Spin size="large" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: '0 auto' }}>
|
||||
<Card style={{ textAlign: 'center' }}>
|
||||
{/* === Иллюстрация === */}
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
alt="AI работает"
|
||||
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* === Заголовок === */}
|
||||
<Title level={3}>{state.message}</Title>
|
||||
|
||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||
Наш AI-ассистент обрабатывает ваши документы и формирует заявление.
|
||||
Это займёт 1-2 минуты.
|
||||
</Paragraph>
|
||||
|
||||
{/* === Прогресс === */}
|
||||
<Progress
|
||||
percent={getProgress()}
|
||||
status="active"
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{/* === Шаги обработки === */}
|
||||
<Steps
|
||||
current={getStepIndex()}
|
||||
size="small"
|
||||
style={{ marginBottom: 24 }}
|
||||
>
|
||||
<Step
|
||||
title="OCR"
|
||||
description={state.ocrTotal > 0 ? `${state.ocrCompleted}/${state.ocrTotal}` : ''}
|
||||
icon={state.currentStep === 'ocr' ? <LoadingOutlined /> : <FileSearchOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Анализ"
|
||||
icon={state.currentStep === 'analysis' ? <LoadingOutlined /> : <RobotOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Заявление"
|
||||
icon={state.currentStep === 'generation' ? <LoadingOutlined /> : <FileTextOutlined />}
|
||||
/>
|
||||
<Step
|
||||
title="Готово"
|
||||
icon={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Steps>
|
||||
|
||||
{/* === Таймер === */}
|
||||
<Space>
|
||||
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
|
||||
<Text type="secondary">
|
||||
Время ожидания: {formatTime(elapsedTime)}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{/* === Подсказка === */}
|
||||
<Paragraph type="secondary" style={{ marginTop: 16, fontSize: 12 }}>
|
||||
Не закрывайте эту страницу. Обработка происходит на сервере.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined, InboxOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
@@ -133,6 +133,8 @@ export default function StepWizardPlan({
|
||||
new Set(formData.wizardSkippedDocuments || [])
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isFormingClaim, setIsFormingClaim] = useState(false); // Состояние ожидания формирования заявления
|
||||
const [ragError, setRagError] = useState<string | null>(null); // Ошибка RAG
|
||||
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
|
||||
done: 0,
|
||||
total: 0,
|
||||
@@ -1070,6 +1072,8 @@ export default function StepWizardPlan({
|
||||
onChange={(e) =>
|
||||
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
||||
}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1146,27 +1150,61 @@ export default function StepWizardPlan({
|
||||
const renderCustomUploads = () => (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginTop: 24, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
||||
title="Документы"
|
||||
extra={
|
||||
<Button type="link" onClick={addCustomBlock} icon={<PlusOutlined />}>
|
||||
Добавить блок
|
||||
</Button>
|
||||
style={{
|
||||
marginTop: 24,
|
||||
borderRadius: 8,
|
||||
border: '2px dashed #d9d9d9',
|
||||
background: '#fafafa'
|
||||
}}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<PlusOutlined style={{ color: '#595959' }} />
|
||||
<span>Дополнительные документы</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{customFileBlocks.length === 0 && (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
Можно добавить произвольные группы документов — например, переписку, дополнительные акты
|
||||
или фото.
|
||||
</Paragraph>
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fff', borderRadius: 8 }}>
|
||||
<Paragraph style={{ marginBottom: 8 }}>
|
||||
<Text strong>Есть ещё документы, которые могут помочь?</Text>
|
||||
</Paragraph>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
Если у вас есть дополнительные документы, которые не указаны в списке выше,
|
||||
вы можете загрузить их здесь. Например:
|
||||
</Paragraph>
|
||||
<ul style={{ margin: '0 0 16px 20px', padding: 0, color: '#666' }}>
|
||||
<li>Дополнительная переписка</li>
|
||||
<li>Скриншоты переговоров</li>
|
||||
<li>Дополнительные чеки или акты</li>
|
||||
<li>Любые другие документы, которые могут быть полезны</li>
|
||||
</ul>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addCustomBlock}
|
||||
block
|
||||
size="large"
|
||||
>
|
||||
Добавить документ
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{customFileBlocks.map((block, idx) => (
|
||||
<Card
|
||||
key={block.id}
|
||||
size="small"
|
||||
type="inner"
|
||||
title={`Группа #${idx + 1}`}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: '#fff'
|
||||
}}
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileTextOutlined style={{ color: '#595959' }} />
|
||||
<span>Дополнительный документ #{idx + 1}</span>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<Button type="link" danger size="small" onClick={() => removeCustomBlock(block.id)}>
|
||||
Удалить
|
||||
@@ -1174,43 +1212,74 @@ export default function StepWizardPlan({
|
||||
}
|
||||
>
|
||||
<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 })}
|
||||
/>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>
|
||||
Название документа <Text type="danger">*</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 'normal', marginLeft: 4 }}>
|
||||
(обязательно, если загружены файлы)
|
||||
</Text>
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Например: Переписка в WhatsApp с менеджером от 15.11.2025"
|
||||
value={block.description}
|
||||
onChange={(e) => updateCustomBlock(block.id, { description: e.target.value })}
|
||||
maxLength={500}
|
||||
showCount
|
||||
style={{ marginBottom: 12 }}
|
||||
status={block.files.length > 0 && !block.description?.trim() ? 'error' : ''}
|
||||
/>
|
||||
{block.files.length > 0 && !block.description?.trim() && (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
⚠️ Укажите название документа, чтобы мы поняли, что это за файлы
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>
|
||||
Категория (необязательно)
|
||||
</Text>
|
||||
<Select
|
||||
value={block.category}
|
||||
placeholder="Выберите категорию или оставьте пустым"
|
||||
onChange={(value) => updateCustomBlock(block.id, { category: value })}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{customCategoryOptions.map((option) => (
|
||||
<Option key={`custom-${option.value}`} value={option.value}>
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Dragger
|
||||
multiple
|
||||
beforeUpload={() => false}
|
||||
fileList={block.files}
|
||||
onChange={({ fileList }) => updateCustomBlock(block.id, { files: fileList })}
|
||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<LoadingOutlined style={{ color: '#595959' }} />
|
||||
<InboxOutlined style={{ color: '#595959', fontSize: 32 }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
||||
<p className="ant-upload-hint">Максимум 10 файлов, до 20 МБ каждый.</p>
|
||||
<p className="ant-upload-hint">
|
||||
Форматы: PDF, JPG, PNG, DOC, DOCX, HEIC. Максимум 10 файлов, до 20 МБ каждый.
|
||||
</p>
|
||||
</Dragger>
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
{customFileBlocks.length > 0 && (
|
||||
<Button onClick={addCustomBlock} icon={<PlusOutlined />}>
|
||||
Добавить ещё документы
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={addCustomBlock}
|
||||
icon={<PlusOutlined />}
|
||||
block
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
Добавить ещё документ
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
@@ -1591,6 +1660,27 @@ export default function StepWizardPlan({
|
||||
const newSkipped = [...skippedDocs, currentDoc.id];
|
||||
setSkippedDocs(newSkipped);
|
||||
|
||||
// ✅ ЛОГИРОВАНИЕ: Пропуск документа
|
||||
console.log('⏭️ Документ пропущен:', {
|
||||
document_id: currentDoc.id,
|
||||
document_name: currentDoc.name,
|
||||
document_type: currentDoc.type || currentDoc.id,
|
||||
was_required: currentDoc.required || false,
|
||||
claim_id: formData.claim_id,
|
||||
session_id: formData.session_id,
|
||||
skipped_documents_count: newSkipped.length,
|
||||
skipped_documents: newSkipped,
|
||||
});
|
||||
|
||||
// ✅ ЛОГИРОВАНИЕ: Отправка события для отладки
|
||||
addDebugEvent?.('documents', 'info', `⏭️ Документ пропущен: ${currentDoc.name}`, {
|
||||
document_type: currentDoc.type || currentDoc.id,
|
||||
document_id: currentDoc.id,
|
||||
was_required: currentDoc.required || false,
|
||||
claim_id: formData.claim_id,
|
||||
skipped_documents: newSkipped,
|
||||
});
|
||||
|
||||
// Находим следующий незагруженный документ (используем обновлённый список)
|
||||
const findNextUnprocessed = (startIndex: number) => {
|
||||
for (let i = startIndex; i < documentsRequired.length; i++) {
|
||||
@@ -1604,11 +1694,52 @@ export default function StepWizardPlan({
|
||||
};
|
||||
const nextIndex = findNextUnprocessed(currentDocIndex + 1);
|
||||
|
||||
// ✅ Сохраняем в formData (это сохранится в БД через n8n при следующем сохранении черновика)
|
||||
updateFormData({
|
||||
documents_skipped: newSkipped,
|
||||
current_doc_index: nextIndex,
|
||||
});
|
||||
|
||||
// ✅ ЯВНОЕ СОХРАНЕНИЕ: Отправляем событие в n8n для сохранения documents_skipped в БД
|
||||
// Используем тот же webhook, что и при загрузке документа
|
||||
if (formData.claim_id && formData.session_id) {
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
formDataToSend.append('claim_id', formData.claim_id);
|
||||
formDataToSend.append('session_id', formData.session_id);
|
||||
formDataToSend.append('document_type', currentDoc.type || currentDoc.id);
|
||||
formDataToSend.append('document_name', currentDoc.name || currentDoc.id);
|
||||
formDataToSend.append('group_index', String(currentDocIndex)); // ✅ Индекс документа
|
||||
if (formData.unified_id) formDataToSend.append('unified_id', formData.unified_id);
|
||||
if (formData.contact_id) formDataToSend.append('contact_id', formData.contact_id);
|
||||
if (formData.phone) formDataToSend.append('phone', formData.phone);
|
||||
|
||||
console.log('💾 Отправка пропущенного документа в n8n:', {
|
||||
claim_id: formData.claim_id,
|
||||
document_type: currentDoc.type || currentDoc.id,
|
||||
document_name: currentDoc.name,
|
||||
group_index: currentDocIndex,
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/documents/skip', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('❌ Ошибка отправки пропущенного документа в n8n:', errorData);
|
||||
// Не блокируем пользователя - данные сохранятся при следующем сохранении черновика
|
||||
} else {
|
||||
const result = await response.json();
|
||||
console.log('✅ Пропущенный документ отправлен в n8n:', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка отправки пропущенного документа в n8n:', error);
|
||||
// Не блокируем пользователя - данные сохранятся при следующем сохранении черновика
|
||||
}
|
||||
}
|
||||
|
||||
// Переход к следующему незагруженному документу
|
||||
setCurrentDocIndex(nextIndex);
|
||||
return;
|
||||
@@ -1714,10 +1845,295 @@ export default function StepWizardPlan({
|
||||
}, [currentDocIndex]);
|
||||
|
||||
// Все документы загружены — переход к ожиданию заявления
|
||||
const handleAllDocsComplete = () => {
|
||||
message.loading('Формируем заявление...', 0);
|
||||
// TODO: Переход к StepWaitingClaim или показ loader
|
||||
onNext();
|
||||
const handleAllDocsComplete = async () => {
|
||||
// ✅ Отправляем кастомные документы, если они есть
|
||||
const customBlocksWithFiles = customFileBlocks.filter(block => block.files.length > 0);
|
||||
|
||||
if (customBlocksWithFiles.length > 0) {
|
||||
try {
|
||||
message.loading('Отправляем дополнительные документы...', 0);
|
||||
|
||||
// Отправляем каждый кастомный блок отдельно
|
||||
for (const block of customBlocksWithFiles) {
|
||||
if (!block.description?.trim()) {
|
||||
message.warning('Пропущен документ без названия');
|
||||
continue;
|
||||
}
|
||||
|
||||
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', 'custom');
|
||||
formDataToSend.append('document_name', block.description);
|
||||
formDataToSend.append('document_description', block.description);
|
||||
|
||||
// Добавляем все файлы блока
|
||||
block.files.forEach((file) => {
|
||||
if (file.originFileObj) {
|
||||
formDataToSend.append('files', file.originFileObj, file.name);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/documents/upload-multiple', {
|
||||
method: 'POST',
|
||||
body: formDataToSend,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('❌ Ошибка отправки кастомного документа:', await response.text());
|
||||
} else {
|
||||
console.log('✅ Кастомный документ отправлен:', block.description);
|
||||
}
|
||||
}
|
||||
|
||||
message.destroy();
|
||||
message.success(`Отправлено ${customBlocksWithFiles.length} дополнительных документов`);
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка отправки кастомных документов:', error);
|
||||
message.destroy();
|
||||
message.warning('Не удалось отправить некоторые дополнительные документы');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Показываем экран ожидания
|
||||
setIsFormingClaim(true);
|
||||
setRagError(null); // Сбрасываем предыдущую ошибку
|
||||
|
||||
// ✅ Запускаем RAG через check-ocr-status
|
||||
try {
|
||||
const response = await fetch('/api/v1/documents/check-ocr-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
claim_id: formData.claim_id,
|
||||
session_id: formData.session_id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ OCR status check:', data);
|
||||
|
||||
// Если есть кэш — сразу переходим
|
||||
if (data.from_cache && data.form_draft) {
|
||||
console.log('✅ Используем кэшированные данные:', data.form_draft);
|
||||
const formDraft = data.form_draft;
|
||||
const user = formDraft.user || {};
|
||||
const project = formDraft.project || {};
|
||||
|
||||
// ✅ Используем тот же маппинг что и в ClaimForm.tsx
|
||||
const claimPlanData = {
|
||||
propertyName: {
|
||||
applicant: {
|
||||
first_name: user.firstname || '',
|
||||
middle_name: user.secondname || '',
|
||||
last_name: user.lastname || '',
|
||||
phone: user.mobile || formData.phone || '',
|
||||
email: user.email || '',
|
||||
birth_date: user.birthday || '',
|
||||
birth_place: user.birthplace || '',
|
||||
address: user.mailingstreet || '',
|
||||
inn: user.inn || '',
|
||||
},
|
||||
case: {
|
||||
category: project.category || '',
|
||||
direction: project.direction || '',
|
||||
},
|
||||
contract_or_service: {
|
||||
subject: project.subject || '',
|
||||
amount_paid: project.agrprice || '',
|
||||
agreement_date: project.agrdate || '',
|
||||
period_start: project.startdate || '',
|
||||
period_end: project.finishdate || '',
|
||||
country: project.country || '',
|
||||
hotel: project.hotel || '',
|
||||
},
|
||||
offenders: (formDraft.offenders || []).map((o: any) => ({
|
||||
name: o.accountname || '',
|
||||
accountname: o.accountname || '',
|
||||
address: o.address || '',
|
||||
email: o.email || '',
|
||||
website: o.website || '',
|
||||
phone: o.phone || '',
|
||||
inn: o.inn || '',
|
||||
ogrn: o.ogrn || '',
|
||||
role: o.role || '',
|
||||
})),
|
||||
claim: {
|
||||
description: project.description || formData.problem_description || '',
|
||||
reason: project.category || '',
|
||||
},
|
||||
meta: {
|
||||
claim_id: formData.claim_id,
|
||||
unified_id: formData.unified_id || '',
|
||||
session_token: formData.session_id,
|
||||
},
|
||||
attachments_names: Array.isArray(data.documents_meta)
|
||||
? [...new Set(data.documents_meta.map((d: any) =>
|
||||
d.field_label || d.original_file_name || d.file_name || 'Документ'
|
||||
))]
|
||||
: [],
|
||||
},
|
||||
session_token: formData.session_id,
|
||||
claim_id: formData.claim_id,
|
||||
prefix: 'clpr_',
|
||||
};
|
||||
|
||||
updateFormData({
|
||||
form_draft: formDraft,
|
||||
claimPlanData: claimPlanData,
|
||||
showClaimConfirmation: true,
|
||||
claim_ready: true,
|
||||
});
|
||||
|
||||
setIsFormingClaim(false);
|
||||
message.success('Данные загружены из кэша');
|
||||
onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе подключаемся к SSE и ждём результат от n8n
|
||||
const sessionId = formData.session_id;
|
||||
console.log('📡 Подключаемся к SSE:', `/api/v1/events/${sessionId}`);
|
||||
|
||||
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const eventData = JSON.parse(event.data);
|
||||
console.log('📥 SSE event:', eventData);
|
||||
|
||||
// Обрабатываем событие ocr_status
|
||||
if (eventData.event_type === 'ocr_status') {
|
||||
if (eventData.status === 'ready') {
|
||||
// ✅ Успех — данные готовы
|
||||
console.log('✅ Заявление готово:', eventData.data);
|
||||
const formDraft = eventData.data?.form_draft;
|
||||
|
||||
// Формируем claimPlanData для StepClaimConfirmation
|
||||
if (formDraft) {
|
||||
const user = formDraft.user || {};
|
||||
const project = formDraft.project || {};
|
||||
|
||||
// ✅ Используем тот же маппинг что и в ClaimForm.tsx
|
||||
const claimPlanData = {
|
||||
propertyName: {
|
||||
applicant: {
|
||||
first_name: user.firstname || '',
|
||||
middle_name: user.secondname || '',
|
||||
last_name: user.lastname || '',
|
||||
phone: user.mobile || formData.phone || '',
|
||||
email: user.email || '',
|
||||
birth_date: user.birthday || '',
|
||||
birth_place: user.birthplace || '',
|
||||
address: user.mailingstreet || '',
|
||||
inn: user.inn || '',
|
||||
},
|
||||
case: {
|
||||
category: project.category || '',
|
||||
direction: project.direction || '',
|
||||
},
|
||||
contract_or_service: {
|
||||
subject: project.subject || '',
|
||||
amount_paid: project.agrprice || '',
|
||||
agreement_date: project.agrdate || '',
|
||||
period_start: project.startdate || '',
|
||||
period_end: project.finishdate || '',
|
||||
country: project.country || '',
|
||||
hotel: project.hotel || '',
|
||||
},
|
||||
offenders: (formDraft.offenders || []).map((o: any) => ({
|
||||
name: o.accountname || '',
|
||||
accountname: o.accountname || '',
|
||||
address: o.address || '',
|
||||
email: o.email || '',
|
||||
website: o.website || '',
|
||||
phone: o.phone || '',
|
||||
inn: o.inn || '',
|
||||
ogrn: o.ogrn || '',
|
||||
role: o.role || '',
|
||||
})),
|
||||
claim: {
|
||||
description: project.description || formData.problem_description || '',
|
||||
reason: project.category || '',
|
||||
},
|
||||
meta: {
|
||||
claim_id: formData.claim_id,
|
||||
unified_id: formData.unified_id || '',
|
||||
session_token: formData.session_id,
|
||||
},
|
||||
attachments_names: Array.isArray(eventData.data?.documents_meta)
|
||||
? [...new Set(eventData.data.documents_meta.map((d: any) =>
|
||||
d.field_label || d.original_file_name || d.file_name || 'Документ'
|
||||
))]
|
||||
: [],
|
||||
},
|
||||
session_token: formData.session_id,
|
||||
claim_id: formData.claim_id,
|
||||
prefix: 'clpr_',
|
||||
};
|
||||
|
||||
updateFormData({
|
||||
form_draft: formDraft,
|
||||
claimPlanData: claimPlanData,
|
||||
showClaimConfirmation: true,
|
||||
claim_ready: true,
|
||||
});
|
||||
} else {
|
||||
updateFormData({
|
||||
claim_ready: true,
|
||||
});
|
||||
}
|
||||
|
||||
setIsFormingClaim(false);
|
||||
message.success(eventData.message || 'Заявление сформировано!');
|
||||
eventSource.close();
|
||||
onNext();
|
||||
} else if (eventData.status === 'error' || eventData.status === 'timeout') {
|
||||
// ❌ Ошибка — показываем кнопку повторить
|
||||
console.error('❌ Ошибка RAG:', eventData.message);
|
||||
setIsFormingClaim(false);
|
||||
setRagError(eventData.message || 'Ошибка формирования заявления');
|
||||
eventSource.close();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка парсинга SSE:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ SSE error:', error);
|
||||
message.destroy();
|
||||
setIsFormingClaim(false);
|
||||
setRagError('Потеряно соединение с сервером');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// Таймаут 3 минуты (RAG может занять время)
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState !== EventSource.CLOSED) {
|
||||
console.warn('⏰ SSE timeout');
|
||||
message.destroy();
|
||||
setIsFormingClaim(false);
|
||||
setRagError('Превышено время ожидания. Попробуйте ещё раз.');
|
||||
eventSource.close();
|
||||
}
|
||||
}, 180000); // 3 минуты для RAG
|
||||
|
||||
} else {
|
||||
console.warn('⚠️ OCR status check failed:', await response.text());
|
||||
message.destroy();
|
||||
onNext();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error calling check-ocr-status:', error);
|
||||
message.destroy();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1833,7 +2249,7 @@ export default function StepWizardPlan({
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button onClick={onPrev}>← К списку заявок</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
@@ -1879,8 +2295,57 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Формируем заявление (экран ожидания) */}
|
||||
{hasNewFlowDocs && allDocsProcessed && isFormingClaim && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
alt="Формируем заявление"
|
||||
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
|
||||
/>
|
||||
<Title level={4}>📝 Формируем заявление...</Title>
|
||||
<Paragraph type="secondary" style={{ maxWidth: 420, margin: '0 auto 24px' }}>
|
||||
Анализируем документы и собираем данные для вашего заявления.
|
||||
Это займёт до 1-2 минут.
|
||||
</Paragraph>
|
||||
<LoadingOutlined style={{ fontSize: 32, color: '#1890ff' }} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ❌ ОШИБКА: Показываем кнопку повторить */}
|
||||
{hasNewFlowDocs && allDocsProcessed && ragError && !isFormingClaim && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Result
|
||||
status="warning"
|
||||
title="Не удалось сформировать заявление"
|
||||
subTitle={ragError}
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="retry"
|
||||
onClick={() => {
|
||||
setRagError(null);
|
||||
handleAllDocsComplete();
|
||||
}}
|
||||
>
|
||||
🔄 Повторить
|
||||
</Button>,
|
||||
<Button
|
||||
key="skip"
|
||||
onClick={() => {
|
||||
setRagError(null);
|
||||
onNext();
|
||||
}}
|
||||
>
|
||||
Пропустить и продолжить
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Все документы загружены */}
|
||||
{hasNewFlowDocs && allDocsProcessed && (() => {
|
||||
{hasNewFlowDocs && allDocsProcessed && !isFormingClaim && !ragError && (() => {
|
||||
// Правильно считаем загруженные и пропущенные документы из documentsRequired
|
||||
const uploadedCount = documentsRequired.filter((doc: any) => {
|
||||
const docId = doc.id || doc.name;
|
||||
@@ -1893,15 +2358,23 @@ export default function StepWizardPlan({
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Title level={4}>✅ Все документы обработаны!</Title>
|
||||
<Paragraph type="secondary">
|
||||
Загружено: {uploadedCount} из {documentsRequired.length}, пропущено: {skippedCount}
|
||||
</Paragraph>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
<Title level={4}>✅ Все обязательные документы обработаны!</Title>
|
||||
<Paragraph type="secondary">
|
||||
Загружено: {uploadedCount} из {documentsRequired.length}, пропущено: {skippedCount}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* ✅ Дополнительные документы */}
|
||||
{renderCustomUploads()}
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Button type="primary" size="large" onClick={handleAllDocsComplete}>
|
||||
Продолжить →
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Функция генерации HTML формы подтверждения заявления
|
||||
// Основана на структуре из n8n Code node "Mini-app Подтверждение данных"
|
||||
|
||||
export function generateConfirmationFormHTML(data: any): string {
|
||||
export function generateConfirmationFormHTML(data: any, contact_data_confirmed: boolean = false): string {
|
||||
// Извлекаем SMS данные (до нормализации, так как структура может быть разной)
|
||||
const smsInputData = {
|
||||
prefix: data.sms_meta?.prefix || data.prefix || '',
|
||||
@@ -290,6 +290,7 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
telegram_id: telegramId,
|
||||
token: data.token || '',
|
||||
sms_meta: smsMetaData,
|
||||
contact_data_confirmed: contact_data_confirmed || false, // ✅ Флаг подтверждения данных контакта
|
||||
});
|
||||
caseJson = caseJson.replace(/</g, '\\u003c');
|
||||
|
||||
@@ -362,6 +363,68 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
border-color:#10b981;
|
||||
background-color:#f0fdf4;
|
||||
}
|
||||
/* ❌ Красная рамка для невалидных полей */
|
||||
.inline-field.invalid{
|
||||
border-color:#ef4444 !important;
|
||||
background-color:#fef2f2 !important;
|
||||
}
|
||||
.inline-field.invalid:focus{
|
||||
border-color:#ef4444 !important;
|
||||
box-shadow:0 0 0 2px rgba(239,68,68,0.1) !important;
|
||||
background-color:#fef2f2 !important;
|
||||
}
|
||||
/* ⚠️ Желтая рамка для незаполненных обязательных полей */
|
||||
.inline-field.required-empty{
|
||||
border-color:#f59e0b !important;
|
||||
background-color:#fffbeb !important;
|
||||
border-width:2px !important;
|
||||
}
|
||||
.inline-field.required-empty:focus{
|
||||
border-color:#f59e0b !important;
|
||||
box-shadow:0 0 0 3px rgba(245,158,11,0.2) !important;
|
||||
background-color:#fffbeb !important;
|
||||
}
|
||||
/* Звёздочка для обязательных полей */
|
||||
.required-marker{
|
||||
color:#ef4444;
|
||||
font-weight:bold;
|
||||
margin-left:2px;
|
||||
}
|
||||
/* Блок с предупреждением о незаполненных полях */
|
||||
.validation-warning{
|
||||
margin:16px 0;
|
||||
padding:12px 16px;
|
||||
background:#fffbeb;
|
||||
border:2px solid #f59e0b;
|
||||
border-radius:8px;
|
||||
font-size:14px;
|
||||
color:#92400e;
|
||||
}
|
||||
.validation-warning-title{
|
||||
font-weight:600;
|
||||
margin-bottom:8px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
}
|
||||
.validation-warning-list{
|
||||
margin:0;
|
||||
padding-left:20px;
|
||||
list-style:none;
|
||||
}
|
||||
.validation-warning-list li{
|
||||
margin:4px 0;
|
||||
padding-left:20px;
|
||||
position:relative;
|
||||
}
|
||||
.validation-warning-list li:before{
|
||||
content:'•';
|
||||
position:absolute;
|
||||
left:0;
|
||||
color:#f59e0b;
|
||||
font-weight:bold;
|
||||
font-size:18px;
|
||||
}
|
||||
.inline-field.large{
|
||||
min-width:200px;max-width:500px;
|
||||
}
|
||||
@@ -709,6 +772,26 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
var dataIndex = index !== undefined ? ' data-index="' + index + '"' : '';
|
||||
var extra = '';
|
||||
|
||||
// ✅ Проверяем, нужно ли блокировать поле (для подтверждённых данных applicant)
|
||||
var isLockedField = contact_data_confirmed && root === 'user' && (
|
||||
key === 'firstname' ||
|
||||
key === 'lastname' ||
|
||||
key === 'middle_name' ||
|
||||
key === 'secondname' || // Отчество (может быть в разных форматах)
|
||||
key === 'inn' ||
|
||||
key === 'birthday' ||
|
||||
key === 'birth_place' ||
|
||||
key === 'birthplace' ||
|
||||
key === 'address' ||
|
||||
key === 'mailingstreet' ||
|
||||
key === 'email'
|
||||
);
|
||||
|
||||
if (isLockedField) {
|
||||
// Блокируем поле - используем readonly
|
||||
return createReadonlyField(root, key, value);
|
||||
}
|
||||
|
||||
if (key === 'inn') {
|
||||
var isIndividual = (root === 'user');
|
||||
if (isIndividual) {
|
||||
@@ -718,14 +801,28 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Телефон: только цифры, максимум 11 символов
|
||||
if (key === 'mobile' || key === 'phone') {
|
||||
extra = ' inputmode="numeric" pattern="[0-9]{10,11}" maxlength="11" autocomplete="tel"';
|
||||
}
|
||||
|
||||
// ✅ Email: тип email для правильной валидации и клавиатуры
|
||||
if (key === 'email') {
|
||||
extra = ' type="email" inputmode="email" autocomplete="email"';
|
||||
}
|
||||
|
||||
// ✅ Добавляем name атрибут для правильной работы форм и автозаполнения
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + (index !== undefined ? '_' + index : '') + '"';
|
||||
var fieldHtml = '<input class="inline-field bind" data-root="' + esc(root) + '" data-key="' + esc(key) + '"' + dataIndex +
|
||||
' id="' + id + '" value="' + esc(value || '') + '" placeholder="' + esc(placeholder || '') + '"' + extra + ' />';
|
||||
' id="' + id + '" ' + nameAttr + ' value="' + esc(value || '') + '" placeholder="' + esc(placeholder || '') + '"' + extra + ' />';
|
||||
return fieldHtml;
|
||||
}
|
||||
|
||||
function createReadonlyField(root, key, value) {
|
||||
var id = 'field_' + root + '_' + key + '_readonly_' + Math.random().toString(36).slice(2);
|
||||
return '<input class="inline-field readonly-field" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" value="' + esc(value || '') + '" readonly />';
|
||||
// ✅ Добавляем name атрибут для правильной работы форм
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
|
||||
return '<input class="inline-field readonly-field" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" ' + nameAttr + ' value="' + esc(value || '') + '" readonly />';
|
||||
}
|
||||
|
||||
function createDateField(root, key, value) {
|
||||
@@ -740,14 +837,26 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
dateValue = parts[2] + '-' + parts[1] + '-' + parts[0];
|
||||
}
|
||||
}
|
||||
return '<input type="date" class="inline-field bind date-field" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" value="' + esc(dateValue) + '" />';
|
||||
|
||||
// ✅ Добавляем name атрибут для правильной работы форм
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
|
||||
|
||||
// ✅ Проверяем, нужно ли блокировать поле (для подтверждённых данных)
|
||||
var isLockedField = contact_data_confirmed && root === 'user' && key === 'birthday';
|
||||
if (isLockedField) {
|
||||
return '<input type="date" class="inline-field readonly-field date-field" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" ' + nameAttr + ' value="' + esc(dateValue) + '" readonly />';
|
||||
}
|
||||
|
||||
return '<input type="date" class="inline-field bind date-field" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" ' + nameAttr + ' value="' + esc(dateValue) + '" />';
|
||||
}
|
||||
|
||||
function createMoneyField(root, key, value) {
|
||||
var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2);
|
||||
// ✅ Добавляем name атрибут для правильной работы форм
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
|
||||
return '<div style="display: flex; align-items: center; gap: 8px;">' +
|
||||
'<input class="inline-field bind money-field" data-root="' + esc(root) + '" data-key="' + esc(key) + '"' +
|
||||
' id="' + id + '" value="' + esc(value || '') + '"' +
|
||||
' id="' + id + '" ' + nameAttr + ' value="' + esc(value || '') + '"' +
|
||||
' inputmode="decimal" pattern="[0-9]*[.,]?[0-9]*" autocomplete="off" />' +
|
||||
'<span style="color: #6b7280; font-size: 14px;">рублей</span>' +
|
||||
'</div>';
|
||||
@@ -755,16 +864,43 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
|
||||
function createTextarea(root, key, value) {
|
||||
var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2);
|
||||
return '<textarea class="inline-field bind full-width" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '">' + esc(value || '') + '</textarea>';
|
||||
// ✅ Добавляем name атрибут для правильной работы форм
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
|
||||
return '<textarea class="inline-field bind full-width" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" ' + nameAttr + '>' + esc(value || '') + '</textarea>';
|
||||
}
|
||||
|
||||
function createBankSelect(root, key, value) {
|
||||
var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2);
|
||||
var datalistId = 'bank-datalist-' + id;
|
||||
// ✅ Добавляем name атрибут для правильной работы форм
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
|
||||
// Создаём input с datalist для автоподстановки
|
||||
var inputHtml = '<input type="text" class="inline-field bind bank-select" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" ' + nameAttr + ' list="' + datalistId + '" placeholder="Начните вводить название банка (обязательно)" autocomplete="off" />';
|
||||
inputHtml += '<datalist id="' + datalistId + '" class="bank-datalist">';
|
||||
inputHtml += '<option value="">Загрузка списка банков...</option>';
|
||||
inputHtml += '</datalist>';
|
||||
// Скрытое поле для bank_id
|
||||
var hiddenId = id + '_id';
|
||||
var hiddenNameAttr = 'name="' + esc(root) + '_bank_id"';
|
||||
inputHtml += '<input type="hidden" class="bank-id-field" data-root="' + esc(root) + '" data-key="bank_id" id="' + hiddenId + '" ' + hiddenNameAttr + ' />';
|
||||
return inputHtml;
|
||||
}
|
||||
|
||||
function createCheckbox(root, key, checked, labelText, required) {
|
||||
var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2);
|
||||
// ✅ Генерируем безопасный id (только буквы, цифры, подчёркивание, дефис)
|
||||
var safeRoot = String(root || '').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
var safeKey = String(key || '').replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
var randomPart = Math.random().toString(36).slice(2);
|
||||
var id = 'field_' + safeRoot + '_' + safeKey + '_' + randomPart;
|
||||
var checkedAttr = checked ? ' checked' : '';
|
||||
var requiredClass = required ? ' required-checkbox' : '';
|
||||
// ✅ Добавляем name атрибут для правильной работы форм
|
||||
var nameAttr = 'name="' + esc(root) + '_' + esc(key) + '"';
|
||||
|
||||
var checkboxHtml = '<label class="checkbox-container' + requiredClass + '" for="' + id + '">';
|
||||
checkboxHtml += '<input type="checkbox" class="checkbox-field bind" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '"' + checkedAttr + ' />';
|
||||
// ✅ Label правильно связан с input через for/id (экранируем id для безопасности)
|
||||
var escapedId = esc(id);
|
||||
var checkboxHtml = '<label class="checkbox-container' + requiredClass + '" for="' + escapedId + '">';
|
||||
checkboxHtml += '<input type="checkbox" class="checkbox-field bind" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + escapedId + '" ' + nameAttr + checkedAttr + ' />';
|
||||
checkboxHtml += '<span class="checkmark"></span>';
|
||||
checkboxHtml += '<span class="checkbox-label">' + labelText + '</span>';
|
||||
checkboxHtml += '</label>';
|
||||
@@ -779,6 +915,9 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
console.log('injected.case:', injected.case);
|
||||
console.log('injected.propertyName:', injected.propertyName);
|
||||
|
||||
// ✅ Извлекаем флаг подтверждения данных из injected
|
||||
var contact_data_confirmed = injected.contact_data_confirmed || false;
|
||||
|
||||
// Достаём объект кейса из «типичных» мест
|
||||
var dataCandidate = null;
|
||||
|
||||
@@ -843,34 +982,45 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
|
||||
var html = '';
|
||||
|
||||
// ✅ Предупреждение о заблокированных данных (если данные подтверждены)
|
||||
if (contact_data_confirmed) {
|
||||
html += '<div style="margin-bottom: 16px; padding: 12px; background: #fff7e6; border: 1px solid #ffd591; border-radius: 4px;">';
|
||||
html += '<p style="margin: 0; color: #ad6800; font-size: 14px;">';
|
||||
html += '<strong>⚠️ Данные подтверждены</strong><br>';
|
||||
html += 'Ваши персональные данные (ФИО, ИНН, дата рождения, адрес) заблокированы для редактирования. ';
|
||||
html += 'Для изменения данных обратитесь в поддержку.';
|
||||
html += '</p>';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div style="text-align:center;margin-bottom:32px">';
|
||||
html += '<h2 style="font-size:20px;margin:0 0 16px;color:#1f2937">В МОО «Клиентправ»</h2>';
|
||||
html += '<p style="margin:0;color:#6b7280">help@clientright.ru</p>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<p><strong>Заявитель:</strong> ';
|
||||
html += createField('user', 'lastname', u.lastname, 'Фамилия (обязательно)');
|
||||
html += createField('user', 'lastname', u.lastname, 'Фамилия') + '<span class="required-marker">*</span>';
|
||||
html += ' ';
|
||||
html += createField('user', 'firstname', u.firstname, 'Имя (обязательно)');
|
||||
html += createField('user', 'firstname', u.firstname, 'Имя') + '<span class="required-marker">*</span>';
|
||||
html += ' ';
|
||||
html += createField('user', 'secondname', u.secondname, 'Отчество');
|
||||
html += '</p>';
|
||||
|
||||
html += '<p><strong>Дата рождения:</strong> ';
|
||||
html += createDateField('user', 'birthday', u.birthday);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>Место рождения:</strong> ';
|
||||
html += createField('user', 'birthplace', u.birthplace, 'Место рождения (обязательно)');
|
||||
html += '</p>';
|
||||
html += createField('user', 'birthplace', u.birthplace, 'Место рождения');
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>ИНН:</strong> ';
|
||||
html += createField('user', 'inn', u.inn, '12-значный ИНН (обязательно)');
|
||||
html += '</p>';
|
||||
html += createField('user', 'inn', u.inn, '12-значный ИНН');
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>Адрес:</strong> ';
|
||||
html += createField('user', 'mailingstreet', u.mailingstreet, 'Адрес регистрации как в паспорте (обязательно)');
|
||||
html += '</p>';
|
||||
html += createField('user', 'mailingstreet', u.mailingstreet, 'Адрес регистрации как в паспорте');
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>Телефон:</strong> ';
|
||||
html += createReadonlyField('user', 'mobile', u.mobile);
|
||||
@@ -885,6 +1035,9 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
// Возмещение
|
||||
html += '<h3 style="font-size:16px;margin:0 0 16px;color:#1f2937">Возмещение:</h3>';
|
||||
html += '<p>Выплата возмещения возможна по системе быстрых платежей (СБП) по номеру телефона заявителя: <strong id="phone-display">' + esc(u.mobile || '') + '</strong></p>';
|
||||
html += '<p><strong>Банк для получения выплаты:</strong> ';
|
||||
html += createBankSelect('user', 'bank_id', u.bank_id || '');
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<div class="section-break"></div>';
|
||||
|
||||
@@ -904,12 +1057,12 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
// Дата события / заключения договора
|
||||
html += '<p><strong>Дата события / заключения договора:</strong> ';
|
||||
html += createDateField('project', 'agrdate', p.agrdate);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
// Сумма оплаты / стоимость
|
||||
html += '<p><strong>Сумма оплаты / стоимость:</strong> ';
|
||||
html += createMoneyField('project', 'agrprice', p.agrprice);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
// Период
|
||||
html += '<p><strong>Период:</strong> ';
|
||||
@@ -934,23 +1087,19 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
|
||||
html += '<p><strong>Наименование:</strong> ';
|
||||
html += createField('offender', 'accountname', offender.accountname, 'Название организации', i);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>ИНН:</strong> ';
|
||||
html += createField('offender', 'inn', offender.inn, 'ИНН организации (10 или 12 цифр)', i);
|
||||
html += '</p>';
|
||||
|
||||
html += '<p><strong>ОГРН:</strong> ';
|
||||
html += createField('offender', 'ogrn', offender.ogrn, 'ОГРН', i);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>Адрес:</strong> ';
|
||||
html += createField('offender', 'address', offender.address, 'Адрес', i);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>E-mail:</strong> ';
|
||||
html += createField('offender', 'email', offender.email, 'email@example.com', i);
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>Телефон:</strong> ';
|
||||
html += createField('offender', 'phone', offender.phone, '+7 (___) ___-__-__', i);
|
||||
@@ -968,15 +1117,18 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
// Причина обращения (редактируемая)
|
||||
html += '<p><strong>Причина обращения:</strong> ';
|
||||
html += createField('project', 'reason', p.reason, 'Можете уточнить или изменить причину обращения');
|
||||
html += '</p>';
|
||||
html += '<span class="required-marker">*</span></p>';
|
||||
|
||||
html += '<p><strong>Описание проблемы:</strong></p>';
|
||||
html += '<p><strong>Описание проблемы:</strong> <span class="required-marker">*</span></p>';
|
||||
html += createTextarea('project', 'description', p.description);
|
||||
|
||||
html += '<p>На основании вышеизложенного и руководствуясь ст. 45 Закона «О защите прав потребителей», ст. 3, ч. 1 ст. 46 ГПК РФ, прошу вас защитить мои потребительские права, обратиться в суд с заявлением о защите моих потребительских прав и/или с коллективным иском о защите группы потребителей, и представлять мои интересы во всех судебных органах РФ, а также обращаться с заявлениями во все госорганы, подавать претензии, письма и жалобы.</p>';
|
||||
|
||||
html += '<div class="section-break"></div>';
|
||||
|
||||
// Блок с предупреждением о незаполненных полях (будет обновляться динамически)
|
||||
html += '<div id="validation-warning-block" style="display:none;"></div>';
|
||||
|
||||
// Согласие на обработку персональных данных
|
||||
html += '<div style="margin:24px 0;">';
|
||||
html += createCheckbox('meta', 'privacyConsent', state.meta && state.meta.privacyConsent,
|
||||
@@ -1108,36 +1260,62 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
return parseFloat(s) > 0;
|
||||
}
|
||||
|
||||
// Список обязательных полей
|
||||
var requiredFieldsList = [
|
||||
{ root: 'user', key: 'lastname', name: 'Фамилия' },
|
||||
{ root: 'user', key: 'firstname', name: 'Имя' },
|
||||
{ root: 'user', key: 'birthday', name: 'Дата рождения' },
|
||||
{ root: 'user', key: 'birthplace', name: 'Место рождения' },
|
||||
{ root: 'user', key: 'mailingstreet', name: 'Адрес' },
|
||||
{ root: 'user', key: 'inn', name: 'ИНН' },
|
||||
{ root: 'project', key: 'agrdate', name: 'Дата договора' },
|
||||
{ root: 'project', key: 'agrprice', name: 'Сумма' },
|
||||
{ root: 'project', key: 'reason', name: 'Причина обращения' },
|
||||
{ root: 'project', key: 'description', name: 'Описание проблемы' },
|
||||
{ root: 'offender', key: 'accountname', name: 'Название организации' },
|
||||
{ root: 'offender', key: 'inn', name: 'ИНН организации' },
|
||||
{ root: 'offender', key: 'address', name: 'Адрес организации' },
|
||||
{ root: 'offender', key: 'email', name: 'E-mail организации' },
|
||||
{ root: 'user', key: 'bank_id', name: 'Банк для получения выплаты' }
|
||||
];
|
||||
|
||||
// Функция проверки, является ли поле обязательным
|
||||
function isRequiredField(root, key) {
|
||||
return requiredFieldsList.some(function(f) {
|
||||
return f.root === root && f.key === key;
|
||||
});
|
||||
}
|
||||
|
||||
// Функция получения значения поля
|
||||
function getFieldValue(root, key, index) {
|
||||
if (root === 'user') {
|
||||
return state.user[key] || '';
|
||||
} else if (root === 'project') {
|
||||
return state.project[key] || '';
|
||||
} else if (root === 'offender') {
|
||||
var offender = state.offenders[index || 0];
|
||||
return (offender && offender[key]) || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Функция проверки, заполнено ли поле
|
||||
function isFieldFilled(root, key, index) {
|
||||
var value = getFieldValue(root, key, index);
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === 'string') {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
return !!value;
|
||||
}
|
||||
|
||||
// Функция проверки всех обязательных полей
|
||||
function validateAllFields() {
|
||||
var requiredFields = [
|
||||
{ root: 'user', key: 'lastname', name: 'Фамилия' },
|
||||
{ root: 'user', key: 'firstname', name: 'Имя' },
|
||||
{ root: 'user', key: 'birthday', name: 'Дата рождения' },
|
||||
{ root: 'user', key: 'birthplace', name: 'Место рождения' },
|
||||
{ root: 'user', key: 'mailingstreet', name: 'Адрес' },
|
||||
{ root: 'user', key: 'inn', name: 'ИНН' },
|
||||
{ root: 'project', key: 'agrdate', name: 'Дата договора' },
|
||||
{ root: 'project', key: 'agrprice', name: 'Сумма' },
|
||||
{ root: 'project', key: 'reason', name: 'Причина обращения' },
|
||||
{ root: 'project', key: 'description', name: 'Описание проблемы' },
|
||||
{ root: 'offender', key: 'accountname', name: 'Название организации' },
|
||||
{ root: 'offender', key: 'address', name: 'Адрес организации' }
|
||||
];
|
||||
|
||||
var errors = [];
|
||||
|
||||
for (var i = 0; i < requiredFields.length; i++) {
|
||||
var field = requiredFields[i];
|
||||
var value = '';
|
||||
if (field.root === 'user') {
|
||||
value = state.user[field.key] || '';
|
||||
} else if (field.root === 'project') {
|
||||
value = state.project[field.key] || '';
|
||||
} else if (field.root === 'offender') {
|
||||
value = (state.offenders[0] && state.offenders[0][field.key]) || '';
|
||||
}
|
||||
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||
for (var i = 0; i < requiredFieldsList.length; i++) {
|
||||
var field = requiredFieldsList[i];
|
||||
if (!isFieldFilled(field.root, field.key, field.root === 'offender' ? 0 : undefined)) {
|
||||
errors.push(field.name);
|
||||
}
|
||||
}
|
||||
@@ -1145,6 +1323,34 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Функция обновления блока с предупреждением о незаполненных полях
|
||||
function updateValidationWarning() {
|
||||
var warningBlock = document.getElementById('validation-warning-block');
|
||||
if (!warningBlock) return;
|
||||
|
||||
var validationErrors = validateAllFields();
|
||||
|
||||
if (validationErrors.length === 0) {
|
||||
warningBlock.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Формируем HTML для предупреждения
|
||||
var warningHtml = '<div class="validation-warning">';
|
||||
warningHtml += '<div class="validation-warning-title">';
|
||||
warningHtml += '⚠️ Пожалуйста, заполните все обязательные поля (' + validationErrors.length + '):';
|
||||
warningHtml += '</div>';
|
||||
warningHtml += '<ul class="validation-warning-list">';
|
||||
for (var i = 0; i < validationErrors.length; i++) {
|
||||
warningHtml += '<li>' + esc(validationErrors[i]) + '</li>';
|
||||
}
|
||||
warningHtml += '</ul>';
|
||||
warningHtml += '</div>';
|
||||
|
||||
warningBlock.innerHTML = warningHtml;
|
||||
warningBlock.style.display = 'block';
|
||||
}
|
||||
|
||||
// Функция обновления состояния кнопки отправки
|
||||
function updateSubmitButton() {
|
||||
var confirmBtn = document.getElementById('confirmBtn');
|
||||
@@ -1153,6 +1359,15 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
var isConsentGiven = state.meta && state.meta.privacyConsent === true;
|
||||
var validationErrors = validateAllFields();
|
||||
|
||||
// Обновляем блок с предупреждением
|
||||
updateValidationWarning();
|
||||
|
||||
// Обновляем стили всех полей
|
||||
var fields = document.querySelectorAll('.bind');
|
||||
Array.prototype.forEach.call(fields, function(field) {
|
||||
updateFieldStyle(field);
|
||||
});
|
||||
|
||||
if (!isConsentGiven) {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.style.opacity = '0.6';
|
||||
@@ -1167,20 +1382,60 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.style.opacity = '0.6';
|
||||
confirmBtn.style.cursor = 'not-allowed';
|
||||
confirmBtn.textContent = '❌ Заполните все поля (' + validationErrors.length + ')';
|
||||
confirmBtn.textContent = '❌ Заполните все обязательные поля (' + validationErrors.length + ')';
|
||||
confirmBtn.title = 'Не заполнены: ' + validationErrors.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Функция для обновления стиля заполненных полей
|
||||
// ✅ Функция для обновления стиля заполненных полей с валидацией
|
||||
function updateFieldStyle(field) {
|
||||
var value = field.type === 'checkbox' ? field.checked : (field.value || '').trim();
|
||||
var hasValue = field.type === 'checkbox' ? value : value.length > 0;
|
||||
var key = field.getAttribute('data-key');
|
||||
var root = field.getAttribute('data-root');
|
||||
var index = field.getAttribute('data-index');
|
||||
var fieldIndex = index !== null ? parseInt(index, 10) : undefined;
|
||||
|
||||
// Убираем все классы сначала
|
||||
field.classList.remove('filled');
|
||||
field.classList.remove('invalid');
|
||||
field.classList.remove('required-empty');
|
||||
|
||||
// Проверяем, является ли поле обязательным
|
||||
var isRequired = isRequiredField(root, key);
|
||||
|
||||
if (hasValue) {
|
||||
field.classList.add('filled');
|
||||
// Проверяем валидность для телефона и email
|
||||
var isValid = true;
|
||||
|
||||
if (key === 'mobile' || key === 'phone') {
|
||||
// Валидация телефона: только цифры, 10-11 символов
|
||||
var cleanPhone = value.replace(/\D/g, '');
|
||||
isValid = cleanPhone.length >= 10 && cleanPhone.length <= 11;
|
||||
} else if (key === 'email') {
|
||||
// Валидация email: должен содержать @ и .
|
||||
isValid = value.includes('@') && value.includes('.') && value.length > 5;
|
||||
} else if (key === 'inn') {
|
||||
// Валидация ИНН: 10 или 12 цифр
|
||||
var cleanInn = value.replace(/\D/g, '');
|
||||
if (root === 'user') {
|
||||
isValid = cleanInn.length === 12;
|
||||
} else {
|
||||
isValid = cleanInn.length === 10 || cleanInn.length === 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
field.classList.add('filled');
|
||||
} else {
|
||||
field.classList.add('invalid');
|
||||
}
|
||||
} else {
|
||||
field.classList.remove('filled');
|
||||
// Поле не заполнено
|
||||
if (isRequired) {
|
||||
// Обязательное поле не заполнено - подсвечиваем жёлтым
|
||||
field.classList.add('required-empty');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,14 +1445,99 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
var fields = document.querySelectorAll('.bind');
|
||||
console.log('Found fields:', fields.length);
|
||||
|
||||
// ✅ Устанавливаем начальный стиль для всех полей
|
||||
// Обработка скрытых полей bank_id
|
||||
var bankIdFields = document.querySelectorAll('.bank-id-field');
|
||||
Array.prototype.forEach.call(bankIdFields, function(field) {
|
||||
field.addEventListener('change', function() {
|
||||
var root = this.getAttribute('data-root');
|
||||
var value = this.value;
|
||||
if (root === 'user') {
|
||||
state.user = state.user || {};
|
||||
state.user.bank_id = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ✅ Устанавливаем начальный стиль для всех полей и форматируем телефоны
|
||||
Array.prototype.forEach.call(fields, function(field) {
|
||||
var key = field.getAttribute('data-key');
|
||||
// Автоформатирование телефона при загрузке: убираем + и нецифровые символы
|
||||
if ((key === 'mobile' || key === 'phone') && field.value) {
|
||||
field.value = field.value.replace(/\D/g, '');
|
||||
}
|
||||
updateFieldStyle(field);
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(fields, function(field) {
|
||||
var fieldKey = field.getAttribute('data-key');
|
||||
|
||||
// ✅ Блокируем ввод нецифровых символов для телефона
|
||||
if (fieldKey === 'mobile' || fieldKey === 'phone') {
|
||||
field.addEventListener('keypress', function(e) {
|
||||
// Разрешаем: цифры, Backspace, Delete, Tab, стрелки
|
||||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Блокируем вставку нецифровых символов
|
||||
field.addEventListener('paste', function(e) {
|
||||
e.preventDefault();
|
||||
var pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
var cleanText = pastedText.replace(/\D/g, '').slice(0, 11);
|
||||
document.execCommand('insertText', false, cleanText);
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Блокируем ввод кириллицы для email (только латиница, цифры и @._-)
|
||||
if (fieldKey === 'email') {
|
||||
field.addEventListener('keypress', function(e) {
|
||||
// Разрешаем: латиница, цифры, @, ., _, -, служебные клавиши
|
||||
if (!/[a-zA-Z0-9@._\-]/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Блокируем вставку кириллицы
|
||||
field.addEventListener('paste', function(e) {
|
||||
e.preventDefault();
|
||||
var pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
var cleanText = pastedText.replace(/[^a-zA-Z0-9@._\-]/g, ''); // Только латиница и допустимые символы
|
||||
document.execCommand('insertText', false, cleanText);
|
||||
});
|
||||
|
||||
// Автоочистка при вводе
|
||||
field.addEventListener('input', function() {
|
||||
var cursorPos = this.selectionStart;
|
||||
var oldLen = this.value.length;
|
||||
this.value = this.value.replace(/[^a-zA-Z0-9@._\-]/g, '');
|
||||
var newLen = this.value.length;
|
||||
// ✅ Для полей типа email setSelectionRange может не работать, используем try-catch
|
||||
try {
|
||||
if (this.type !== 'email') {
|
||||
this.setSelectionRange(Math.min(cursorPos, newLen), Math.min(cursorPos, newLen));
|
||||
}
|
||||
} catch (e) {
|
||||
// Игнорируем ошибку для полей типа email
|
||||
console.debug('setSelectionRange not supported for email field');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Обработка ввода
|
||||
field.addEventListener('input', function() {
|
||||
var key = this.getAttribute('data-key');
|
||||
|
||||
// ✅ Автоформатирование телефона: убираем + и нецифровые символы (на всякий случай)
|
||||
if (key === 'mobile' || key === 'phone') {
|
||||
var cursorPos = this.selectionStart;
|
||||
var oldLen = this.value.length;
|
||||
this.value = this.value.replace(/\D/g, '').slice(0, 11); // Оставляем только цифры, макс 11
|
||||
var newLen = this.value.length;
|
||||
// Корректируем позицию курсора
|
||||
this.setSelectionRange(Math.min(cursorPos, newLen), Math.min(cursorPos, newLen));
|
||||
}
|
||||
|
||||
// ✅ Обновляем стиль при изменении
|
||||
updateFieldStyle(this);
|
||||
// Автозамена запятой на точку для денежных полей
|
||||
@@ -1206,7 +1546,6 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
}
|
||||
|
||||
var root = this.getAttribute('data-root');
|
||||
var key = this.getAttribute('data-key');
|
||||
var value = this.type === 'checkbox' ? this.checked : this.value;
|
||||
|
||||
// Для полей дат конвертируем YYYY-MM-DD в DD.MM.YYYY для сохранения
|
||||
@@ -1222,6 +1561,12 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
// Обновляем состояние
|
||||
if (root === 'user') {
|
||||
state.user = state.user || {};
|
||||
// Для bank_id не сохраняем название банка, только ID из скрытого поля
|
||||
if (key === 'bank_id' && this.classList.contains('bank-select')) {
|
||||
// Это текстовое поле для названия банка - не сохраняем в state
|
||||
// bank_id будет сохранён из скрытого поля
|
||||
return;
|
||||
}
|
||||
state.user[key] = value;
|
||||
|
||||
// Обновляем телефон в СБП
|
||||
@@ -1322,6 +1667,142 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузка списка банков СБП
|
||||
function loadBanks() {
|
||||
var bankInputs = document.querySelectorAll('.bank-select');
|
||||
if (bankInputs.length === 0) {
|
||||
console.log('Bank select fields not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Loading NSPK banks...');
|
||||
|
||||
fetch('http://212.193.27.93/api/payouts/dictionaries/nspk-banks')
|
||||
.then(function(response) {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.json();
|
||||
})
|
||||
.then(function(banks) {
|
||||
console.log('Loaded ' + banks.length + ' banks');
|
||||
|
||||
// Сортируем по названию
|
||||
banks.sort(function(a, b) {
|
||||
return a.bankname.localeCompare(b.bankname, 'ru');
|
||||
});
|
||||
|
||||
// Сохраняем список банков глобально для поиска
|
||||
window.__banksList = banks;
|
||||
|
||||
// Заполняем все datalist элементы
|
||||
Array.prototype.forEach.call(bankInputs, function(input) {
|
||||
var datalistId = input.getAttribute('list');
|
||||
var datalist = document.getElementById(datalistId);
|
||||
var hiddenId = input.id + '_id';
|
||||
var hiddenField = document.getElementById(hiddenId);
|
||||
var currentBankId = state.user?.bank_id || '';
|
||||
var currentBankName = '';
|
||||
|
||||
if (!datalist) {
|
||||
console.error('Datalist not found for input:', input.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Очищаем datalist
|
||||
datalist.innerHTML = '';
|
||||
|
||||
// Заполняем datalist опциями
|
||||
banks.forEach(function(bank) {
|
||||
var option = document.createElement('option');
|
||||
option.value = bank.bankname;
|
||||
option.setAttribute('data-bank-id', bank.bankid);
|
||||
datalist.appendChild(option);
|
||||
|
||||
// Если это текущий банк, устанавливаем значение
|
||||
if (bank.bankid === currentBankId) {
|
||||
currentBankName = bank.bankname;
|
||||
}
|
||||
});
|
||||
|
||||
// Устанавливаем текущее значение если есть
|
||||
if (currentBankName) {
|
||||
input.value = currentBankName;
|
||||
if (hiddenField) {
|
||||
hiddenField.value = currentBankId;
|
||||
}
|
||||
// ✅ Сохраняем название банка в state
|
||||
state.user = state.user || {};
|
||||
state.user.bank_name = currentBankName;
|
||||
input.classList.add('filled');
|
||||
updateFieldStyle(input);
|
||||
}
|
||||
|
||||
// Обработчик изменения для поиска банка по названию
|
||||
input.addEventListener('input', function() {
|
||||
var inputValue = this.value.trim();
|
||||
var foundBank = null;
|
||||
|
||||
// Ищем точное совпадение
|
||||
if (inputValue) {
|
||||
foundBank = banks.find(function(b) {
|
||||
return b.bankname.toLowerCase() === inputValue.toLowerCase();
|
||||
});
|
||||
}
|
||||
|
||||
if (foundBank) {
|
||||
// Найден банк - сохраняем ID и название
|
||||
if (hiddenField) {
|
||||
hiddenField.value = foundBank.bankid;
|
||||
}
|
||||
state.user = state.user || {};
|
||||
state.user.bank_id = foundBank.bankid;
|
||||
state.user.bank_name = foundBank.bankname; // ✅ Сохраняем название банка
|
||||
this.classList.add('filled');
|
||||
} else {
|
||||
// Банк не найден - очищаем ID и название
|
||||
if (hiddenField) {
|
||||
hiddenField.value = '';
|
||||
}
|
||||
state.user = state.user || {};
|
||||
state.user.bank_id = '';
|
||||
state.user.bank_name = ''; // ✅ Очищаем название банка
|
||||
this.classList.remove('filled');
|
||||
}
|
||||
updateFieldStyle(this);
|
||||
updateSubmitButton();
|
||||
});
|
||||
|
||||
// Обработчик выбора из списка
|
||||
input.addEventListener('change', function() {
|
||||
var inputValue = this.value.trim();
|
||||
var foundBank = banks.find(function(b) {
|
||||
return b.bankname.toLowerCase() === inputValue.toLowerCase();
|
||||
});
|
||||
|
||||
if (foundBank) {
|
||||
if (hiddenField) {
|
||||
hiddenField.value = foundBank.bankid;
|
||||
}
|
||||
state.user = state.user || {};
|
||||
state.user.bank_id = foundBank.bankid;
|
||||
state.user.bank_name = foundBank.bankname; // ✅ Сохраняем название банка
|
||||
this.classList.add('filled');
|
||||
updateFieldStyle(this);
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.error('Error loading banks:', error);
|
||||
Array.prototype.forEach.call(bankInputs, function(input) {
|
||||
var datalistId = input.getAttribute('list');
|
||||
var datalist = document.getElementById(datalistId);
|
||||
if (datalist) {
|
||||
datalist.innerHTML = '<option value="">Ошибка загрузки банков. Обновите страницу.</option>';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
try {
|
||||
console.log('=== НАЧАЛО ИНИЦИАЛИЗАЦИИ ===');
|
||||
@@ -1349,6 +1830,12 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
renderStatement();
|
||||
console.log('renderStatement completed');
|
||||
|
||||
// Загружаем список банков СБП
|
||||
console.log('Loading banks...');
|
||||
setTimeout(function() {
|
||||
loadBanks();
|
||||
}, 100);
|
||||
|
||||
// Валидируем уже заполненные поля
|
||||
setTimeout(function(){
|
||||
console.log('Starting field validation...');
|
||||
@@ -1380,6 +1867,17 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
return;
|
||||
}
|
||||
|
||||
// Собираем bank_id из скрытых полей перед отправкой
|
||||
var bankIdFields = document.querySelectorAll('.bank-id-field');
|
||||
Array.prototype.forEach.call(bankIdFields, function(field) {
|
||||
var root = field.getAttribute('data-root');
|
||||
var bankId = field.value;
|
||||
if (root === 'user' && bankId) {
|
||||
state.user = state.user || {};
|
||||
state.user.bank_id = bankId;
|
||||
}
|
||||
});
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'claim_confirmed',
|
||||
data: {
|
||||
|
||||
@@ -76,7 +76,8 @@ interface FormData {
|
||||
fullName?: string;
|
||||
email?: string;
|
||||
paymentMethod?: string;
|
||||
bankName?: string;
|
||||
bankId?: string; // ID банка из NSPK API
|
||||
bankName?: string; // Название банка для отображения
|
||||
cardNumber?: string;
|
||||
accountNumber?: string;
|
||||
}
|
||||
@@ -107,7 +108,7 @@ export default function ClaimForm() {
|
||||
const [hasDrafts, setHasDrafts] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
console.log('🔥 ClaimForm v3.8 - 2025-11-20 15:10 - Fix session_id priority in loadDraft');
|
||||
}, []);
|
||||
|
||||
@@ -559,6 +560,19 @@ export default function ClaimForm() {
|
||||
const claim = data.claim;
|
||||
const payload = claim.payload || {};
|
||||
|
||||
// ✅ Сохраняем флаги подтверждения данных контакта
|
||||
const contact_data_confirmed = data.contact_data_confirmed || false;
|
||||
const contact_data_can_edit = data.contact_data_can_edit !== false; // По умолчанию true
|
||||
const contact_data_confirmed_at = data.contact_data_confirmed_at || null;
|
||||
const contact_data_from_crm = data.contact_data_from_crm || null;
|
||||
|
||||
console.log('🔒 Статус данных контакта:', {
|
||||
contact_data_confirmed,
|
||||
contact_data_can_edit,
|
||||
contact_data_confirmed_at,
|
||||
has_crm_data: !!contact_data_from_crm
|
||||
});
|
||||
|
||||
// ✅ Для telegram черновиков данные могут быть в payload.body
|
||||
const body = payload.body || {};
|
||||
const isTelegramFormat = !!payload.body;
|
||||
@@ -613,8 +627,13 @@ export default function ClaimForm() {
|
||||
const hasDocuments = Array.isArray(documentsMeta) && documentsMeta.length > 0;
|
||||
const isDraft = claim.status_code === 'draft';
|
||||
|
||||
// ✅ НОВОЕ: Проверяем наличие form_draft (собранные данные из RAG)
|
||||
const formDraft = payload.form_draft;
|
||||
const hasFormDraft = !!(formDraft && formDraft.user && formDraft.offenders);
|
||||
const isDraftDocsComplete = claim.status_code === 'draft_docs_complete';
|
||||
|
||||
const allStepsFilled = hasDescription && hasWizardPlan && hasAnswers && hasDocuments;
|
||||
const isReadyForConfirmation = allStepsFilled && isDraft;
|
||||
const isReadyForConfirmation = (allStepsFilled && isDraft) || (hasFormDraft && isDraftDocsComplete);
|
||||
|
||||
console.log('🔍 Проверка полноты черновика:', {
|
||||
hasDescription,
|
||||
@@ -622,6 +641,8 @@ export default function ClaimForm() {
|
||||
hasAnswers,
|
||||
hasDocuments,
|
||||
isDraft,
|
||||
hasFormDraft,
|
||||
isDraftDocsComplete,
|
||||
allStepsFilled,
|
||||
isReadyForConfirmation,
|
||||
problemDescriptionFound: !!problemDescription,
|
||||
@@ -707,24 +728,124 @@ export default function ClaimForm() {
|
||||
// ✅ Если все шаги заполнены и статус = draft → переходим к форме подтверждения
|
||||
if (isReadyForConfirmation) {
|
||||
console.log('✅ Все шаги заполнены, преобразуем данные для формы подтверждения');
|
||||
console.log('✅ hasFormDraft:', hasFormDraft, 'isDraftDocsComplete:', isDraftDocsComplete);
|
||||
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
// Преобразуем данные из БД в формат propertyName для формы подтверждения
|
||||
const claimPlanData = transformDraftToClaimPlanFormat({
|
||||
claim,
|
||||
payload,
|
||||
body,
|
||||
isTelegramFormat,
|
||||
finalClaimId,
|
||||
actualSessionId,
|
||||
currentFormData: formData,
|
||||
});
|
||||
let claimPlanData;
|
||||
|
||||
// ✅ НОВОЕ: Если есть form_draft — используем его!
|
||||
if (hasFormDraft && formDraft) {
|
||||
console.log('✅ Используем form_draft из БД:', formDraft);
|
||||
console.log('✅ project.description:', formDraft.project?.description);
|
||||
console.log('✅ offenders:', formDraft.offenders);
|
||||
console.log('✅ documentsMeta:', documentsMeta);
|
||||
console.log('✅ documentsMeta[0]?.field_label:', documentsMeta[0]?.field_label);
|
||||
|
||||
const user = formDraft.user || {};
|
||||
const project = formDraft.project || {};
|
||||
|
||||
// Преобразуем form_draft в формат propertyName (с правильными именами полей!)
|
||||
claimPlanData = {
|
||||
propertyName: {
|
||||
applicant: {
|
||||
// Маппинг полей user → applicant
|
||||
first_name: user.firstname || '',
|
||||
middle_name: user.secondname || '',
|
||||
last_name: user.lastname || '',
|
||||
phone: user.mobile || '',
|
||||
email: user.email || '',
|
||||
birth_date: user.birthday || '',
|
||||
birth_place: user.birthplace || '',
|
||||
address: user.mailingstreet || '',
|
||||
inn: user.inn || '',
|
||||
},
|
||||
case: {
|
||||
category: project.category || '',
|
||||
direction: project.direction || '',
|
||||
},
|
||||
contract_or_service: {
|
||||
subject: project.subject || '',
|
||||
amount_paid: project.agrprice || '',
|
||||
agreement_date: project.agrdate || '',
|
||||
period_start: project.startdate || '',
|
||||
period_end: project.finishdate || '',
|
||||
country: project.country || '',
|
||||
hotel: project.hotel || '',
|
||||
},
|
||||
offenders: (formDraft.offenders || []).map((o: any) => ({
|
||||
name: o.accountname || '', // ✅ Форма ожидает 'name', а не 'accountname'
|
||||
accountname: o.accountname || '', // Дублируем для совместимости
|
||||
address: o.address || '',
|
||||
email: o.email || '',
|
||||
website: o.website || '',
|
||||
phone: o.phone || '',
|
||||
inn: o.inn || '',
|
||||
ogrn: o.ogrn || '',
|
||||
role: o.role || '',
|
||||
})),
|
||||
claim: {
|
||||
description: project.description || problemDescription || '', // ✅ Описание проблемы
|
||||
reason: project.category || '', // ✅ Причина обращения
|
||||
},
|
||||
meta: {
|
||||
claim_id: finalClaimId,
|
||||
unified_id: formData.unified_id || '',
|
||||
session_token: actualSessionId,
|
||||
},
|
||||
// ✅ Используем field_label (человекочитаемые названия) вместо имён файлов
|
||||
attachments_names: documentsMeta.map((d: any) => d.field_label || d.original_file_name || d.file_name || 'Документ'),
|
||||
},
|
||||
session_token: actualSessionId,
|
||||
claim_id: finalClaimId,
|
||||
prefix: 'clpr_',
|
||||
};
|
||||
|
||||
console.log('✅ claimPlanData сформирован:', claimPlanData);
|
||||
console.log('✅ claimPlanData.propertyName.claim.description:', claimPlanData.propertyName.claim.description);
|
||||
console.log('✅ claimPlanData.propertyName.offenders:', claimPlanData.propertyName.offenders);
|
||||
} else {
|
||||
// Старый способ: преобразуем данные из БД
|
||||
claimPlanData = transformDraftToClaimPlanFormat({
|
||||
claim,
|
||||
payload,
|
||||
body,
|
||||
isTelegramFormat,
|
||||
finalClaimId,
|
||||
actualSessionId,
|
||||
currentFormData: formData,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('✅ claimPlanData для формы подтверждения:', claimPlanData);
|
||||
|
||||
// ✅ Если данные подтверждены и есть данные из CRM - используем их
|
||||
if (contact_data_confirmed && contact_data_from_crm) {
|
||||
// Обновляем applicant данные из CRM
|
||||
if (claimPlanData?.propertyName?.applicant) {
|
||||
claimPlanData.propertyName.applicant = {
|
||||
...claimPlanData.propertyName.applicant,
|
||||
first_name: contact_data_from_crm.firstname || claimPlanData.propertyName.applicant.first_name,
|
||||
last_name: contact_data_from_crm.lastname || claimPlanData.propertyName.applicant.last_name,
|
||||
middle_name: contact_data_from_crm.cf_1157 || claimPlanData.propertyName.applicant.middle_name,
|
||||
inn: contact_data_from_crm.cf_1257 || claimPlanData.propertyName.applicant.inn,
|
||||
birth_date: contact_data_from_crm.birthday || claimPlanData.propertyName.applicant.birth_date,
|
||||
birth_place: contact_data_from_crm.cf_1263 || claimPlanData.propertyName.applicant.birth_place,
|
||||
address: contact_data_from_crm.mailingstreet || claimPlanData.propertyName.applicant.address,
|
||||
email: contact_data_from_crm.email || claimPlanData.propertyName.applicant.email,
|
||||
phone: contact_data_from_crm.mobile || claimPlanData.propertyName.applicant.phone,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем данные заявления в formData
|
||||
updateFormData({
|
||||
claimPlanData: claimPlanData,
|
||||
showClaimConfirmation: true,
|
||||
// ✅ Флаги подтверждения данных
|
||||
contact_data_confirmed: contact_data_confirmed,
|
||||
contact_data_can_edit: contact_data_can_edit,
|
||||
contact_data_confirmed_at: contact_data_confirmed_at,
|
||||
});
|
||||
|
||||
// Переход к шагу подтверждения произойдёт автоматически через useEffect
|
||||
@@ -900,17 +1021,18 @@ export default function ClaimForm() {
|
||||
is_new_project: formData.is_new_project,
|
||||
|
||||
// Основные поля формы (для удобства в n8n)
|
||||
voucher: formData.voucher,
|
||||
phone: formData.phone,
|
||||
voucher: formData.voucher,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
event_type: formData.eventType,
|
||||
payment_method: formData.paymentMethod,
|
||||
bank_name: formData.bankName,
|
||||
card_number: formData.cardNumber,
|
||||
account_number: formData.accountNumber,
|
||||
event_type: formData.eventType,
|
||||
payment_method: formData.paymentMethod,
|
||||
bank_id: formData.bankId, // ID банка из NSPK API
|
||||
bank_name: formData.bankName, // Название банка для отображения
|
||||
card_number: formData.cardNumber,
|
||||
account_number: formData.accountNumber,
|
||||
|
||||
// Старый блок документов + новые загрузки визарда (пока как есть)
|
||||
documents: formData.documents || {},
|
||||
documents: formData.documents || {},
|
||||
wizard_uploads: formData.wizardUploads || {},
|
||||
|
||||
// Всё состояние формы целиком — на всякий случай
|
||||
@@ -947,7 +1069,7 @@ export default function ClaimForm() {
|
||||
});
|
||||
// Помечаем, что заявка отправлена, и показываем заглушку.
|
||||
setIsSubmitted(true);
|
||||
message.success('Данные отправлены, заявка принята в обработку.');
|
||||
message.success('Поздравляем! Ваше обращение направлено в Клиентправ.');
|
||||
} catch (error) {
|
||||
message.error('Ошибка соединения с сервером');
|
||||
addDebugEvent('form', 'error', '❌ Ошибка соединения', { error: String(error) });
|
||||
@@ -980,8 +1102,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 1: Phone (телефон + SMS верификация)
|
||||
stepsArray.push({
|
||||
title: 'Телефон',
|
||||
description: 'Подтверждение по SMS',
|
||||
title: 'Вход',
|
||||
description: 'Подтверждение телефона',
|
||||
content: (
|
||||
<Step1Phone
|
||||
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
||||
@@ -1064,8 +1186,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 2: свободное описание
|
||||
stepsArray.push({
|
||||
title: 'Описание',
|
||||
description: 'Что случилось?',
|
||||
title: 'Обращение',
|
||||
description: 'Опишите ситуацию',
|
||||
content: (
|
||||
<StepDescription
|
||||
formData={formData}
|
||||
@@ -1078,13 +1200,18 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 3: AI Рекомендации
|
||||
stepsArray.push({
|
||||
title: 'Рекомендации',
|
||||
description: 'AI ассистент',
|
||||
title: 'Документы',
|
||||
description: 'Загрузка файлов',
|
||||
content: (
|
||||
<StepWizardPlan
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onPrev={prevStep}
|
||||
onPrev={() => {
|
||||
// Возвращаемся к списку заявок
|
||||
setShowDraftSelection(true);
|
||||
setSelectedDraftId(null);
|
||||
setCurrentStep(0);
|
||||
}}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
@@ -1099,44 +1226,51 @@ export default function ClaimForm() {
|
||||
content: (
|
||||
<StepClaimConfirmation
|
||||
claimPlanData={formData.claimPlanData}
|
||||
contact_data_confirmed={formData.contact_data_confirmed}
|
||||
onPrev={prevStep}
|
||||
onNext={nextStep}
|
||||
onSubmitted={() => setIsSubmitted(true)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 3: Policy (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Проверка полиса',
|
||||
description: 'Полис ERV',
|
||||
content: (
|
||||
<Step1Policy
|
||||
formData={{ ...formData, session_id: sessionIdRef.current }} // ✅ claim_id уже в formData от n8n
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// Шаги для СТАРОГО флоу (страхование ERV) — НЕ показываем для нового флоу защиты прав
|
||||
const isNewClaimFlow = formData.documents_required && formData.documents_required.length > 0;
|
||||
|
||||
if (!isNewClaimFlow) {
|
||||
// Шаг 3: Policy (только для старого флоу)
|
||||
stepsArray.push({
|
||||
title: 'Проверка полиса',
|
||||
description: 'Полис ERV',
|
||||
content: (
|
||||
<Step1Policy
|
||||
formData={{ ...formData, session_id: sessionIdRef.current }}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Шаг 4: Event Type Selection (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Тип события',
|
||||
description: 'Выбор случая',
|
||||
content: (
|
||||
<Step2EventType
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// Шаг 4: Event Type Selection (только для старого флоу)
|
||||
stepsArray.push({
|
||||
title: 'Тип события',
|
||||
description: 'Выбор случая',
|
||||
content: (
|
||||
<Step2EventType
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаги 3+: Document Upload (динамически, если выбран eventType)
|
||||
if (formData.eventType && documentConfigs.length > 0) {
|
||||
// Шаги Document Upload (только для старого флоу — если выбран eventType)
|
||||
if (!isNewClaimFlow && formData.eventType && documentConfigs.length > 0) {
|
||||
documentConfigs.forEach((docConfig, index) => {
|
||||
stepsArray.push({
|
||||
title: `Документ ${index + 1}`,
|
||||
@@ -1160,8 +1294,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Последний шаг: Payment (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Оплата',
|
||||
description: 'Контакты и выплата',
|
||||
title: 'Заявление',
|
||||
description: 'Подтверждение',
|
||||
content: (
|
||||
<Step3Payment
|
||||
formData={formData} // ✅ claim_id уже в formData
|
||||
@@ -1233,7 +1367,7 @@ export default function ClaimForm() {
|
||||
{/* Левая часть - Форма */}
|
||||
<Col xs={24} lg={14}>
|
||||
<Card
|
||||
title="Подать заявку на выплату"
|
||||
title="Подать обращение о защите прав потребителя"
|
||||
className="claim-form-card"
|
||||
extra={
|
||||
!isSubmitted && (
|
||||
@@ -1257,19 +1391,19 @@ export default function ClaimForm() {
|
||||
)}
|
||||
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
|
||||
{currentStep > 0 && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
🔄 Начать заново
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
background: '#fff',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
🔄 Начать заново
|
||||
</button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
@@ -1277,22 +1411,22 @@ export default function ClaimForm() {
|
||||
>
|
||||
{isSubmitted ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Мы изучаем ваш вопрос и документы</h3>
|
||||
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>
|
||||
<p style={{ color: '#666666', maxWidth: 480, margin: '0 auto 24px' }}>
|
||||
Заявка отправлена в работу. Юристы проверят информацию и свяжутся с вами по указанным контактам.
|
||||
В ближайшее время на указанную Вами электронную почту поступит письмо, подтверждающее регистрацию вашего обращения.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Steps current={currentStep} className="steps">
|
||||
{steps.map((item, index) => (
|
||||
<Step
|
||||
key={`step-${index}`}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
<Steps current={currentStep} className="steps">
|
||||
{steps.map((item, index) => (
|
||||
<Step
|
||||
key={`step-${index}`}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
<div className="steps-content">
|
||||
{steps[currentStep] ? steps[currentStep].content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
|
||||
@@ -9,7 +9,15 @@ export default defineConfig({
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://host.docker.internal:8200',
|
||||
changeOrigin: true
|
||||
changeOrigin: true,
|
||||
// SSE support
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
// Disable buffering for SSE
|
||||
proxyRes.headers['cache-control'] = 'no-cache';
|
||||
proxyRes.headers['x-accel-buffering'] = 'no';
|
||||
});
|
||||
}
|
||||
},
|
||||
'/events': {
|
||||
target: 'http://host.docker.internal:8200',
|
||||
|
||||
Reference in New Issue
Block a user