Профиль: валидация, календарь, ИНН 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:
28
CHANGELOG_PROFILE_VALIDATION.md
Normal file
28
CHANGELOG_PROFILE_VALIDATION.md
Normal 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 для подсказок адресов.
|
||||
@@ -7,7 +7,7 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional, Any, Dict
|
||||
from typing import Optional, Any, Dict, Union
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -37,6 +37,27 @@ class AuthUniversalResponse(BaseModel):
|
||||
phone: Optional[str] = None
|
||||
contact_id: Optional[str] = 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)
|
||||
@@ -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"))
|
||||
|
||||
# Флаг «профиль требует внимания»: приходит из 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 (закрыть приложение и попросить контакт в чате)
|
||||
need_contact = (
|
||||
data.get("need_contact") is True
|
||||
@@ -198,6 +233,8 @@ async def auth_universal(request: AuthUniversalRequest):
|
||||
"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,
|
||||
"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"))
|
||||
try:
|
||||
@@ -222,4 +259,6 @@ async def auth_universal(request: AuthUniversalRequest):
|
||||
phone=session_data.get("phone"),
|
||||
contact_id=session_data.get("contact_id"),
|
||||
has_drafts=session_data.get("has_drafts", False),
|
||||
need_profile_confirm=need_profile_confirm,
|
||||
profile_needs_attention=profile_needs_attention,
|
||||
)
|
||||
|
||||
@@ -14,13 +14,10 @@ router = APIRouter(prefix="/api/v1/banks", tags=["Banks"])
|
||||
@router.get("/nspk")
|
||||
async def get_nspk_banks():
|
||||
"""
|
||||
Получить список банков СБП из внешнего API
|
||||
Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP)
|
||||
Получить список банков из внешнего API (BANK_IP в .env или nspk_banks_api_url).
|
||||
"""
|
||||
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:
|
||||
# URL внешнего API
|
||||
external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(external_api_url)
|
||||
|
||||
|
||||
@@ -2,36 +2,7 @@
|
||||
Профиль пользователя: контактные данные из CRM через n8n webhook.
|
||||
|
||||
GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id.
|
||||
unified_id берётся из сессии по session_token или передаётся явно.
|
||||
|
||||
----- Что уходит на 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.
|
||||
GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -128,6 +99,46 @@ class ProfileContactUpdateRequest(BaseModel):
|
||||
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")
|
||||
async def get_profile_contact(
|
||||
session_token: Optional[str] = Query(None, description="Токен сессии"),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Конфигурация приложения
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List, Optional
|
||||
@@ -138,9 +139,17 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
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)
|
||||
@@ -221,11 +230,21 @@ class Settings(BaseSettings):
|
||||
# ============================================
|
||||
# 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]:
|
||||
"""Список (bot_id, token) для проверки подписи MAX initData. Один токен — [('default', token)]."""
|
||||
token = (self.max_bot_token or "").strip()
|
||||
"""Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)]."""
|
||||
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:
|
||||
return [("default", token)]
|
||||
return []
|
||||
|
||||
@@ -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,7 +310,134 @@ 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 }) => (
|
||||
{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}
|
||||
@@ -218,7 +446,8 @@ export default function Profile({ onNavigate }: ProfileProps) {
|
||||
>
|
||||
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
|
||||
</Form.Item>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>
|
||||
Сохранить изменения
|
||||
|
||||
Reference in New Issue
Block a user