diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index a3f0e74..bc9a053 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -173,6 +173,23 @@ export interface SubscriptionRequestHistory { records: SubscriptionRequestRecord[]; } +export interface UserActivityItem { + type: string; + subtype: string | null; + source: string | null; + title: string | null; + amount_kopeks: number | null; + timestamp: string; + meta: Record | null; +} + +export interface UserActivityResponse { + items: UserActivityItem[]; + total: number; + offset: number; + limit: number; +} + export interface UserNodeUsageItem { node_uuid: string; node_name: string; @@ -699,6 +716,19 @@ export const adminUsersApi = { return response.data; }, + // Unified activity timeline (bot + cabinet actions) + getUserActivity: async ( + userId: number, + offset = 0, + limit = 25, + types?: string, + ): Promise => { + const params: Record = { 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 getSubscriptionRequestHistory: async ( userId: number, diff --git a/src/components/admin/userDetail/ActivityTab.tsx b/src/components/admin/userDetail/ActivityTab.tsx new file mode 100644 index 0000000..108adb6 --- /dev/null +++ b/src/components/admin/userDetail/ActivityTab.tsx @@ -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 = { + 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, +): 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 ( + + {label} + + ); +} + +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 ( + + {sign} + {rubles.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ₽ + + ); +} + +export function ActivityTab({ userId, formatDate }: ActivityTabProps) { + const { t } = useTranslation(); + + const [filter, setFilter] = useState('all'); + const [items, setItems] = useState([]); + 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 ( +
+ } + tone="accent" + loading={!initialLoaded && loading} + /> + + {/* Filter chips */} +
+ {FILTERS.map(({ key }) => ( + + ))} +
+ + {/* Timeline */} + {!initialLoaded && loading ? ( +
+
+
+ ) : items.length === 0 ? ( +
+ +

{t('admin.users.detail.activity.empty')}

+
+ ) : ( +
+ {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 ( +
+ {/* Icon column + connecting line */} +
+
+ +
+ {!isLast &&
} +
+ + {/* Content */} +
+
+
+ {typeLabel} + {item.subtype && } + {item.source && ( + + {t(`admin.users.detail.activity.sources.${item.source}`, { + defaultValue: item.source, + })} + + )} +
+ +
+ {item.title && ( +

+ {item.title.length > 160 ? `${item.title.slice(0, 160)}…` : item.title} +

+ )} +

+ {formatRelativeTime(item.timestamp, t)} +

+
+
+ ); + })} + + {/* Load more */} + {items.length < total && ( + + )} +
+ )} +
+ ); +} diff --git a/src/locales/en.json b/src/locales/en.json index e0541f3..3ebb3e2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3378,7 +3378,8 @@ "sync": "Synchronization", "tickets": "Tickets", "gifts": "Gifts", - "referrals": "Referrals" + "referrals": "Referrals", + "activity": "Activity" }, "noTickets": "No tickets from this user", "ticketsCount": "tickets", @@ -3579,6 +3580,66 @@ "searchPlaceholder": "Search by name, email, or Telegram ID...", "alreadyReferred": "already has referrer", "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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 64b6dee..a41c9ed 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2882,7 +2882,8 @@ "sync": "همگام‌سازی", "tickets": "تیکت‌ها", "gifts": "هدایا", - "referrals": "معرفی‌ها" + "referrals": "معرفی‌ها", + "activity": "فعالیت" }, "noTickets": "این کاربر تیکتی ندارد", "ticketsCount": "تیکت", @@ -3083,7 +3084,67 @@ "areYouSure": "آیا مطمئنید می‌خواهید ادامه دهید؟", "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": "کاربر یافت نشد", "purchaseCount": "تعداد خرید: {{count}}", diff --git a/src/locales/ru.json b/src/locales/ru.json index 3415f79..4b054b5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3785,7 +3785,8 @@ "sync": "Синхронизация", "tickets": "Тикеты", "gifts": "Подарки", - "referrals": "Рефералы" + "referrals": "Рефералы", + "activity": "Активность" }, "noTickets": "У пользователя нет тикетов", "ticketsCount": "тикетов", @@ -3985,6 +3986,66 @@ "searchPlaceholder": "Поиск по имени, email или Telegram ID...", "alreadyReferred": "уже чей-то реферал", "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": "Пользователь не найден", diff --git a/src/locales/zh.json b/src/locales/zh.json index 08eb37c..21b34a7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2881,7 +2881,8 @@ "sync": "同步", "tickets": "工单", "gifts": "礼物", - "referrals": "推荐" + "referrals": "推荐", + "activity": "活动记录" }, "noTickets": "该用户没有工单", "ticketsCount": "个工单", @@ -3082,7 +3083,67 @@ "areYouSure": "确定要继续?", "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": "未找到用户", "purchaseCount": "购买次数:{{count}}", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 828a50e..ba63740 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -24,6 +24,7 @@ import { GiftsTab } from '../components/admin/userDetail/GiftsTab'; import { SyncTab } from '../components/admin/userDetail/SyncTab'; import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab'; import { BalanceTab } from '../components/admin/userDetail/BalanceTab'; +import { ActivityTab } from '../components/admin/userDetail/ActivityTab'; import { TicketsTab } from '../components/admin/userDetail/TicketsTab'; import { InfoTab } from '../components/admin/userDetail/InfoTab'; import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab'; @@ -49,7 +50,7 @@ export default function AdminUserDetail() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState< - 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' + 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' | 'activity' >('info'); const [syncStatus, setSyncStatus] = useState(null); const [tariffs, setTariffs] = useState([]); @@ -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" 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')) .map((tab) => (
@@ -948,6 +961,11 @@ export default function AdminUserDetail() { {activeTab === 'referrals' && user && userId && ( )} + + {/* Activity Tab */} + {activeTab === 'activity' && userId && ( + + )}
);