feat: add Referrals tab to admin user detail page

- New Рефералы tab with three sections:
  - Referred By: shows who referred this user, assign/remove referrer
  - Stats grid: total referrals, earnings, commission, referral code
  - Referrals list: all referred users with navigate and remove buttons
- User search with debounce for assigning referrer
- Shows warning when selected user already has a referrer
- API methods: assignReferrer, removeReferrer, removeReferral
- i18n translations for all 4 locales (en, ru, zh, fa)
This commit is contained in:
Fringg
2026-03-29 03:43:47 +03:00
parent 3e6c0212a6
commit e32663f291
6 changed files with 444 additions and 6 deletions

View File

@@ -536,6 +536,34 @@ export const adminUsersApi = {
return response.data; return response.data;
}, },
// Assign a referrer to this user
assignReferrer: async (
userId: number,
referrerId: number,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/assign-referrer`, {
referrer_id: referrerId,
});
return response.data;
},
// Remove this user's referrer
removeReferrer: async (userId: number): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/referrer`);
return response.data;
},
// Remove a specific referral from this user's list
removeReferral: async (
userId: number,
referralUserId: number,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(
`/cabinet/admin/users/${userId}/referrals/${referralUserId}`,
);
return response.data;
},
// Get referrals // Get referrals
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => { getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, { const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {

View File

@@ -2940,7 +2940,8 @@
"balance": "Balance", "balance": "Balance",
"sync": "Synchronization", "sync": "Synchronization",
"tickets": "Tickets", "tickets": "Tickets",
"gifts": "Gifts" "gifts": "Gifts",
"referrals": "Referrals"
}, },
"noTickets": "No tickets from this user", "noTickets": "No tickets from this user",
"ticketsCount": "tickets", "ticketsCount": "tickets",
@@ -3100,6 +3101,26 @@
"unknownUser": "Unknown", "unknownUser": "Unknown",
"totalSent": "Total sent", "totalSent": "Total sent",
"totalReceived": "Total received" "totalReceived": "Total received"
},
"referrals": {
"referredBy": "Referred By",
"noReferrer": "No referrer assigned",
"assignReferrer": "Assign Referrer",
"removeReferrer": "Remove",
"referrerAssigned": "Referrer assigned successfully",
"referrerRemoved": "Referrer removed successfully",
"totalReferrals": "Total Referrals",
"totalEarnings": "Total Earnings",
"commission": "Commission",
"referralCode": "Referral Code",
"default": "default",
"referralsList": "Referrals",
"noReferrals": "No referrals yet",
"removeReferral": "Remove referral",
"referralRemoved": "Referral removed successfully",
"searchPlaceholder": "Search by name, email, or Telegram ID...",
"alreadyReferred": "already has referrer",
"noUsersFound": "No users found"
} }
} }
}, },

View File

@@ -2562,7 +2562,8 @@
"balance": "موجودی", "balance": "موجودی",
"sync": "همگام‌سازی", "sync": "همگام‌سازی",
"tickets": "تیکت‌ها", "tickets": "تیکت‌ها",
"gifts": "هدایا" "gifts": "هدایا",
"referrals": "معرفی‌ها"
}, },
"noTickets": "این کاربر تیکتی ندارد", "noTickets": "این کاربر تیکتی ندارد",
"ticketsCount": "تیکت", "ticketsCount": "تیکت",
@@ -2718,6 +2719,26 @@
"unknownUser": "ناشناس", "unknownUser": "ناشناس",
"totalSent": "کل ارسال شده", "totalSent": "کل ارسال شده",
"totalReceived": "کل دریافت شده" "totalReceived": "کل دریافت شده"
},
"referrals": {
"referredBy": "معرفی‌شده توسط",
"noReferrer": "معرفی‌کننده‌ای تعیین نشده",
"assignReferrer": "تعیین معرفی‌کننده",
"removeReferrer": "حذف",
"referrerAssigned": "معرفی‌کننده تعیین شد",
"referrerRemoved": "معرفی‌کننده حذف شد",
"totalReferrals": "کل معرفی‌ها",
"totalEarnings": "کل درآمد",
"commission": "کمیسیون",
"referralCode": "کد معرفی",
"default": "پیش‌فرض",
"referralsList": "لیست معرفی‌ها",
"noReferrals": "هنوز معرفی‌ای نیست",
"removeReferral": "حذف معرفی",
"referralRemoved": "معرفی حذف شد",
"searchPlaceholder": "جستجو بر اساس نام، ایمیل یا شناسه تلگرام...",
"alreadyReferred": "قبلاً معرفی شده",
"noUsersFound": "کاربری یافت نشد"
} }
} }
}, },

View File

@@ -3452,7 +3452,8 @@
"balance": "Баланс", "balance": "Баланс",
"sync": "Синхронизация", "sync": "Синхронизация",
"tickets": "Тикеты", "tickets": "Тикеты",
"gifts": "Подарки" "gifts": "Подарки",
"referrals": "Рефералы"
}, },
"noTickets": "У пользователя нет тикетов", "noTickets": "У пользователя нет тикетов",
"ticketsCount": "тикетов", "ticketsCount": "тикетов",
@@ -3611,6 +3612,26 @@
"unknownUser": "Неизвестный", "unknownUser": "Неизвестный",
"totalSent": "Всего отправлено", "totalSent": "Всего отправлено",
"totalReceived": "Всего получено" "totalReceived": "Всего получено"
},
"referrals": {
"referredBy": "Приглашён",
"noReferrer": "Реферер не назначен",
"assignReferrer": "Назначить реферера",
"removeReferrer": "Удалить",
"referrerAssigned": "Реферер назначен",
"referrerRemoved": "Реферер удалён",
"totalReferrals": "Всего рефералов",
"totalEarnings": "Всего заработано",
"commission": "Комиссия",
"referralCode": "Реферальный код",
"default": "стандарт",
"referralsList": "Рефералы",
"noReferrals": "Рефералов пока нет",
"removeReferral": "Удалить реферала",
"referralRemoved": "Реферал удалён",
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
"alreadyReferred": "уже чей-то реферал",
"noUsersFound": "Пользователи не найдены"
} }
} }
}, },

View File

@@ -2561,7 +2561,8 @@
"balance": "余额", "balance": "余额",
"sync": "同步", "sync": "同步",
"tickets": "工单", "tickets": "工单",
"gifts": "礼物" "gifts": "礼物",
"referrals": "推荐"
}, },
"noTickets": "该用户没有工单", "noTickets": "该用户没有工单",
"ticketsCount": "个工单", "ticketsCount": "个工单",
@@ -2717,6 +2718,26 @@
"unknownUser": "未知", "unknownUser": "未知",
"totalSent": "已发送总数", "totalSent": "已发送总数",
"totalReceived": "已收到总数" "totalReceived": "已收到总数"
},
"referrals": {
"referredBy": "推荐人",
"noReferrer": "未分配推荐人",
"assignReferrer": "分配推荐人",
"removeReferrer": "移除",
"referrerAssigned": "推荐人已分配",
"referrerRemoved": "推荐人已移除",
"totalReferrals": "总推荐数",
"totalEarnings": "总收益",
"commission": "佣金",
"referralCode": "推荐码",
"default": "默认",
"referralsList": "推荐列表",
"noReferrals": "暂无推荐",
"removeReferral": "移除推荐",
"referralRemoved": "推荐已移除",
"searchPlaceholder": "按名称、邮箱或 Telegram ID 搜索...",
"alreadyReferred": "已有推荐人",
"noUsersFound": "未找到用户"
} }
} }
}, },

View File

@@ -296,7 +296,7 @@ export default function AdminUserDetail() {
const [user, setUser] = useState<UserDetailResponse | null>(null); const [user, setUser] = useState<UserDetailResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals'
>('info'); >('info');
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null); const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]); const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
@@ -306,6 +306,15 @@ export default function AdminUserDetail() {
const [referrals, setReferrals] = useState<UserListItem[]>([]); const [referrals, setReferrals] = useState<UserListItem[]>([]);
const [referralsLoading, setReferralsLoading] = useState(false); const [referralsLoading, setReferralsLoading] = useState(false);
// Referrals tab state
const [referralsList, setReferralsList] = useState<UserListItem[]>([]);
const [referralsTotal, setReferralsTotal] = useState(0);
const [referralsListLoading, setReferralsListLoading] = useState(false);
const [referrerSearchQuery, setReferrerSearchQuery] = useState('');
const [showReferrerSearch, setShowReferrerSearch] = useState(false);
const [referrerSearchResults, setReferrerSearchResults] = useState<UserListItem[]>([]);
const referrerSearchRef = useRef<HTMLDivElement>(null);
// Panel info & node usage // Panel info & node usage
const [panelInfo, setPanelInfo] = useState<UserPanelInfo | null>(null); const [panelInfo, setPanelInfo] = useState<UserPanelInfo | null>(null);
const [panelInfoLoading, setPanelInfoLoading] = useState(false); const [panelInfoLoading, setPanelInfoLoading] = useState(false);
@@ -441,6 +450,20 @@ export default function AdminUserDetail() {
} }
}, [userId]); }, [userId]);
const loadReferralsList = useCallback(async () => {
if (!userId) return;
setReferralsListLoading(true);
try {
const data = await adminUsersApi.getReferrals(userId, 0, 100);
setReferralsList(data.users || []);
setReferralsTotal(data.total || 0);
} catch {
// silent
} finally {
setReferralsListLoading(false);
}
}, [userId]);
const loadPanelInfo = useCallback(async () => { const loadPanelInfo = useCallback(async () => {
if (!userId) return; if (!userId) return;
try { try {
@@ -559,6 +582,7 @@ export default function AdminUserDetail() {
} }
if (activeTab === 'tickets') loadTickets(); if (activeTab === 'tickets') loadTickets();
if (activeTab === 'gifts') loadGifts(); if (activeTab === 'gifts') loadGifts();
if (activeTab === 'referrals') loadReferralsList();
}, [ }, [
activeTab, activeTab,
loadSyncStatus, loadSyncStatus,
@@ -568,6 +592,7 @@ export default function AdminUserDetail() {
loadSubscriptionData, loadSubscriptionData,
loadPromoGroups, loadPromoGroups,
loadGifts, loadGifts,
loadReferralsList,
hasPermission, hasPermission,
]); ]);
@@ -872,6 +897,90 @@ export default function AdminUserDetail() {
} }
}; };
// Referrals tab: assign referrer
const handleAssignReferrer = async (referrerId: number) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.assignReferrer(userId, referrerId);
await loadUser();
setShowReferrerSearch(false);
setReferrerSearchQuery('');
setReferrerSearchResults([]);
notify.success(t('admin.users.detail.referrals.referrerAssigned'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
// Referrals tab: remove referrer
const handleRemoveReferrer = async () => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.removeReferrer(userId);
await loadUser();
notify.success(t('admin.users.detail.referrals.referrerRemoved'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
// Referrals tab: remove a referral
const handleRemoveReferral = async (referralUserId: number) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.removeReferral(userId, referralUserId);
await loadReferralsList();
await loadUser();
notify.success(t('admin.users.detail.referrals.referralRemoved'));
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { detail?: string } } };
notify.error(axiosErr?.response?.data?.detail || t('common.error'));
} finally {
setActionLoading(false);
}
};
// Referrals tab: debounced user search for referrer assignment
useEffect(() => {
if (referrerSearchQuery.length < 2 || !showReferrerSearch) {
setReferrerSearchResults([]);
return;
}
const timer = setTimeout(async () => {
try {
const data = await adminUsersApi.getUsers({ search: referrerSearchQuery, limit: 10 });
setReferrerSearchResults(data.users || []);
} catch {
setReferrerSearchResults([]);
}
}, 300);
return () => clearTimeout(timer);
}, [referrerSearchQuery, showReferrerSearch]);
// Referrals tab: close search dropdown on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (referrerSearchRef.current && !referrerSearchRef.current.contains(e.target as Node)) {
setShowReferrerSearch(false);
setReferrerSearchQuery('');
setReferrerSearchResults([]);
}
};
if (showReferrerSearch) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [showReferrerSearch]);
const handleResetTrial = async () => { const handleResetTrial = async () => {
if (!userId) return; if (!userId) return;
setActionLoading(true); setActionLoading(true);
@@ -1033,7 +1142,7 @@ export default function AdminUserDetail() {
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1" className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
style={{ WebkitOverflowScrolling: 'touch' }} style={{ WebkitOverflowScrolling: 'touch' }}
> >
{(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts'] as const) {(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts', 'referrals'] as const)
.filter((tab) => tab !== 'sync' || hasPermission('users:sync')) .filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
.map((tab) => ( .map((tab) => (
<button <button
@@ -1051,6 +1160,7 @@ export default function AdminUserDetail() {
{tab === 'sync' && t('admin.users.detail.tabs.sync')} {tab === 'sync' && t('admin.users.detail.tabs.sync')}
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')} {tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
{tab === 'gifts' && t('admin.users.detail.tabs.gifts')} {tab === 'gifts' && t('admin.users.detail.tabs.gifts')}
{tab === 'referrals' && t('admin.users.detail.tabs.referrals')}
</button> </button>
))} ))}
</div> </div>
@@ -2863,6 +2973,222 @@ export default function AdminUserDetail() {
)} )}
</div> </div>
)} )}
{/* Referrals Tab */}
{activeTab === 'referrals' && user && (
<div className="space-y-6">
{/* Section 1: Who referred this user */}
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/40 p-5">
<h3 className="mb-4 text-base font-semibold text-dark-100">
{t('admin.users.detail.referrals.referredBy')}
</h3>
{user.referral.referred_by_id ? (
<div className="flex items-center justify-between gap-3">
<button
onClick={() => navigate(`/admin/users/${user.referral.referred_by_id}`)}
className="flex items-center gap-3 rounded-xl bg-dark-700/30 px-4 py-3 transition-colors hover:bg-dark-700/50"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-500/20 text-sm font-bold text-accent-400">
{(user.referral.referred_by_username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{user.referral.referred_by_username ||
`ID: ${user.referral.referred_by_id}`}
</div>
<div className="text-xs text-dark-500">
ID: {user.referral.referred_by_id}
</div>
</div>
</button>
<button
onClick={handleRemoveReferrer}
disabled={actionLoading}
className="rounded-lg border border-error-500/30 bg-error-500/10 px-3 py-2 text-sm text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
>
{t('admin.users.detail.referrals.removeReferrer')}
</button>
</div>
) : (
<div>
{showReferrerSearch ? (
<div ref={referrerSearchRef} className="relative">
<div className="flex gap-2">
<input
type="text"
value={referrerSearchQuery}
onChange={(e) => setReferrerSearchQuery(e.target.value)}
placeholder={t('admin.users.detail.referrals.searchPlaceholder')}
className="flex-1 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
autoFocus
/>
<button
onClick={() => {
setShowReferrerSearch(false);
setReferrerSearchQuery('');
setReferrerSearchResults([]);
}}
className="rounded-lg bg-dark-700 px-3 py-2.5 text-sm text-dark-400 hover:bg-dark-600"
>
{t('common.cancel')}
</button>
</div>
{referrerSearchQuery.length >= 2 && referrerSearchResults.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-60 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{referrerSearchResults
.filter((u) => u.id !== userId)
.map((u) => (
<button
key={u.id}
onClick={() => handleAssignReferrer(u.id)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-dark-700/50"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-600/50 text-xs font-bold text-dark-300">
{(u.full_name || u.username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm text-dark-100">
{u.full_name || u.username || `ID: ${u.id}`}
</div>
<div className="text-xs text-dark-500">
{u.telegram_id ? `TG: ${u.telegram_id}` : `ID: ${u.id}`}
</div>
</div>
</button>
))}
{referrerSearchResults.filter((u) => u.id !== userId).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-dark-500">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
)}
{referrerSearchQuery.length >= 2 && referrerSearchResults.length === 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 rounded-xl border border-dark-700 bg-dark-800 py-4 text-center text-sm text-dark-500 shadow-xl">
{t('admin.users.detail.referrals.noUsersFound')}
</div>
)}
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-dark-500">
{t('admin.users.detail.referrals.noReferrer')}
</span>
<button
onClick={() => setShowReferrerSearch(true)}
className="rounded-lg bg-accent-500/15 px-3 py-2 text-sm text-accent-400 transition-colors hover:bg-accent-500/25"
>
{t('admin.users.detail.referrals.assignReferrer')}
</button>
</div>
)}
</div>
)}
</div>
{/* Section 2: Referral stats */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalReferrals')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.referrals_count}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.totalEarnings')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.commission')}
</div>
<div className="mt-1 text-xl font-bold text-dark-100">
{user.referral.commission_percent ?? t('admin.users.detail.referrals.default')}%
</div>
</div>
<div className="rounded-xl bg-dark-800/40 p-4">
<div className="text-xs text-dark-500">
{t('admin.users.detail.referrals.referralCode')}
</div>
<div className="mt-1 truncate font-mono text-sm text-dark-100">
{user.referral.referral_code}
</div>
</div>
</div>
{/* Section 3: Referrals list */}
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/40 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-dark-100">
{t('admin.users.detail.referrals.referralsList')} ({referralsTotal})
</h3>
</div>
{referralsListLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : referralsList.length === 0 ? (
<div className="py-8 text-center text-dark-500">
{t('admin.users.detail.referrals.noReferrals')}
</div>
) : (
<div className="space-y-2">
{referralsList.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between rounded-xl bg-dark-700/20 px-4 py-3"
>
<button
onClick={() => navigate(`/admin/users/${ref.id}`)}
className="flex items-center gap-3 text-left"
>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-dark-600/50 text-sm font-bold text-dark-300">
{(ref.full_name || ref.username || '?')[0].toUpperCase()}
</div>
<div>
<div className="text-sm font-medium text-dark-100">
{ref.full_name || ref.username || `ID: ${ref.id}`}
</div>
<div className="text-xs text-dark-500">
{ref.telegram_id ? `TG: ${ref.telegram_id}` : `ID: ${ref.id}`}
</div>
</div>
</button>
<button
onClick={() => handleRemoveReferral(ref.id)}
disabled={actionLoading}
className="rounded-lg p-2 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400 disabled:opacity-50"
title={t('admin.users.detail.referrals.removeReferral')}
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
); );