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:
AI Assistant
2025-12-04 12:22:23 +03:00
parent 64385c430d
commit 080e7ec105
69 changed files with 17034 additions and 1439 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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 || 'Неверный код');
}

View File

@@ -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>
);
}

View File

@@ -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 />}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
})()}

View File

@@ -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: {

View File

@@ -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' }}>

View File

@@ -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',