diff --git a/package-lock.json b/package-lock.json index e2d03b0..da16049 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@tanstack/react-table": "^8.21.3", "@telegram-apps/sdk-react": "^3.3.9", "autoprefixer": "^10.4.24", "axios": "^1.6.0", @@ -2626,6 +2627,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@telegram-apps/bridge": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.11.0.tgz", diff --git a/package.json b/package.json index 99093a2..262d056 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@tanstack/react-table": "^8.21.3", "@telegram-apps/sdk-react": "^3.3.9", "autoprefixer": "^10.4.24", "axios": "^1.6.0", diff --git a/src/App.tsx b/src/App.tsx index 58bdcaa..0e5031a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,6 +69,7 @@ const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend')); const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); +const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); @@ -565,6 +566,16 @@ function App() { } /> + + + + + + } + /> ; + total_bytes: number; +} + +export interface TrafficUsageResponse { + items: UserTrafficItem[]; + nodes: TrafficNodeInfo[]; + total: number; + offset: number; + limit: number; + period_days: number; + available_tariffs: string[]; + available_statuses: string[]; +} + +export interface ExportCsvResponse { + success: boolean; + message: string; +} + +export type TrafficParams = { + period?: number; + limit?: number; + offset?: number; + search?: string; + sort_by?: string; + sort_desc?: boolean; + tariffs?: string; + statuses?: string; + nodes?: string; + start_date?: string; + end_date?: string; +}; + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +const trafficCache = new Map(); + +function buildCacheKey(params: TrafficParams): string { + return JSON.stringify({ + period: params.period ?? 30, + limit: params.limit ?? 50, + offset: params.offset ?? 0, + search: params.search ?? '', + sort_by: params.sort_by ?? 'total_bytes', + sort_desc: params.sort_desc ?? true, + tariffs: params.tariffs ?? '', + statuses: params.statuses ?? '', + nodes: params.nodes ?? '', + start_date: params.start_date ?? '', + end_date: params.end_date ?? '', + }); +} + +export const adminTrafficApi = { + getTrafficUsage: async ( + params: TrafficParams, + options?: { skipCache?: boolean }, + ): Promise => { + const key = buildCacheKey(params); + + if (!options?.skipCache) { + const cached = trafficCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; + } + } + + const response = await apiClient.get('/cabinet/admin/traffic', { params }); + const data: TrafficUsageResponse = response.data; + + trafficCache.set(key, { data, timestamp: Date.now() }); + + return data; + }, + + getCached: (params: TrafficParams): TrafficUsageResponse | null => { + const key = buildCacheKey(params); + const cached = trafficCache.get(key); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data; + } + return null; + }, + + invalidateCache: () => { + trafficCache.clear(); + }, + + exportCsv: async (data: { + period: number; + start_date?: string; + end_date?: string; + tariffs?: string; + statuses?: string; + nodes?: string; + total_threshold_gb?: number; + node_threshold_gb?: number; + }): Promise => { + const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data); + return response.data; + }, +}; diff --git a/src/api/promocodes.ts b/src/api/promocodes.ts index 83acb23..cde1307 100644 --- a/src/api/promocodes.ts +++ b/src/api/promocodes.ts @@ -183,4 +183,12 @@ export const promocodesApi = { deletePromoGroup: async (id: number): Promise => { await apiClient.delete(`/cabinet/admin/promo-groups/${id}`); }, + + // Deactivate user's active discount (admin) + deactivateDiscount: async (userId: number): Promise<{ success: boolean; message: string }> => { + const response = await apiClient.post( + `/cabinet/admin/promocodes/deactivate-discount/${userId}`, + ); + return response.data; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 3f70580..f8e5d23 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -761,7 +761,8 @@ "campaigns": "Campaigns", "promoOffers": "Promo Offers", "promocodes": "Promo Codes", - "promoGroups": "Discount Groups" + "promoGroups": "Discount Groups", + "trafficUsage": "Traffic Usage" }, "panel": { "title": "Admin Panel", @@ -783,7 +784,45 @@ "campaignsDesc": "Advertising campaigns", "promoOffersDesc": "Personal discounts", "promocodesDesc": "Manage promo codes", - "promoGroupsDesc": "Discount groups for users" + "promoGroupsDesc": "Discount groups for users", + "trafficUsageDesc": "Per-user traffic by nodes" + }, + "trafficUsage": { + "title": "Traffic Usage", + "subtitle": "Per-user traffic statistics by nodes", + "period": "Period", + "days": "d", + "exportCsv": "Export CSV", + "exportSuccess": "CSV sent to your Telegram", + "exportError": "Failed to export", + "user": "User", + "tariff": "Tariff", + "devices": "Devices", + "trafficLimit": "Limit", + "total": "Total", + "noData": "No traffic data", + "loading": "Loading traffic data...", + "noTariff": "\u2014", + "search": "Search by name or username", + "allTariffs": "All tariffs", + "nodes": "Nodes", + "allNodes": "All nodes", + "status": "Status", + "allStatuses": "All", + "statusActive": "Active", + "statusTrial": "Trial", + "statusExpired": "Expired", + "statusDisabled": "Disabled", + "customDates": "Custom dates", + "dateFrom": "From", + "dateTo": "To", + "totalThreshold": "Total GB/d", + "nodeThreshold": "Node GB/d", + "risk": "Risk", + "riskLow": "Low", + "riskMedium": "Medium", + "riskHigh": "High", + "riskCritical": "Critical" }, "emailTemplates": { "title": "Email Templates", @@ -1886,6 +1925,22 @@ "title": "Actions", "areYouSure": "Are you sure?" }, + "promoGroup": "Promo group", + "noPromoGroup": "Not set", + "changePromoGroup": "Change", + "selectPromoGroup": "Select group", + "removePromoGroup": "Remove", + "activePromoOffer": "Active promo offer", + "discount": "Discount", + "source": "Source", + "expiresAt": "Expires", + "deactivateOffer": "Deactivate", + "sendOffer": "Send offer", + "discountPercent": "Discount %", + "validHours": "Valid hours", + "offerSent": "Offer sent", + "offerSendError": "Failed to send offer", + "offerDeactivated": "Offer deactivated", "subscription": { "current": "Current subscription", "tariff": "Tariff", diff --git a/src/locales/fa.json b/src/locales/fa.json index 5b7e28b..8d2eb68 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -642,7 +642,8 @@ "promocodes": "کدهای تخفیف", "promoGroups": "گروه‌های تخفیف", "remnawave": "RemnaWave", - "users": "کاربران" + "users": "کاربران", + "trafficUsage": "مصرف ترافیک" }, "panel": { "title": "پنل مدیریت", @@ -664,7 +665,45 @@ "promocodesDesc": "مدیریت کدهای تخفیف", "promoGroupsDesc": "گروه‌های تخفیف برای کاربران", "remnawaveDesc": "مدیریت پنل و آمار", - "usersDesc": "مدیریت کاربران ربات" + "usersDesc": "مدیریت کاربران ربات", + "trafficUsageDesc": "ترافیک هر کاربر بر اساس نود" + }, + "trafficUsage": { + "title": "مصرف ترافیک", + "subtitle": "آمار ترافیک کاربران بر اساس نودها", + "period": "دوره", + "days": "روز", + "exportCsv": "خروجی CSV", + "exportSuccess": "CSV به تلگرام شما ارسال شد", + "exportError": "خطا در خروجی", + "user": "کاربر", + "tariff": "تعرفه", + "devices": "دستگاه‌ها", + "trafficLimit": "محدودیت", + "total": "کل", + "noData": "داده ترافیکی موجود نیست", + "loading": "در حال بارگذاری داده‌های ترافیک...", + "noTariff": "\u2014", + "search": "جستجو بر اساس نام یا نام کاربری", + "allTariffs": "همه تعرفه‌ها", + "nodes": "نودها", + "allNodes": "همه نودها", + "status": "وضعیت", + "allStatuses": "همه", + "statusActive": "فعال", + "statusTrial": "آزمایشی", + "statusExpired": "منقضی", + "statusDisabled": "غیرفعال", + "customDates": "تاریخ دلخواه", + "dateFrom": "از", + "dateTo": "تا", + "totalThreshold": "کل GB/روز", + "nodeThreshold": "نود GB/روز", + "risk": "ریسک", + "riskLow": "کم", + "riskMedium": "متوسط", + "riskHigh": "بالا", + "riskCritical": "بحرانی" }, "emailTemplates": { "title": "قالب‌های ایمیل", @@ -1585,6 +1624,22 @@ "subscription": "• خرید اشتراک ممنوع", "reason": "دلیل" }, + "promoGroup": "گروه تبلیغاتی", + "noPromoGroup": "تنظیم نشده", + "changePromoGroup": "تغییر", + "selectPromoGroup": "انتخاب گروه", + "removePromoGroup": "حذف", + "activePromoOffer": "پیشنهاد فعال", + "discount": "تخفیف", + "source": "منبع", + "expiresAt": "انقضا", + "deactivateOffer": "غیرفعال کردن", + "sendOffer": "ارسال پیشنهاد", + "discountPercent": "تخفیف %", + "validHours": "مدت اعتبار (ساعت)", + "offerSent": "پیشنهاد ارسال شد", + "offerSendError": "خطا در ارسال پیشنهاد", + "offerDeactivated": "پیشنهاد غیرفعال شد", "subscription": { "current": "اشتراک فعلی", "tariff": "تعرفه", diff --git a/src/locales/ru.json b/src/locales/ru.json index 7dd81b3..91fb441 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -782,7 +782,8 @@ "campaigns": "Кампании", "promoOffers": "Промопредложения", "promocodes": "Промокоды", - "promoGroups": "Группы скидок" + "promoGroups": "Группы скидок", + "trafficUsage": "Расход трафика" }, "panel": { "title": "Панель администратора", @@ -804,7 +805,45 @@ "campaignsDesc": "Рекламные кампании", "promoOffersDesc": "Персональные скидки", "promocodesDesc": "Управление промокодами", - "promoGroupsDesc": "Группы скидок для пользователей" + "promoGroupsDesc": "Группы скидок для пользователей", + "trafficUsageDesc": "Трафик пользователей по нодам" + }, + "trafficUsage": { + "title": "Расход трафика", + "subtitle": "Статистика трафика по пользователям и нодам", + "period": "Период", + "days": "д", + "exportCsv": "Экспорт CSV", + "exportSuccess": "CSV отправлен в ваш Telegram", + "exportError": "Ошибка экспорта", + "user": "Пользователь", + "tariff": "Тариф", + "devices": "Устройства", + "trafficLimit": "Лимит", + "total": "Всего", + "noData": "Нет данных о трафике", + "loading": "Загрузка данных о трафике...", + "noTariff": "\u2014", + "search": "Поиск по имени или юзернейму", + "allTariffs": "Все тарифы", + "nodes": "Ноды", + "allNodes": "Все ноды", + "status": "Статус", + "allStatuses": "Все", + "statusActive": "Активная", + "statusTrial": "Пробная", + "statusExpired": "Истекла", + "statusDisabled": "Отключена", + "customDates": "Выбрать даты", + "dateFrom": "От", + "dateTo": "До", + "totalThreshold": "Всего ГБ/д", + "nodeThreshold": "Нода ГБ/д", + "risk": "Риск", + "riskLow": "Низкий", + "riskMedium": "Средний", + "riskHigh": "Высокий", + "riskCritical": "Критический" }, "emailTemplates": { "title": "Email-шаблоны", @@ -2408,6 +2447,22 @@ "subscription": "• Запрет покупки подписки", "reason": "Причина" }, + "promoGroup": "Промо-группа", + "noPromoGroup": "Не задана", + "changePromoGroup": "Изменить", + "selectPromoGroup": "Выберите группу", + "removePromoGroup": "Убрать", + "activePromoOffer": "Активное промо-предложение", + "discount": "Скидка", + "source": "Источник", + "expiresAt": "Истекает", + "deactivateOffer": "Отключить", + "sendOffer": "Отправить предложение", + "discountPercent": "Скидка %", + "validHours": "Срок действия (часы)", + "offerSent": "Предложение отправлено", + "offerSendError": "Ошибка отправки предложения", + "offerDeactivated": "Предложение отключено", "actions": { "title": "Действия", "areYouSure": "Уверены?" @@ -2957,164 +3012,6 @@ "uptime": "Аптайм" } }, - "adminUsers": { - "title": "Пользователи", - "subtitle": "Управление пользователями", - "searchPlaceholder": "Поиск по ID, username, имени...", - "filters": { - "all": "Все", - "active": "Активные", - "blocked": "Заблокированные", - "deleted": "Удалённые" - }, - "sort": { - "created_at": "Дата регистрации", - "balance": "Баланс", - "traffic": "Трафик", - "last_activity": "Активность", - "total_spent": "Потрачено", - "purchase_count": "Покупки" - }, - "stats": { - "totalUsers": "Всего пользователей", - "activeUsers": "Активных", - "blockedUsers": "Заблокировано", - "withSubscription": "С подпиской", - "newToday": "Новых сегодня", - "newWeek": "За неделю", - "newMonth": "За месяц", - "totalBalance": "Общий баланс", - "activeToday": "Активны сегодня", - "activeWeek": "За неделю", - "activeMonth": "За месяц" - }, - "table": { - "user": "Пользователь", - "status": "Статус", - "balance": "Баланс", - "subscription": "Подписка", - "spent": "Потрачено", - "registered": "Регистрация", - "activity": "Активность", - "noUsers": "Пользователи не найдены" - }, - "userStatus": { - "active": "Активен", - "blocked": "Заблокирован", - "deleted": "Удалён" - }, - "subscriptionStatus": { - "active": "Активна", - "trial": "Триал", - "expired": "Истекла", - "none": "Нет подписки" - }, - "detail": { - "title": "Информация о пользователе", - "tabs": { - "info": "Инфо", - "subscription": "Подписка", - "balance": "Баланс", - "sync": "Синхронизация" - }, - "telegramId": "Telegram ID", - "username": "Username", - "fullName": "Имя", - "email": "Email", - "emailVerified": "Подтверждён", - "emailNotVerified": "Не подтверждён", - "language": "Язык", - "registeredAt": "Регистрация", - "lastActivity": "Последняя активность", - "cabinetLogin": "Вход в кабинет", - "referralCode": "Реферальный код", - "referralsCount": "Рефералов", - "referralEarnings": "Заработано", - "referredBy": "Приглашён", - "promoGroup": "Промо-группа", - "noPromoGroup": "Не задана", - "restrictions": "Ограничения", - "restrictionTopup": "Пополнение баланса", - "restrictionSubscription": "Покупка подписки", - "restrictionReason": "Причина", - "noRestrictions": "Нет ограничений", - "totalSpent": "Всего потрачено", - "purchaseCount": "Покупок", - "usedPromocodes": "Использовано промокодов", - "lifetimeTraffic": "Трафик за всё время", - "recentTransactions": "Последние транзакции", - "noTransactions": "Нет транзакций" - }, - "subscription": { - "noSubscription": "Подписка отсутствует", - "currentTariff": "Текущий тариф", - "status": "Статус", - "endDate": "Окончание", - "daysRemaining": "Осталось дней", - "trafficUsed": "Использовано трафика", - "deviceLimit": "Лимит устройств", - "autopay": "Автоплатёж", - "enabled": "Включён", - "disabled": "Отключён", - "actions": { - "extend": "Продлить", - "changeTariff": "Сменить тариф", - "setTraffic": "Установить трафик", - "toggleAutopay": "Вкл/выкл автоплатёж", - "cancel": "Отменить подписку", - "create": "Создать подписку" - }, - "extendDays": "Дней", - "selectTariff": "Выберите тариф", - "trafficLimitGb": "Лимит ГБ", - "trafficUsedGb": "Использовано ГБ", - "isTrial": "Пробный период" - }, - "balance": { - "currentBalance": "Текущий баланс", - "addFunds": "Пополнить", - "deductFunds": "Списать", - "amount": "Сумма", - "description": "Описание", - "createTransaction": "Создать транзакцию", - "transactionHistory": "История транзакций" - }, - "sync": { - "title": "Синхронизация с панелью", - "status": "Статус синхронизации", - "lastSync": "Последняя синхронизация", - "neverSynced": "Никогда", - "botData": "Данные бота", - "panelData": "Данные панели", - "notFound": "Не найден в панели", - "differences": "Различия", - "noDifferences": "Различий нет", - "syncFromPanel": "Синхронизировать из панели", - "syncToPanel": "Синхронизировать в панель", - "remawave_uuid": "Remnawave UUID", - "notLinked": "Не привязан" - }, - "actions": { - "block": "Заблокировать", - "unblock": "Разблокировать", - "delete": "Удалить", - "blockReason": "Причина блокировки", - "confirmBlock": "Подтвердить блокировку", - "confirmDelete": "Подтвердить удаление" - }, - "messages": { - "loadError": "Не удалось загрузить пользователей", - "userLoadError": "Не удалось загрузить пользователя", - "balanceUpdated": "Баланс обновлён", - "balanceError": "Ошибка обновления баланса", - "subscriptionUpdated": "Подписка обновлена", - "subscriptionError": "Ошибка обновления подписки", - "statusUpdated": "Статус обновлён", - "statusError": "Ошибка обновления статуса", - "syncSuccess": "Синхронизация выполнена", - "syncError": "Ошибка синхронизации" - } - }, "profile": { "title": "Профиль", "accountInfo": "Информация об аккаунте", diff --git a/src/locales/zh.json b/src/locales/zh.json index 568726e..a5c9c11 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -642,7 +642,8 @@ "promocodes": "促销码", "promoGroups": "折扣组", "remnawave": "RemnaWave", - "users": "用户" + "users": "用户", + "trafficUsage": "流量使用" }, "panel": { "title": "管理面板", @@ -664,7 +665,45 @@ "promocodesDesc": "管理促销码", "promoGroupsDesc": "用户折扣组", "remnawaveDesc": "面板管理和统计", - "usersDesc": "管理机器人用户" + "usersDesc": "管理机器人用户", + "trafficUsageDesc": "按节点统计用户流量" + }, + "trafficUsage": { + "title": "流量使用", + "subtitle": "按节点的用户流量统计", + "period": "周期", + "days": "天", + "exportCsv": "导出CSV", + "exportSuccess": "CSV已发送到您的Telegram", + "exportError": "导出失败", + "user": "用户", + "tariff": "套餐", + "devices": "设备", + "trafficLimit": "限制", + "total": "总计", + "noData": "无流量数据", + "loading": "正在加载流量数据...", + "noTariff": "\u2014", + "search": "按名称或用户名搜索", + "allTariffs": "所有套餐", + "nodes": "节点", + "allNodes": "所有节点", + "status": "状态", + "allStatuses": "全部", + "statusActive": "活跃", + "statusTrial": "试用", + "statusExpired": "已过期", + "statusDisabled": "已禁用", + "customDates": "自定义日期", + "dateFrom": "从", + "dateTo": "到", + "totalThreshold": "总流量GB/天", + "nodeThreshold": "节点GB/天", + "risk": "风险", + "riskLow": "低", + "riskMedium": "中", + "riskHigh": "高", + "riskCritical": "严重" }, "paymentMethods": { "title": "支付方法", @@ -1584,6 +1623,22 @@ "subscription": "• 禁止购买订阅", "reason": "原因" }, + "promoGroup": "优惠组", + "noPromoGroup": "未设置", + "changePromoGroup": "更改", + "selectPromoGroup": "选择组", + "removePromoGroup": "移除", + "activePromoOffer": "活跃促销优惠", + "discount": "折扣", + "source": "来源", + "expiresAt": "到期时间", + "deactivateOffer": "停用", + "sendOffer": "发送优惠", + "discountPercent": "折扣 %", + "validHours": "有效期(小时)", + "offerSent": "优惠已发送", + "offerSendError": "发送优惠失败", + "offerDeactivated": "优惠已停用", "subscription": { "current": "当前订阅", "tariff": "套餐", diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 2352470..ff86da8 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -216,6 +216,16 @@ const EnvelopeIcon = () => ( ); +const ArrowsUpDownIcon = () => ( + + + +); + const ChevronRightIcon = () => ( @@ -317,6 +327,12 @@ export default function AdminPanel() { title: t('admin.nav.payments'), description: t('admin.panel.paymentsDesc'), }, + { + to: '/admin/traffic-usage', + icon: , + title: t('admin.nav.trafficUsage'), + description: t('admin.panel.trafficUsageDesc'), + }, ], }, { diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx new file mode 100644 index 0000000..33ff729 --- /dev/null +++ b/src/pages/AdminTrafficUsage.tsx @@ -0,0 +1,1788 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router'; +import { useTranslation } from 'react-i18next'; +import { + useReactTable, + getCoreRowModel, + flexRender, + type ColumnDef, + type SortingState, + type RowData, +} from '@tanstack/react-table'; +import { + adminTrafficApi, + type UserTrafficItem, + type TrafficNodeInfo, + type TrafficUsageResponse, + type TrafficParams, +} from '../api/adminTraffic'; +import { usePlatform } from '../platform/hooks/usePlatform'; + +// ============ TanStack Table module augmentation ============ + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + sticky?: boolean; + align?: 'left' | 'center'; + bold?: boolean; + } +} + +// ============ Utils ============ + +const formatBytes = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +}; + +const getFlagEmoji = (countryCode: string): string => { + if (!countryCode || countryCode.length !== 2) return ''; + const codePoints = countryCode + .toUpperCase() + .split('') + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +}; + +const toBackendSortField = (columnId: string): string => { + if (columnId === 'user') return 'full_name'; + return columnId; +}; + +// ============ Risk assessment helpers ============ + +const bytesToGbPerDay = (bytes: number, days: number): number => + days > 0 ? bytes / days / 1024 ** 3 : 0; + +const getRatio = (gbPerDay: number, threshold: number): number => + threshold > 0 ? gbPerDay / threshold : 0; + +const getRowBgColor = (ratio: number): string | undefined => { + if (ratio <= 0) return undefined; + const clamped = Math.min(ratio, 1.5); + const hue = 120 - Math.min(clamped, 1) * 120; + const opacity = clamped <= 1 ? 0.06 + clamped * 0.07 : 0.13 + (clamped - 1) * 0.14; + return `hsla(${hue}, 70%, 45%, ${opacity})`; +}; + +const getNodeTextColor = (ratio: number): string => { + const clamped = Math.min(Math.max(ratio, 0), 1.5); + let hue: number; + if (clamped <= 0.7) { + hue = 210 - (clamped / 0.7) * 180; // 210 (blue) → 30 (amber) + } else { + hue = Math.max(0, 30 - ((clamped - 0.7) / 0.8) * 30); // 30 (amber) → 0 (red) + } + const saturation = 70 + clamped * 15; + const lightness = 65 - clamped * 10; + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +}; + +type RiskLevel = 'low' | 'medium' | 'high' | 'critical'; + +const getRiskLevel = (ratio: number): RiskLevel => { + if (ratio < 0.5) return 'low'; + if (ratio < 0.8) return 'medium'; + if (ratio < 1.2) return 'high'; + return 'critical'; +}; + +interface RiskResult { + ratio: number; + gbPerDay: number; // the dominant daily value (total or worst node) + totalRatio: number; + maxNodeRatio: number; +} + +const getCompositeRisk = ( + row: UserTrafficItem, + totalThreshold: number, + nodeThreshold: number, + days: number, +): RiskResult => { + const dailyTotal = bytesToGbPerDay(row.total_bytes, days); + const totalR = totalThreshold > 0 ? getRatio(dailyTotal, totalThreshold) : 0; + + let maxNodeR = 0; + let worstNodeGbPerDay = 0; + if (nodeThreshold > 0) { + for (const b of Object.values(row.node_traffic)) { + const daily = bytesToGbPerDay(b || 0, days); + const r = getRatio(daily, nodeThreshold); + if (r > maxNodeR) { + maxNodeR = r; + worstNodeGbPerDay = daily; + } + } + } + + // The dominant metric determines what GB/d we show + const ratio = Math.max(totalR, maxNodeR); + const gbPerDay = totalR >= maxNodeR ? dailyTotal : worstNodeGbPerDay; + + return { ratio, gbPerDay, totalRatio: totalR, maxNodeRatio: maxNodeR }; +}; + +const RISK_STYLES: Record = { + low: { + dot: 'bg-success-400', + text: 'text-success-400', + bar: 'bg-success-400', + bg: 'bg-success-400/10', + }, + medium: { + dot: 'bg-warning-400', + text: 'text-warning-400', + bar: 'bg-warning-400', + bg: 'bg-warning-400/10', + }, + high: { + dot: 'bg-orange-400', + text: 'text-orange-400', + bar: 'bg-orange-400', + bg: 'bg-orange-400/10', + }, + critical: { + dot: 'bg-error-400 animate-pulse', + text: 'text-error-400', + bar: 'bg-error-400', + bg: 'bg-error-400/10', + }, +}; + +const formatGbPerDay = (gbPerDay: number): string => { + if (gbPerDay < 0.01) return '<0.01'; + if (gbPerDay < 10) return gbPerDay.toFixed(2); + if (gbPerDay < 100) return gbPerDay.toFixed(1); + return Math.round(gbPerDay).toString(); +}; + +// ============ Icons ============ + +const SearchIcon = () => ( + + + +); + +const ChevronLeftIcon = () => ( + + + +); + +const ChevronRightIcon = () => ( + + + +); + +const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( + + + +); + +const DownloadIcon = () => ( + + + +); + +const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => ( + + {direction === 'asc' ? ( + + ) : direction === 'desc' ? ( + + ) : ( + + )} + +); + +const FilterIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + +const ServerIcon = () => ( + + + +); + +const CalendarIcon = () => ( + + + +); + +const XIcon = () => ( + + + +); + +const StatusIcon = () => ( + + + +); + +const GlobeIcon = () => ( + + + +); + +const ShieldIcon = () => ( + + + +); + +const ServerSmallIcon = () => ( + + + +); + +// ============ Progress Bar ============ + +function ProgressBar({ loading }: { loading: boolean }) { + const [progress, setProgress] = useState(0); + const [visible, setVisible] = useState(false); + const intervalRef = useRef>(undefined); + + useEffect(() => { + if (loading) { + setProgress(0); + setVisible(true); + // Fast initial progress, then slow down + intervalRef.current = setInterval(() => { + setProgress((prev) => { + if (prev < 30) return prev + 8; + if (prev < 60) return prev + 3; + if (prev < 85) return prev + 1; + if (prev < 95) return prev + 0.3; + return prev; + }); + }, 100); + } else { + if (visible) { + setProgress(100); + clearInterval(intervalRef.current); + const timer = setTimeout(() => { + setVisible(false); + setProgress(0); + }, 300); + return () => clearTimeout(timer); + } + } + return () => clearInterval(intervalRef.current); + }, [loading, visible]); + + if (!visible) return null; + + return ( +
+
+
+ ); +} + +// ============ Components ============ + +const PERIODS = [1, 3, 7, 14, 30] as const; + +function PeriodSelector({ + value, + onChange, + label, + dateMode, + customStart, + customEnd, + onToggleDateMode, + onCustomStartChange, + onCustomEndChange, +}: { + value: number; + onChange: (v: number) => void; + label: string; + dateMode: boolean; + customStart: string; + customEnd: string; + onToggleDateMode: () => void; + onCustomStartChange: (v: string) => void; + onCustomEndChange: (v: string) => void; +}) { + const { t } = useTranslation(); + + // Limit: last 31 days + const today = new Date().toISOString().split('T')[0]; + const minDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + if (dateMode) { + return ( +
+ + {t('admin.trafficUsage.dateFrom')} + onCustomStartChange(e.target.value)} + className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" + /> + {t('admin.trafficUsage.dateTo')} + onCustomEndChange(e.target.value)} + className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" + /> + +
+ ); + } + + return ( +
+ {label} +
+ {PERIODS.map((p) => ( + + ))} +
+ +
+ ); +} + +function TariffFilter({ + available, + selected, + onChange, +}: { + available: string[]; + selected: Set; + onChange: (next: Set) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (available.length === 0) return null; + + const allSelected = selected.size === 0; + const activeCount = selected.size; + + const toggle = (tariff: string) => { + const next = new Set(selected); + if (next.has(tariff)) { + next.delete(tariff); + } else { + next.add(tariff); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((tariff) => { + const checked = selected.has(tariff); + return ( + + ); + })} +
+
+ )} +
+ ); +} + +const STATUS_COLORS: Record = { + active: 'bg-success-500', + trial: 'bg-warning-500', + expired: 'bg-error-500', + disabled: 'bg-dark-500', +}; + +function StatusFilter({ + available, + selected, + onChange, +}: { + available: string[]; + selected: Set; + onChange: (next: Set) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (available.length === 0) return null; + + const allSelected = selected.size === 0; + const activeCount = selected.size; + + const toggle = (status: string) => { + const next = new Set(selected); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + const statusLabel = (s: string) => { + const key = `admin.trafficUsage.status${s.charAt(0).toUpperCase() + s.slice(1)}`; + return t(key, s); + }; + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((s) => { + const checked = selected.has(s); + return ( + + ); + })} +
+
+ )} +
+ ); +} + +function NodeFilter({ + available, + selected, + onChange, +}: { + available: TrafficNodeInfo[]; + selected: Set; + onChange: (next: Set) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (available.length === 0) return null; + + const allSelected = selected.size === 0; + const activeCount = selected.size; + + const toggle = (uuid: string) => { + const next = new Set(selected); + if (next.has(uuid)) { + next.delete(uuid); + } else { + next.add(uuid); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((node) => { + const checked = selected.has(node.node_uuid); + return ( + + ); + })} +
+
+ )} +
+ ); +} + +function CountryFilter({ + available, + selected, + onChange, +}: { + available: { code: string; count: number }[]; + selected: Set; + onChange: (next: Set) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (available.length === 0) return null; + + const allSelected = selected.size === 0; + const activeCount = selected.size; + + const toggle = (code: string) => { + const next = new Set(selected); + if (next.has(code)) { + next.delete(code); + } else { + next.add(code); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map(({ code, count }) => { + const checked = selected.has(code); + return ( + + ); + })} +
+
+ )} +
+ ); +} + +// ============ Risk Badge ============ + +function RiskBadge({ + level, + ratio, + gbPerDay, +}: { + level: RiskLevel; + ratio: number; + gbPerDay: number; +}) { + const style = RISK_STYLES[level]; + const barWidth = Math.min(ratio * 100, 100); + + return ( +
+
+ + + {formatGbPerDay(gbPerDay)} + + GB/d +
+ {/* Mini progress bar showing ratio to threshold */} +
+
+
+
+ ); +} + +// ============ Main Page ============ + +export default function AdminTrafficUsage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { capabilities } = usePlatform(); + + const [items, setItems] = useState([]); + const [nodes, setNodes] = useState([]); + const [availableTariffs, setAvailableTariffs] = useState([]); + const [availableStatuses, setAvailableStatuses] = useState([]); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [period, setPeriod] = useState(30); + const [dateMode, setDateMode] = useState(false); + const [customStart, setCustomStart] = useState(''); + const [customEnd, setCustomEnd] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [committedSearch, setCommittedSearch] = useState(''); + const [selectedTariffs, setSelectedTariffs] = useState>(new Set()); + const [selectedStatuses, setSelectedStatuses] = useState>(new Set()); + const [selectedNodes, setSelectedNodes] = useState>(new Set()); + const [selectedCountries, setSelectedCountries] = useState>(new Set()); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [exporting, setExporting] = useState(false); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + const [sorting, setSorting] = useState([{ id: 'total_bytes', desc: true }]); + const [columnSizing, setColumnSizing] = useState>({}); + const [totalThreshold, setTotalThreshold] = useState(''); + const [nodeThreshold, setNodeThreshold] = useState(''); + const [periodDays, setPeriodDays] = useState(30); + + const limit = 50; + const hasData = items.length > 0 || nodes.length > 0; + + const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes'; + const sortDesc = sorting[0]?.desc ?? true; + const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined; + const statusesParam = selectedStatuses.size > 0 ? [...selectedStatuses].join(',') : undefined; + + // Merge country filter into node UUIDs so backend filters data consistently + const mergedNodesParam = useMemo(() => { + const countryUuids = + selectedCountries.size > 0 + ? new Set( + nodes.filter((n) => selectedCountries.has(n.country_code)).map((n) => n.node_uuid), + ) + : null; + const nodeUuids = selectedNodes.size > 0 ? new Set(selectedNodes) : null; + + let merged: Set | null = null; + if (countryUuids && nodeUuids) { + merged = new Set([...countryUuids].filter((id) => nodeUuids.has(id))); + } else { + merged = countryUuids || nodeUuids; + } + return merged && merged.size > 0 ? [...merged].join(',') : undefined; + }, [nodes, selectedCountries, selectedNodes]); + + const buildParams = useCallback((): TrafficParams => { + const params: TrafficParams = { + limit, + offset, + search: committedSearch || undefined, + sort_by: sortBy, + sort_desc: sortDesc, + tariffs: tariffsParam, + statuses: statusesParam, + nodes: mergedNodesParam, + }; + if (dateMode && customStart && customEnd) { + params.start_date = customStart; + params.end_date = customEnd; + } else { + params.period = period; + } + return params; + }, [ + period, + offset, + committedSearch, + sortBy, + sortDesc, + tariffsParam, + statusesParam, + mergedNodesParam, + dateMode, + customStart, + customEnd, + ]); + + const applyData = useCallback((data: TrafficUsageResponse) => { + setItems(data.items); + setNodes(data.nodes); + setTotal(data.total); + setAvailableTariffs(data.available_tariffs); + setAvailableStatuses(data.available_statuses); + setPeriodDays(data.period_days); + }, []); + + const loadData = useCallback( + async (skipCache = false) => { + const params = buildParams(); + + // Check cache first — apply instantly without any loading state + if (!skipCache) { + const cached = adminTrafficApi.getCached(params); + if (cached) { + applyData(cached); + setInitialLoading(false); + return; + } + } + + try { + setLoading(true); + const data = await adminTrafficApi.getTrafficUsage(params, { skipCache }); + applyData(data); + } catch { + // silently fail — keep stale data visible + } finally { + setLoading(false); + setInitialLoading(false); + } + }, + [buildParams, applyData], + ); + + // Load on param change + useEffect(() => { + loadData(); + }, [loadData]); + + // Prefetch adjacent periods in background (only in period mode) + useEffect(() => { + if (dateMode) return; + const prefetchPeriods = PERIODS.filter((p) => p !== period); + const timer = setTimeout(() => { + prefetchPeriods.forEach((p) => { + const params: TrafficParams = { + period: p, + limit, + offset: 0, + sort_by: 'total_bytes', + sort_desc: true, + }; + if (!adminTrafficApi.getCached(params)) { + adminTrafficApi.getTrafficUsage(params).catch(() => {}); + } + }); + }, 500); + return () => clearTimeout(timer); + }, [period, dateMode]); + + useEffect(() => { + if (toast) { + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + } + }, [toast]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setOffset(0); + setCommittedSearch(searchInput); + }; + + const handleExport = async () => { + try { + setExporting(true); + const exportData: { + period: number; + start_date?: string; + end_date?: string; + tariffs?: string; + statuses?: string; + nodes?: string; + total_threshold_gb?: number; + node_threshold_gb?: number; + } = { period }; + if (dateMode && customStart && customEnd) { + exportData.start_date = customStart; + exportData.end_date = customEnd; + } + if (tariffsParam) exportData.tariffs = tariffsParam; + if (statusesParam) exportData.statuses = statusesParam; + if (mergedNodesParam) exportData.nodes = mergedNodesParam; + + if (totalThresholdNum > 0) exportData.total_threshold_gb = totalThresholdNum; + if (nodeThresholdNum > 0) exportData.node_threshold_gb = nodeThresholdNum; + + await adminTrafficApi.exportCsv(exportData); + setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); + } catch { + setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' }); + } finally { + setExporting(false); + } + }; + + const handlePeriodChange = (p: number) => { + setPeriod(p); + setOffset(0); + }; + + const handleToggleDateMode = () => { + if (dateMode) { + // Switch back to period mode + setDateMode(false); + setCustomStart(''); + setCustomEnd(''); + setOffset(0); + } else { + // Switch to date mode — pre-fill with last N days + const end = new Date(); + const start = new Date(end.getTime() - period * 24 * 60 * 60 * 1000); + setCustomStart(start.toISOString().split('T')[0]); + setCustomEnd(end.toISOString().split('T')[0]); + setDateMode(true); + setOffset(0); + } + }; + + const handleCustomStartChange = (v: string) => { + setCustomStart(v); + setOffset(0); + }; + + const handleCustomEndChange = (v: string) => { + setCustomEnd(v); + setOffset(0); + }; + + const handleSortingChange = (updater: SortingState | ((old: SortingState) => SortingState)) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + setSorting(next); + setOffset(0); + }; + + const handleTariffChange = (next: Set) => { + setSelectedTariffs(next); + setOffset(0); + }; + + const handleStatusChange = (next: Set) => { + setSelectedStatuses(next); + setOffset(0); + }; + + const handleNodeChange = (next: Set) => { + setSelectedNodes(next); + setOffset(0); + }; + + const handleCountryChange = (next: Set) => { + setSelectedCountries(next); + setOffset(0); + }; + + const handleRefresh = () => { + loadData(true); + }; + + const availableCountries = useMemo(() => { + const map = new Map(); + for (const n of nodes) { + if (n.country_code) map.set(n.country_code, (map.get(n.country_code) || 0) + 1); + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, count]) => ({ code, count })); + }, [nodes]); + + // When country/node filter is active, show only matching node columns + const displayNodes = useMemo(() => { + let filtered = nodes; + if (selectedCountries.size > 0) { + filtered = filtered.filter((n) => selectedCountries.has(n.country_code)); + } + if (selectedNodes.size > 0) { + filtered = filtered.filter((n) => selectedNodes.has(n.node_uuid)); + } + return filtered; + }, [nodes, selectedCountries, selectedNodes]); + + const totalThresholdNum = Math.max(0, parseFloat(totalThreshold) || 0); + const hasTotalThreshold = totalThresholdNum > 0; + const nodeThresholdNum = Math.max(0, parseFloat(nodeThreshold) || 0); + const hasNodeThreshold = nodeThresholdNum > 0; + const hasAnyThreshold = hasTotalThreshold || hasNodeThreshold; + + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ + { + id: 'user', + accessorFn: (row) => row.full_name, + header: t('admin.trafficUsage.user'), + enableSorting: true, + size: 120, + minSize: 40, + maxSize: 200, + cell: ({ row }) => { + const item = row.original; + return ( +
+
+ {item.full_name?.[0] || '?'} +
+
+
{item.full_name}
+ {item.username && ( +
+ @{item.username} +
+ )} +
+
+ ); + }, + meta: { sticky: true }, + }, + { + accessorKey: 'tariff_name', + header: t('admin.trafficUsage.tariff'), + enableSorting: true, + size: 120, + minSize: 80, + cell: ({ getValue }) => ( + + {(getValue() as string | null) || t('admin.trafficUsage.noTariff')} + + ), + }, + { + accessorKey: 'device_limit', + header: t('admin.trafficUsage.devices'), + enableSorting: true, + size: 80, + minSize: 60, + meta: { align: 'center' as const }, + cell: ({ getValue }) => ( + {getValue() as number} + ), + }, + { + accessorKey: 'traffic_limit_gb', + header: t('admin.trafficUsage.trafficLimit'), + enableSorting: true, + size: 80, + minSize: 60, + meta: { align: 'center' as const }, + cell: ({ getValue }) => { + const gb = getValue() as number; + return {gb > 0 ? `${gb} GB` : '\u221E'}; + }, + }, + ...displayNodes.map( + (node): ColumnDef => ({ + id: `node_${node.node_uuid}`, + accessorFn: (row) => row.node_traffic[node.node_uuid] || 0, + header: `${getFlagEmoji(node.country_code)} ${node.node_name}`, + enableSorting: true, + size: 110, + minSize: 80, + meta: { align: 'center' as const }, + cell: ({ getValue }) => { + const bytes = getValue() as number; + if (bytes <= 0) { + return {'\u2014'}; + } + const dailyNode = bytesToGbPerDay(bytes, periodDays); + const nodeRatio = hasNodeThreshold ? getRatio(dailyNode, nodeThresholdNum) : 0; + const textColor = hasNodeThreshold ? getNodeTextColor(nodeRatio) : undefined; + return ( +
+ 0.8 ? 600 : undefined, + }} + > + {formatBytes(bytes)} + + {hasNodeThreshold && ( + + {formatGbPerDay(dailyNode)} GB/d + + )} +
+ ); + }, + }), + ), + ]; + + // Risk column — insert before total when any threshold is set + if (hasAnyThreshold) { + cols.push({ + id: 'risk', + header: t('admin.trafficUsage.risk'), + size: 100, + minSize: 80, + meta: { align: 'center' as const }, + accessorFn: (row) => { + const result = getCompositeRisk(row, totalThresholdNum, nodeThresholdNum, periodDays); + return result.ratio; + }, + enableSorting: false, + cell: ({ row }) => { + const result = getCompositeRisk( + row.original, + totalThresholdNum, + nodeThresholdNum, + periodDays, + ); + const level = getRiskLevel(result.ratio); + return ; + }, + }); + } + + cols.push({ + accessorKey: 'total_bytes', + header: t('admin.trafficUsage.total'), + enableSorting: true, + size: 110, + minSize: 80, + meta: { align: 'center' as const, bold: true }, + cell: ({ getValue }) => { + const bytes = getValue() as number; + if (bytes <= 0) { + return {'\u2014'}; + } + const dailyTotal = bytesToGbPerDay(bytes, periodDays); + return ( +
+ {formatBytes(bytes)} + {hasTotalThreshold && ( + + {formatGbPerDay(dailyTotal)} GB/d + + )} +
+ ); + }, + }); + + return cols; + }, [ + displayNodes, + t, + hasAnyThreshold, + hasTotalThreshold, + hasNodeThreshold, + totalThresholdNum, + nodeThresholdNum, + periodDays, + ]); + + const table = useReactTable({ + data: items, + columns, + state: { sorting, columnSizing }, + onSortingChange: handleSortingChange, + onColumnSizingChange: setColumnSizing, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + enableSortingRemoval: false, + enableColumnResizing: true, + columnResizeMode: 'onChange', + }); + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return ( +
+ {/* Progress bar — shown during background refresh */} + + + {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+

{t('admin.trafficUsage.title')}

+

{t('admin.trafficUsage.subtitle')}

+
+
+ +
+ + {/* Controls */} +
+
+ + + + + + + {/* Threshold inputs */} +
+ + setTotalThreshold(e.target.value)} + placeholder={t('admin.trafficUsage.totalThreshold')} + step="0.1" + min="0" + max="9999" + className="w-20 bg-transparent text-xs text-dark-200 placeholder-dark-500 [appearance:textfield] focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + {totalThreshold && ( + + )} +
+
+ + setNodeThreshold(e.target.value)} + placeholder={t('admin.trafficUsage.nodeThreshold')} + step="0.1" + min="0" + max="9999" + className="w-20 bg-transparent text-xs text-dark-200 placeholder-dark-500 [appearance:textfield] focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + {nodeThreshold && ( + + )} +
+ + +
+ +
+
+ setSearchInput(e.target.value)} + placeholder={t('admin.trafficUsage.search')} + className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none" + /> +
+ +
+
+
+
+ + {/* Table */} + {initialLoading && !hasData ? ( +
+
+
+ ) : !hasData && !loading ? ( +
{t('admin.trafficUsage.noData')}
+ ) : ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta; + const isSticky = meta?.sticky; + const align = meta?.align === 'center' ? 'text-center' : 'text-left'; + const isBold = meta?.bold; + + return ( + + ); + })} + + ))} + + + {table.getRowModel().rows.map((row) => { + const compositeRatio = hasAnyThreshold + ? getCompositeRisk( + row.original, + totalThresholdNum, + nodeThresholdNum, + periodDays, + ).ratio + : 0; + const rowBg = hasAnyThreshold ? getRowBgColor(compositeRatio) : undefined; + + return ( + navigate(`/admin/users/${row.original.user_id}`)} + > + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta; + const isSticky = meta?.sticky; + const align = meta?.align === 'center' ? 'text-center' : 'text-left'; + + return ( + + ); + })} + + ); + })} + +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && ( + + )} +
e.stopPropagation()} + className="absolute -right-2 top-0 z-20 h-full w-5 cursor-col-resize select-none" + style={{ touchAction: 'none' }} + > +
+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {offset + 1} + {'\u2013'} + {Math.min(offset + limit, total)} / {total} +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 3aeec94..9e9fbdc 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -15,6 +15,8 @@ import { type UpdateSubscriptionRequest, } from '../api/adminUsers'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin'; +import { promocodesApi, type PromoGroup } from '../api/promocodes'; +import { promoOffersApi } from '../api/promoOffers'; import { ticketsApi } from '../api/tickets'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; @@ -180,6 +182,15 @@ export default function AdminUserDetail() { const [subDays, setSubDays] = useState(30); const [selectedTariffId, setSelectedTariffId] = useState(null); + // Promo group + const [promoGroups, setPromoGroups] = useState([]); + const [editingPromoGroup, setEditingPromoGroup] = useState(false); + + // Send promo offer + const [offerDiscountPercent, setOfferDiscountPercent] = useState(''); + const [offerValidHours, setOfferValidHours] = useState(24); + const [offerSending, setOfferSending] = useState(false); + const userId = id ? parseInt(id, 10) : null; const loadUser = useCallback(async () => { @@ -282,6 +293,15 @@ export default function AdminUserDetail() { await Promise.all([loadPanelInfo(), loadNodeUsage()]); }, [loadPanelInfo, loadNodeUsage]); + const loadPromoGroups = useCallback(async () => { + try { + const data = await promocodesApi.getPromoGroups({ limit: 100 }); + setPromoGroups(data.items); + } catch { + // ignore + } + }, []); + const handleTicketReply = async () => { if (!selectedTicketId || !replyText.trim()) return; setReplySending(true); @@ -332,14 +352,25 @@ export default function AdminUserDetail() { }, [userId, loadUser, navigate]); useEffect(() => { - if (activeTab === 'info') loadReferrals(); + if (activeTab === 'info') { + loadReferrals(); + loadPromoGroups(); + } if (activeTab === 'sync') loadSyncStatus(); if (activeTab === 'subscription') { loadTariffs(); loadSubscriptionData(); } if (activeTab === 'tickets') loadTickets(); - }, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadSubscriptionData]); + }, [ + activeTab, + loadSyncStatus, + loadTariffs, + loadTickets, + loadReferrals, + loadSubscriptionData, + loadPromoGroups, + ]); const handleUpdateBalance = async (isAdd: boolean) => { if (balanceAmount === '' || !userId) return; @@ -364,17 +395,16 @@ export default function AdminUserDetail() { } }; - const handleUpdateSubscription = async () => { + const handleUpdateSubscription = async (overrideAction?: string) => { if (!userId) return; setActionLoading(true); try { + const action = overrideAction || subAction; const data: UpdateSubscriptionRequest = { - action: subAction as UpdateSubscriptionRequest['action'], - ...(subAction === 'extend' ? { days: toNumber(subDays, 30) } : {}), - ...(subAction === 'change_tariff' && selectedTariffId - ? { tariff_id: selectedTariffId } - : {}), - ...(subAction === 'create' + action: action as UpdateSubscriptionRequest['action'], + ...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}), + ...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}), + ...(action === 'create' ? { days: toNumber(subDays, 30), ...(selectedTariffId ? { tariff_id: selectedTariffId } : {}), @@ -451,7 +481,7 @@ export default function AdminUserDetail() { if (confirmingAction === actionKey) { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(null); - executeFn(); + executeFn().catch(() => {}); } else { if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); setConfirmingAction(actionKey); @@ -459,6 +489,57 @@ export default function AdminUserDetail() { } }; + const handleChangePromoGroup = async (groupId: number | null) => { + if (!userId) return; + setActionLoading(true); + try { + await adminUsersApi.updatePromoGroup(userId, groupId); + await loadUser(); + setEditingPromoGroup(false); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleDeactivateOffer = async () => { + if (!userId) return; + setActionLoading(true); + try { + await promocodesApi.deactivateDiscount(userId); + notify.success(t('admin.users.detail.offerDeactivated'), t('common.success')); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + + const handleSendOffer = async () => { + if (!userId || offerDiscountPercent === '' || offerValidHours === '') return; + setOfferSending(true); + try { + await promoOffersApi.broadcastOffer({ + user_id: userId, + notification_type: 'admin_personal', + discount_percent: toNumber(offerDiscountPercent), + valid_hours: toNumber(offerValidHours, 24), + effect_type: 'percent_discount', + send_notification: true, + }); + notify.success(t('admin.users.detail.offerSent'), t('common.success')); + setOfferDiscountPercent(''); + setOfferValidHours(24); + await loadUser(); + } catch { + notify.error(t('admin.users.detail.offerSendError'), t('common.error')); + } finally { + setOfferSending(false); + } + }; + const handleResetTrial = async () => { if (!userId) return; setActionLoading(true); @@ -717,6 +798,56 @@ export default function AdminUserDetail() {
)} + {/* Promo Group */} +
+
+ {t('admin.users.detail.promoGroup')} + +
+ {editingPromoGroup ? ( +
+ + {user.promo_group && ( + + )} +
+ ) : ( +
+ {user.promo_group?.name || ( + {t('admin.users.detail.noPromoGroup')} + )} +
+ )} +
+ {/* Referral */}
@@ -982,7 +1113,7 @@ export default function AdminUserDetail() { )}
+ {/* Active promo offer */} + {user.promo_offer_discount_percent > 0 && ( +
+
+ + {t('admin.users.detail.activePromoOffer')} + + +
+
+
+
+ {user.promo_offer_discount_percent}% +
+
{t('admin.users.detail.discount')}
+
+
+
+ {user.promo_offer_discount_source || '-'} +
+
{t('admin.users.detail.source')}
+
+
+
+ {user.promo_offer_discount_expires_at + ? formatDate(user.promo_offer_discount_expires_at) + : '-'} +
+
{t('admin.users.detail.expiresAt')}
+
+
+
+ )} + + {/* Send promo offer */} +
+
+ {t('admin.users.detail.sendOffer')} +
+
+ + + +
+
+ {/* Recent transactions */} {user.recent_transactions.length > 0 && (