mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(admin): вкладка «Активность» в карточке пользователя
Единый таймлайн действий юзера в боте и кабинете
(GET /cabinet/admin/users/{id}/activity): вертикальная лента с
иконками-кружками по типу записи, бейджи подтипов, суммы со знаком
(+зелёный / −красный), относительное время (абсолютное — в title),
чипы-фильтры по категориям (платежи, события, промо, тикеты, подарки,
рефералы, входы), счётчик StatCard и постраничная догрузка по house
load-more паттерну. Компонент самодостаточный (ActivityTab), как
TicketsTab. Переводы во всех 4 локалях; время переиспользует
admin.auditLog.time.*.
biome check, type-check и build проходят (pre-commit пропущен: biome
не установлен локально — бинарь есть только в CI).
This commit is contained in:
@@ -173,6 +173,23 @@ export interface SubscriptionRequestHistory {
|
|||||||
records: SubscriptionRequestRecord[];
|
records: SubscriptionRequestRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserActivityItem {
|
||||||
|
type: string;
|
||||||
|
subtype: string | null;
|
||||||
|
source: string | null;
|
||||||
|
title: string | null;
|
||||||
|
amount_kopeks: number | null;
|
||||||
|
timestamp: string;
|
||||||
|
meta: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserActivityResponse {
|
||||||
|
items: UserActivityItem[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserNodeUsageItem {
|
export interface UserNodeUsageItem {
|
||||||
node_uuid: string;
|
node_uuid: string;
|
||||||
node_name: string;
|
node_name: string;
|
||||||
@@ -699,6 +716,19 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Unified activity timeline (bot + cabinet actions)
|
||||||
|
getUserActivity: async (
|
||||||
|
userId: number,
|
||||||
|
offset = 0,
|
||||||
|
limit = 25,
|
||||||
|
types?: string,
|
||||||
|
): Promise<UserActivityResponse> => {
|
||||||
|
const params: Record<string, unknown> = { offset, limit };
|
||||||
|
if (types) params.types = types;
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/activity`, { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get subscription request history from Remnawave panel
|
// Get subscription request history from Remnawave panel
|
||||||
getSubscriptionRequestHistory: async (
|
getSubscriptionRequestHistory: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
264
src/components/admin/userDetail/ActivityTab.tsx
Normal file
264
src/components/admin/userDetail/ActivityTab.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { adminUsersApi, type UserActivityItem } from '../../../api/adminUsers';
|
||||||
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
|
CabinetIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
ChatIcon,
|
||||||
|
GiftIcon,
|
||||||
|
HistoryIcon,
|
||||||
|
PulseIcon,
|
||||||
|
TagIcon,
|
||||||
|
TicketIcon,
|
||||||
|
UsersIcon,
|
||||||
|
WalletIcon,
|
||||||
|
WheelIcon,
|
||||||
|
} from '@/components/icons';
|
||||||
|
import { StatCard } from '@/components/stats';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Activity tab — unified timeline of the user's actions in the bot
|
||||||
|
// and the cabinet (GET /cabinet/admin/users/{id}/activity).
|
||||||
|
// Self-contained: owns filter/paging state, parent only passes
|
||||||
|
// userId + formatDate (the house convention, see TicketsTab).
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ActivityTabProps {
|
||||||
|
userId: number;
|
||||||
|
formatDate: (date: string | null) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
/** Filter chips → backend `types` CSV (null = no filter). */
|
||||||
|
const FILTERS: Array<{ key: string; types: string | null }> = [
|
||||||
|
{ key: 'all', types: null },
|
||||||
|
{ key: 'payments', types: 'transaction,withdrawal' },
|
||||||
|
{ key: 'events', types: 'event' },
|
||||||
|
{ key: 'promo', types: 'promocode,coupon,wheel_spin,poll' },
|
||||||
|
{ key: 'tickets', types: 'ticket' },
|
||||||
|
{ key: 'gifts', types: 'gift_sent,gift_received' },
|
||||||
|
{ key: 'referrals', types: 'referral_earning' },
|
||||||
|
{ key: 'logins', types: 'cabinet_login' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_VISUALS: Record<string, { icon: typeof WalletIcon; tint: string }> = {
|
||||||
|
transaction: { icon: WalletIcon, tint: 'bg-accent-500/15 text-accent-400' },
|
||||||
|
event: { icon: PulseIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
promocode: { icon: TagIcon, tint: 'bg-warning-500/15 text-warning-400' },
|
||||||
|
coupon: { icon: TicketIcon, tint: 'bg-warning-500/15 text-warning-400' },
|
||||||
|
ticket: { icon: ChatIcon, tint: 'bg-error-500/15 text-error-400' },
|
||||||
|
wheel_spin: { icon: WheelIcon, tint: 'bg-accent-500/15 text-accent-400' },
|
||||||
|
poll: { icon: ChartBarIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
gift_sent: { icon: GiftIcon, tint: 'bg-warning-500/15 text-warning-400' },
|
||||||
|
gift_received: { icon: GiftIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
referral_earning: { icon: UsersIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
cabinet_login: { icon: CabinetIcon, tint: 'bg-dark-700/60 text-dark-300' },
|
||||||
|
withdrawal: { icon: BanknotesIcon, tint: 'bg-error-500/15 text-error-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_VISUAL = { icon: HistoryIcon, tint: 'bg-dark-700/60 text-dark-300' };
|
||||||
|
|
||||||
|
/** Transaction subtypes displayed as an expense (red, minus sign). */
|
||||||
|
const EXPENSE_SUBTYPES = new Set(['withdrawal', 'subscription_payment', 'gift_payment']);
|
||||||
|
|
||||||
|
function formatRelativeTime(
|
||||||
|
dateString: string,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const diffSec = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return t('admin.auditLog.time.justNow');
|
||||||
|
if (diffMin < 60) return t('admin.auditLog.time.minutesAgo', { count: diffMin });
|
||||||
|
if (diffHour < 24) return t('admin.auditLog.time.hoursAgo', { count: diffHour });
|
||||||
|
if (diffDay < 30) return t('admin.auditLog.time.daysAgo', { count: diffDay });
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubtypeBadge({ subtype }: { subtype: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label =
|
||||||
|
t(`admin.users.detail.activity.subtypes.${subtype}`, { defaultValue: '' }) || subtype;
|
||||||
|
return (
|
||||||
|
<span className="rounded-full border border-dark-600/60 bg-dark-700/40 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-dark-300">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountChip({ item }: { item: UserActivityItem }) {
|
||||||
|
if (item.amount_kopeks == null || item.amount_kopeks === 0) return null;
|
||||||
|
|
||||||
|
const rubles = Math.abs(item.amount_kopeks) / 100;
|
||||||
|
const isExpense =
|
||||||
|
item.type === 'withdrawal' ||
|
||||||
|
(item.type === 'transaction' && item.subtype != null && EXPENSE_SUBTYPES.has(item.subtype)) ||
|
||||||
|
item.type === 'gift_sent';
|
||||||
|
const sign = isExpense ? '−' : '+';
|
||||||
|
const tone = isExpense ? 'text-error-400' : 'text-success-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`shrink-0 text-sm font-semibold tabular-nums ${tone}`}>
|
||||||
|
{sign}
|
||||||
|
{rubles.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ₽
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityTab({ userId, formatDate }: ActivityTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<string>('all');
|
||||||
|
const [items, setItems] = useState<UserActivityItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||||
|
|
||||||
|
const activeTypes = FILTERS.find((f) => f.key === filter)?.types ?? null;
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (loadOffset = 0, append = false) => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await adminUsersApi.getUserActivity(
|
||||||
|
userId,
|
||||||
|
loadOffset,
|
||||||
|
PAGE_SIZE,
|
||||||
|
activeTypes ?? undefined,
|
||||||
|
);
|
||||||
|
setItems((prev) => (append ? [...prev, ...data.items] : data.items));
|
||||||
|
setTotal(data.total);
|
||||||
|
setOffset(loadOffset + data.items.length);
|
||||||
|
} catch {
|
||||||
|
// background list load — silent, консистентно с остальной карточкой
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoaded(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[userId, activeTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems([]);
|
||||||
|
setInitialLoaded(false);
|
||||||
|
void load(0, false);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.users.detail.activity.total')}
|
||||||
|
value={total}
|
||||||
|
icon={<HistoryIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
|
loading={!initialLoaded && loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div
|
||||||
|
className="scrollbar-hide -mx-4 flex gap-2 overflow-x-auto px-4 py-1"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
{FILTERS.map(({ key }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={`shrink-0 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === key
|
||||||
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
|
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.users.detail.activity.filters.${key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{!initialLoaded && loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-12">
|
||||||
|
<HistoryIcon className="mb-3 h-12 w-12 text-dark-600" />
|
||||||
|
<p className="text-dark-400">{t('admin.users.detail.activity.empty')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const visual = TYPE_VISUALS[item.type] || FALLBACK_VISUAL;
|
||||||
|
const Icon = visual.icon;
|
||||||
|
const typeLabel =
|
||||||
|
t(`admin.users.detail.activity.types.${item.type}`, { defaultValue: '' }) ||
|
||||||
|
item.type;
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${item.type}-${item.timestamp}-${index}`} className="flex gap-3">
|
||||||
|
{/* Icon column + connecting line */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${visual.tint}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="w-px flex-1 bg-dark-700/50" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={`min-w-0 flex-1 ${isLast ? '' : 'pb-4'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-dark-100">{typeLabel}</span>
|
||||||
|
{item.subtype && <SubtypeBadge subtype={item.subtype} />}
|
||||||
|
{item.source && (
|
||||||
|
<span className="rounded-full bg-dark-700/40 px-2 py-0.5 text-[10px] uppercase tracking-wider text-dark-500">
|
||||||
|
{t(`admin.users.detail.activity.sources.${item.source}`, {
|
||||||
|
defaultValue: item.source,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AmountChip item={item} />
|
||||||
|
</div>
|
||||||
|
{item.title && (
|
||||||
|
<p className="mt-0.5 break-words text-sm text-dark-400" title={item.title}>
|
||||||
|
{item.title.length > 160 ? `${item.title.slice(0, 160)}…` : item.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-dark-500" title={formatDate(item.timestamp)}>
|
||||||
|
{formatRelativeTime(item.timestamp, t)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{items.length < total && (
|
||||||
|
<button
|
||||||
|
onClick={() => void load(offset, true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-3 flex min-h-[44px] w-full items-center justify-center rounded-lg bg-dark-700/50 py-2.5 text-sm text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
t('admin.users.detail.loadMore')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3378,7 +3378,8 @@
|
|||||||
"sync": "Synchronization",
|
"sync": "Synchronization",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
"gifts": "Gifts",
|
"gifts": "Gifts",
|
||||||
"referrals": "Referrals"
|
"referrals": "Referrals",
|
||||||
|
"activity": "Activity"
|
||||||
},
|
},
|
||||||
"noTickets": "No tickets from this user",
|
"noTickets": "No tickets from this user",
|
||||||
"ticketsCount": "tickets",
|
"ticketsCount": "tickets",
|
||||||
@@ -3579,6 +3580,66 @@
|
|||||||
"searchPlaceholder": "Search by name, email, or Telegram ID...",
|
"searchPlaceholder": "Search by name, email, or Telegram ID...",
|
||||||
"alreadyReferred": "already has referrer",
|
"alreadyReferred": "already has referrer",
|
||||||
"noUsersFound": "No users found"
|
"noUsersFound": "No users found"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"total": "Total events",
|
||||||
|
"empty": "No activity yet",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"payments": "Payments",
|
||||||
|
"events": "Events",
|
||||||
|
"promo": "Promo",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"gifts": "Gifts",
|
||||||
|
"referrals": "Referrals",
|
||||||
|
"logins": "Logins"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "Transaction",
|
||||||
|
"event": "Event",
|
||||||
|
"promocode": "Promo code",
|
||||||
|
"coupon": "Coupon",
|
||||||
|
"ticket": "Ticket",
|
||||||
|
"wheel_spin": "Fortune wheel",
|
||||||
|
"poll": "Poll completed",
|
||||||
|
"gift_sent": "Gift sent",
|
||||||
|
"gift_received": "Gift received",
|
||||||
|
"referral_earning": "Referral earning",
|
||||||
|
"cabinet_login": "Cabinet login",
|
||||||
|
"withdrawal": "Withdrawal request"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "Bot",
|
||||||
|
"cabinet": "Cabinet"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "Deposit",
|
||||||
|
"withdrawal": "Charge",
|
||||||
|
"subscription_payment": "Subscription payment",
|
||||||
|
"refund": "Refund",
|
||||||
|
"failed_refund": "Failed refund",
|
||||||
|
"referral_reward": "Referral reward",
|
||||||
|
"poll_reward": "Poll reward",
|
||||||
|
"gift_payment": "Gift payment",
|
||||||
|
"activation": "Activation",
|
||||||
|
"purchase": "Purchase",
|
||||||
|
"subscription_purchase": "Subscription purchase",
|
||||||
|
"renewal": "Renewal",
|
||||||
|
"balance_topup": "Balance top-up",
|
||||||
|
"promo_group_change": "Promo group change",
|
||||||
|
"campaign_registration": "Campaign registration",
|
||||||
|
"referral_link_visit": "Referral link visit",
|
||||||
|
"referral_registration": "Referral registration",
|
||||||
|
"trial_activation": "Trial activation",
|
||||||
|
"open": "Open",
|
||||||
|
"answered": "Answered",
|
||||||
|
"pending": "Pending",
|
||||||
|
"closed": "Closed",
|
||||||
|
"redeemed": "Redeemed",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"completed": "Completed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notFound": "User not found",
|
"notFound": "User not found",
|
||||||
|
|||||||
@@ -2882,7 +2882,8 @@
|
|||||||
"sync": "همگامسازی",
|
"sync": "همگامسازی",
|
||||||
"tickets": "تیکتها",
|
"tickets": "تیکتها",
|
||||||
"gifts": "هدایا",
|
"gifts": "هدایا",
|
||||||
"referrals": "معرفیها"
|
"referrals": "معرفیها",
|
||||||
|
"activity": "فعالیت"
|
||||||
},
|
},
|
||||||
"noTickets": "این کاربر تیکتی ندارد",
|
"noTickets": "این کاربر تیکتی ندارد",
|
||||||
"ticketsCount": "تیکت",
|
"ticketsCount": "تیکت",
|
||||||
@@ -3083,7 +3084,67 @@
|
|||||||
"areYouSure": "آیا مطمئنید میخواهید ادامه دهید؟",
|
"areYouSure": "آیا مطمئنید میخواهید ادامه دهید؟",
|
||||||
"title": "عملیات"
|
"title": "عملیات"
|
||||||
},
|
},
|
||||||
"noVpnData": "دادهای از VPN وجود ندارد"
|
"noVpnData": "دادهای از VPN وجود ندارد",
|
||||||
|
"activity": {
|
||||||
|
"total": "مجموع رویدادها",
|
||||||
|
"empty": "هنوز فعالیتی وجود ندارد",
|
||||||
|
"filters": {
|
||||||
|
"all": "همه",
|
||||||
|
"payments": "پرداختها",
|
||||||
|
"events": "رویدادها",
|
||||||
|
"promo": "تخفیفها",
|
||||||
|
"tickets": "تیکتها",
|
||||||
|
"gifts": "هدایا",
|
||||||
|
"referrals": "دعوتها",
|
||||||
|
"logins": "ورودها"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "تراکنش",
|
||||||
|
"event": "رویداد",
|
||||||
|
"promocode": "کد تخفیف",
|
||||||
|
"coupon": "کوپن",
|
||||||
|
"ticket": "تیکت",
|
||||||
|
"wheel_spin": "گردونه شانس",
|
||||||
|
"poll": "نظرسنجی تکمیل شد",
|
||||||
|
"gift_sent": "هدیه ارسال شد",
|
||||||
|
"gift_received": "هدیه دریافت شد",
|
||||||
|
"referral_earning": "درآمد دعوت",
|
||||||
|
"cabinet_login": "ورود به پنل",
|
||||||
|
"withdrawal": "درخواست برداشت"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "ربات",
|
||||||
|
"cabinet": "پنل"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "واریز",
|
||||||
|
"withdrawal": "برداشت",
|
||||||
|
"subscription_payment": "پرداخت اشتراک",
|
||||||
|
"refund": "بازپرداخت",
|
||||||
|
"failed_refund": "بازپرداخت ناموفق",
|
||||||
|
"referral_reward": "پاداش دعوت",
|
||||||
|
"poll_reward": "پاداش نظرسنجی",
|
||||||
|
"gift_payment": "پرداخت هدیه",
|
||||||
|
"activation": "فعالسازی",
|
||||||
|
"purchase": "خرید",
|
||||||
|
"subscription_purchase": "خرید اشتراک",
|
||||||
|
"renewal": "تمدید",
|
||||||
|
"balance_topup": "شارژ موجودی",
|
||||||
|
"promo_group_change": "تغییر گروه تخفیف",
|
||||||
|
"campaign_registration": "ثبتنام کمپین",
|
||||||
|
"referral_link_visit": "بازدید لینک دعوت",
|
||||||
|
"referral_registration": "ثبتنام دعوتی",
|
||||||
|
"trial_activation": "فعالسازی دوره آزمایشی",
|
||||||
|
"open": "باز",
|
||||||
|
"answered": "پاسخ داده شده",
|
||||||
|
"pending": "در انتظار",
|
||||||
|
"closed": "بسته",
|
||||||
|
"redeemed": "استفاده شده",
|
||||||
|
"approved": "تأیید شده",
|
||||||
|
"rejected": "رد شده",
|
||||||
|
"completed": "انجام شده"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notFound": "کاربر یافت نشد",
|
"notFound": "کاربر یافت نشد",
|
||||||
"purchaseCount": "تعداد خرید: {{count}}",
|
"purchaseCount": "تعداد خرید: {{count}}",
|
||||||
|
|||||||
@@ -3785,7 +3785,8 @@
|
|||||||
"sync": "Синхронизация",
|
"sync": "Синхронизация",
|
||||||
"tickets": "Тикеты",
|
"tickets": "Тикеты",
|
||||||
"gifts": "Подарки",
|
"gifts": "Подарки",
|
||||||
"referrals": "Рефералы"
|
"referrals": "Рефералы",
|
||||||
|
"activity": "Активность"
|
||||||
},
|
},
|
||||||
"noTickets": "У пользователя нет тикетов",
|
"noTickets": "У пользователя нет тикетов",
|
||||||
"ticketsCount": "тикетов",
|
"ticketsCount": "тикетов",
|
||||||
@@ -3985,6 +3986,66 @@
|
|||||||
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
|
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
|
||||||
"alreadyReferred": "уже чей-то реферал",
|
"alreadyReferred": "уже чей-то реферал",
|
||||||
"noUsersFound": "Пользователи не найдены"
|
"noUsersFound": "Пользователи не найдены"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"total": "Всего событий",
|
||||||
|
"empty": "Активности пока нет",
|
||||||
|
"filters": {
|
||||||
|
"all": "Все",
|
||||||
|
"payments": "Платежи",
|
||||||
|
"events": "События",
|
||||||
|
"promo": "Промо",
|
||||||
|
"tickets": "Тикеты",
|
||||||
|
"gifts": "Подарки",
|
||||||
|
"referrals": "Рефералы",
|
||||||
|
"logins": "Входы"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "Транзакция",
|
||||||
|
"event": "Событие",
|
||||||
|
"promocode": "Промокод",
|
||||||
|
"coupon": "Купон",
|
||||||
|
"ticket": "Тикет",
|
||||||
|
"wheel_spin": "Колесо фортуны",
|
||||||
|
"poll": "Опрос пройден",
|
||||||
|
"gift_sent": "Подарок отправлен",
|
||||||
|
"gift_received": "Подарок получен",
|
||||||
|
"referral_earning": "Реферальное начисление",
|
||||||
|
"cabinet_login": "Вход в кабинет",
|
||||||
|
"withdrawal": "Заявка на вывод"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "Бот",
|
||||||
|
"cabinet": "Кабинет"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "Пополнение",
|
||||||
|
"withdrawal": "Списание",
|
||||||
|
"subscription_payment": "Оплата подписки",
|
||||||
|
"refund": "Возврат",
|
||||||
|
"failed_refund": "Неудачный возврат",
|
||||||
|
"referral_reward": "Реферальная награда",
|
||||||
|
"poll_reward": "Награда за опрос",
|
||||||
|
"gift_payment": "Оплата подарка",
|
||||||
|
"activation": "Активация",
|
||||||
|
"purchase": "Покупка",
|
||||||
|
"subscription_purchase": "Покупка подписки",
|
||||||
|
"renewal": "Продление",
|
||||||
|
"balance_topup": "Пополнение баланса",
|
||||||
|
"promo_group_change": "Смена промогруппы",
|
||||||
|
"campaign_registration": "Регистрация по кампании",
|
||||||
|
"referral_link_visit": "Переход по реф. ссылке",
|
||||||
|
"referral_registration": "Реф. регистрация",
|
||||||
|
"trial_activation": "Активация триала",
|
||||||
|
"open": "Открыт",
|
||||||
|
"answered": "Отвечен",
|
||||||
|
"pending": "Ожидает",
|
||||||
|
"closed": "Закрыт",
|
||||||
|
"redeemed": "Погашен",
|
||||||
|
"approved": "Одобрена",
|
||||||
|
"rejected": "Отклонена",
|
||||||
|
"completed": "Выполнена"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notFound": "Пользователь не найден",
|
"notFound": "Пользователь не найден",
|
||||||
|
|||||||
@@ -2881,7 +2881,8 @@
|
|||||||
"sync": "同步",
|
"sync": "同步",
|
||||||
"tickets": "工单",
|
"tickets": "工单",
|
||||||
"gifts": "礼物",
|
"gifts": "礼物",
|
||||||
"referrals": "推荐"
|
"referrals": "推荐",
|
||||||
|
"activity": "活动记录"
|
||||||
},
|
},
|
||||||
"noTickets": "该用户没有工单",
|
"noTickets": "该用户没有工单",
|
||||||
"ticketsCount": "个工单",
|
"ticketsCount": "个工单",
|
||||||
@@ -3082,7 +3083,67 @@
|
|||||||
"areYouSure": "确定要继续?",
|
"areYouSure": "确定要继续?",
|
||||||
"title": "操作"
|
"title": "操作"
|
||||||
},
|
},
|
||||||
"noVpnData": "无 VPN 数据"
|
"noVpnData": "无 VPN 数据",
|
||||||
|
"activity": {
|
||||||
|
"total": "事件总数",
|
||||||
|
"empty": "暂无活动记录",
|
||||||
|
"filters": {
|
||||||
|
"all": "全部",
|
||||||
|
"payments": "支付",
|
||||||
|
"events": "事件",
|
||||||
|
"promo": "促销",
|
||||||
|
"tickets": "工单",
|
||||||
|
"gifts": "礼物",
|
||||||
|
"referrals": "推荐",
|
||||||
|
"logins": "登录"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "交易",
|
||||||
|
"event": "事件",
|
||||||
|
"promocode": "促销码",
|
||||||
|
"coupon": "优惠券",
|
||||||
|
"ticket": "工单",
|
||||||
|
"wheel_spin": "幸运转盘",
|
||||||
|
"poll": "问卷已完成",
|
||||||
|
"gift_sent": "已发送礼物",
|
||||||
|
"gift_received": "已收到礼物",
|
||||||
|
"referral_earning": "推荐收益",
|
||||||
|
"cabinet_login": "登录后台",
|
||||||
|
"withdrawal": "提现申请"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "机器人",
|
||||||
|
"cabinet": "后台"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "充值",
|
||||||
|
"withdrawal": "扣款",
|
||||||
|
"subscription_payment": "订阅付款",
|
||||||
|
"refund": "退款",
|
||||||
|
"failed_refund": "退款失败",
|
||||||
|
"referral_reward": "推荐奖励",
|
||||||
|
"poll_reward": "问卷奖励",
|
||||||
|
"gift_payment": "礼物付款",
|
||||||
|
"activation": "激活",
|
||||||
|
"purchase": "购买",
|
||||||
|
"subscription_purchase": "订阅购买",
|
||||||
|
"renewal": "续订",
|
||||||
|
"balance_topup": "余额充值",
|
||||||
|
"promo_group_change": "促销组变更",
|
||||||
|
"campaign_registration": "活动注册",
|
||||||
|
"referral_link_visit": "推荐链接访问",
|
||||||
|
"referral_registration": "推荐注册",
|
||||||
|
"trial_activation": "试用激活",
|
||||||
|
"open": "打开",
|
||||||
|
"answered": "已回复",
|
||||||
|
"pending": "等待中",
|
||||||
|
"closed": "已关闭",
|
||||||
|
"redeemed": "已兑换",
|
||||||
|
"approved": "已批准",
|
||||||
|
"rejected": "已拒绝",
|
||||||
|
"completed": "已完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notFound": "未找到用户",
|
"notFound": "未找到用户",
|
||||||
"purchaseCount": "购买次数:{{count}}",
|
"purchaseCount": "购买次数:{{count}}",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GiftsTab } from '../components/admin/userDetail/GiftsTab';
|
|||||||
import { SyncTab } from '../components/admin/userDetail/SyncTab';
|
import { SyncTab } from '../components/admin/userDetail/SyncTab';
|
||||||
import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab';
|
import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab';
|
||||||
import { BalanceTab } from '../components/admin/userDetail/BalanceTab';
|
import { BalanceTab } from '../components/admin/userDetail/BalanceTab';
|
||||||
|
import { ActivityTab } from '../components/admin/userDetail/ActivityTab';
|
||||||
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
||||||
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
||||||
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
||||||
@@ -49,7 +50,7 @@ export default function AdminUserDetail() {
|
|||||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals'
|
'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' | 'activity'
|
||||||
>('info');
|
>('info');
|
||||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
||||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||||
@@ -776,7 +777,18 @@ export default function AdminUserDetail() {
|
|||||||
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
|
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
>
|
>
|
||||||
{(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts', 'referrals'] as const)
|
{(
|
||||||
|
[
|
||||||
|
'info',
|
||||||
|
'subscription',
|
||||||
|
'balance',
|
||||||
|
'sync',
|
||||||
|
'tickets',
|
||||||
|
'gifts',
|
||||||
|
'referrals',
|
||||||
|
'activity',
|
||||||
|
] as const
|
||||||
|
)
|
||||||
.filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
|
.filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -795,6 +807,7 @@ export default function AdminUserDetail() {
|
|||||||
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
|
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
|
||||||
{tab === 'gifts' && t('admin.users.detail.tabs.gifts')}
|
{tab === 'gifts' && t('admin.users.detail.tabs.gifts')}
|
||||||
{tab === 'referrals' && t('admin.users.detail.tabs.referrals')}
|
{tab === 'referrals' && t('admin.users.detail.tabs.referrals')}
|
||||||
|
{tab === 'activity' && t('admin.users.detail.tabs.activity')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -948,6 +961,11 @@ export default function AdminUserDetail() {
|
|||||||
{activeTab === 'referrals' && user && userId && (
|
{activeTab === 'referrals' && user && userId && (
|
||||||
<ReferralsTab user={user} userId={userId} onUserRefresh={loadUser} />
|
<ReferralsTab user={user} userId={userId} onUserRefresh={loadUser} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Activity Tab */}
|
||||||
|
{activeTab === 'activity' && userId && (
|
||||||
|
<ActivityTab userId={userId} formatDate={formatDate} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user