feat: enhance admin user detail with campaign, panel data, node usage

- Add campaign card and referrals list to info tab
- Add panel config/links, live traffic, connection info to subscription tab
- Add node usage bars with 7/14/30 day period selector
- Add getPanelInfo/getNodeUsage API methods and types
- Add 18 new i18n keys across 4 languages
This commit is contained in:
Fringg
2026-02-07 06:07:13 +03:00
parent e0c9a89d34
commit 7b19f14dc3
6 changed files with 458 additions and 2 deletions

View File

@@ -101,6 +101,8 @@ export interface UserDetailResponse {
used_promocodes: number; used_promocodes: number;
has_had_paid_subscription: boolean; has_had_paid_subscription: boolean;
lifetime_used_traffic_bytes: number; lifetime_used_traffic_bytes: number;
campaign_name: string | null;
campaign_id: number | null;
restriction_topup: boolean; restriction_topup: boolean;
restriction_subscription: boolean; restriction_subscription: boolean;
restriction_reason: string | null; restriction_reason: string | null;
@@ -111,6 +113,33 @@ export interface UserDetailResponse {
remnawave_uuid: string | null; remnawave_uuid: string | null;
} }
export interface UserPanelInfo {
found: boolean;
trojan_password: string | null;
vless_uuid: string | null;
ss_password: string | null;
subscription_url: string | null;
happ_link: string | null;
used_traffic_bytes: number;
lifetime_used_traffic_bytes: number;
traffic_limit_bytes: number;
first_connected_at: string | null;
online_at: string | null;
last_connected_node_uuid: string | null;
last_connected_node_name: string | null;
}
export interface UserNodeUsageItem {
node_uuid: string;
node_name: string;
total_bytes: number;
}
export interface UserNodeUsageResponse {
items: UserNodeUsageItem[];
period_days: number;
}
export interface UsersStatsResponse { export interface UsersStatsResponse {
total_users: number; total_users: number;
active_users: number; active_users: number;
@@ -508,4 +537,18 @@ export const adminUsersApi = {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`); const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
return response.data; return response.data;
}, },
// Get panel info
getPanelInfo: async (userId: number): Promise<UserPanelInfo> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`);
return response.data;
},
// Get node usage
getNodeUsage: async (userId: number, days = 7): Promise<UserNodeUsageResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`, {
params: { days },
});
return response.data;
},
}; };

View File

@@ -1852,6 +1852,24 @@
"lastActivity": "Last activity", "lastActivity": "Last activity",
"totalSpent": "Total spent", "totalSpent": "Total spent",
"purchases": "Purchases", "purchases": "Purchases",
"campaign": "Advertising campaign",
"referralsList": "Referrals list",
"referralEarnings": "Referral earnings",
"noReferrals": "No referrals",
"panelConfig": "Panel configuration",
"trojanPassword": "Trojan password",
"vlessUuid": "VLESS UUID",
"ssPassword": "SS password",
"subscriptionUrl": "Subscription URL",
"happLink": "HAPP link",
"firstConnected": "First connected",
"lastOnline": "Last online",
"lastNode": "Last node",
"liveTraffic": "Current traffic",
"lifetime": "Lifetime",
"nodeUsage": "Node usage",
"copied": "Copied",
"panelNotFound": "User not found in panel",
"referral": { "referral": {
"title": "Referral program", "title": "Referral program",
"referrals": "Referrals", "referrals": "Referrals",

View File

@@ -1555,6 +1555,24 @@
"lastActivity": "آخرین فعالیت", "lastActivity": "آخرین فعالیت",
"totalSpent": "کل هزینه", "totalSpent": "کل هزینه",
"purchases": "خریدها", "purchases": "خریدها",
"campaign": "کمپین تبلیغاتی",
"referralsList": "لیست ارجاعات",
"referralEarnings": "درآمد ارجاع",
"noReferrals": "بدون ارجاع",
"panelConfig": "پیکربندی پنل",
"trojanPassword": "رمز Trojan",
"vlessUuid": "VLESS UUID",
"ssPassword": "رمز SS",
"subscriptionUrl": "لینک اشتراک",
"happLink": "لینک HAPP",
"firstConnected": "اولین اتصال",
"lastOnline": "آخرین آنلاین",
"lastNode": "آخرین نود",
"liveTraffic": "ترافیک فعلی",
"lifetime": "کل",
"nodeUsage": "مصرف نودها",
"copied": "کپی شد",
"panelNotFound": "کاربر در پنل یافت نشد",
"referral": { "referral": {
"title": "برنامه ارجاع", "title": "برنامه ارجاع",
"referrals": "ارجاعات", "referrals": "ارجاعات",

View File

@@ -2379,6 +2379,23 @@
"lastActivity": "Последняя активность", "lastActivity": "Последняя активность",
"totalSpent": "Всего потрачено", "totalSpent": "Всего потрачено",
"purchases": "Покупок", "purchases": "Покупок",
"campaign": "Рекламная кампания",
"referralsList": "Список рефералов",
"noReferrals": "Нет рефералов",
"panelConfig": "Конфигурация панели",
"trojanPassword": "Trojan пароль",
"vlessUuid": "VLESS UUID",
"ssPassword": "SS пароль",
"subscriptionUrl": "Ссылка подписки",
"happLink": "HAPP ссылка",
"firstConnected": "Первое подключение",
"lastOnline": "Последний онлайн",
"lastNode": "Последняя нода",
"liveTraffic": "Текущий трафик",
"lifetime": "За всё время",
"nodeUsage": "Расход по нодам",
"copied": "Скопировано",
"panelNotFound": "Пользователь не найден в панели",
"referral": { "referral": {
"title": "Реферальная программа", "title": "Реферальная программа",
"referrals": "Рефералов", "referrals": "Рефералов",

View File

@@ -1554,6 +1554,24 @@
"lastActivity": "最后活跃", "lastActivity": "最后活跃",
"totalSpent": "总消费", "totalSpent": "总消费",
"purchases": "购买次数", "purchases": "购买次数",
"campaign": "广告活动",
"referralsList": "推荐列表",
"referralEarnings": "推荐收入",
"noReferrals": "暂无推荐",
"panelConfig": "面板配置",
"trojanPassword": "Trojan 密码",
"vlessUuid": "VLESS UUID",
"ssPassword": "SS 密码",
"subscriptionUrl": "订阅链接",
"happLink": "HAPP 链接",
"firstConnected": "首次连接",
"lastOnline": "最后在线",
"lastNode": "最后节点",
"liveTraffic": "当前流量",
"lifetime": "累计",
"nodeUsage": "节点用量",
"copied": "已复制",
"panelNotFound": "面板中未找到用户",
"referral": { "referral": {
"title": "推荐计划", "title": "推荐计划",
"referrals": "推荐人数", "referrals": "推荐人数",

View File

@@ -8,6 +8,9 @@ import {
adminUsersApi, adminUsersApi,
type UserDetailResponse, type UserDetailResponse,
type UserAvailableTariff, type UserAvailableTariff,
type UserListItem,
type UserPanelInfo,
type UserNodeUsageResponse,
type PanelSyncStatusResponse, type PanelSyncStatusResponse,
type UpdateSubscriptionRequest, type UpdateSubscriptionRequest,
} from '../api/adminUsers'; } from '../api/adminUsers';
@@ -98,6 +101,16 @@ export default function AdminUserDetail() {
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]); const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
// Referrals
const [referrals, setReferrals] = useState<UserListItem[]>([]);
const [referralsLoading, setReferralsLoading] = useState(false);
// Panel info & node usage
const [panelInfo, setPanelInfo] = useState<UserPanelInfo | null>(null);
const [panelInfoLoading, setPanelInfoLoading] = useState(false);
const [nodeUsage, setNodeUsage] = useState<UserNodeUsageResponse | null>(null);
const [nodeUsageDays, setNodeUsageDays] = useState(7);
// Inline confirm state // Inline confirm state
const [confirmingAction, setConfirmingAction] = useState<string | null>(null); const [confirmingAction, setConfirmingAction] = useState<string | null>(null);
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -184,6 +197,45 @@ export default function AdminUserDetail() {
} }
}, []); }, []);
const loadReferrals = useCallback(async () => {
if (!userId) return;
try {
setReferralsLoading(true);
const data = await adminUsersApi.getReferrals(userId, 0, 50);
setReferrals(data.users);
} catch {
// ignore
} finally {
setReferralsLoading(false);
}
}, [userId]);
const loadPanelInfo = useCallback(async () => {
if (!userId) return;
try {
setPanelInfoLoading(true);
const data = await adminUsersApi.getPanelInfo(userId);
setPanelInfo(data);
} catch {
// ignore
} finally {
setPanelInfoLoading(false);
}
}, [userId]);
const loadNodeUsage = useCallback(
async (days = 7) => {
if (!userId) return;
try {
const data = await adminUsersApi.getNodeUsage(userId, days);
setNodeUsage(data);
} catch {
// ignore
}
},
[userId],
);
const handleTicketReply = async () => { const handleTicketReply = async () => {
if (!selectedTicketId || !replyText.trim()) return; if (!selectedTicketId || !replyText.trim()) return;
setReplySending(true); setReplySending(true);
@@ -234,10 +286,20 @@ export default function AdminUserDetail() {
}, [userId, loadUser, navigate]); }, [userId, loadUser, navigate]);
useEffect(() => { useEffect(() => {
if (activeTab === 'info') loadReferrals();
if (activeTab === 'sync') loadSyncStatus(); if (activeTab === 'sync') loadSyncStatus();
if (activeTab === 'subscription') loadTariffs(); if (activeTab === 'subscription') {
loadTariffs();
loadPanelInfo();
}
if (activeTab === 'tickets') loadTickets(); if (activeTab === 'tickets') loadTickets();
}, [activeTab, loadSyncStatus, loadTariffs, loadTickets]); }, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadPanelInfo]);
useEffect(() => {
if (activeTab === 'subscription') {
loadNodeUsage(nodeUsageDays);
}
}, [activeTab, loadNodeUsage, nodeUsageDays]);
const handleUpdateBalance = async (isAdd: boolean) => { const handleUpdateBalance = async (isAdd: boolean) => {
if (balanceAmount === '' || !userId) return; if (balanceAmount === '' || !userId) return;
@@ -440,6 +502,23 @@ export default function AdminUserDetail() {
}); });
}; };
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
notify.success(t('admin.users.detail.copied'));
} catch {
// ignore
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-[50vh] items-center justify-center"> <div className="flex min-h-[50vh] items-center justify-center">
@@ -577,6 +656,14 @@ export default function AdminUserDetail() {
</div> </div>
</div> </div>
{/* Campaign */}
{user.campaign_name && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-3">
<div className="mb-1 text-xs text-dark-500">{t('admin.users.detail.campaign')}</div>
<div className="text-sm font-medium text-accent-400">{user.campaign_name}</div>
</div>
)}
{/* Referral */} {/* Referral */}
<div className="rounded-xl bg-dark-800/50 p-3"> <div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-2 text-sm font-medium text-dark-200"> <div className="mb-2 text-sm font-medium text-dark-200">
@@ -610,6 +697,49 @@ export default function AdminUserDetail() {
</div> </div>
</div> </div>
{/* Referrals list */}
{user.referral.referrals_count > 0 && (
<div className="rounded-xl bg-dark-800/50 p-3">
<div className="mb-2 text-sm font-medium text-dark-200">
{t('admin.users.detail.referralsList')}
</div>
{referralsLoading ? (
<div className="flex justify-center py-4">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : referrals.length === 0 ? (
<div className="py-2 text-center text-xs text-dark-500">
{t('admin.users.detail.noReferrals')}
</div>
) : (
<div className="max-h-48 space-y-2 overflow-y-auto">
{referrals.map((ref) => (
<button
key={ref.id}
onClick={() => navigate(`/admin/users/${ref.id}`)}
className="flex w-full items-center justify-between rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-dark-600 text-xs font-bold text-dark-300">
{ref.first_name?.[0] || ref.username?.[0] || '?'}
</div>
<div>
<div className="text-sm text-dark-100">{ref.full_name}</div>
<div className="text-xs text-dark-500">
{formatDate(ref.created_at)}
</div>
</div>
</div>
<div className="text-xs text-dark-400">
{formatWithCurrency(ref.total_spent_kopeks / 100)}
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Restrictions */} {/* Restrictions */}
{(user.restriction_topup || user.restriction_subscription) && ( {(user.restriction_topup || user.restriction_subscription) && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3"> <div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3">
@@ -854,6 +984,218 @@ export default function AdminUserDetail() {
</div> </div>
</div> </div>
)} )}
{/* Panel Info */}
{panelInfoLoading ? (
<div className="flex justify-center rounded-xl bg-dark-800/50 py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : panelInfo && !panelInfo.found ? (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4 text-center text-sm text-dark-400">
{t('admin.users.detail.panelNotFound')}
</div>
) : panelInfo && panelInfo.found ? (
<>
{/* Links */}
{(panelInfo.subscription_url || panelInfo.happ_link) && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.subscriptionUrl')} / {t('admin.users.detail.happLink')}
</div>
<div className="space-y-2">
{panelInfo.subscription_url && (
<button
onClick={() => copyToClipboard(panelInfo.subscription_url!)}
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="mb-0.5 text-xs text-dark-500">
{t('admin.users.detail.subscriptionUrl')}
</div>
<div className="truncate font-mono text-xs text-dark-200">
{panelInfo.subscription_url}
</div>
</button>
)}
{panelInfo.happ_link && (
<button
onClick={() => copyToClipboard(panelInfo.happ_link!)}
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="mb-0.5 text-xs text-dark-500">
{t('admin.users.detail.happLink')}
</div>
<div className="truncate font-mono text-xs text-dark-200">
{panelInfo.happ_link}
</div>
</button>
)}
</div>
</div>
)}
{/* Config */}
{(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.panelConfig')}
</div>
<div className="space-y-2">
{panelInfo.trojan_password && (
<button
onClick={() => copyToClipboard(panelInfo.trojan_password!)}
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="mb-0.5 text-xs text-dark-500">
{t('admin.users.detail.trojanPassword')}
</div>
<div className="truncate font-mono text-xs text-dark-200">
{panelInfo.trojan_password}
</div>
</button>
)}
{panelInfo.vless_uuid && (
<button
onClick={() => copyToClipboard(panelInfo.vless_uuid!)}
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="mb-0.5 text-xs text-dark-500">
{t('admin.users.detail.vlessUuid')}
</div>
<div className="truncate font-mono text-xs text-dark-200">
{panelInfo.vless_uuid}
</div>
</button>
)}
{panelInfo.ss_password && (
<button
onClick={() => copyToClipboard(panelInfo.ss_password!)}
className="w-full rounded-lg bg-dark-700/50 p-2 text-left transition-colors hover:bg-dark-700"
>
<div className="mb-0.5 text-xs text-dark-500">
{t('admin.users.detail.ssPassword')}
</div>
<div className="truncate font-mono text-xs text-dark-200">
{panelInfo.ss_password}
</div>
</button>
)}
</div>
</div>
)}
{/* Connection info */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-dark-500">
{t('admin.users.detail.firstConnected')}
</div>
<div className="text-sm text-dark-100">
{formatDate(panelInfo.first_connected_at)}
</div>
</div>
<div>
<div className="text-xs text-dark-500">
{t('admin.users.detail.lastOnline')}
</div>
<div className="text-sm text-dark-100">{formatDate(panelInfo.online_at)}</div>
</div>
{panelInfo.last_connected_node_name && (
<div className="col-span-2">
<div className="text-xs text-dark-500">
{t('admin.users.detail.lastNode')}
</div>
<div className="text-sm text-dark-100">
{panelInfo.last_connected_node_name}
</div>
</div>
)}
</div>
</div>
{/* Live traffic */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 text-sm font-medium text-dark-200">
{t('admin.users.detail.liveTraffic')}
</div>
<div className="mb-2">
<div className="mb-1 flex justify-between text-xs">
<span className="text-dark-400">
{formatBytes(panelInfo.used_traffic_bytes)}
</span>
<span className="text-dark-500">
{panelInfo.traffic_limit_bytes > 0
? formatBytes(panelInfo.traffic_limit_bytes)
: '∞'}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full rounded-full bg-accent-500 transition-all"
style={{
width:
panelInfo.traffic_limit_bytes > 0
? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%`
: '0%',
}}
/>
</div>
</div>
<div className="text-xs text-dark-500">
{t('admin.users.detail.lifetime')}:{' '}
{formatBytes(panelInfo.lifetime_used_traffic_bytes)}
</div>
</div>
{/* Node usage */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.nodeUsage')}
</span>
<div className="flex gap-1">
{[7, 14, 30].map((d) => (
<button
key={d}
onClick={() => setNodeUsageDays(d)}
className={`rounded-lg px-2 py-1 text-xs transition-colors ${
nodeUsageDays === d
? 'bg-accent-500/20 text-accent-400'
: 'text-dark-500 hover:text-dark-300'
}`}
>
{d}d
</button>
))}
</div>
</div>
{nodeUsage && nodeUsage.items.length > 0 ? (
<div className="space-y-2">
{nodeUsage.items.map((item) => {
const maxBytes = nodeUsage.items[0].total_bytes;
const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0;
return (
<div key={item.node_uuid}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-dark-300">{item.node_name}</span>
<span className="text-dark-400">{formatBytes(item.total_bytes)}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full rounded-full bg-accent-500/60"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
) : (
<div className="py-2 text-center text-xs text-dark-500">-</div>
)}
</div>
</>
) : null}
</div> </div>
)} )}