feat: Улучшена форма полиса - маска ввода и загрузка скана
Изменения в UX (Step1Policy): ✅ Автоматическая маска ввода E1000-302538524 - Тире вставляется автоматически - Не нужно вводить вручную ✅ Расширенная автозамена кириллицы: - А→A, а→A, С→C, с→C, Е→E, е→E и т.д. - Поддержка строчных и заглавных ✅ Автоматический uppercase - Все буквы автоматически заглавные ✅ Логика при ненайденном полисе: - НЕ переходит на следующий шаг - Показывает поле загрузки скана прямо на месте - Кнопка "Продолжить со сканом" - Поддержка изображений и PDF ✅ Обработка paste: - Корректная обработка вставки текста - Применяются все правила форматирования Backend (policy.py): ✅ Убран вывод holder_name (для продакшна) - API не возвращает персональные данные - Только found: true/false Формат полиса: Ввод: k78486489849494 или К7848-6489849494 Результат: K7848-648984949
This commit is contained in:
@@ -32,8 +32,8 @@ async def check_policy(request: PolicyCheckRequest):
|
||||
return {
|
||||
"success": True,
|
||||
"found": True,
|
||||
"message": "Полис найден в базе",
|
||||
"policy_data": policy
|
||||
"message": "Полис найден в базе"
|
||||
# policy_data не отдаем (для продакшна)
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Button, message } from 'antd';
|
||||
import { FileProtectOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { Form, Input, Button, message, Upload } from 'antd';
|
||||
import { FileProtectOutlined, MailOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
interface Props {
|
||||
formData: any;
|
||||
@@ -8,34 +9,73 @@ interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
// Функция автозамены кириллицы на латиницу
|
||||
// Расширенная функция автозамены кириллицы на латиницу
|
||||
const cyrillicToLatin = (text: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
'А': 'A', 'В': 'B', 'С': 'C', 'Е': 'E', 'Н': 'H', 'К': 'K',
|
||||
'М': 'M', 'О': 'O', 'Р': 'P', 'Т': 'T', 'Х': 'X',
|
||||
'а': 'a', 'в': 'b', 'с': 'c', 'е': 'e', 'н': 'h', 'к': 'k',
|
||||
'м': 'm', 'о': 'o', 'р': 'p', 'т': 't', 'х': 'x'
|
||||
'А': 'A', 'а': 'A',
|
||||
'В': 'B', 'в': 'B',
|
||||
'С': 'C', 'с': 'C',
|
||||
'Е': 'E', 'е': 'E',
|
||||
'Н': 'H', 'н': 'H',
|
||||
'К': 'K', 'к': 'K',
|
||||
'М': 'M', 'м': 'M',
|
||||
'О': 'O', 'о': 'O',
|
||||
'Р': 'P', 'р': 'P',
|
||||
'Т': 'T', 'т': 'T',
|
||||
'Х': 'X', 'х': 'X',
|
||||
'У': 'Y', 'у': 'Y'
|
||||
};
|
||||
|
||||
return text.split('').map(char => map[char] || char).join('');
|
||||
};
|
||||
|
||||
// Функция форматирования полиса с маской E1000-302538524
|
||||
const formatVoucher = (value: string): string => {
|
||||
// Удаляем все кроме букв и цифр
|
||||
const cleaned = value.replace(/[^A-Za-z0-9]/g, '');
|
||||
|
||||
// Применяем автозамену кириллицы и uppercase
|
||||
const latinUpper = cyrillicToLatin(cleaned).toUpperCase();
|
||||
|
||||
// Применяем маску: буква + 4 цифры + тире + 9 цифр
|
||||
if (latinUpper.length <= 1) {
|
||||
return latinUpper;
|
||||
} else if (latinUpper.length <= 5) {
|
||||
return latinUpper;
|
||||
} else if (latinUpper.length <= 14) {
|
||||
return latinUpper.slice(0, 5) + '-' + latinUpper.slice(5);
|
||||
} else {
|
||||
return latinUpper.slice(0, 5) + '-' + latinUpper.slice(5, 14);
|
||||
}
|
||||
};
|
||||
|
||||
export default function Step1Policy({ formData, updateFormData, onNext }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [policyNotFound, setPolicyNotFound] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
// Обработчик изменения поля полиса с автозаменой
|
||||
// Обработчик изменения поля полиса с автозаменой и маской
|
||||
const handleVoucherChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const converted = cyrillicToLatin(value.toUpperCase());
|
||||
form.setFieldValue('voucher', converted);
|
||||
const formatted = formatVoucher(e.target.value);
|
||||
form.setFieldValue('voucher', formatted);
|
||||
};
|
||||
|
||||
// Обработчик paste для корректной обработки вставки
|
||||
const handleVoucherPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
const formatted = formatVoucher(pastedText);
|
||||
form.setFieldValue('voucher', formatted);
|
||||
};
|
||||
|
||||
const checkPolicy = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const values = await form.validateFields(['voucher', 'email']);
|
||||
|
||||
setLoading(true);
|
||||
setPolicyNotFound(false);
|
||||
|
||||
// Проверка полиса через API
|
||||
const response = await fetch('http://147.45.146.17:8100/api/v1/policy/check', {
|
||||
method: 'POST',
|
||||
@@ -50,13 +90,14 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props)
|
||||
|
||||
if (response.ok) {
|
||||
if (result.found) {
|
||||
message.success(`Полис найден! Владелец: ${result.policy_data?.holder_name || 'не указан'}`);
|
||||
// Полис найден - переходим дальше
|
||||
message.success('Полис найден в базе данных');
|
||||
updateFormData(values);
|
||||
onNext();
|
||||
} else {
|
||||
message.warning('Полис не найден в базе. Продолжайте — на следующем шаге загрузите скан.');
|
||||
updateFormData(values);
|
||||
onNext();
|
||||
// Полис НЕ найден - показываем загрузку скана
|
||||
message.warning('Полис не найден в базе. Загрузите скан полиса');
|
||||
setPolicyNotFound(true);
|
||||
}
|
||||
} else {
|
||||
message.error(result.detail || 'Ошибка проверки полиса');
|
||||
@@ -72,6 +113,26 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props)
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadChange = ({ fileList: newFileList }: any) => {
|
||||
setFileList(newFileList);
|
||||
};
|
||||
|
||||
const handleSubmitWithScan = async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.error('Загрузите скан полиса');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
updateFormData({ ...values, policyScanUploaded: true, policyScanFiles: fileList });
|
||||
message.success('Данные сохранены');
|
||||
onNext();
|
||||
} catch (error) {
|
||||
message.error('Заполните все обязательные поля');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
@@ -86,16 +147,17 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props)
|
||||
{ required: true, message: 'Введите номер полиса' },
|
||||
{
|
||||
pattern: /^[A-Z]\d{4}-\d{9}$/,
|
||||
message: 'Формат: E1000-302538524 (буква, 4 цифры, тире, 9 цифр)'
|
||||
message: 'Формат: E1000-302538524'
|
||||
}
|
||||
]}
|
||||
tooltip="Формат: E1000-302538524. Кириллица автоматически заменяется на латиницу"
|
||||
tooltip="Формат: E1000-302538524. Тире вставляется автоматически"
|
||||
>
|
||||
<Input
|
||||
prefix={<FileProtectOutlined />}
|
||||
placeholder="E1000-302538524"
|
||||
placeholder="E1000302538524"
|
||||
size="large"
|
||||
onChange={handleVoucherChange}
|
||||
onPaste={handleVoucherPaste}
|
||||
maxLength={15}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -116,23 +178,87 @@ export default function Step1Policy({ formData, updateFormData, onNext }: Props)
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={checkPolicy}
|
||||
loading={loading}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
Проверить полис и продолжить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{!policyNotFound && (
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={checkPolicy}
|
||||
loading={loading}
|
||||
size="large"
|
||||
block
|
||||
>
|
||||
Проверить полис и продолжить
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#f0f9ff', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||
💡 Если полис не найден в базе, на следующем шаге вы сможете загрузить его скан
|
||||
</p>
|
||||
</div>
|
||||
{policyNotFound && (
|
||||
<>
|
||||
<div style={{
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
background: '#fff7e6',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #ffa940'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: '#d46b08', fontWeight: 500 }}>
|
||||
⚠️ Полис не найден в базе данных
|
||||
</p>
|
||||
<p style={{ margin: '8px 0 0 0', fontSize: 13, color: '#666' }}>
|
||||
Загрузите скан/фото полиса для продолжения
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Скан полиса"
|
||||
name="policyScan"
|
||||
rules={[{ required: true, message: 'Загрузите скан полиса' }]}
|
||||
>
|
||||
<Upload
|
||||
listType="picture"
|
||||
fileList={fileList}
|
||||
onChange={handleUploadChange}
|
||||
beforeUpload={() => false}
|
||||
accept="image/*,.pdf"
|
||||
maxCount={3}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} size="large" block>
|
||||
Выбрать файл (фото или PDF)
|
||||
</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPolicyNotFound(false);
|
||||
setFileList([]);
|
||||
}}
|
||||
size="large"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmitWithScan}
|
||||
size="large"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Продолжить со сканом
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!policyNotFound && (
|
||||
<div style={{ marginTop: 16, padding: 12, background: '#f0f9ff', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||
💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user