import { useState, useEffect, useCallback, useRef } 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 PanelSyncStatusResponse, type UpdateSubscriptionRequest, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; import { ticketsApi } from '../api/tickets'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // ============ 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', disabled: 'bg-dark-600 text-dark-400 border-dark-500', }; return ( {status} ); } // ============ Main Page ============ export default function AdminUserDetail() { const { t } = useTranslation(); const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); const notify = useNotify(); const { id } = useParams<{ id: string }>(); 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' >('info'); const [syncStatus, setSyncStatus] = useState(null); const [tariffs, setTariffs] = useState([]); const [actionLoading, setActionLoading] = useState(false); // 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 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); setSyncStatus(data); } catch (error) { console.error('Failed to load sync status:', error); } }, [userId]); 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 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); } }; 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 === 'sync') loadSyncStatus(); if (activeTab === 'subscription') loadTariffs(); if (activeTab === 'tickets') loadTickets(); }, [activeTab, loadSyncStatus, loadTariffs, loadTickets]); 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 () => { if (!userId) return; setActionLoading(true); try { const data: UpdateSubscriptionRequest = { action: subAction as UpdateSubscriptionRequest['action'], ...(subAction === 'extend' ? { days: toNumber(subDays, 30) } : {}), ...(subAction === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), ...(subAction === '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, }); 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 }); 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(); } else { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(actionKey); confirmTimerRef.current = setTimeout(() => setConfirmingAction(null), 3000); } }; 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', }); }; if (loading) { return ( ); } if (!user) { return ( {t('admin.users.notFound')} navigate('/admin/users')} className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600" > {t('common.back')} ); } 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'] as const).map((tab) => ( setActiveTab(tab)} className={`shrink-0 whitespace-nowrap rounded-xl px-4 py-2.5 text-sm font-medium transition-all ${ activeTab === tab ? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30' : 'bg-dark-800/50 text-dark-400 active:bg-dark-700' }`} > {tab === 'info' && t('admin.users.detail.tabs.info')} {tab === 'subscription' && t('admin.users.detail.tabs.subscription')} {tab === 'balance' && t('admin.users.detail.tabs.balance')} {tab === 'sync' && t('admin.users.detail.tabs.sync')} {tab === 'tickets' && t('admin.users.detail.tabs.tickets')} ))} {/* Content */} {/* Info Tab */} {activeTab === 'info' && ( {/* Status */} {t('admin.users.detail.status')} {user.status === 'active' ? ( {t('admin.users.actions.block')} ) : user.status === 'blocked' ? ( {t('admin.users.actions.unblock')} ) : 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} {/* Referral */} {t('admin.users.detail.referral.title')} {user.referral.referrals_count} {t('admin.users.detail.referral.referrals')} {formatWithCurrency(user.referral.total_earnings_kopeks / 100)} {t('admin.users.detail.referral.earned')} {user.referral.commission_percent || 0}% {t('admin.users.detail.referral.commission')} {/* 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')} handleInlineConfirm('resetTrial', handleResetTrial)} disabled={actionLoading} className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${ confirmingAction === 'resetTrial' ? 'bg-blue-500 text-white' : 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25' }`} > {confirmingAction === 'resetTrial' ? t('admin.users.detail.actions.areYouSure') : t('admin.users.userActions.resetTrial')} handleInlineConfirm('resetSubscription', handleResetSubscription)} disabled={actionLoading} className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${ confirmingAction === 'resetSubscription' ? 'bg-amber-500 text-white' : 'bg-amber-500/15 text-amber-400 hover:bg-amber-500/25' }`} > {confirmingAction === 'resetSubscription' ? t('admin.users.detail.actions.areYouSure') : t('admin.users.userActions.resetSubscription')} handleInlineConfirm('disable', handleDisableUser)} disabled={actionLoading} className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${ confirmingAction === 'disable' ? 'bg-dark-500 text-white' : 'bg-dark-700 text-dark-300 hover:bg-dark-600' }`} > {confirmingAction === 'disable' ? t('admin.users.detail.actions.areYouSure') : t('admin.users.userActions.disable')} handleInlineConfirm('fullDelete', handleFullDeleteUser)} disabled={actionLoading} className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${ confirmingAction === 'fullDelete' ? 'bg-rose-500 text-white' : 'bg-rose-500/15 text-rose-400 hover:bg-rose-500/25' }`} > {confirmingAction === 'fullDelete' ? t('admin.users.detail.actions.areYouSure') : t('admin.users.userActions.delete')} )} {/* Subscription Tab */} {activeTab === 'subscription' && ( {user.subscription ? ( <> {/* Current subscription */} {t('admin.users.detail.subscription.current')} {t('admin.users.detail.subscription.tariff')} {user.subscription.tariff_name || t('admin.users.detail.subscription.notSpecified')} {t('admin.users.detail.subscription.validUntil')} {formatDate(user.subscription.end_date)} {t('admin.users.detail.subscription.traffic')} {user.subscription.traffic_used_gb.toFixed(1)} /{' '} {user.subscription.traffic_limit_gb} {t('common.units.gb')} {t('admin.users.detail.subscription.devices')} {user.subscription.device_limit} {/* Actions */} {t('admin.users.detail.subscription.actions')} setSubAction(e.target.value)} className="input" > {t('admin.users.detail.subscription.extend')} {t('admin.users.detail.subscription.changeTariff')} {t('admin.users.detail.subscription.cancel')} {t('admin.users.detail.subscription.activate')} {subAction === 'extend' && ( )} {subAction === 'change_tariff' && ( setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null) } className="input" > {t('admin.users.detail.subscription.selectTariff')} {tariffs.map((tariffItem) => ( {tariffItem.name}{' '} {!tariffItem.is_available && t('admin.users.detail.subscription.unavailable')} ))} )} {actionLoading ? t('admin.users.actions.applying') : t('admin.users.actions.apply')} > ) : ( {t('admin.users.detail.subscription.noActive')} setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null) } className="input" > {t('admin.users.detail.subscription.selectTariff')} {tariffs.map((tariffItem) => ( {tariffItem.name} ))} { setSubAction('create'); handleUpdateSubscription(); }} disabled={actionLoading} className="btn-primary w-full" > {actionLoading ? t('admin.users.detail.subscription.creating') : t('admin.users.detail.subscription.create')} )} )} {/* Balance Tab */} {activeTab === 'balance' && ( {/* Current balance */} {t('admin.users.detail.balance.current')} {formatWithCurrency(user.balance_rubles)} {/* Add/subtract form */} setBalanceDescription(e.target.value)} placeholder={t('admin.users.detail.balance.descriptionPlaceholder')} className="input" maxLength={500} /> handleUpdateBalance(true)} disabled={actionLoading || balanceAmount === ''} className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50" > {t('admin.users.detail.balance.add')} handleUpdateBalance(false)} disabled={actionLoading || balanceAmount === ''} className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50" > {t('admin.users.detail.balance.subtract')} {/* 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' && ( {/* 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 */} Remnawave UUID {syncStatus?.remnawave_uuid || user.remnawave_uuid || t('admin.users.detail.sync.notLinked')} {/* Sync buttons */} {t('admin.users.detail.sync.fromPanel')} {t('admin.users.detail.sync.toPanel')} )} {/* Tickets Tab */} {activeTab === 'tickets' && ( {selectedTicketId ? ( /* Ticket Chat View */ ticketDetailLoading ? ( ) : selectedTicket ? ( {/* Chat header */} { setSelectedTicketId(null); setSelectedTicket(null); }} className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-dark-800 transition-colors hover:bg-dark-700" > #{selectedTicket.id} {selectedTicket.title} {selectedTicket.status} {formatDate(selectedTicket.created_at)} {/* 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_type} )} {msg.media_caption && msg.media_type === 'photo' && ( {msg.media_caption} )} )} ))} {/* Reply form */} {selectedTicket.status !== 'closed' && ( setReplyText(e.target.value)} placeholder={t('admin.tickets.replyPlaceholder')} rows={2} className="input flex-1 resize-none" onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleTicketReply(); } }} /> {replySending ? ( ) : ( )} )} ) : null ) : ticketsLoading ? ( ) : tickets.length === 0 ? ( {t('admin.users.detail.noTickets')} ) : ( <> {ticketsTotal} {t('admin.users.detail.ticketsCount')} {tickets.map((ticket) => { const statusStyles: Record = { open: 'bg-blue-500/20 text-blue-400 border-blue-500/30', pending: 'bg-warning-500/20 text-warning-400 border-warning-500/30', answered: 'bg-success-500/20 text-success-400 border-success-500/30', closed: 'bg-dark-600 text-dark-400 border-dark-500', }; return ( setSelectedTicketId(ticket.id)} className="w-full rounded-xl bg-dark-800/50 p-4 text-left transition-colors hover:bg-dark-700/50" > #{ticket.id} {ticket.title} {ticket.status} {formatDate(ticket.created_at)} {ticket.messages_count} {t('admin.users.detail.messagesCount')} {ticket.last_message && ( {ticket.last_message.is_from_admin ? '> ' : ''} {ticket.last_message.message_text} )} ); })} > )} )} ); }
{t('admin.users.notFound')}
{msg.message_text}
{msg.media_caption}
{t('admin.users.detail.noTickets')}