Профиль: валидация, календарь, ИНН 12 цифр, email, DaData адреса, банки из BANK_IP, подсказка ИНН (ФНС)
- Backend: N8N_AUTH_WEBHOOK из env (fallback), банки из BANK_IP, эндпоинт /api/v1/profile/dadata/address для подсказок адресов (FORMA_DADATA_*). - Config: bank_ip, bank_api_url, forma_dadata_api_key, forma_dadata_secret. - Frontend Profile: DatePicker для даты рождения, ИНН 12 цифр + ссылка на ФНС, валидация email, чекбокс «Совпадает с адресом регистрации», AutoComplete адресов через DaData, Select банков из /api/v1/banks/nspk (bankId/bankName). Подробности в CHANGELOG_PROFILE_VALIDATION.md.
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Button, Card, Checkbox, Descriptions, Form, Input, Select, DatePicker, AutoComplete, Spin, Typography, message } from 'antd';
|
||||
import { User } from 'lucide-react';
|
||||
import dayjs from 'dayjs';
|
||||
import './Profile.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const DATE_FORMAT = 'DD.MM.YYYY';
|
||||
|
||||
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
|
||||
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
|
||||
@@ -28,12 +30,30 @@ function getValue(obj: Record<string, unknown>, keys: string[]): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Парсим дату из строки (DD.MM.YYYY, YYYY-MM-DD и т.д.) в dayjs или null */
|
||||
function parseBirthDate(s: string): dayjs.Dayjs | null {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return null;
|
||||
const d = dayjs(trimmed, [DATE_FORMAT, 'YYYY-MM-DD', 'DD.MM.YYYY'], true);
|
||||
return d.isValid() ? d : null;
|
||||
}
|
||||
|
||||
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
|
||||
function canEditProfile(contact: Record<string, unknown>): boolean {
|
||||
const v = contact?.verification ?? contact?.Verification;
|
||||
return v === '0' || v === 0;
|
||||
}
|
||||
|
||||
interface BankOption {
|
||||
id?: string;
|
||||
name?: string;
|
||||
bankid?: string;
|
||||
bankname?: string;
|
||||
value?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface ProfileProps {
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
@@ -44,6 +64,61 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [sameAsRegistration, setSameAsRegistration] = useState(false);
|
||||
const [banks, setBanks] = useState<BankOption[]>([]);
|
||||
const [banksLoading, setBanksLoading] = useState(false);
|
||||
const [addressOptionsReg, setAddressOptionsReg] = useState<{ value: string }[]>([]);
|
||||
const [addressOptionsMail, setAddressOptionsMail] = useState<{ value: string }[]>([]);
|
||||
const [dadataLoadingReg, setDadataLoadingReg] = useState(false);
|
||||
const [dadataLoadingMail, setDadataLoadingMail] = useState(false);
|
||||
|
||||
const loadBanks = useCallback(async () => {
|
||||
setBanksLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/banks/nspk');
|
||||
if (!res.ok) {
|
||||
setBanks([]);
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => []);
|
||||
const list = Array.isArray(data) ? data : (data?.data || data?.items || []);
|
||||
const normalized: BankOption[] = list.map((b: Record<string, unknown> | string) => {
|
||||
if (typeof b === 'string') return { value: b, label: b };
|
||||
const name = (b?.bankName ?? b?.bankname ?? b?.name ?? b?.title ?? b?.value ?? '').toString().trim();
|
||||
const id = (b?.bankId ?? b?.bankid ?? b?.id ?? b?.value ?? name).toString().trim();
|
||||
return { bankid: id, bankname: name, value: name, label: name };
|
||||
}).filter((b: BankOption) => b.value || b.label);
|
||||
setBanks(normalized);
|
||||
} catch {
|
||||
setBanks([]);
|
||||
} finally {
|
||||
setBanksLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchAddress = useCallback(async (query: string, setOptions: (o: { value: string }[]) => void, setLoading: (v: boolean) => void) => {
|
||||
if (!query || query.length < 2) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/profile/dadata/address?query=${encodeURIComponent(query)}&count=10`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const suggestions = (data?.suggestions || []).map((s: { value?: string; unrestricted_value?: string }) => ({
|
||||
value: (s.unrestricted_value || s.value || '').trim(),
|
||||
})).filter((s: { value: string }) => s.value);
|
||||
setOptions(suggestions);
|
||||
} catch {
|
||||
setOptions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (canEditProfile(contact || {})) loadBanks();
|
||||
}, [contact, loadBanks]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -90,10 +165,18 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
: null;
|
||||
setContact(first);
|
||||
if (first && canEditProfile(first)) {
|
||||
const initial: Record<string, string> = {};
|
||||
const initial: Record<string, string | dayjs.Dayjs | null> = {};
|
||||
PROFILE_FIELDS.forEach(({ key, keys }) => {
|
||||
initial[key] = getValue(first, keys) || '';
|
||||
const raw = getValue(first, keys) || '';
|
||||
if (key === 'birth_date') {
|
||||
initial[key] = parseBirthDate(raw) || (raw ? dayjs(raw) : null);
|
||||
} else {
|
||||
initial[key] = raw;
|
||||
}
|
||||
});
|
||||
const regAddr = (initial.registration_address as string) || '';
|
||||
const mailAddr = (initial.mailing_address as string) || '';
|
||||
setSameAsRegistration(!!regAddr && regAddr === mailAddr);
|
||||
form.setFieldsValue(initial);
|
||||
}
|
||||
})
|
||||
@@ -106,6 +189,22 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
return () => { cancelled = true; };
|
||||
}, [onNavigate, form]);
|
||||
|
||||
const handleSameAsRegistrationChange = (e: { target: { checked: boolean } }) => {
|
||||
const checked = e.target.checked;
|
||||
setSameAsRegistration(checked);
|
||||
if (checked) {
|
||||
const reg = form.getFieldValue('registration_address') || '';
|
||||
form.setFieldsValue({ mailing_address: reg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationAddressChange = () => {
|
||||
if (sameAsRegistration) {
|
||||
const reg = form.getFieldValue('registration_address') || '';
|
||||
form.setFieldsValue({ mailing_address: reg });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!contact || !canEditProfile(contact)) return;
|
||||
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
|
||||
@@ -124,6 +223,8 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const birthDateVal = values.birth_date;
|
||||
const birthDateStr = dayjs.isDayjs(birthDateVal) ? birthDateVal.format(DATE_FORMAT) : (birthDateVal && String(birthDateVal).trim()) || '';
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/v1/profile/contact/update', {
|
||||
@@ -135,7 +236,7 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
last_name: values.last_name ?? '',
|
||||
first_name: values.first_name ?? '',
|
||||
middle_name: values.middle_name ?? '',
|
||||
birth_date: values.birth_date ?? '',
|
||||
birth_date: birthDateStr,
|
||||
birth_place: values.birth_place ?? '',
|
||||
inn: values.inn ?? '',
|
||||
email: values.email ?? '',
|
||||
@@ -152,7 +253,7 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
return;
|
||||
}
|
||||
message.success('Профиль сохранён');
|
||||
setContact({ ...contact, ...values });
|
||||
setContact({ ...contact, ...values, birth_date: birthDateStr });
|
||||
} catch (e) {
|
||||
message.error('Не удалось сохранить профиль, попробуйте позже');
|
||||
} finally {
|
||||
@@ -209,16 +310,144 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
|
||||
>
|
||||
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
|
||||
</Form.Item>
|
||||
))}
|
||||
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => {
|
||||
if (key === 'birth_date') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Укажите дату рождения' }]}
|
||||
>
|
||||
<DatePicker
|
||||
format={DATE_FORMAT}
|
||||
placeholder="Выберите дату"
|
||||
style={{ width: '100%' }}
|
||||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'inn') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[
|
||||
{ required: true, message: 'Введите ИНН' },
|
||||
{ pattern: /^\d{12}$/, message: 'ИНН должен содержать ровно 12 цифр' },
|
||||
]}
|
||||
help={
|
||||
<span style={{ fontSize: 12, color: 'var(--ant-color-text-secondary, rgba(0,0,0,0.45))' }}>
|
||||
Узнать свой ИНН вы можете{' '}
|
||||
<a href="https://service.nalog.ru/static/personal-data.html?svc=inn&from=%2Finn.do" target="_blank" rel="noopener noreferrer">
|
||||
здесь
|
||||
</a>
|
||||
{' '}(сервис ФНС России).
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
maxLength={12}
|
||||
placeholder="12 цифр"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.replace(/\D/g, '').slice(0, 12);
|
||||
form.setFieldValue('inn', v);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'email') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[
|
||||
{ required: true, message: 'Введите электронную почту' },
|
||||
{ type: 'email', message: 'Введите корректный email' },
|
||||
]}
|
||||
>
|
||||
<Input type="email" placeholder="example@mail.ru" />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'registration_address') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Обязательное поле' }]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={addressOptionsReg}
|
||||
placeholder="Начните вводить адрес"
|
||||
onSearch={(q) => searchAddress(q, setAddressOptionsReg, setDadataLoadingReg)}
|
||||
onChange={handleRegistrationAddressChange}
|
||||
notFoundContent={dadataLoadingReg ? 'Загрузка...' : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (key === 'mailing_address') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item style={{ marginBottom: 8 }}>
|
||||
<Checkbox checked={sameAsRegistration} onChange={handleSameAsRegistrationChange}>
|
||||
Совпадает с адресом регистрации
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Обязательное поле' }]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={addressOptionsMail}
|
||||
placeholder="Начните вводить адрес"
|
||||
onSearch={(q) => searchAddress(q, setAddressOptionsMail, setDadataLoadingMail)}
|
||||
disabled={sameAsRegistration}
|
||||
notFoundContent={dadataLoadingMail ? 'Загрузка...' : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (key === 'bank_for_compensation') {
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={[{ required: true, message: 'Выберите банк' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder={banksLoading ? 'Загрузка банков...' : 'Выберите банк'}
|
||||
loading={banksLoading}
|
||||
optionFilterProp="label"
|
||||
filterOption={(input, opt) => (opt?.label ?? '').toString().toLowerCase().includes((input || '').toLowerCase())}
|
||||
options={banks.map((b) => ({ value: b.value || b.bankname || b.label, label: b.label || b.bankname || b.value }))}
|
||||
notFoundContent={banksLoading ? 'Загрузка...' : 'Банк не найден'}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
key={key}
|
||||
name={key}
|
||||
label={label}
|
||||
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
|
||||
>
|
||||
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
Сохранить изменения
|
||||
|
||||
Reference in New Issue
Block a user