From c5fbc6a28aa4c8b6f49cd96c9248664e9ce85b24 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 17 Jan 2026 06:04:53 +0300 Subject: [PATCH] Add files via upload --- src/pages/AdminPanel.tsx | 16 + src/pages/AdminUsers.tsx | 965 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 981 insertions(+) create mode 100644 src/pages/AdminUsers.tsx diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index f623d42..e13bb32 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -69,6 +69,12 @@ const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -228,6 +234,16 @@ export default function AdminPanel() { bgColor: 'bg-orange-500/20', textColor: 'text-orange-400' }, + { + to: '/admin/users', + icon: , + mobileIcon: , + title: t('admin.nav.users', 'Пользователи'), + description: t('admin.panel.usersDesc', 'Управление пользователями'), + color: 'indigo', + bgColor: 'bg-indigo-500/20', + textColor: 'text-indigo-400' + }, ] return ( diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx new file mode 100644 index 0000000..4d6270f --- /dev/null +++ b/src/pages/AdminUsers.tsx @@ -0,0 +1,965 @@ +import { useState, useEffect, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useCurrency } from '../hooks/useCurrency' +import { + adminUsersApi, + type UserListItem, + type UserDetailResponse, + type UsersStatsResponse, + type UserAvailableTariff, + type PanelSyncStatusResponse, +} from '../api/adminUsers' + +// ============ Icons ============ + +const UsersIcon = ({ className = "w-6 h-6" }: { className?: string }) => ( + + + +) + +const SearchIcon = () => ( + + + +) + +const ChevronLeftIcon = () => ( + + + +) + +const ChevronRightIcon = () => ( + + + +) + +const XMarkIcon = () => ( + + + +) + +const RefreshIcon = ({ className = "w-5 h-5" }: { className?: string }) => ( + + + +) + +const PlusIcon = () => ( + + + +) + +const MinusIcon = () => ( + + + +) + +const BanIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const SyncIcon = ({ className = "w-5 h-5" }: { className?: string }) => ( + + + +) + +const TelegramIcon = () => ( + + + +) + +// ============ Components ============ + +interface StatCardProps { + title: string + value: string | number + subtitle?: string + color: 'blue' | 'green' | 'yellow' | 'red' | 'purple' +} + +function StatCard({ title, value, subtitle, color }: StatCardProps) { + const colors = { + blue: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + green: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', + yellow: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + red: 'bg-rose-500/20 text-rose-400 border-rose-500/30', + purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30', + } + + return ( +
+
{value}
+
{title}
+ {subtitle &&
{subtitle}
} +
+ ) +} + +function StatusBadge({ status }: { status: string }) { + const styles: Record = { + active: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', + blocked: 'bg-rose-500/20 text-rose-400 border-rose-500/30', + deleted: 'bg-dark-600 text-dark-400 border-dark-500', + trial: 'bg-blue-500/20 text-blue-400 border-blue-500/30', + expired: 'bg-amber-500/20 text-amber-400 border-amber-500/30', + disabled: 'bg-dark-600 text-dark-400 border-dark-500', + } + + return ( + + {status} + + ) +} + +// ============ User List Component ============ + +interface UserRowProps { + user: UserListItem + onSelect: (user: UserListItem) => void + formatMoney: (kopeks: number) => string +} + +function UserRow({ user, onSelect, formatMoney }: UserRowProps) { + return ( +
onSelect(user)} + className="flex items-center gap-4 p-4 bg-dark-800/50 rounded-xl border border-dark-700 hover:border-dark-600 hover:bg-dark-800 cursor-pointer transition-all" + > + {/* Avatar */} +
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+ + {/* Info */} +
+
+ {user.full_name} + {user.username && ( + @{user.username} + )} +
+
+ + + {user.telegram_id} + + + {user.subscription_status && ( + + )} +
+
+ + {/* Balance */} +
+
{formatMoney(user.balance_kopeks)}
+
+ {user.purchase_count > 0 ? `${user.purchase_count} покупок` : 'Нет покупок'} +
+
+ + +
+ ) +} + +// ============ User Detail Modal ============ + +interface UserDetailModalProps { + userId: number + onClose: () => void + onUpdate: () => void +} + +function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) { + const { t } = useTranslation() + const { formatMoney } = useCurrency() + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info') + const [syncStatus, setSyncStatus] = useState(null) + const [tariffs, setTariffs] = useState([]) + const [actionLoading, setActionLoading] = useState(false) + + // Balance form + const [balanceAmount, setBalanceAmount] = useState('') + const [balanceDescription, setBalanceDescription] = useState('') + + // Subscription form + const [subAction, setSubAction] = useState('extend') + const [subDays, setSubDays] = useState('30') + const [selectedTariffId, setSelectedTariffId] = useState(null) + + const loadUser = useCallback(async () => { + try { + setLoading(true) + const data = await adminUsersApi.getUser(userId) + setUser(data) + } catch (error) { + console.error('Failed to load user:', error) + } finally { + setLoading(false) + } + }, [userId]) + + const loadSyncStatus = useCallback(async () => { + try { + const data = await adminUsersApi.getSyncStatus(userId) + setSyncStatus(data) + } catch (error) { + console.error('Failed to load sync status:', error) + } + }, [userId]) + + const loadTariffs = useCallback(async () => { + try { + const data = await adminUsersApi.getAvailableTariffs(userId, true) + setTariffs(data.tariffs) + } catch (error) { + console.error('Failed to load tariffs:', error) + } + }, [userId]) + + useEffect(() => { + loadUser() + }, [loadUser]) + + useEffect(() => { + if (activeTab === 'sync') loadSyncStatus() + if (activeTab === 'subscription') loadTariffs() + }, [activeTab, loadSyncStatus, loadTariffs]) + + const handleUpdateBalance = async (isAdd: boolean) => { + if (!balanceAmount) return + setActionLoading(true) + try { + const amount = Math.abs(parseFloat(balanceAmount) * 100) + await adminUsersApi.updateBalance(userId, { + amount_kopeks: isAdd ? amount : -amount, + description: balanceDescription || (isAdd ? 'Начисление админом' : 'Списание админом'), + }) + await loadUser() + setBalanceAmount('') + setBalanceDescription('') + onUpdate() + } catch (error) { + console.error('Failed to update balance:', error) + } finally { + setActionLoading(false) + } + } + + const handleUpdateSubscription = async () => { + setActionLoading(true) + try { + const data: Record = { action: subAction } + if (subAction === 'extend') data.days = parseInt(subDays) + if (subAction === 'change_tariff' && selectedTariffId) data.tariff_id = selectedTariffId + if (subAction === 'create') { + data.days = parseInt(subDays) + if (selectedTariffId) data.tariff_id = selectedTariffId + } + await adminUsersApi.updateSubscription(userId, data as any) + await loadUser() + onUpdate() + } catch (error) { + console.error('Failed to update subscription:', error) + } finally { + setActionLoading(false) + } + } + + const handleBlockUser = async () => { + if (!confirm('Заблокировать пользователя?')) return + setActionLoading(true) + try { + await adminUsersApi.blockUser(userId) + await loadUser() + onUpdate() + } catch (error) { + console.error('Failed to block user:', error) + } finally { + setActionLoading(false) + } + } + + const handleUnblockUser = async () => { + setActionLoading(true) + try { + await adminUsersApi.unblockUser(userId) + await loadUser() + onUpdate() + } catch (error) { + console.error('Failed to unblock user:', error) + } finally { + setActionLoading(false) + } + } + + const handleSyncFromPanel = async () => { + setActionLoading(true) + try { + await adminUsersApi.syncFromPanel(userId, { update_subscription: true, update_traffic: true }) + await loadUser() + await loadSyncStatus() + onUpdate() + } catch (error) { + console.error('Failed to sync from panel:', error) + } finally { + setActionLoading(false) + } + } + + const handleSyncToPanel = async () => { + setActionLoading(true) + try { + await adminUsersApi.syncToPanel(userId, { create_if_missing: true }) + await loadUser() + await loadSyncStatus() + onUpdate() + } catch (error) { + console.error('Failed to sync to panel:', error) + } finally { + setActionLoading(false) + } + } + + const formatDate = (date: string | null) => { + if (!date) return '-' + return new Date(date).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + if (loading) { + return ( +
+
+
+
+
+ ) + } + + if (!user) return null + + return ( +
+
+ {/* Header */} +
+
+
+ {user.first_name?.[0] || user.username?.[0] || '?'} +
+
+
{user.full_name}
+
+ + {user.telegram_id} + {user.username && @{user.username}} +
+
+
+ +
+ + {/* Tabs */} +
+ {(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {/* Info Tab */} + {activeTab === 'info' && ( +
+ {/* Status */} +
+ Статус +
+ + {user.status === 'active' ? ( + + ) : user.status === 'blocked' ? ( + + ) : null} +
+
+ + {/* Details grid */} +
+
+
Email
+
{user.email || '-'}
+
+
+
Язык
+
{user.language}
+
+
+
Регистрация
+
{formatDate(user.created_at)}
+
+
+
Последняя активность
+
{formatDate(user.last_activity)}
+
+
+
Всего потрачено
+
{formatMoney(user.total_spent_kopeks)}
+
+
+
Покупок
+
{user.purchase_count}
+
+
+ + {/* Referral */} +
+
Реферальная программа
+
+
+
{user.referral.referrals_count}
+
Рефералов
+
+
+
{formatMoney(user.referral.total_earnings_kopeks)}
+
Заработано
+
+
+
{user.referral.commission_percent || 0}%
+
Комиссия
+
+
+
+ + {/* Restrictions */} + {(user.restriction_topup || user.restriction_subscription) && ( +
+
Ограничения
+ {user.restriction_topup &&
• Запрет пополнения
} + {user.restriction_subscription &&
• Запрет покупки подписки
} + {user.restriction_reason &&
Причина: {user.restriction_reason}
} +
+ )} +
+ )} + + {/* Subscription Tab */} + {activeTab === 'subscription' && ( +
+ {user.subscription ? ( + <> + {/* Current subscription */} +
+
+ Текущая подписка + +
+
+
+
Тариф
+
{user.subscription.tariff_name || 'Не указан'}
+
+
+
Действует до
+
{formatDate(user.subscription.end_date)}
+
+
+
Трафик
+
+ {user.subscription.traffic_used_gb.toFixed(1)} / {user.subscription.traffic_limit_gb} ГБ +
+
+
+
Устройств
+
{user.subscription.device_limit}
+
+
+
+ + {/* Actions */} +
+
Действия
+
+ + + {subAction === 'extend' && ( + setSubDays(e.target.value)} + placeholder="Дней" + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100" + /> + )} + + {subAction === 'change_tariff' && ( + + )} + + +
+
+ + ) : ( +
+
Нет активной подписки
+
+ + setSubDays(e.target.value)} + placeholder="Дней" + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100" + /> + +
+
+ )} +
+ )} + + {/* Balance Tab */} + {activeTab === 'balance' && ( +
+ {/* Current balance */} +
+
Текущий баланс
+
{formatMoney(user.balance_kopeks)}
+
+ + {/* Add/subtract form */} +
+ setBalanceAmount(e.target.value)} + placeholder="Сумма в рублях" + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100" + /> + setBalanceDescription(e.target.value)} + placeholder="Описание (опционально)" + className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100" + /> +
+ + +
+
+ + {/* Recent transactions */} + {user.recent_transactions.length > 0 && ( +
+
Последние транзакции
+
+ {user.recent_transactions.map((tx) => ( +
+
+
{tx.description || tx.type}
+
{formatDate(tx.created_at)}
+
+
= 0 ? 'text-emerald-400' : 'text-rose-400'}> + {tx.amount_kopeks >= 0 ? '+' : ''}{formatMoney(tx.amount_kopeks)} +
+
+ ))} +
+
+ )} +
+ )} + + {/* Sync Tab */} + {activeTab === 'sync' && ( +
+ {/* Sync status */} + {syncStatus && ( +
+
+ {syncStatus.has_differences ? ( + Есть расхождения + ) : ( + Синхронизировано + )} +
+ + {syncStatus.differences.length > 0 && ( +
+ {syncStatus.differences.map((diff, i) => ( +
• {diff}
+ ))} +
+ )} + +
+
+
Бот
+
+
+ Статус: + {syncStatus.bot_subscription_status || '-'} +
+
+ Трафик: + {syncStatus.bot_traffic_used_gb.toFixed(2)} ГБ +
+
+
+
+
Панель
+
+
+ Статус: + {syncStatus.panel_status || '-'} +
+
+ Трафик: + {syncStatus.panel_traffic_used_gb.toFixed(2)} ГБ +
+
+
+
+
+ )} + + {/* UUID info */} +
+
Remnawave UUID
+
+ {user.remnawave_uuid || 'Не привязан'} +
+
+ + {/* Sync buttons */} +
+ + +
+
+ )} +
+
+
+ ) +} + +// ============ Main Page ============ + +export default function AdminUsers() { + const { t } = useTranslation() + const { formatMoney } = useCurrency() + + const [users, setUsers] = useState([]) + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('') + const [sortBy, setSortBy] = useState('created_at') + const [offset, setOffset] = useState(0) + const [total, setTotal] = useState(0) + const [selectedUserId, setSelectedUserId] = useState(null) + + const limit = 20 + + const loadUsers = useCallback(async () => { + try { + setLoading(true) + const params: Record = { offset, limit, sort_by: sortBy } + if (search) params.search = search + if (statusFilter) params.status = statusFilter + + const data = await adminUsersApi.getUsers(params as any) + setUsers(data.users) + setTotal(data.total) + } catch (error) { + console.error('Failed to load users:', error) + } finally { + setLoading(false) + } + }, [offset, search, statusFilter, sortBy]) + + const loadStats = useCallback(async () => { + try { + const data = await adminUsersApi.getStats() + setStats(data) + } catch (error) { + console.error('Failed to load stats:', error) + } + }, []) + + useEffect(() => { + loadUsers() + }, [loadUsers]) + + useEffect(() => { + loadStats() + }, [loadStats]) + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + setOffset(0) + loadUsers() + } + + const totalPages = Math.ceil(total / limit) + const currentPage = Math.floor(offset / limit) + 1 + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Пользователи

+

Управление пользователями бота

+
+
+ +
+ + {/* Stats */} + {stats && ( +
+ + + + + +
+ )} + + {/* Filters */} +
+
+
+ + setSearch(e.target.value)} + placeholder="Поиск по ID, имени, username..." + className="w-full pl-10 pr-4 py-2 bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none" + style={{ paddingLeft: '2.5rem' }} + /> +
+ +
+
+
+ + +
+ + {/* Users list */} +
+ {loading ? ( +
+
+
+ ) : users.length === 0 ? ( +
+ Пользователи не найдены +
+ ) : ( + users.map((user) => ( + setSelectedUserId(u.id)} + formatMoney={formatMoney} + /> + )) + )} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Показано {offset + 1}-{Math.min(offset + limit, total)} из {total} +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} + + {/* User detail modal */} + {selectedUserId && ( + setSelectedUserId(null)} + onUpdate={() => { loadUsers(); loadStats() }} + /> + )} +
+ ) +}