import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; import { useCurrency } from '../hooks/useCurrency'; import { useNotify } from '../platform/hooks/useNotify'; import { adminUsersApi, type UserDetailResponse, type UserAvailableTariff, type UserListItem, type UserPanelInfo, type UserNodeUsageResponse, type PanelSyncStatusResponse, type UpdateSubscriptionRequest, type AdminUserGiftsResponse, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; import { promoOffersApi } from '../api/promoOffers'; import { ticketsApi } from '../api/tickets'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; // ============ Helpers ============ const getCountryFlag = (code: string | null | undefined): string => { if (!code) return ''; const codeMap: Record = { RU: '\u{1F1F7}\u{1F1FA}', US: '\u{1F1FA}\u{1F1F8}', DE: '\u{1F1E9}\u{1F1EA}', NL: '\u{1F1F3}\u{1F1F1}', GB: '\u{1F1EC}\u{1F1E7}', UK: '\u{1F1EC}\u{1F1E7}', FR: '\u{1F1EB}\u{1F1F7}', FI: '\u{1F1EB}\u{1F1EE}', SE: '\u{1F1F8}\u{1F1EA}', NO: '\u{1F1F3}\u{1F1F4}', PL: '\u{1F1F5}\u{1F1F1}', TR: '\u{1F1F9}\u{1F1F7}', JP: '\u{1F1EF}\u{1F1F5}', SG: '\u{1F1F8}\u{1F1EC}', HK: '\u{1F1ED}\u{1F1F0}', KR: '\u{1F1F0}\u{1F1F7}', AU: '\u{1F1E6}\u{1F1FA}', CA: '\u{1F1E8}\u{1F1E6}', CH: '\u{1F1E8}\u{1F1ED}', AT: '\u{1F1E6}\u{1F1F9}', IT: '\u{1F1EE}\u{1F1F9}', ES: '\u{1F1EA}\u{1F1F8}', BR: '\u{1F1E7}\u{1F1F7}', IN: '\u{1F1EE}\u{1F1F3}', AE: '\u{1F1E6}\u{1F1EA}', IL: '\u{1F1EE}\u{1F1F1}', KZ: '\u{1F1F0}\u{1F1FF}', UA: '\u{1F1FA}\u{1F1E6}', CZ: '\u{1F1E8}\u{1F1FF}', RO: '\u{1F1F7}\u{1F1F4}', LV: '\u{1F1F1}\u{1F1FB}', LT: '\u{1F1F1}\u{1F1F9}', EE: '\u{1F1EA}\u{1F1EA}', BG: '\u{1F1E7}\u{1F1EC}', HU: '\u{1F1ED}\u{1F1FA}', MD: '\u{1F1F2}\u{1F1E9}', }; return codeMap[code.toUpperCase()] || code; }; // ============ Icons ============ const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( ); const PlusIcon = () => ( ); const MinusIcon = () => ( ); const ArrowDownIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( ); const ArrowUpIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( ); const TelegramIcon = () => ( ); // ============ Components ============ function StatusBadge({ status }: { status: string }) { const styles: Record = { active: 'bg-success-500/20 text-success-400 border-success-500/30', blocked: 'bg-error-500/20 text-error-400 border-error-500/30', deleted: 'bg-dark-600 text-dark-400 border-dark-500', trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30', expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30', limited: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', disabled: 'bg-dark-600 text-dark-400 border-dark-500', }; return ( {status} ); } function GiftStatusBadge({ status }: { status: string }) { const { t } = useTranslation(); const styles: Record = { pending: 'bg-warning-500/20 text-warning-400 border-warning-500/30', paid: 'bg-accent-500/20 text-accent-400 border-accent-500/30', delivered: 'bg-success-500/20 text-success-400 border-success-500/30', pending_activation: 'bg-accent-500/20 text-accent-400 border-accent-500/30', failed: 'bg-error-500/20 text-error-400 border-error-500/30', expired: 'bg-dark-600 text-dark-400 border-dark-500', }; const fallback = 'bg-dark-600 text-dark-400 border-dark-500'; const label = t(`admin.users.detail.gifts.status.${status}`, { defaultValue: '' }) || status; return ( {label} ); } function GiftCard({ gift, direction, locale, onNavigateToUser, }: { gift: import('../api/adminUsers').AdminUserGiftItem; direction: 'sent' | 'received'; locale: string; onNavigateToUser: (userId: number) => void; }) { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const isSent = direction === 'sent'; const otherPartyLabel = isSent ? t('admin.users.detail.gifts.recipient') : t('admin.users.detail.gifts.sender'); const otherPartyName = isSent ? gift.receiver_username ? `@${gift.receiver_username}` : gift.gift_recipient_value || t('admin.users.detail.gifts.codeOnly') : gift.buyer_username ? `@${gift.buyer_username}` : gift.buyer_full_name || t('admin.users.detail.gifts.unknownUser'); const otherPartyId = isSent ? gift.receiver_user_id : gift.buyer_user_id; const dateOpts: Intl.DateTimeFormatOptions = { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }; return (
{/* Header */}
{gift.tariff_name || '—'}
{gift.period_days} {t('admin.users.detail.gifts.days')} · {gift.device_limit}{' '} {t('admin.users.detail.gifts.devices')}
{/* Details grid */}
{otherPartyLabel}:{' '} {otherPartyName} {otherPartyId && ( )}
{t('admin.users.detail.gifts.amount')}:{' '} {formatWithCurrency(gift.amount_kopeks / 100)}
{t('admin.users.detail.gifts.paymentMethod')}:{' '} {gift.payment_method || '—'}
{t('admin.users.detail.gifts.createdAt')}:{' '} {gift.created_at ? new Date(gift.created_at).toLocaleString(locale, dateOpts) : '—'}
{gift.paid_at && (
{t('admin.users.detail.gifts.paidAt')}:{' '} {new Date(gift.paid_at).toLocaleString(locale, dateOpts)}
)} {gift.delivered_at && (
{t('admin.users.detail.gifts.deliveredAt')}:{' '} {new Date(gift.delivered_at).toLocaleString(locale, dateOpts)}
)}
{/* Gift message */} {gift.gift_message && (
“{gift.gift_message}”
)} {/* Token */}
GIFT-{gift.token}
); } // ============ Main Page ============ export default function AdminUserDetail() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); const notify = useNotify(); const { id } = useParams<{ id: string }>(); const hasPermission = usePermissionStore((s) => s.hasPermission); const localeMap: Record = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' }; const locale = localeMap[i18n.language] || 'ru-RU'; const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState< 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' >('info'); const [syncStatus, setSyncStatus] = useState(null); const [tariffs, setTariffs] = useState([]); const [actionLoading, setActionLoading] = useState(false); // Referrals const [referrals, setReferrals] = useState([]); const [referralsLoading, setReferralsLoading] = useState(false); // Referrals tab state const [referralsList, setReferralsList] = useState([]); const [referralsTotal, setReferralsTotal] = useState(0); const [referralsListLoading, setReferralsListLoading] = useState(false); const [referrerSearchQuery, setReferrerSearchQuery] = useState(''); const [showReferrerSearch, setShowReferrerSearch] = useState(false); const [referrerSearchResults, setReferrerSearchResults] = useState([]); const [referrerSearchLoading, setReferrerSearchLoading] = useState(false); const referrerSearchRef = useRef(null); // Add referral (bind someone as this user's referral) const [showAddReferral, setShowAddReferral] = useState(false); const [addReferralSearchQuery, setAddReferralSearchQuery] = useState(''); const [addReferralSearchResults, setAddReferralSearchResults] = useState([]); const [addReferralSearchLoading, setAddReferralSearchLoading] = useState(false); const addReferralSearchRef = useRef(null); // Panel info & node usage const [panelInfo, setPanelInfo] = useState(null); const [panelInfoLoading, setPanelInfoLoading] = useState(false); const [nodeUsage, setNodeUsage] = useState(null); const [nodeUsageDays, setNodeUsageDays] = useState(7); // Inline confirm state const [confirmingAction, setConfirmingAction] = useState(null); const confirmTimerRef = useRef | null>(null); // Balance form const [balanceAmount, setBalanceAmount] = useState(''); const [balanceDescription, setBalanceDescription] = useState(''); // Tickets const [tickets, setTickets] = useState([]); const [ticketsLoading, setTicketsLoading] = useState(false); const [ticketsTotal, setTicketsTotal] = useState(0); const [selectedTicketId, setSelectedTicketId] = useState(null); const [selectedTicket, setSelectedTicket] = useState(null); const [ticketDetailLoading, setTicketDetailLoading] = useState(false); const [replyText, setReplyText] = useState(''); const [replySending, setReplySending] = useState(false); const messagesEndRef = useRef(null); // Subscription form const [subAction, setSubAction] = useState('extend'); const [subDays, setSubDays] = useState(30); const [selectedTariffId, setSelectedTariffId] = useState(null); const [activeSubscriptionId, setActiveSubscriptionId] = useState(null); const hasAutoSelectedSub = useRef(false); const [subscriptionDetailView, setSubscriptionDetailView] = useState(false); // Promo group const [promoGroups, setPromoGroups] = useState([]); const [editingPromoGroup, setEditingPromoGroup] = useState(false); // Referral commission const [editingReferralCommission, setEditingReferralCommission] = useState(false); const [referralCommissionValue, setReferralCommissionValue] = useState(''); // Send promo offer const [offerDiscountPercent, setOfferDiscountPercent] = useState(''); const [offerValidHours, setOfferValidHours] = useState(24); const [offerSending, setOfferSending] = useState(false); // Traffic packages const [selectedTrafficGb, setSelectedTrafficGb] = useState(''); // Devices const [devices, setDevices] = useState< { hwid: string; platform: string; device_model: string; created_at: string | null }[] >([]); const [devicesTotal, setDevicesTotal] = useState(0); const [deviceLimit, setDeviceLimit] = useState(0); const [devicesLoading, setDevicesLoading] = useState(false); // Gifts const [giftsData, setGiftsData] = useState(null); const [giftsLoading, setGiftsLoading] = useState(false); const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { if (!userId) return; try { setLoading(true); const data = await adminUsersApi.getUser(userId); setUser(data); } catch (error) { console.error('Failed to load user:', error); navigate('/admin/users'); } finally { setLoading(false); } }, [userId, navigate]); const loadSyncStatus = useCallback(async () => { if (!userId) return; try { const data = await adminUsersApi.getSyncStatus(userId, activeSubscriptionId ?? undefined); setSyncStatus(data); } catch (error) { console.error('Failed to load sync status:', error); } }, [userId, activeSubscriptionId]); const loadTariffs = useCallback(async () => { if (!userId) return; try { const data = await adminUsersApi.getAvailableTariffs(userId, true); setTariffs(data.tariffs); } catch (error) { console.error('Failed to load tariffs:', error); } }, [userId]); const loadTickets = useCallback(async () => { if (!userId) return; try { setTicketsLoading(true); const data = await adminApi.getTickets({ user_id: userId, per_page: 50 }); setTickets(data.items); setTicketsTotal(data.total); } catch (error) { console.error('Failed to load tickets:', error); } finally { setTicketsLoading(false); } }, [userId]); const loadTicketDetail = useCallback(async (ticketId: number) => { try { setTicketDetailLoading(true); const data = await adminApi.getTicket(ticketId); setSelectedTicket(data); } catch (error) { console.error('Failed to load ticket detail:', error); } finally { setTicketDetailLoading(false); } }, []); const loadReferrals = useCallback(async () => { if (!userId) return; try { setReferralsLoading(true); const data = await adminUsersApi.getReferrals(userId, 0, 50); setReferrals(data.users || []); } catch { } finally { setReferralsLoading(false); } }, [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 () => { if (!userId) return; try { setPanelInfoLoading(true); const data = await adminUsersApi.getPanelInfo(userId, activeSubscriptionId ?? undefined); setPanelInfo(data); } catch { } finally { setPanelInfoLoading(false); } }, [userId, activeSubscriptionId]); const loadNodeUsage = useCallback(async () => { if (!userId) return; try { const data = await adminUsersApi.getNodeUsage(userId, activeSubscriptionId ?? undefined); setNodeUsage(data); } catch {} }, [userId, activeSubscriptionId]); const loadDevices = useCallback(async () => { if (!userId) return; try { setDevicesLoading(true); const data = await adminUsersApi.getUserDevices(userId, activeSubscriptionId ?? undefined); setDevices(data.devices); setDevicesTotal(data.total); setDeviceLimit(data.device_limit); } catch { } finally { setDevicesLoading(false); } }, [userId, activeSubscriptionId]); const loadSubscriptionData = useCallback(async () => { await Promise.all([loadPanelInfo(), loadNodeUsage(), loadDevices()]); }, [loadPanelInfo, loadNodeUsage, loadDevices]); const loadGifts = useCallback(async () => { if (!userId) return; try { setGiftsLoading(true); const data = await adminUsersApi.getUserGifts(userId); setGiftsData(data); } catch { } finally { setGiftsLoading(false); } }, [userId]); const loadPromoGroups = useCallback(async () => { try { const data = await promocodesApi.getPromoGroups({ limit: 100 }); setPromoGroups(data.items); } catch {} }, []); const handleTicketReply = async () => { if (!selectedTicketId || !replyText.trim()) return; setReplySending(true); try { await adminApi.replyToTicket(selectedTicketId, replyText); setReplyText(''); await loadTicketDetail(selectedTicketId); await loadTickets(); } catch (error) { console.error('Failed to reply:', error); } finally { setReplySending(false); } }; const handleTicketStatusChange = async (newStatus: string) => { if (!selectedTicketId) return; setActionLoading(true); try { await adminApi.updateTicketStatus(selectedTicketId, newStatus); await loadTicketDetail(selectedTicketId); await loadTickets(); } catch (error) { console.error('Failed to update ticket status:', error); } finally { setActionLoading(false); } }; useEffect(() => { if (selectedTicketId) { loadTicketDetail(selectedTicketId); } }, [selectedTicketId, loadTicketDetail]); useEffect(() => { if (selectedTicket && messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [selectedTicket]); useEffect(() => { if (!userId || isNaN(userId)) { navigate('/admin/users'); return; } loadUser(); }, [userId, loadUser, navigate]); useEffect(() => { if (activeTab === 'info') { loadReferrals(); if (hasPermission('users:promo_group')) loadPromoGroups(); } if (activeTab === 'sync' && hasPermission('users:sync')) loadSyncStatus(); if (activeTab === 'subscription') { loadTariffs(); loadSubscriptionData(); } if (activeTab === 'tickets') loadTickets(); if (activeTab === 'gifts') loadGifts(); if (activeTab === 'referrals') loadReferralsList(); }, [ activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadSubscriptionData, loadPromoGroups, loadGifts, loadReferralsList, hasPermission, ]); const handleUpdateBalance = async (isAdd: boolean) => { if (balanceAmount === '' || !userId) return; setActionLoading(true); try { const amount = Math.abs(toNumber(balanceAmount) * 100); await adminUsersApi.updateBalance(userId, { amount_kopeks: isAdd ? amount : -amount, description: balanceDescription || (isAdd ? t('admin.users.detail.balance.addByAdmin') : t('admin.users.detail.balance.subtractByAdmin')), }); await loadUser(); setBalanceAmount(''); setBalanceDescription(''); } catch (error) { console.error('Failed to update balance:', error); } finally { setActionLoading(false); } }; const handleUpdateSubscription = async (overrideAction?: string) => { if (!userId) return; const action = overrideAction || subAction; if ((action === 'extend' || action === 'shorten') && toNumber(subDays, 0) <= 0) { notify.error(t('admin.users.detail.subscription.invalidDays')); return; } setActionLoading(true); try { const data: UpdateSubscriptionRequest = { action: action as UpdateSubscriptionRequest['action'], ...(activeSubscriptionId && action !== 'create' ? { subscription_id: activeSubscriptionId } : {}), ...(action === 'extend' || action === 'shorten' ? { days: toNumber(subDays, 30) } : {}), ...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), ...(action === 'create' ? { days: toNumber(subDays, 30), ...(selectedTariffId ? { tariff_id: selectedTariffId } : {}), } : {}), }; await adminUsersApi.updateSubscription(userId, data); await loadUser(); } catch (error) { console.error('Failed to update subscription:', error); } finally { setActionLoading(false); } }; const handleBlockUser = async () => { if (!userId || !confirm(t('admin.users.confirm.block'))) return; setActionLoading(true); try { await adminUsersApi.blockUser(userId); await loadUser(); } catch (error) { console.error('Failed to block user:', error); } finally { setActionLoading(false); } }; const handleUnblockUser = async () => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.unblockUser(userId); await loadUser(); } catch (error) { console.error('Failed to unblock user:', error); } finally { setActionLoading(false); } }; const handleSyncFromPanel = async () => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.syncFromPanel( userId, { update_subscription: true, update_traffic: true, }, activeSubscriptionId ?? undefined, ); await loadUser(); await loadSyncStatus(); } catch (error) { console.error('Failed to sync from panel:', error); } finally { setActionLoading(false); } }; const handleSyncToPanel = async () => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.syncToPanel( userId, { create_if_missing: true }, activeSubscriptionId ?? undefined, ); await loadUser(); await loadSyncStatus(); } catch (error) { console.error('Failed to sync to panel:', error); } finally { setActionLoading(false); } }; const handleInlineConfirm = (actionKey: string, executeFn: () => Promise) => { if (confirmingAction === actionKey) { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(null); executeFn().catch(() => {}); } else { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(actionKey); confirmTimerRef.current = setTimeout(() => setConfirmingAction(null), 3000); } }; const handleDeleteDevice = async (hwid: string) => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.deleteUserDevice(userId, hwid, activeSubscriptionId ?? undefined); notify.success(t('admin.users.detail.devices.deleted')); await loadDevices(); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleResetDevices = async () => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.resetUserDevices(userId, activeSubscriptionId ?? undefined); notify.success(t('admin.users.detail.devices.allDeleted')); await loadDevices(); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleAddTraffic = async (gb: number) => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.updateSubscription(userId, { action: 'add_traffic', traffic_gb: gb, ...(activeSubscriptionId ? { subscription_id: activeSubscriptionId } : {}), }); notify.success(t('admin.users.detail.subscription.trafficAdded')); setSelectedTrafficGb(''); await loadUser(); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleRemoveTraffic = async (purchaseId: number) => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.updateSubscription(userId, { action: 'remove_traffic', traffic_purchase_id: purchaseId, ...(activeSubscriptionId ? { subscription_id: activeSubscriptionId } : {}), }); notify.success(t('admin.users.detail.subscription.trafficRemoved')); await loadUser(); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleSetDeviceLimit = async (newLimit: number) => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.updateSubscription(userId, { action: 'set_device_limit', device_limit: newLimit, ...(activeSubscriptionId ? { subscription_id: activeSubscriptionId } : {}), }); notify.success(t('admin.users.detail.subscription.deviceLimitUpdated')); await loadUser(); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; // Multi-subscription: pick active subscription or first from list const userSubscriptions = useMemo(() => user?.subscriptions ?? [], [user?.subscriptions]); const selectedSub = userSubscriptions.find((s) => s.id === activeSubscriptionId) ?? user?.subscription ?? null; // Auto-select first subscription when user loads (one-time init) useEffect(() => { if (user && userSubscriptions.length > 0 && !hasAutoSelectedSub.current) { const activeSub = userSubscriptions.find((s) => s.is_active) ?? userSubscriptions[0]; setActiveSubscriptionId(activeSub.id); hasAutoSelectedSub.current = true; } }, [user, userSubscriptions]); const currentTariff = tariffs.find((t) => t.id === selectedSub?.tariff_id) || null; const handleChangePromoGroup = async (groupId: number | null) => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.updatePromoGroup(userId, groupId); await loadUser(); setEditingPromoGroup(false); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleUpdateReferralCommission = async () => { if (!userId) return; setActionLoading(true); try { const value = referralCommissionValue === '' ? null : toNumber(referralCommissionValue); if (value !== null && (value < 0 || value > 100)) { notify.error(t('admin.users.detail.referral.invalidPercent'), t('common.error')); return; } await adminUsersApi.updateReferralCommission(userId, value); await loadUser(); setEditingReferralCommission(false); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleDeactivateOffer = async () => { if (!userId) return; setActionLoading(true); try { await promocodesApi.deactivateDiscount(userId); notify.success(t('admin.users.detail.offerDeactivated'), t('common.success')); await loadUser(); } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleSendOffer = async () => { if (!userId || offerDiscountPercent === '' || offerValidHours === '') return; setOfferSending(true); try { await promoOffersApi.broadcastOffer({ user_id: userId, notification_type: 'admin_personal', discount_percent: toNumber(offerDiscountPercent), valid_hours: toNumber(offerValidHours, 24), effect_type: 'percent_discount', send_notification: true, }); notify.success(t('admin.users.detail.offerSent'), t('common.success')); setOfferDiscountPercent(''); setOfferValidHours(24); await loadUser(); } catch { notify.error(t('admin.users.detail.offerSendError'), t('common.error')); } finally { setOfferSending(false); } }; // 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); } }; // Add a referral: assign current user as referrer of selected user const handleAddReferral = async (targetUserId: number) => { if (!userId) return; setActionLoading(true); try { await adminUsersApi.assignReferrer(targetUserId, userId); await loadReferralsList(); await loadUser(); setShowAddReferral(false); setAddReferralSearchQuery(''); setAddReferralSearchResults([]); notify.success(t('admin.users.detail.referrals.referralAdded')); } 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([]); setReferrerSearchLoading(false); return; } setReferrerSearchLoading(true); let cancelled = false; const timer = setTimeout(async () => { try { const data = await adminUsersApi.getUsers({ search: referrerSearchQuery, limit: 10 }); if (!cancelled) { setReferrerSearchResults(data.users || []); setReferrerSearchLoading(false); } } catch { if (!cancelled) { setReferrerSearchResults([]); setReferrerSearchLoading(false); } } }, 300); return () => { cancelled = true; 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]); // Referrals tab: debounced search for adding referral useEffect(() => { if (addReferralSearchQuery.length < 2 || !showAddReferral) { setAddReferralSearchResults([]); setAddReferralSearchLoading(false); return; } setAddReferralSearchLoading(true); let cancelled = false; const timer = setTimeout(async () => { try { const data = await adminUsersApi.getUsers({ search: addReferralSearchQuery, limit: 10 }); if (!cancelled) { setAddReferralSearchResults(data.users || []); setAddReferralSearchLoading(false); } } catch { if (!cancelled) { setAddReferralSearchResults([]); setAddReferralSearchLoading(false); } } }, 300); return () => { cancelled = true; clearTimeout(timer); }; }, [addReferralSearchQuery, showAddReferral]); // Referrals tab: close add-referral dropdown on click outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( addReferralSearchRef.current && !addReferralSearchRef.current.contains(e.target as Node) ) { setShowAddReferral(false); setAddReferralSearchQuery(''); setAddReferralSearchResults([]); } }; if (showAddReferral) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [showAddReferral]); const handleResetTrial = async () => { if (!userId) return; setActionLoading(true); try { const result = await adminUsersApi.resetTrial(userId); if (result.success) { notify.success(t('admin.users.userActions.success.resetTrial'), t('common.success')); await loadUser(); } else { notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); } } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleResetSubscription = async () => { if (!userId) return; setActionLoading(true); try { const result = await adminUsersApi.resetSubscription(userId); if (result.success) { notify.success(t('admin.users.userActions.success.resetSubscription'), t('common.success')); await loadUser(); } else { notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); } } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleDisableUser = async () => { if (!userId) return; setActionLoading(true); try { const result = await adminUsersApi.disableUser(userId); if (result.success) { notify.success(t('admin.users.userActions.success.disable'), t('common.success')); await loadUser(); } else { notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); } } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const handleFullDeleteUser = async () => { if (!userId) return; setActionLoading(true); try { const result = await adminUsersApi.fullDeleteUser(userId); if (result.success) { notify.success(t('admin.users.userActions.success.delete'), t('common.success')); navigate('/admin/users'); } else { notify.error(result.message || t('admin.users.userActions.error'), t('common.error')); } } catch { notify.error(t('admin.users.userActions.error'), t('common.error')); } finally { setActionLoading(false); } }; const formatDate = (date: string | null) => { if (!date) return '-'; return new Date(date).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); }; // Compute node usage for selected period from cached 30-day data const nodeUsageForPeriod = (() => { if (!nodeUsage || nodeUsage.items.length === 0) return []; return nodeUsage.items .map((item) => { const daily = item.daily_bytes || []; const sliced = daily.slice(-nodeUsageDays); const total = sliced.reduce((sum, v) => sum + v, 0); return { ...item, total_bytes: total }; }) .sort((a, b) => b.total_bytes - a.total_bytes); })(); const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; }; const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); notify.success(t('admin.users.detail.copied')); } catch {} }; if (loading) { return (
); } if (!user) { return (

{t('admin.users.notFound')}

); } return (
{/* Header */}
{user.first_name?.[0] || user.username?.[0] || '?'}
{user.full_name}
{user.telegram_id} {user.username && @{user.username}}
{/* Tabs */}
{(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts', 'referrals'] as const) .filter((tab) => tab !== 'sync' || hasPermission('users:sync')) .map((tab) => ( ))}
{/* Content */}
{/* Info Tab */} {activeTab === 'info' && (
{/* Status */}
{t('admin.users.detail.status')}
{user.status === 'active' ? ( ) : user.status === 'blocked' ? ( ) : null}
{/* Details grid */}
Email
{user.email || '-'}
{t('admin.users.detail.language')}
{user.language}
{t('admin.users.detail.registration')}
{formatDate(user.created_at)}
{t('admin.users.detail.lastActivity')}
{formatDate(user.last_activity)}
{t('admin.users.detail.totalSpent')}
{formatWithCurrency(user.total_spent_kopeks / 100)}
{t('admin.users.detail.purchases')}
{user.purchase_count}
{/* Campaign */} {user.campaign_name && (
{t('admin.users.detail.campaign')}
{user.campaign_name}
)} {/* Promo Group */}
{t('admin.users.detail.promoGroup')} {hasPermission('users:promo_group') && ( )}
{editingPromoGroup ? (
{user.promo_group && ( )}
) : (
{user.promo_group?.name || ( {t('admin.users.detail.noPromoGroup')} )}
)}
{/* Referral */}
{t('admin.users.detail.referral.title')} {hasPermission('users:referral') && ( )}
{user.referral.referrals_count}
{t('admin.users.detail.referral.referrals')}
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
{t('admin.users.detail.referral.earned')}
{editingReferralCommission ? (
) : ( <>
{user.referral.commission_percent != null ? `${user.referral.commission_percent}%` : t('admin.users.detail.referral.default')}
{t('admin.users.detail.referral.commission')}
)}
{/* Referrals list */} {user.referral.referrals_count > 0 && (
{t('admin.users.detail.referralsList')}
{referralsLoading ? (
) : referrals.length === 0 ? (
{t('admin.users.detail.noReferrals')}
) : (
{referrals.map((ref) => ( ))}
)}
)} {/* Restrictions */} {(user.restriction_topup || user.restriction_subscription) && (
{t('admin.users.detail.restrictions.title')}
{user.restriction_topup && (
{t('admin.users.detail.restrictions.topup')}
)} {user.restriction_subscription && (
{t('admin.users.detail.restrictions.subscription')}
)} {user.restriction_reason && (
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
)}
)} {/* Actions */}
{t('admin.users.detail.actions.title')}
)} {/* Subscription Tab */} {activeTab === 'subscription' && (
{/* Multi-subscription: Level 1 — subscription list */} {userSubscriptions.length > 1 && !subscriptionDetailView && ( <>
{userSubscriptions.map((sub) => ( ))}
{/* Create new subscription — at list level */} {hasPermission('users:subscription') && (
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
)} )} {/* Level 2 — subscription detail (or single subscription) */} {(subscriptionDetailView || userSubscriptions.length <= 1) && selectedSub ? ( <> {/* Back to list (multi-subscription) */} {subscriptionDetailView && userSubscriptions.length > 1 && ( )} {/* Current subscription */}
{t('admin.users.detail.subscription.current')} {userSubscriptions.length > 1 && ( #{selectedSub.id} )}
{t('admin.users.detail.subscription.tariff')}
{selectedSub.tariff_name || t('admin.users.detail.subscription.notSpecified')}
{t('admin.users.detail.subscription.validUntil')}
{formatDate(selectedSub.end_date)}
{t('admin.users.detail.subscription.traffic')}
{panelInfo?.found ? (panelInfo.used_traffic_bytes / (1024 * 1024 * 1024)).toFixed(1) : selectedSub.traffic_used_gb.toFixed(1)}{' '} / {selectedSub.traffic_limit_gb} {t('common.units.gb')}
{t('admin.users.detail.subscription.devices')}
{selectedSub.device_limit}
{/* Traffic Packages */} {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
{t('admin.users.detail.subscription.trafficPackages')} {selectedSub.purchased_traffic_gb > 0 && ( ({selectedSub.purchased_traffic_gb} {t('common.units.gb')}) )}
{selectedSub.traffic_purchases.map((tp) => (
{tp.traffic_gb} {t('common.units.gb')} {tp.is_expired ? ( {t('admin.users.detail.subscription.expired')} ) : ( {tp.days_remaining}{' '} {t('admin.users.detail.subscription.daysLeft')} )}
{!tp.is_expired && ( )}
))}
)} {/* Add Traffic */} {currentTariff && currentTariff.traffic_topup_enabled && Object.keys(currentTariff.traffic_topup_packages).length > 0 && (
{t('admin.users.detail.subscription.addTraffic')}
{t('admin.users.detail.subscription.addTrafficNote')}
)} {/* Actions */} {hasPermission('users:subscription') && (
{t('admin.users.detail.subscription.actions')}
{(subAction === 'extend' || subAction === 'shorten') && ( )} {subAction === 'change_tariff' && ( )}
)} ) : null} {/* Create new subscription — only for single-sub users or no subs */} {hasPermission('users:subscription') && userSubscriptions.length <= 1 && (
{userSubscriptions.length === 0 && (
{t('admin.users.detail.subscription.noActive')}
)}
{t('admin.users.detail.subscription.createNew', 'Создать подписку')}
)} {/* Panel Info, Traffic, Devices — only inside subscription detail */} {(subscriptionDetailView || userSubscriptions.length <= 1) && ( <> {panelInfoLoading ? (
) : panelInfo && !panelInfo.found ? (
{t('admin.users.detail.panelNotFound')}
) : panelInfo && panelInfo.found ? ( <> {/* Links */} {(panelInfo.subscription_url || panelInfo.happ_link) && (
{t('admin.users.detail.subscriptionUrl')} /{' '} {t('admin.users.detail.happLink')}
{panelInfo.subscription_url && ( )} {panelInfo.happ_link && ( )}
)} {/* Config */} {(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && (
{t('admin.users.detail.panelConfig')}
{panelInfo.trojan_password && ( )} {panelInfo.vless_uuid && ( )} {panelInfo.ss_password && ( )}
)} {/* Connection info */}
{t('admin.users.detail.firstConnected')}
{formatDate(panelInfo.first_connected_at)}
{t('admin.users.detail.lastOnline')}
{formatDate(panelInfo.online_at)}
{panelInfo.last_connected_node_name && (
{t('admin.users.detail.lastNode')}
{panelInfo.last_connected_node_name}
)}
{/* Live traffic */}
{t('admin.users.detail.liveTraffic')}
{formatBytes(panelInfo.used_traffic_bytes)} {panelInfo.traffic_limit_bytes > 0 ? formatBytes(panelInfo.traffic_limit_bytes) : '∞'}
0 ? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%` : '0%', }} />
{t('admin.users.detail.lifetime')}:{' '} {formatBytes(panelInfo.lifetime_used_traffic_bytes)}
{/* Node usage */}
{t('admin.users.detail.nodeUsage')}
{[1, 3, 7, 14, 30].map((d) => ( ))}
{nodeUsageForPeriod.length > 0 ? (
{nodeUsageForPeriod.map((item) => { const maxBytes = nodeUsageForPeriod[0].total_bytes; const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; return (
{item.country_code && ( {getCountryFlag(item.country_code)} )} {item.node_name} {formatBytes(item.total_bytes)}
); })}
) : (
-
)}
) : null} {/* Devices */}
{t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit})
{devices.length > 0 && ( )}
{devicesLoading ? (
) : devices.length > 0 ? (
{devices.map((device) => (
{device.platform || device.device_model || device.hwid.slice(0, 12)}
{device.device_model && device.platform && ( {device.device_model} )} {device.hwid.slice(0, 8)}... {device.created_at && ( {new Date(device.created_at).toLocaleDateString(locale)} )}
))}
) : (
{t('admin.users.detail.devices.none')}
)}
)}
)} {/* Balance Tab */} {activeTab === 'balance' && (
{/* Current balance */}
{t('admin.users.detail.balance.current')}
{formatWithCurrency(user.balance_rubles)}
{/* Add/subtract form */} {hasPermission('users:balance') && (
setBalanceDescription(e.target.value)} placeholder={t('admin.users.detail.balance.descriptionPlaceholder')} className="input" maxLength={500} />
)} {/* Active promo offer */} {user.promo_offer_discount_percent > 0 && (
{t('admin.users.detail.activePromoOffer')}
{user.promo_offer_discount_percent}%
{t('admin.users.detail.discount')}
{user.promo_offer_discount_source || '-'}
{t('admin.users.detail.source')}
{user.promo_offer_discount_expires_at ? formatDate(user.promo_offer_discount_expires_at) : '-'}
{t('admin.users.detail.expiresAt')}
)} {/* Send promo offer */} {hasPermission('users:send_offer') && (
{t('admin.users.detail.sendOffer')}
)} {/* Recent transactions */} {user.recent_transactions.length > 0 && (
{t('admin.users.detail.balance.recentTransactions')}
{user.recent_transactions.map((tx) => (
{tx.description || tx.type}
{formatDate(tx.created_at)}
= 0 ? 'text-success-400' : 'text-error-400'} > {tx.amount_kopeks >= 0 ? '+' : ''} {formatWithCurrency(tx.amount_rubles)}
))}
)}
)} {/* Sync Tab */} {activeTab === 'sync' && (
{/* Subscription selector for multi-tariff */} {userSubscriptions.length > 1 && (
{t('admin.users.detail.sync.selectSubscription')}
)} {/* Sync status */} {syncStatus && (
{syncStatus.has_differences ? ( {t('admin.users.detail.sync.hasDifferences')} ) : ( {t('admin.users.detail.sync.synced')} )}
{syncStatus.differences.length > 0 && (
{syncStatus.differences.map((diff, i) => (
• {diff}
))}
)}
{t('admin.users.detail.sync.bot')}
{t('admin.users.detail.sync.statusLabel')}: {syncStatus.bot_subscription_status || '-'}
{t('admin.users.detail.sync.until')}: {syncStatus.bot_subscription_end_date ? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString( locale, ) : '-'}
{t('admin.users.detail.sync.traffic')}: {syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
{t('admin.users.detail.sync.devices')}: {syncStatus.bot_device_limit}
{t('admin.users.detail.sync.squads')}: {syncStatus.bot_squads?.length || 0}
{t('admin.users.detail.sync.panel')}
{t('admin.users.detail.sync.statusLabel')}: {syncStatus.panel_status || '-'}
{t('admin.users.detail.sync.until')}: {syncStatus.panel_expire_at ? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale) : '-'}
{t('admin.users.detail.sync.traffic')}: {syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
{t('admin.users.detail.sync.devices')}: {syncStatus.panel_device_limit}
{t('admin.users.detail.sync.squads')}: {syncStatus.panel_squads?.length || 0}
)} {/* UUID info */}
{syncStatus?.subscription_tariff_name && (
{syncStatus.subscription_tariff_name}
)}
Remnawave UUID
{syncStatus?.remnawave_uuid || user.remnawave_uuid || t('admin.users.detail.sync.notLinked')}
{/* Sync buttons */}
)} {/* Tickets Tab */} {activeTab === 'tickets' && (
{selectedTicketId ? ( /* Ticket Chat View */ ticketDetailLoading ? (
) : selectedTicket ? (
{/* Chat header */}
#{selectedTicket.id} {selectedTicket.title}
{selectedTicket.status} {formatDate(selectedTicket.created_at)}
{/* Status buttons */}
{(['open', 'pending', 'answered', 'closed'] as const).map((s) => ( ))}
{/* Messages */}
{selectedTicket.messages.map((msg) => (
{msg.is_from_admin ? t('admin.tickets.adminLabel') : t('admin.tickets.userLabel')} {formatDate(msg.created_at)}

{msg.message_text}

{msg.has_media && msg.media_file_id && (
{msg.media_type === 'photo' ? ( {msg.media_caption ) : ( {msg.media_caption || msg.media_type} )} {msg.media_caption && msg.media_type === 'photo' && (

{msg.media_caption}

)}
)}
))}
{/* Reply form */} {selectedTicket.status !== 'closed' && (