Профиль: валидация, календарь, ИНН 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:
Fedor
2026-02-27 18:31:41 +03:00
parent b5c31b43dd
commit c39b12630e
6 changed files with 379 additions and 56 deletions

View File

@@ -0,0 +1,28 @@
# Изменения: форма профиля, валидация, DaData, банки
## Backend
### auth_universal.py
- Чтение N8N_AUTH_WEBHOOK: fallback на `os.environ.get("N8N_AUTH_WEBHOOK")`, если в config нет поля `n8n_auth_webhook` (чтобы webhook auth_miniapp вызывался при отсутствии config.py на хосте).
### banks.py
- URL списка банков берётся из .env: `BANK_IP` (в config — `bank_ip`), fallback на `bank_api_url` и запасной URL. Прокси запроса к внешнему API для мини-аппа.
### profile.py
- Новый эндпоинт `GET /api/v1/profile/dadata/address?query=...&count=10` — подсказки адресов через DaData API (ключи FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). Ответ: `{ "suggestions": [ { "value", "unrestricted_value" } ] }`.
### config.py
- Добавлены поля: `bank_ip` (BANK_IP), `bank_api_url`; `forma_dadata_api_key`, `forma_dadata_secret` (FORMA_DADATA_*).
## Frontend (Profile.tsx)
- **Дата рождения:** календарь (DatePicker), формат DD.MM.YYYY, нельзя выбрать будущую дату.
- **ИНН:** строго 12 цифр, валидация и ввод только цифр; подсказка «Узнать свой ИНН вы можете здесь» со ссылкой на сервис ФНС (service.nalog.ru).
- **Email:** валидация формата (type: email).
- **Адрес регистрации / Почтовый адрес:** чекбокс «Совпадает с адресом регистрации» — при включении почтовый подставляется и блокируется; оба поля — AutoComplete с подсказками из DaData (запрос к /api/v1/profile/dadata/address).
- **Банк для возмещения:** выпадающий список (Select) с поиском, данные с /api/v1/banks/nspk (API из BANK_IP); учтён формат ответа с полями bankId, bankName (camelCase).
## .env
- BANK_IP — URL API списка банков (например http://212.193.27.93/api/payouts/dictionaries/nspk-banks).
- FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET — ключи DaData для подсказок адресов.

View File

@@ -7,7 +7,7 @@
import logging import logging
import os import os
import uuid import uuid
from typing import Optional, Any, Dict from typing import Optional, Any, Dict, Union
import httpx import httpx
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -37,6 +37,27 @@ class AuthUniversalResponse(BaseModel):
phone: Optional[str] = None phone: Optional[str] = None
contact_id: Optional[str] = None contact_id: Optional[str] = None
has_drafts: Optional[bool] = None has_drafts: Optional[bool] = None
need_profile_confirm: Optional[bool] = None
profile_needs_attention: Optional[bool] = None
def _to_bool(v: Any) -> Optional[bool]:
if v is None:
return None
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
if v == 1:
return True
if v == 0:
return False
if isinstance(v, str):
s = v.strip().lower()
if s in ("1", "true", "yes", "y", "да"):
return True
if s in ("0", "false", "no", "n", "нет", ""):
return False
return None
@router.post("", response_model=AuthUniversalResponse) @router.post("", response_model=AuthUniversalResponse)
@@ -152,6 +173,20 @@ async def auth_universal(request: AuthUniversalRequest):
logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id")) logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id"))
# Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт
need_profile_confirm = _to_bool(
data.get("need_profile_confirm")
if "need_profile_confirm" in data
else data.get("needProfileConfirm")
)
profile_needs_attention = _to_bool(
data.get("profile_needs_attention")
if "profile_needs_attention" in data
else data.get("profileNeedsAttention")
)
if profile_needs_attention is None:
profile_needs_attention = need_profile_confirm
# 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате) # 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате)
need_contact = ( need_contact = (
data.get("need_contact") is True data.get("need_contact") is True
@@ -198,6 +233,8 @@ async def auth_universal(request: AuthUniversalRequest):
"contact_id": _contact_id, "contact_id": _contact_id,
"has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False, "has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False,
"chat_id": channel_user_id, "chat_id": channel_user_id,
"need_profile_confirm": need_profile_confirm,
"profile_needs_attention": profile_needs_attention,
} }
logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone")) logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone"))
try: try:
@@ -222,4 +259,6 @@ async def auth_universal(request: AuthUniversalRequest):
phone=session_data.get("phone"), phone=session_data.get("phone"),
contact_id=session_data.get("contact_id"), contact_id=session_data.get("contact_id"),
has_drafts=session_data.get("has_drafts", False), has_drafts=session_data.get("has_drafts", False),
need_profile_confirm=need_profile_confirm,
profile_needs_attention=profile_needs_attention,
) )

View File

@@ -14,13 +14,10 @@ router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
@router.get("/nspk") @router.get("/nspk")
async def get_nspk_banks(): async def get_nspk_banks():
""" """
Получить список банков СБП из внешнего API Получить список банков из внешнего API (BANK_IP в .env или nspk_banks_api_url).
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
""" """
external_api_url = (getattr(settings, "bank_ip", None) or getattr(settings, "bank_api_url", None) or "").strip() or "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
try: try:
# URL внешнего API
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(external_api_url) response = await client.get(external_api_url)

View File

@@ -2,36 +2,7 @@
Профиль пользователя: контактные данные из CRM через n8n webhook. Профиль пользователя: контактные данные из CRM через n8n webhook.
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id. GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
unified_id берётся из сессии по session_token или передаётся явно. GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
----- Что уходит на N8N_CONTACT_WEBHOOK (POST body) -----
- unified_id (str): идентификатор пользователя в CRM
- entry_channel (str): "telegram" | "max" | "web"
- chat_id (str, опционально): Telegram user id или Max user id
- session_token, contact_id, phone (опционально)
----- Как n8n должен возвращать ответ -----
1) Ничего не нашло (контакт не найден в CRM или нет данных):
- HTTP 200
- Тело: пустой массив [] ИЛИ объект {"items": []}
Пример: [] или {"items": []}
2) Нашло контакт(ы):
- HTTP 200
- Тело: массив контактов ИЛИ объект с полем items/contact/data:
• [] → нормализуется в {"items": []}
{"items": [...]} → как есть
{"contact": {...}} → один контакт в items
{"contact": [...]} → массив в items
{"data": [...]} → массив в items
• один объект {...} → один элемент в items
Поля контакта (snake_case или camelCase, фронт смотрит оба):
last_name/lastName, first_name/firstName, middle_name/middleName,
birth_date/birthDate, birth_place/birthPlace, inn, email,
registration_address/address/mailingstreet, mailing_address/postal_address,
bank_for_compensation/bank, phone/mobile/mobile_phone.
""" """
import logging import logging
@@ -128,6 +99,46 @@ class ProfileContactUpdateRequest(BaseModel):
phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)") phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)")
DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
@router.get("/dadata/address")
async def get_dadata_address_suggestions(
query: str = Query(..., min_length=1, description="Строка поиска адреса"),
count: int = Query(10, ge=1, le=20),
):
"""
Подсказки адресов через DaData (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env).
Возвращает список { value, unrestricted_value } для подстановки в форму профиля.
"""
api_key = (getattr(settings, "forma_dadata_api_key", None) or "").strip()
secret = (getattr(settings, "forma_dadata_secret", None) or "").strip()
if not api_key or not secret:
raise HTTPException(status_code=503, detail="DaData не настроен (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET)")
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
DADATA_SUGGEST_URL,
json={"query": query.strip(), "count": count},
headers={
"Authorization": f"Token {api_key}",
"X-Secret": secret,
"Content-Type": "application/json",
},
)
if response.status_code != 200:
logger.warning("DaData address suggest вернул %s: %s", response.status_code, response.text[:300])
return {"suggestions": []}
data = response.json()
suggestions = data.get("suggestions") or []
return {"suggestions": [{"value": s.get("value", ""), "unrestricted_value": s.get("unrestricted_value", "")} for s in suggestions]}
except httpx.TimeoutException:
return {"suggestions": []}
except Exception as e:
logger.exception("Ошибка DaData suggest: %s", e)
return {"suggestions": []}
@router.get("/contact") @router.get("/contact")
async def get_profile_contact( async def get_profile_contact(
session_token: Optional[str] = Query(None, description="Токен сессии"), session_token: Optional[str] = Query(None, description="Токен сессии"),

View File

@@ -2,6 +2,7 @@
Конфигурация приложения Конфигурация приложения
""" """
import os import os
import json
from pathlib import Path from pathlib import Path
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import List, Optional from typing import List, Optional
@@ -138,9 +139,17 @@ class Settings(BaseSettings):
aviationstack_base_url: str = "http://api.aviationstack.com/v1" aviationstack_base_url: str = "http://api.aviationstack.com/v1"
# ============================================ # ============================================
# NSPK BANKS API # NSPK BANKS API (и альтернативный BANK_IP из .env)
# ============================================ # ============================================
nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json" nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json"
bank_ip: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
bank_api_url: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
# ============================================
# DADATA (подсказки адресов в форме профиля)
# ============================================
forma_dadata_api_key: str = "" # FORMA_DADATA_API_KEY
forma_dadata_secret: str = "" # FORMA_DADATA_SECRET
# ============================================ # ============================================
# SMS SERVICE (SigmaSMS) # SMS SERVICE (SigmaSMS)
@@ -221,11 +230,21 @@ class Settings(BaseSettings):
# ============================================ # ============================================
# MAX (мессенджер) — Mini App auth # MAX (мессенджер) — Mini App auth
# ============================================ # ============================================
max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp max_bot_token: str = "" # Токен бота MAX (один бот)
max_bot_tokens: str = "" # Мультибот: JSON {"bot_id": "token", ...}. Если задан — используется вместо max_bot_token.
def get_max_bot_tokens(self) -> List[tuple]: def get_max_bot_tokens(self) -> List[tuple]:
"""Список (bot_id, token) для проверки подписи MAX initData. Один токен — [('default', token)].""" """Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)]."""
token = (self.max_bot_token or "").strip() s = (self.max_bot_tokens or os.environ.get("MAX_BOT_TOKENS") or "").strip()
if s:
try:
d = json.loads(s)
out = [(k, str(v).strip()) for k, v in d.items() if v and str(v).strip()]
if out:
return out
except Exception:
pass
token = (self.max_bot_token or os.environ.get("MAX_BOT_TOKEN") or "").strip()
if token: if token:
return [("default", token)] return [("default", token)]
return [] return []

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd'; import { Button, Card, Checkbox, Descriptions, Form, Input, Select, DatePicker, AutoComplete, Spin, Typography, message } from 'antd';
import { User } from 'lucide-react'; import { User } from 'lucide-react';
import dayjs from 'dayjs';
import './Profile.css'; import './Profile.css';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const DATE_FORMAT = 'DD.MM.YYYY';
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */ /** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [ 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 ''; 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). Иначе — только просмотр. */ /** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
function canEditProfile(contact: Record<string, unknown>): boolean { function canEditProfile(contact: Record<string, unknown>): boolean {
const v = contact?.verification ?? contact?.Verification; const v = contact?.verification ?? contact?.Verification;
return v === '0' || v === 0; return v === '0' || v === 0;
} }
interface BankOption {
id?: string;
name?: string;
bankid?: string;
bankname?: string;
value?: string;
label?: string;
}
interface ProfileProps { interface ProfileProps {
onNavigate?: (path: string) => void; onNavigate?: (path: string) => void;
} }
@@ -44,6 +64,61 @@ export default function Profile({ onNavigate }: ProfileProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [contact, setContact] = useState<Record<string, unknown> | null>(null); const [contact, setContact] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm(); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -90,10 +165,18 @@ export default function Profile({ onNavigate }: ProfileProps) {
: null; : null;
setContact(first); setContact(first);
if (first && canEditProfile(first)) { if (first && canEditProfile(first)) {
const initial: Record<string, string> = {}; const initial: Record<string, string | dayjs.Dayjs | null> = {};
PROFILE_FIELDS.forEach(({ key, keys }) => { 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); form.setFieldsValue(initial);
} }
}) })
@@ -106,6 +189,22 @@ export default function Profile({ onNavigate }: ProfileProps) {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [onNavigate, form]); }, [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 () => { const handleSave = async () => {
if (!contact || !canEditProfile(contact)) return; if (!contact || !canEditProfile(contact)) return;
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token'); const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
@@ -124,6 +223,8 @@ export default function Profile({ onNavigate }: ProfileProps) {
} catch { } catch {
return; return;
} }
const birthDateVal = values.birth_date;
const birthDateStr = dayjs.isDayjs(birthDateVal) ? birthDateVal.format(DATE_FORMAT) : (birthDateVal && String(birthDateVal).trim()) || '';
setSaving(true); setSaving(true);
try { try {
const res = await fetch('/api/v1/profile/contact/update', { const res = await fetch('/api/v1/profile/contact/update', {
@@ -135,7 +236,7 @@ export default function Profile({ onNavigate }: ProfileProps) {
last_name: values.last_name ?? '', last_name: values.last_name ?? '',
first_name: values.first_name ?? '', first_name: values.first_name ?? '',
middle_name: values.middle_name ?? '', middle_name: values.middle_name ?? '',
birth_date: values.birth_date ?? '', birth_date: birthDateStr,
birth_place: values.birth_place ?? '', birth_place: values.birth_place ?? '',
inn: values.inn ?? '', inn: values.inn ?? '',
email: values.email ?? '', email: values.email ?? '',
@@ -152,7 +253,7 @@ export default function Profile({ onNavigate }: ProfileProps) {
return; return;
} }
message.success('Профиль сохранён'); message.success('Профиль сохранён');
setContact({ ...contact, ...values }); setContact({ ...contact, ...values, birth_date: birthDateStr });
} catch (e) { } catch (e) {
message.error('Не удалось сохранить профиль, попробуйте позже'); message.error('Не удалось сохранить профиль, попробуйте позже');
} finally { } finally {
@@ -209,16 +310,144 @@ export default function Profile({ onNavigate }: ProfileProps) {
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>} title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
> >
<Form form={form} layout="vertical" onFinish={handleSave}> <Form form={form} layout="vertical" onFinish={handleSave}>
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => ( {PROFILE_FIELDS.map(({ key, keys, label, editable }) => {
<Form.Item if (key === 'birth_date') {
key={key} return (
name={key} <Form.Item
label={label} key={key}
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined} name={key}
> label={label}
<Input disabled={!editable} placeholder={editable ? undefined : '—'} /> rules={[{ required: true, message: 'Укажите дату рождения' }]}
</Form.Item> >
))} <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> <Form.Item>
<Button type="primary" htmlType="submit" loading={saving}> <Button type="primary" htmlType="submit" loading={saving}>
Сохранить изменения Сохранить изменения