Files
aiform_prod/frontend/src/pages/Profile.tsx
Fedor 9c65b6a4ea Profile: редактируемый профиль при verification="0", сохранение через N8N_PROFILE_UPDATE_WEBHOOK
- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env).
- Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK.
- Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.
2026-02-27 08:34:27 +03:00

254 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd';
import { User } from 'lucide-react';
import './Profile.css';
const { Title, Text } = Typography;
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия', editable: true },
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя', editable: true },
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество', editable: true },
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения', editable: true },
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения', editable: true },
{ key: 'inn', keys: ['inn'], label: 'ИНН', editable: true },
{ key: 'email', keys: ['email'], label: 'Электронная почта', editable: true },
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации', editable: true },
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес', editable: true },
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения', editable: true },
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон', editable: false },
];
function getValue(obj: Record<string, unknown>, keys: string[]): string {
for (const k of keys) {
const v = obj[k];
if (v != null && String(v).trim() !== '') return String(v).trim();
}
return '';
}
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
function canEditProfile(contact: Record<string, unknown>): boolean {
const v = contact?.verification ?? contact?.Verification;
return v === '0' || v === 0;
}
interface ProfileProps {
onNavigate?: (path: string) => void;
}
export default function Profile({ onNavigate }: ProfileProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
useEffect(() => {
let cancelled = false;
const token = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|| localStorage.getItem('session_token');
if (!token) {
setLoading(false);
onNavigate?.('/hello');
return;
}
const entryChannel =
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
: 'web';
const chatId = (() => {
if (typeof window === 'undefined') return undefined;
const tg = (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id;
if (tg != null) return String(tg);
const max = (window as any).WebApp?.initDataUnsafe?.user?.id;
if (max != null) return String(max);
return undefined;
})();
setLoading(true);
setError(null);
const params = new URLSearchParams({ session_token: token, entry_channel: entryChannel });
if (chatId) params.set('chat_id', chatId);
fetch(`/api/v1/profile/contact?${params.toString()}`)
.then((res) => {
if (!res.ok) {
if (res.status === 401) {
try { sessionStorage.removeItem('session_token'); } catch (_) {}
localStorage.removeItem('session_token');
throw new Error('Сессия истекла');
}
throw new Error('Ошибка загрузки');
}
return res.json();
})
.then((data: { items?: unknown[] }) => {
if (cancelled) return;
const items = Array.isArray(data?.items) ? data.items : [];
const first = items.length > 0 && typeof items[0] === 'object' && items[0] !== null
? (items[0] as Record<string, unknown>)
: null;
setContact(first);
if (first && canEditProfile(first)) {
const initial: Record<string, string> = {};
PROFILE_FIELDS.forEach(({ key, keys }) => {
initial[key] = getValue(first, keys) || '';
});
form.setFieldsValue(initial);
}
})
.catch((e) => {
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [onNavigate, form]);
const handleSave = async () => {
if (!contact || !canEditProfile(contact)) return;
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
if (!token) {
message.error('Сессия истекла');
onNavigate?.('/hello');
return;
}
const entryChannel =
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
: 'web';
let values: Record<string, string>;
try {
values = await form.validateFields();
} catch {
return;
}
setSaving(true);
try {
const res = await fetch('/api/v1/profile/contact/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: token,
entry_channel: entryChannel,
last_name: values.last_name ?? '',
first_name: values.first_name ?? '',
middle_name: values.middle_name ?? '',
birth_date: values.birth_date ?? '',
birth_place: values.birth_place ?? '',
inn: values.inn ?? '',
email: values.email ?? '',
registration_address: values.registration_address ?? '',
mailing_address: values.mailing_address ?? '',
bank_for_compensation: values.bank_for_compensation ?? '',
phone: getValue(contact, ['phone', 'mobile', 'mobile_phone']) || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = typeof data?.detail === 'string' ? data.detail : data?.detail?.[0]?.msg || 'Не удалось сохранить профиль';
message.error(detail);
return;
}
message.success('Профиль сохранён');
setContact({ ...contact, ...values });
} catch (e) {
message.error('Не удалось сохранить профиль, попробуйте позже');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="profile-page">
<Card className="profile-card">
<div className="profile-loading">
<Spin size="large" tip="Загрузка профиля..." />
</div>
</Card>
</div>
);
}
if (error) {
return (
<div className="profile-page">
<Card className="profile-card">
<Title level={4}>Профиль</Title>
<Text type="danger">{error}</Text>
<div style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => onNavigate?.('/hello')}>
Войти снова
</Button>
</div>
</Card>
</div>
);
}
if (!contact) {
return (
<div className="profile-page">
<Card className="profile-card">
<Title level={4}>Профиль</Title>
<Text type="secondary">Контактных данных пока нет. Они появятся после обработки ваших обращений.</Text>
</Card>
</div>
);
}
const canEdit = canEditProfile(contact);
if (canEdit) {
return (
<div className="profile-page">
<Card
className="profile-card"
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
extra={onNavigate ? <Button type="link" onClick={() => onNavigate('/')}>Домой</Button> : null}
>
<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>
))}
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
Сохранить изменения
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}
const items = PROFILE_FIELDS.map(({ keys, label }) => ({
key: keys[0],
label,
children: getValue(contact, keys) || '—',
}));
return (
<div className="profile-page">
<Card className="profile-card" title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}>
<Descriptions column={1} size="small" bordered>
{items.map((item) => (
<Descriptions.Item key={item.key} label={item.label}>
{item.children}
</Descriptions.Item>
))}
</Descriptions>
</Card>
</div>
);
}