From 8bd3c007bcceae947fc6f269694dc70a93c69db9 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 08:10:44 +0300 Subject: [PATCH 01/13] feat: add promo group and promo offer management to AdminUserDetail - Info tab: promo group card with inline dropdown to change/remove group - Balance tab: active promo offer display with deactivate button - Balance tab: send promo offer form (discount %, valid hours) - Add deactivateDiscount API method to promocodesApi - Add locale keys for ru/en/zh/fa - Fix race condition in subscription create (stale state read) - Fix unhandled promise in handleInlineConfirm - Remove unused legacy adminUsers block from ru.json --- src/api/promocodes.ts | 8 ++ src/locales/en.json | 16 +++ src/locales/fa.json | 16 +++ src/locales/ru.json | 174 +++---------------------- src/locales/zh.json | 16 +++ src/pages/AdminUserDetail.tsx | 238 +++++++++++++++++++++++++++++++--- 6 files changed, 295 insertions(+), 173 deletions(-) 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..62ac714 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1886,6 +1886,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..06eae91 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1585,6 +1585,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..2d3654b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -2408,6 +2408,22 @@ "subscription": "• Запрет покупки подписки", "reason": "Причина" }, + "promoGroup": "Промо-группа", + "noPromoGroup": "Не задана", + "changePromoGroup": "Изменить", + "selectPromoGroup": "Выберите группу", + "removePromoGroup": "Убрать", + "activePromoOffer": "Активное промо-предложение", + "discount": "Скидка", + "source": "Источник", + "expiresAt": "Истекает", + "deactivateOffer": "Отключить", + "sendOffer": "Отправить предложение", + "discountPercent": "Скидка %", + "validHours": "Срок действия (часы)", + "offerSent": "Предложение отправлено", + "offerSendError": "Ошибка отправки предложения", + "offerDeactivated": "Предложение отключено", "actions": { "title": "Действия", "areYouSure": "Уверены?" @@ -2957,164 +2973,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..6d8bd78 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1584,6 +1584,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/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 && (
From a034a6068ccea07c6581427d3e80af754b175820 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 09:04:55 +0300 Subject: [PATCH 02/13] feat: add admin traffic usage page with TanStack Table Add AdminTrafficUsage page showing per-user traffic statistics by node with period filtering (1/3/7/14/30d), server-side sorting, search, pagination, and CSV export. Uses @tanstack/react-table with manual sorting and dynamic node columns. --- package-lock.json | 34 +++ package.json | 1 + src/App.tsx | 11 + src/api/adminTraffic.ts | 53 ++++ src/locales/en.json | 24 +- src/locales/fa.json | 24 +- src/locales/ru.json | 24 +- src/locales/zh.json | 24 +- src/pages/AdminPanel.tsx | 16 + src/pages/AdminTrafficUsage.tsx | 513 ++++++++++++++++++++++++++++++++ 10 files changed, 716 insertions(+), 8 deletions(-) create mode 100644 src/api/adminTraffic.ts create mode 100644 src/pages/AdminTrafficUsage.tsx 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; +} + +export interface ExportCsvResponse { + success: boolean; + message: string; +} + +export const adminTrafficApi = { + getTrafficUsage: async (params: { + period?: number; + limit?: number; + offset?: number; + search?: string; + sort_by?: string; + sort_desc?: boolean; + }): Promise => { + const response = await apiClient.get('/cabinet/admin/traffic', { params }); + return response.data; + }, + + exportCsv: async (data: { period: number }): Promise => { + const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data); + return response.data; + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index 62ac714..86a9396 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,26 @@ "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" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 06eae91..cd7f032 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,26 @@ "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": "جستجو بر اساس نام یا نام کاربری" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 2d3654b..21a1147 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,26 @@ "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": "Поиск по имени или юзернейму" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6d8bd78..cbff87b 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,26 @@ "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": "按名称或用户名搜索" }, "paymentMethods": { "title": "支付方法", 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..8d1198a --- /dev/null +++ b/src/pages/AdminTrafficUsage.tsx @@ -0,0 +1,513 @@ +import { useState, useEffect, useCallback, useMemo } 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 } 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 (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); +}; + +/** Map TanStack column IDs to backend sort_by field names. */ +const toBackendSortField = (columnId: string): string => { + if (columnId === 'user') return 'full_name'; + return columnId; +}; + +// ============ 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' ? ( + + ) : ( + + )} + +); + +// ============ Components ============ + +const PERIODS = [1, 3, 7, 14, 30] as const; + +function PeriodSelector({ + value, + onChange, + label, +}: { + value: number; + onChange: (v: number) => void; + label: string; +}) { + const { t } = useTranslation(); + return ( +
+ {label} +
+ {PERIODS.map((p) => ( + + ))} +
+
+ ); +} + +// ============ Main Page ============ + +export default function AdminTrafficUsage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { capabilities } = usePlatform(); + + const [items, setItems] = useState([]); + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [period, setPeriod] = useState(30); + const [searchInput, setSearchInput] = useState(''); + const [committedSearch, setCommittedSearch] = useState(''); + 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 limit = 50; + + const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes'; + const sortDesc = sorting[0]?.desc ?? true; + + const loadData = useCallback(async () => { + try { + setLoading(true); + const data = await adminTrafficApi.getTrafficUsage({ + period, + limit, + offset, + search: committedSearch || undefined, + sort_by: sortBy, + sort_desc: sortDesc, + }); + setItems(data.items); + setNodes(data.nodes); + setTotal(data.total); + } catch { + // silently fail — toast could be added if needed + } finally { + setLoading(false); + } + }, [period, offset, committedSearch, sortBy, sortDesc]); + + useEffect(() => { + loadData(); + }, [loadData]); + + 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); + await adminTrafficApi.exportCsv({ period }); + 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 handleSortingChange = (updater: SortingState | ((old: SortingState) => SortingState)) => { + const next = typeof updater === 'function' ? updater(sorting) : updater; + setSorting(next); + setOffset(0); + }; + + // Build columns dynamically based on nodes + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = [ + { + id: 'user', + accessorFn: (row) => row.full_name, + header: t('admin.trafficUsage.user'), + enableSorting: true, + 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, + cell: ({ getValue }) => ( + + {(getValue() as string | null) || t('admin.trafficUsage.noTariff')} + + ), + }, + { + accessorKey: 'device_limit', + header: t('admin.trafficUsage.devices'), + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ getValue }) => ( + {getValue() as number} + ), + }, + { + accessorKey: 'traffic_limit_gb', + header: t('admin.trafficUsage.trafficLimit'), + enableSorting: true, + meta: { align: 'center' as const }, + cell: ({ getValue }) => { + const gb = getValue() as number; + return {gb > 0 ? `${gb} GB` : '\u221E'}; + }, + }, + // Dynamic node columns + ...nodes.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, + meta: { align: 'center' as const }, + cell: ({ getValue }) => { + const bytes = getValue() as number; + return ( + + {bytes > 0 ? formatBytes(bytes) : '\u2014'} + + ); + }, + }), + ), + { + accessorKey: 'total_bytes', + header: t('admin.trafficUsage.total'), + enableSorting: true, + meta: { align: 'center' as const, bold: true }, + cell: ({ getValue }) => { + const bytes = getValue() as number; + return ( + + {bytes > 0 ? formatBytes(bytes) : '\u2014'} + + ); + }, + }, + ]; + return cols; + }, [nodes, t]); + + const table = useReactTable({ + data: items, + columns, + state: { sorting }, + onSortingChange: handleSortingChange, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + enableSortingRemoval: false, + }); + + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} + + {/* Header */} +
+
+ {!capabilities.hasBackButton && ( + + )} +
+

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

+

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

+
+
+ +
+ + {/* Controls: period + export + search */} +
+
+ + +
+ +
+
+ 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 */} + {loading ? ( +
+
+
+ ) : items.length === 0 ? ( +
{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) => ( + 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() && ( + + )} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {offset + 1} + {'\u2013'} + {Math.min(offset + limit, total)} / {total} +
+
+ + + {currentPage} / {totalPages} + + +
+
+ )} +
+ ); +} From cfb7ce72f2fde01dc548b9c4b263f8b3b0a37074 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 09:30:23 +0300 Subject: [PATCH 03/13] feat: add tariff checkbox filter, column resizing to traffic page - TariffFilter dropdown with checkboxes for filtering by tariff - Column resizing via TanStack Table resize handles - Add allTariffs locale key to all 4 languages - Pass tariffs param to API --- src/api/adminTraffic.ts | 2 + src/locales/en.json | 3 +- src/locales/fa.json | 3 +- src/locales/ru.json | 3 +- src/locales/zh.json | 3 +- src/pages/AdminTrafficUsage.tsx | 205 ++++++++++++++++++++++++++++++-- 6 files changed, 205 insertions(+), 14 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index f6fd7b9..1a1a91f 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -26,6 +26,7 @@ export interface TrafficUsageResponse { offset: number; limit: number; period_days: number; + available_tariffs: string[]; } export interface ExportCsvResponse { @@ -41,6 +42,7 @@ export const adminTrafficApi = { search?: string; sort_by?: string; sort_desc?: boolean; + tariffs?: string; }): Promise => { const response = await apiClient.get('/cabinet/admin/traffic', { params }); return response.data; diff --git a/src/locales/en.json b/src/locales/en.json index 86a9396..de83ae0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -803,7 +803,8 @@ "noData": "No traffic data", "loading": "Loading traffic data...", "noTariff": "\u2014", - "search": "Search by name or username" + "search": "Search by name or username", + "allTariffs": "All tariffs" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index cd7f032..2bf6548 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -684,7 +684,8 @@ "noData": "داده ترافیکی موجود نیست", "loading": "در حال بارگذاری داده‌های ترافیک...", "noTariff": "\u2014", - "search": "جستجو بر اساس نام یا نام کاربری" + "search": "جستجو بر اساس نام یا نام کاربری", + "allTariffs": "همه تعرفه‌ها" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 21a1147..0ae6c1a 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -824,7 +824,8 @@ "noData": "Нет данных о трафике", "loading": "Загрузка данных о трафике...", "noTariff": "\u2014", - "search": "Поиск по имени или юзернейму" + "search": "Поиск по имени или юзернейму", + "allTariffs": "Все тарифы" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index cbff87b..551abbd 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -684,7 +684,8 @@ "noData": "无流量数据", "loading": "正在加载流量数据...", "noTariff": "\u2014", - "search": "按名称或用户名搜索" + "search": "按名称或用户名搜索", + "allTariffs": "所有套餐" }, "paymentMethods": { "title": "支付方法", diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 8d1198a..90c4671 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { @@ -42,7 +42,6 @@ const getFlagEmoji = (countryCode: string): string => { return String.fromCodePoint(...codePoints); }; -/** Map TanStack column IDs to backend sort_by field names. */ const toBackendSortField = (columnId: string): string => { if (columnId === 'user') return 'full_name'; return columnId; @@ -114,6 +113,22 @@ const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => ( ); +const FilterIcon = () => ( + + + +); + +const ChevronDownIcon = () => ( + + + +); + // ============ Components ============ const PERIODS = [1, 3, 7, 14, 30] as const; @@ -151,6 +166,135 @@ function PeriodSelector({ ); } +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 ( + + ); + })} +
+
+ )} +
+ ); +} + // ============ Main Page ============ export default function AdminTrafficUsage() { @@ -160,20 +304,24 @@ export default function AdminTrafficUsage() { const [items, setItems] = useState([]); const [nodes, setNodes] = useState([]); + const [availableTariffs, setAvailableTariffs] = useState([]); const [loading, setLoading] = useState(true); const [period, setPeriod] = useState(30); const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); + const [selectedTariffs, setSelectedTariffs] = 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 limit = 50; 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 loadData = useCallback(async () => { try { @@ -185,16 +333,18 @@ export default function AdminTrafficUsage() { search: committedSearch || undefined, sort_by: sortBy, sort_desc: sortDesc, + tariffs: tariffsParam, }); setItems(data.items); setNodes(data.nodes); setTotal(data.total); + setAvailableTariffs(data.available_tariffs); } catch { - // silently fail — toast could be added if needed + // silently fail } finally { setLoading(false); } - }, [period, offset, committedSearch, sortBy, sortDesc]); + }, [period, offset, committedSearch, sortBy, sortDesc, tariffsParam]); useEffect(() => { loadData(); @@ -236,7 +386,11 @@ export default function AdminTrafficUsage() { setOffset(0); }; - // Build columns dynamically based on nodes + const handleTariffChange = (next: Set) => { + setSelectedTariffs(next); + setOffset(0); + }; + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -244,6 +398,8 @@ export default function AdminTrafficUsage() { accessorFn: (row) => row.full_name, header: t('admin.trafficUsage.user'), enableSorting: true, + size: 200, + minSize: 140, cell: ({ row }) => { const item = row.original; return ( @@ -266,6 +422,8 @@ export default function AdminTrafficUsage() { accessorKey: 'tariff_name', header: t('admin.trafficUsage.tariff'), enableSorting: true, + size: 120, + minSize: 80, cell: ({ getValue }) => ( {(getValue() as string | null) || t('admin.trafficUsage.noTariff')} @@ -276,6 +434,8 @@ export default function AdminTrafficUsage() { accessorKey: 'device_limit', header: t('admin.trafficUsage.devices'), enableSorting: true, + size: 80, + minSize: 60, meta: { align: 'center' as const }, cell: ({ getValue }) => ( {getValue() as number} @@ -285,19 +445,22 @@ export default function AdminTrafficUsage() { 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'}; }, }, - // Dynamic node columns ...nodes.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; @@ -313,6 +476,8 @@ export default function AdminTrafficUsage() { 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; @@ -330,11 +495,14 @@ export default function AdminTrafficUsage() { const table = useReactTable({ data: items, columns, - state: { sorting }, + state: { sorting, columnSizing }, onSortingChange: handleSortingChange, + onColumnSizingChange: setColumnSizing, getCoreRowModel: getCoreRowModel(), manualSorting: true, enableSortingRemoval: false, + enableColumnResizing: true, + columnResizeMode: 'onChange', }); const totalPages = Math.ceil(total / limit); @@ -376,7 +544,7 @@ export default function AdminTrafficUsage() {
- {/* Controls: period + export + search */} + {/* Controls */}
+
) : (
- +
{table.getHeaderGroups().map((headerGroup) => ( @@ -432,17 +605,28 @@ export default function AdminTrafficUsage() { return ( ); })} @@ -467,6 +651,7 @@ export default function AdminTrafficUsage() { className={`px-3 py-2 ${align} ${ isSticky ? 'sticky left-0 z-10 bg-dark-900' : '' }`} + style={{ width: cell.column.getSize() }} > {flexRender(cell.column.columnDef.cell, cell.getContext())} From 471c37b7b3f64c08f2d749f4089009eb53ae7cac Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 09:47:02 +0300 Subject: [PATCH 04/13] fix: add client-side caching and smooth loading for traffic page - Add 5-min in-memory cache in API layer keyed by request params - Replace blocking spinner with animated progress bar during refresh - Keep stale data visible while fetching (opacity dim) - Prefetch all periods in background for instant switching --- src/api/adminTraffic.ts | 67 +++++-- src/pages/AdminTrafficUsage.tsx | 308 ++++++++++++++++++++++---------- 2 files changed, 273 insertions(+), 102 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 1a1a91f..e00cdeb 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -34,18 +34,65 @@ export interface ExportCsvResponse { message: string; } +export type TrafficParams = { + period?: number; + limit?: number; + offset?: number; + search?: string; + sort_by?: string; + sort_desc?: boolean; + tariffs?: string; +}; + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +const trafficCache = new Map(); + +function buildCacheKey(params: TrafficParams): string { + return JSON.stringify({ + p: params.period ?? 30, + l: params.limit ?? 50, + o: params.offset ?? 0, + s: params.search ?? '', + sb: params.sort_by ?? 'total_bytes', + sd: params.sort_desc ?? true, + t: params.tariffs ?? '', + }); +} + export const adminTrafficApi = { - getTrafficUsage: async (params: { - period?: number; - limit?: number; - offset?: number; - search?: string; - sort_by?: string; - sort_desc?: boolean; - tariffs?: string; - }): Promise => { + 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 }); - return response.data; + 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 }): Promise => { diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 90c4671..4d1179a 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -9,7 +9,12 @@ import { type SortingState, type RowData, } from '@tanstack/react-table'; -import { adminTrafficApi, type UserTrafficItem, type TrafficNodeInfo } from '../api/adminTraffic'; +import { + adminTrafficApi, + type UserTrafficItem, + type TrafficNodeInfo, + type TrafficParams, +} from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; // ============ TanStack Table module augmentation ============ @@ -129,6 +134,53 @@ const ChevronDownIcon = () => ( ); +// ============ 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; @@ -305,7 +357,8 @@ export default function AdminTrafficUsage() { const [items, setItems] = useState([]); const [nodes, setNodes] = useState([]); const [availableTariffs, setAvailableTariffs] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); const [period, setPeriod] = useState(30); const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); @@ -318,38 +371,94 @@ export default function AdminTrafficUsage() { const [columnSizing, setColumnSizing] = useState>({}); 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 loadData = useCallback(async () => { - try { - setLoading(true); - const data = await adminTrafficApi.getTrafficUsage({ - period, - limit, - offset, - search: committedSearch || undefined, - sort_by: sortBy, - sort_desc: sortDesc, - tariffs: tariffsParam, - }); + const buildParams = useCallback( + (): TrafficParams => ({ + period, + limit, + offset, + search: committedSearch || undefined, + sort_by: sortBy, + sort_desc: sortDesc, + tariffs: tariffsParam, + }), + [period, offset, committedSearch, sortBy, sortDesc, tariffsParam], + ); + + const applyData = useCallback( + (data: { + items: UserTrafficItem[]; + nodes: TrafficNodeInfo[]; + total: number; + available_tariffs: string[]; + }) => { setItems(data.items); setNodes(data.nodes); setTotal(data.total); setAvailableTariffs(data.available_tariffs); - } catch { - // silently fail - } finally { - setLoading(false); - } - }, [period, offset, committedSearch, sortBy, sortDesc, tariffsParam]); + }, + [], + ); + 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 + useEffect(() => { + 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); + // Only prefetch once on mount + when period changes + }, [period]); + useEffect(() => { if (toast) { const timer = setTimeout(() => setToast(null), 3000); @@ -391,6 +500,10 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleRefresh = () => { + loadData(true); + }; + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -509,7 +622,10 @@ export default function AdminTrafficUsage() { const currentPage = Math.floor(offset / limit) + 1; return ( -
+
+ {/* Progress bar — shown during background refresh */} + + {/* Toast */} {toast && (
{t('admin.trafficUsage.subtitle')}

-
@@ -584,83 +704,87 @@ export default function AdminTrafficUsage() {
{/* Table */} - {loading ? ( + {initialLoading && !hasData ? (
- ) : items.length === 0 ? ( + ) : !hasData && !loading ? (
{t('admin.trafficUsage.noData')}
) : ( -
-
{flexRender(header.column.columnDef.header, header.getContext())} {header.column.getCanSort() && ( )} +
e.stopPropagation()} + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${ + header.column.getIsResizing() + ? 'bg-accent-500' + : 'bg-transparent hover:bg-dark-500' + }`} + />
- - {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; +
+
+
+ + {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) => ( + 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 ( + - ))} - - - {table.getRowModel().rows.map((row) => ( - 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-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${ - header.column.getIsResizing() - ? 'bg-accent-500' - : 'bg-transparent hover:bg-dark-500' + return ( +
+ {flexRender(header.column.columnDef.header, header.getContext())} + {header.column.getCanSort() && ( + + )} +
e.stopPropagation()} + className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${ + header.column.getIsResizing() + ? 'bg-accent-500' + : 'bg-transparent hover:bg-dark-500' + }`} + /> +
- - ); - })} -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ style={{ width: cell.column.getSize() }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} + + +
)} From 0301fd856639a0d70cb2a7201cfe80b3936dbc8d Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 11:20:17 +0300 Subject: [PATCH 05/13] feat: add node/status filters, custom date range, connected devices to traffic page - Add NodeFilter dropdown with flag emojis and checkbox selection - Add StatusFilter dropdown with colored status dots - Add DateRangePicker with From/To date inputs, toggle from period mode - Devices column now shows N/M (connected/limit) with accent color - Export CSV passes all active filters to backend - New i18n keys for nodes, statuses, dates in ru/en/zh/fa --- src/api/adminTraffic.ts | 20 +- src/locales/en.json | 13 +- src/locales/fa.json | 13 +- src/locales/ru.json | 13 +- src/locales/zh.json | 13 +- src/pages/AdminTrafficUsage.tsx | 577 +++++++++++++++++++++++++++----- 6 files changed, 562 insertions(+), 87 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index e00cdeb..0d39a35 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -15,6 +15,7 @@ export interface UserTrafficItem { subscription_status: string | null; traffic_limit_gb: number; device_limit: number; + connected_devices: number; node_traffic: Record; total_bytes: number; } @@ -27,6 +28,7 @@ export interface TrafficUsageResponse { limit: number; period_days: number; available_tariffs: string[]; + available_statuses: string[]; } export interface ExportCsvResponse { @@ -42,6 +44,10 @@ export type TrafficParams = { sort_by?: string; sort_desc?: boolean; tariffs?: string; + nodes?: string; + statuses?: string; + start_date?: string; + end_date?: string; }; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -57,6 +63,10 @@ function buildCacheKey(params: TrafficParams): string { sb: params.sort_by ?? 'total_bytes', sd: params.sort_desc ?? true, t: params.tariffs ?? '', + n: params.nodes ?? '', + st: params.statuses ?? '', + sd2: params.start_date ?? '', + ed: params.end_date ?? '', }); } @@ -95,7 +105,15 @@ export const adminTrafficApi = { trafficCache.clear(); }, - exportCsv: async (data: { period: number }): Promise => { + exportCsv: async (data: { + period: number; + start_date?: string; + end_date?: string; + tariffs?: string; + nodes?: string; + statuses?: string; + search?: string; + }): Promise => { const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data); return response.data; }, diff --git a/src/locales/en.json b/src/locales/en.json index de83ae0..c5d3152 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -804,7 +804,18 @@ "loading": "Loading traffic data...", "noTariff": "\u2014", "search": "Search by name or username", - "allTariffs": "All tariffs" + "allTariffs": "All tariffs", + "nodes": "Nodes", + "allNodes": "All nodes", + "status": "Status", + "allStatuses": "All", + "statusActive": "Active", + "statusTrial": "Trial", + "statusExpired": "Expired", + "statusDisabled": "Disabled", + "customDates": "Dates", + "dateFrom": "From", + "dateTo": "To" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2bf6548..aace454 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -685,7 +685,18 @@ "loading": "در حال بارگذاری داده‌های ترافیک...", "noTariff": "\u2014", "search": "جستجو بر اساس نام یا نام کاربری", - "allTariffs": "همه تعرفه‌ها" + "allTariffs": "همه تعرفه‌ها", + "nodes": "نودها", + "allNodes": "همه نودها", + "status": "وضعیت", + "allStatuses": "همه", + "statusActive": "فعال", + "statusTrial": "آزمایشی", + "statusExpired": "منقضی", + "statusDisabled": "غیرفعال", + "customDates": "تاریخ", + "dateFrom": "از", + "dateTo": "تا" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0ae6c1a..7b04e20 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -825,7 +825,18 @@ "loading": "Загрузка данных о трафике...", "noTariff": "\u2014", "search": "Поиск по имени или юзернейму", - "allTariffs": "Все тарифы" + "allTariffs": "Все тарифы", + "nodes": "Ноды", + "allNodes": "Все ноды", + "status": "Статус", + "allStatuses": "Все", + "statusActive": "Активная", + "statusTrial": "Пробная", + "statusExpired": "Истекла", + "statusDisabled": "Отключена", + "customDates": "Даты", + "dateFrom": "От", + "dateTo": "До" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index 551abbd..e2bc9e5 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -685,7 +685,18 @@ "loading": "正在加载流量数据...", "noTariff": "\u2014", "search": "按名称或用户名搜索", - "allTariffs": "所有套餐" + "allTariffs": "所有套餐", + "nodes": "节点", + "allNodes": "所有节点", + "status": "状态", + "allStatuses": "全部", + "statusActive": "活跃", + "statusTrial": "试用", + "statusExpired": "已过期", + "statusDisabled": "已禁用", + "customDates": "日期", + "dateFrom": "从", + "dateTo": "到" }, "paymentMethods": { "title": "支付方法", diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 4d1179a..27e3f03 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -52,6 +52,13 @@ const toBackendSortField = (columnId: string): string => { return columnId; }; +const formatDateForInput = (date: Date): string => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +}; + // ============ Icons ============ const SearchIcon = () => ( @@ -134,6 +141,32 @@ const ChevronDownIcon = () => ( ); +const CalendarIcon = () => ( + + + +); + +const ServerIcon = () => ( + + + +); + +const XIcon = () => ( + + + +); + // ============ Progress Bar ============ function ProgressBar({ loading }: { loading: boolean }) { @@ -181,6 +214,47 @@ function ProgressBar({ loading }: { loading: boolean }) { ); } +// ============ Checkbox helper ============ + +function Checkbox({ checked }: { checked: boolean }) { + return ( + + {checked && ( + + + + )} + + ); +} + +// ============ Status dot helper ============ + +const STATUS_COLORS: Record = { + active: 'bg-green-500', + trial: 'bg-yellow-500', + expired: 'bg-red-500', + disabled: 'bg-gray-500', +}; + +function StatusDot({ status }: { status: string }) { + return ( + + ); +} + // ============ Components ============ const PERIODS = [1, 3, 7, 14, 30] as const; @@ -189,31 +263,96 @@ function PeriodSelector({ value, onChange, label, + dateMode, + onToggleDateMode, }: { value: number; onChange: (v: number) => void; label: string; + dateMode: boolean; + onToggleDateMode: () => void; }) { const { t } = useTranslation(); return (
{label} -
- {PERIODS.map((p) => ( - - ))} -
+ {!dateMode && ( +
+ {PERIODS.map((p) => ( + + ))} +
+ )} + +
+ ); +} + +function DateRangePicker({ + startDate, + endDate, + onStartChange, + onEndChange, + onClose, +}: { + startDate: string; + endDate: string; + onStartChange: (v: string) => void; + onEndChange: (v: string) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const today = formatDateForInput(new Date()); + const minDate = formatDateForInput(new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)); + + return ( +
+ + + onStartChange(e.target.value)} + min={minDate} + max={endDate || today} + 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" + /> + + onEndChange(e.target.value)} + min={startDate || minDate} + max={today} + 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" + /> +
); } @@ -284,62 +423,211 @@ function TariffFilter({ allSelected ? 'text-accent-400' : 'text-dark-300' }`} > - - {allSelected && ( - - - - )} - + {t('admin.trafficUsage.allTariffs')}
- {available.map((tariff) => { - const checked = selected.has(tariff); - return ( - - ); - })} + {available.map((tariff) => ( + + ))} +
+
+ )} +
+ ); +} + +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 = (nodeUuid: string) => { + const next = new Set(selected); + if (next.has(nodeUuid)) { + next.delete(nodeUuid); + } else { + next.add(nodeUuid); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((node) => ( + + ))} +
+
+ )} +
+ ); +} + +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 statusLabels: Record = { + active: t('admin.trafficUsage.statusActive'), + trial: t('admin.trafficUsage.statusTrial'), + expired: t('admin.trafficUsage.statusExpired'), + disabled: t('admin.trafficUsage.statusDisabled'), + }; + + 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()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((status) => ( + + ))}
)} @@ -356,13 +644,20 @@ export default function AdminTrafficUsage() { const [items, setItems] = useState([]); const [nodes, setNodes] = useState([]); + const [allNodes, setAllNodes] = 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 [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [selectedTariffs, setSelectedTariffs] = useState>(new Set()); + const [selectedNodes, setSelectedNodes] = useState>(new Set()); + const [selectedStatuses, setSelectedStatuses] = useState>(new Set()); + const [dateMode, setDateMode] = useState(false); + const [customStart, setCustomStart] = useState(''); + const [customEnd, setCustomEnd] = useState(''); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); const [exporting, setExporting] = useState(false); @@ -376,18 +671,36 @@ export default function AdminTrafficUsage() { 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 nodesParam = selectedNodes.size > 0 ? [...selectedNodes].join(',') : undefined; + const statusesParam = selectedStatuses.size > 0 ? [...selectedStatuses].join(',') : undefined; const buildParams = useCallback( (): TrafficParams => ({ - period, + period: dateMode ? undefined : period, limit, offset, search: committedSearch || undefined, sort_by: sortBy, sort_desc: sortDesc, tariffs: tariffsParam, + nodes: nodesParam, + statuses: statusesParam, + start_date: dateMode && customStart ? customStart : undefined, + end_date: dateMode && customEnd ? customEnd : undefined, }), - [period, offset, committedSearch, sortBy, sortDesc, tariffsParam], + [ + period, + offset, + committedSearch, + sortBy, + sortDesc, + tariffsParam, + nodesParam, + statusesParam, + dateMode, + customStart, + customEnd, + ], ); const applyData = useCallback( @@ -396,11 +709,13 @@ export default function AdminTrafficUsage() { nodes: TrafficNodeInfo[]; total: number; available_tariffs: string[]; + available_statuses: string[]; }) => { setItems(data.items); setNodes(data.nodes); setTotal(data.total); setAvailableTariffs(data.available_tariffs); + setAvailableStatuses(data.available_statuses); }, [], ); @@ -409,6 +724,9 @@ export default function AdminTrafficUsage() { async (skipCache = false) => { const params = buildParams(); + // In date mode, require both dates + if (dateMode && (!customStart || !customEnd)) return; + // Check cache first — apply instantly without any loading state if (!skipCache) { const cached = adminTrafficApi.getCached(params); @@ -430,7 +748,7 @@ export default function AdminTrafficUsage() { setInitialLoading(false); } }, - [buildParams, applyData], + [buildParams, applyData, dateMode, customStart, customEnd], ); // Load on param change @@ -438,8 +756,36 @@ export default function AdminTrafficUsage() { loadData(); }, [loadData]); - // Prefetch adjacent periods in background + // Track all available nodes (before node filtering) — fetch once on mount with default params useEffect(() => { + const fetchAllNodes = async () => { + const params: TrafficParams = { + period: 30, + limit: 1, + offset: 0, + sort_by: 'total_bytes', + sort_desc: true, + }; + try { + const data = await adminTrafficApi.getTrafficUsage(params); + setAllNodes(data.nodes); + } catch { + // ignore + } + }; + fetchAllNodes(); + }, []); + + // Update allNodes when we get unfiltered response + useEffect(() => { + if (selectedNodes.size === 0 && nodes.length > 0) { + setAllNodes(nodes); + } + }, [nodes, selectedNodes.size]); + + // 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) => { @@ -456,8 +802,7 @@ export default function AdminTrafficUsage() { }); }, 500); return () => clearTimeout(timer); - // Only prefetch once on mount + when period changes - }, [period]); + }, [period, dateMode]); useEffect(() => { if (toast) { @@ -475,7 +820,15 @@ export default function AdminTrafficUsage() { const handleExport = async () => { try { setExporting(true); - await adminTrafficApi.exportCsv({ period }); + await adminTrafficApi.exportCsv({ + period, + start_date: dateMode && customStart ? customStart : undefined, + end_date: dateMode && customEnd ? customEnd : undefined, + tariffs: tariffsParam, + nodes: nodesParam, + statuses: statusesParam, + search: committedSearch || undefined, + }); setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); } catch { setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' }); @@ -500,10 +853,41 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleNodeChange = (next: Set) => { + setSelectedNodes(next); + setOffset(0); + }; + + const handleStatusChange = (next: Set) => { + setSelectedStatuses(next); + setOffset(0); + }; + const handleRefresh = () => { loadData(true); }; + const handleToggleDateMode = () => { + if (!dateMode) { + const today = new Date(); + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + setCustomStart(formatDateForInput(weekAgo)); + setCustomEnd(formatDateForInput(today)); + } + setDateMode(!dateMode); + setOffset(0); + }; + + const handleCustomStartChange = (v: string) => { + setCustomStart(v); + setOffset(0); + }; + + const handleCustomEndChange = (v: string) => { + setCustomEnd(v); + setOffset(0); + }; + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -544,15 +928,26 @@ export default function AdminTrafficUsage() { ), }, { - accessorKey: 'device_limit', + id: 'device_limit', + accessorFn: (row) => row.device_limit, header: t('admin.trafficUsage.devices'), enableSorting: true, size: 80, minSize: 60, meta: { align: 'center' as const }, - cell: ({ getValue }) => ( - {getValue() as number} - ), + cell: ({ row }) => { + const item = row.original; + const connected = item.connected_devices; + const limit = item.device_limit; + return ( + + 0 ? 'text-accent-400' : 'text-dark-500'}> + {connected} + + /{limit} + + ); + }, }, { accessorKey: 'traffic_limit_gb', @@ -667,16 +1062,34 @@ export default function AdminTrafficUsage() { {/* Controls */}
- + {dateMode ? ( + + ) : ( + + )} + + - ))} -
- )} - -
- ); -} - -function DateRangePicker({ - startDate, - endDate, - onStartChange, - onEndChange, - onClose, -}: { - startDate: string; - endDate: string; - onStartChange: (v: string) => void; - onEndChange: (v: string) => void; - onClose: () => void; -}) { - const { t } = useTranslation(); - const today = formatDateForInput(new Date()); - const minDate = formatDateForInput(new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)); - - return ( -
- - - onStartChange(e.target.value)} - min={minDate} - max={endDate || today} - 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" - /> - - onEndChange(e.target.value)} - min={startDate || minDate} - max={today} - 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" - /> - +
+ {PERIODS.map((p) => ( + + ))} +
); } @@ -423,211 +284,62 @@ function TariffFilter({ allSelected ? 'text-accent-400' : 'text-dark-300' }`} > - + + {allSelected && ( + + + + )} + {t('admin.trafficUsage.allTariffs')}
- {available.map((tariff) => ( - - ))} -
-
- )} -
- ); -} - -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 = (nodeUuid: string) => { - const next = new Set(selected); - if (next.has(nodeUuid)) { - next.delete(nodeUuid); - } else { - next.add(nodeUuid); - } - onChange(next); - }; - - const selectAll = () => onChange(new Set()); - - return ( -
- - - {open && ( -
- - -
- -
- {available.map((node) => ( - - ))} -
-
- )} -
- ); -} - -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 statusLabels: Record = { - active: t('admin.trafficUsage.statusActive'), - trial: t('admin.trafficUsage.statusTrial'), - expired: t('admin.trafficUsage.statusExpired'), - disabled: t('admin.trafficUsage.statusDisabled'), - }; - - 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()); - - return ( -
- - - {open && ( -
- - -
- -
- {available.map((status) => ( - - ))} + {available.map((tariff) => { + const checked = selected.has(tariff); + return ( + + ); + })}
)} @@ -644,20 +356,13 @@ export default function AdminTrafficUsage() { const [items, setItems] = useState([]); const [nodes, setNodes] = useState([]); - const [allNodes, setAllNodes] = 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 [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [selectedTariffs, setSelectedTariffs] = useState>(new Set()); - const [selectedNodes, setSelectedNodes] = useState>(new Set()); - const [selectedStatuses, setSelectedStatuses] = useState>(new Set()); - const [dateMode, setDateMode] = useState(false); - const [customStart, setCustomStart] = useState(''); - const [customEnd, setCustomEnd] = useState(''); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); const [exporting, setExporting] = useState(false); @@ -671,36 +376,18 @@ export default function AdminTrafficUsage() { 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 nodesParam = selectedNodes.size > 0 ? [...selectedNodes].join(',') : undefined; - const statusesParam = selectedStatuses.size > 0 ? [...selectedStatuses].join(',') : undefined; const buildParams = useCallback( (): TrafficParams => ({ - period: dateMode ? undefined : period, + period, limit, offset, search: committedSearch || undefined, sort_by: sortBy, sort_desc: sortDesc, tariffs: tariffsParam, - nodes: nodesParam, - statuses: statusesParam, - start_date: dateMode && customStart ? customStart : undefined, - end_date: dateMode && customEnd ? customEnd : undefined, }), - [ - period, - offset, - committedSearch, - sortBy, - sortDesc, - tariffsParam, - nodesParam, - statusesParam, - dateMode, - customStart, - customEnd, - ], + [period, offset, committedSearch, sortBy, sortDesc, tariffsParam], ); const applyData = useCallback( @@ -709,13 +396,11 @@ export default function AdminTrafficUsage() { nodes: TrafficNodeInfo[]; total: number; available_tariffs: string[]; - available_statuses: string[]; }) => { setItems(data.items); setNodes(data.nodes); setTotal(data.total); setAvailableTariffs(data.available_tariffs); - setAvailableStatuses(data.available_statuses); }, [], ); @@ -724,9 +409,6 @@ export default function AdminTrafficUsage() { async (skipCache = false) => { const params = buildParams(); - // In date mode, require both dates - if (dateMode && (!customStart || !customEnd)) return; - // Check cache first — apply instantly without any loading state if (!skipCache) { const cached = adminTrafficApi.getCached(params); @@ -748,7 +430,7 @@ export default function AdminTrafficUsage() { setInitialLoading(false); } }, - [buildParams, applyData, dateMode, customStart, customEnd], + [buildParams, applyData], ); // Load on param change @@ -756,36 +438,8 @@ export default function AdminTrafficUsage() { loadData(); }, [loadData]); - // Track all available nodes (before node filtering) — fetch once on mount with default params + // Prefetch adjacent periods in background useEffect(() => { - const fetchAllNodes = async () => { - const params: TrafficParams = { - period: 30, - limit: 1, - offset: 0, - sort_by: 'total_bytes', - sort_desc: true, - }; - try { - const data = await adminTrafficApi.getTrafficUsage(params); - setAllNodes(data.nodes); - } catch { - // ignore - } - }; - fetchAllNodes(); - }, []); - - // Update allNodes when we get unfiltered response - useEffect(() => { - if (selectedNodes.size === 0 && nodes.length > 0) { - setAllNodes(nodes); - } - }, [nodes, selectedNodes.size]); - - // 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) => { @@ -802,7 +456,8 @@ export default function AdminTrafficUsage() { }); }, 500); return () => clearTimeout(timer); - }, [period, dateMode]); + // Only prefetch once on mount + when period changes + }, [period]); useEffect(() => { if (toast) { @@ -820,15 +475,7 @@ export default function AdminTrafficUsage() { const handleExport = async () => { try { setExporting(true); - await adminTrafficApi.exportCsv({ - period, - start_date: dateMode && customStart ? customStart : undefined, - end_date: dateMode && customEnd ? customEnd : undefined, - tariffs: tariffsParam, - nodes: nodesParam, - statuses: statusesParam, - search: committedSearch || undefined, - }); + await adminTrafficApi.exportCsv({ period }); setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); } catch { setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' }); @@ -853,41 +500,10 @@ export default function AdminTrafficUsage() { setOffset(0); }; - const handleNodeChange = (next: Set) => { - setSelectedNodes(next); - setOffset(0); - }; - - const handleStatusChange = (next: Set) => { - setSelectedStatuses(next); - setOffset(0); - }; - const handleRefresh = () => { loadData(true); }; - const handleToggleDateMode = () => { - if (!dateMode) { - const today = new Date(); - const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - setCustomStart(formatDateForInput(weekAgo)); - setCustomEnd(formatDateForInput(today)); - } - setDateMode(!dateMode); - setOffset(0); - }; - - const handleCustomStartChange = (v: string) => { - setCustomStart(v); - setOffset(0); - }; - - const handleCustomEndChange = (v: string) => { - setCustomEnd(v); - setOffset(0); - }; - const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -928,26 +544,15 @@ export default function AdminTrafficUsage() { ), }, { - id: 'device_limit', - accessorFn: (row) => row.device_limit, + accessorKey: 'device_limit', header: t('admin.trafficUsage.devices'), enableSorting: true, size: 80, minSize: 60, meta: { align: 'center' as const }, - cell: ({ row }) => { - const item = row.original; - const connected = item.connected_devices; - const limit = item.device_limit; - return ( - - 0 ? 'text-accent-400' : 'text-dark-500'}> - {connected} - - /{limit} - - ); - }, + cell: ({ getValue }) => ( + {getValue() as number} + ), }, { accessorKey: 'traffic_limit_gb', @@ -1062,34 +667,16 @@ export default function AdminTrafficUsage() { {/* Controls */}
- {dateMode ? ( - - ) : ( - - )} + - - +
+ ); + } + return (
{label} @@ -214,6 +301,13 @@ function PeriodSelector({ ))}
+
); } @@ -347,6 +441,277 @@ function TariffFilter({ ); } +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 ( + + ); + })} +
+
+ )} +
+ ); +} + // ============ Main Page ============ export default function AdminTrafficUsage() { @@ -357,12 +722,18 @@ export default function AdminTrafficUsage() { 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 [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); const [exporting, setExporting] = useState(false); @@ -376,34 +747,48 @@ export default function AdminTrafficUsage() { 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; + const nodesParam = selectedNodes.size > 0 ? [...selectedNodes].join(',') : undefined; - const buildParams = useCallback( - (): TrafficParams => ({ - period, + const buildParams = useCallback((): TrafficParams => { + const params: TrafficParams = { limit, offset, search: committedSearch || undefined, sort_by: sortBy, sort_desc: sortDesc, tariffs: tariffsParam, - }), - [period, offset, committedSearch, sortBy, sortDesc, tariffsParam], - ); + statuses: statusesParam, + nodes: nodesParam, + }; + 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, + nodesParam, + dateMode, + customStart, + customEnd, + ]); - const applyData = useCallback( - (data: { - items: UserTrafficItem[]; - nodes: TrafficNodeInfo[]; - total: number; - available_tariffs: string[]; - }) => { - setItems(data.items); - setNodes(data.nodes); - setTotal(data.total); - setAvailableTariffs(data.available_tariffs); - }, - [], - ); + const applyData = useCallback((data: TrafficUsageResponse) => { + setItems(data.items); + setNodes(data.nodes); + setTotal(data.total); + setAvailableTariffs(data.available_tariffs); + setAvailableStatuses(data.available_statuses); + }, []); const loadData = useCallback( async (skipCache = false) => { @@ -438,8 +823,9 @@ export default function AdminTrafficUsage() { loadData(); }, [loadData]); - // Prefetch adjacent periods in background + // 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) => { @@ -456,8 +842,7 @@ export default function AdminTrafficUsage() { }); }, 500); return () => clearTimeout(timer); - // Only prefetch once on mount + when period changes - }, [period]); + }, [period, dateMode]); useEffect(() => { if (toast) { @@ -475,7 +860,22 @@ export default function AdminTrafficUsage() { const handleExport = async () => { try { setExporting(true); - await adminTrafficApi.exportCsv({ period }); + const exportData: { + period: number; + start_date?: string; + end_date?: string; + tariffs?: string; + statuses?: string; + nodes?: string; + } = { period }; + if (dateMode && customStart && customEnd) { + exportData.start_date = customStart; + exportData.end_date = customEnd; + } + if (tariffsParam) exportData.tariffs = tariffsParam; + if (statusesParam) exportData.statuses = statusesParam; + if (nodesParam) exportData.nodes = nodesParam; + await adminTrafficApi.exportCsv(exportData); setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); } catch { setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' }); @@ -489,6 +889,34 @@ export default function AdminTrafficUsage() { 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); @@ -500,10 +928,26 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleStatusChange = (next: Set) => { + setSelectedStatuses(next); + setOffset(0); + }; + + const handleNodeChange = (next: Set) => { + setSelectedNodes(next); + setOffset(0); + }; + const handleRefresh = () => { loadData(true); }; + // When node filter is active, show only selected node columns + const displayNodes = useMemo( + () => (selectedNodes.size > 0 ? nodes.filter((n) => selectedNodes.has(n.node_uuid)) : nodes), + [nodes, selectedNodes], + ); + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -566,7 +1010,7 @@ export default function AdminTrafficUsage() { return {gb > 0 ? `${gb} GB` : '\u221E'}; }, }, - ...nodes.map( + ...displayNodes.map( (node): ColumnDef => ({ id: `node_${node.node_uuid}`, accessorFn: (row) => row.node_traffic[node.node_uuid] || 0, @@ -603,7 +1047,7 @@ export default function AdminTrafficUsage() { }, ]; return cols; - }, [nodes, t]); + }, [displayNodes, t]); const table = useReactTable({ data: items, @@ -671,12 +1115,24 @@ export default function AdminTrafficUsage() { value={period} onChange={handlePeriodChange} label={t('admin.trafficUsage.period')} + dateMode={dateMode} + customStart={customStart} + customEnd={customEnd} + onToggleDateMode={handleToggleDateMode} + onCustomStartChange={handleCustomStartChange} + onCustomEndChange={handleCustomEndChange} /> + + + )} +
+
+ + 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 && ( + + )} +
+
From e60b846eca6dfb0d31a191c990ddccb5c8089d07 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 12:59:11 +0300 Subject: [PATCH 09/13] fix: improve risk calculation display with actual GB/d values - RiskBadge now shows real daily GB/d value + mini progress bar - Node cells show daily rate subtitle when threshold is active - Total column shows daily rate subtitle when threshold is active - getCompositeRisk returns both ratio and dominant GB/d value - formatGbPerDay helper for clean number formatting --- src/pages/AdminTrafficUsage.tsx | 176 ++++++++++++++++++++++++-------- 1 file changed, 131 insertions(+), 45 deletions(-) diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index d84c568..32beb08 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -91,31 +91,74 @@ const getRiskLevel = (ratio: number): RiskLevel => { return 'critical'; }; -const getCompositeRatio = ( +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, -): number => { +): RiskResult => { const dailyTotal = bytesToGbPerDay(row.total_bytes, days); const totalR = totalThreshold > 0 ? getRatio(dailyTotal, totalThreshold) : 0; - const nodeR = - nodeThreshold > 0 - ? Math.max( - 0, - ...Object.values(row.node_traffic).map((b) => - getRatio(bytesToGbPerDay(b || 0, days), nodeThreshold), - ), - ) - : 0; - return Math.max(totalR, nodeR); + + 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' }, - medium: { dot: 'bg-warning-400', text: 'text-warning-400' }, - high: { dot: 'bg-orange-400', text: 'text-orange-400' }, - critical: { dot: 'bg-error-400 animate-pulse', text: 'text-error-400' }, +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 ============ @@ -811,17 +854,34 @@ function NodeFilter({ // ============ Risk Badge ============ -function RiskBadge({ level, ratio }: { level: RiskLevel; ratio: number }) { - const { t } = useTranslation(); +function RiskBadge({ + level, + ratio, + gbPerDay, +}: { + level: RiskLevel; + ratio: number; + gbPerDay: number; +}) { const style = RISK_STYLES[level]; - const labelKey = `admin.trafficUsage.risk${level.charAt(0).toUpperCase() + level.slice(1)}`; - const pct = Math.round(ratio * 100); + const barWidth = Math.min(ratio * 100, 100); return ( -
- - {pct}% - {t(labelKey)} +
+
+ + + {formatGbPerDay(gbPerDay)} + + GB/d +
+ {/* Mini progress bar showing ratio to threshold */} +
+
+
); } @@ -1155,15 +1215,25 @@ export default function AdminTrafficUsage() { const nodeRatio = hasNodeThreshold ? getRatio(dailyNode, nodeThresholdNum) : 0; const textColor = hasNodeThreshold ? getNodeTextColor(nodeRatio) : undefined; return ( - 0.8 ? 600 : undefined, - }} - > - {formatBytes(bytes)} - +
+ 0.8 ? 600 : undefined, + }} + > + {formatBytes(bytes)} + + {hasNodeThreshold && ( + + {formatGbPerDay(dailyNode)} GB/d + + )} +
); }, }), @@ -1178,13 +1248,20 @@ export default function AdminTrafficUsage() { size: 100, minSize: 80, meta: { align: 'center' as const }, - accessorFn: (row) => - getCompositeRatio(row, totalThresholdNum, nodeThresholdNum, periodDays), + accessorFn: (row) => { + const result = getCompositeRisk(row, totalThresholdNum, nodeThresholdNum, periodDays); + return result.ratio; + }, enableSorting: false, - cell: ({ getValue }) => { - const maxRatio = getValue() as number; - const level = getRiskLevel(maxRatio); - return ; + cell: ({ row }) => { + const result = getCompositeRisk( + row.original, + totalThresholdNum, + nodeThresholdNum, + periodDays, + ); + const level = getRiskLevel(result.ratio); + return ; }, }); } @@ -1198,10 +1275,19 @@ export default function AdminTrafficUsage() { 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 ( - - {bytes > 0 ? formatBytes(bytes) : '\u2014'} - +
+ {formatBytes(bytes)} + {hasTotalThreshold && ( + + {formatGbPerDay(dailyTotal)} GB/d + + )} +
); }, }); @@ -1429,12 +1515,12 @@ export default function AdminTrafficUsage() { {table.getRowModel().rows.map((row) => { const compositeRatio = hasAnyThreshold - ? getCompositeRatio( + ? getCompositeRisk( row.original, totalThresholdNum, nodeThresholdNum, periodDays, - ) + ).ratio : 0; const rowBg = hasAnyThreshold ? getRowBgColor(compositeRatio) : undefined; From da273d6776adc7212057f5857884d58144b89134 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 13:11:01 +0300 Subject: [PATCH 10/13] fix: widen column resize touch target for mobile devices Expand invisible touch area from 4px to 20px for comfortable finger interaction on iPhone. Visual indicator stays thin (4px) inside the wider hit zone. Use inline touchAction: none to prevent scroll conflict. --- src/pages/AdminTrafficUsage.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 32beb08..1fed0d2 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -1500,12 +1500,17 @@ export default function AdminTrafficUsage() { onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} onClick={(e) => e.stopPropagation()} - className={`absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none ${ - header.column.getIsResizing() - ? 'bg-accent-500' - : 'bg-transparent hover:bg-dark-500' - }`} - /> + className="absolute -right-2 top-0 z-20 h-full w-5 cursor-col-resize select-none" + style={{ touchAction: 'none' }} + > +
+
); })} From 12663a59a7aaec87933e9437d329d452f09ee2fe Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 13:17:08 +0300 Subject: [PATCH 11/13] fix: allow user column to shrink smaller on mobile Reduce minSize from 80px to 40px so the user column can be compressed further on narrow screens. --- src/pages/AdminTrafficUsage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 1fed0d2..5a3266c 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -1140,7 +1140,7 @@ export default function AdminTrafficUsage() { header: t('admin.trafficUsage.user'), enableSorting: true, size: 120, - minSize: 80, + minSize: 40, maxSize: 200, cell: ({ row }) => { const item = row.original; From 471e2c8c43212c03b72d8f270182b731738836bd Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 13:29:14 +0300 Subject: [PATCH 12/13] feat: add country filter and risk columns to traffic CSV export - Add CountryFilter dropdown with flag emoji + node count per country - Country filter merges with node filter for both table and CSV export - Pass total/node GB/day thresholds to backend for risk columns in CSV - CSV now includes Risk Level, Risk Ratio, Risk GB/day when thresholds set --- src/api/adminTraffic.ts | 2 + src/pages/AdminTrafficUsage.tsx | 207 ++++++++++++++++++++++++++++++-- 2 files changed, 200 insertions(+), 9 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 4ecf16c..8af4dcd 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -111,6 +111,8 @@ export const adminTrafficApi = { 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/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 5a3266c..aff28b4 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -279,6 +279,16 @@ const StatusIcon = () => ( ); +const GlobeIcon = () => ( + + + +); + const ShieldIcon = () => ( ; + 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({ @@ -908,6 +1046,7 @@ export default function AdminTrafficUsage() { 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); @@ -925,7 +1064,25 @@ export default function AdminTrafficUsage() { const sortDesc = sorting[0]?.desc ?? true; const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined; const statusesParam = selectedStatuses.size > 0 ? [...selectedStatuses].join(',') : undefined; - const nodesParam = selectedNodes.size > 0 ? [...selectedNodes].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 = { @@ -936,7 +1093,7 @@ export default function AdminTrafficUsage() { sort_desc: sortDesc, tariffs: tariffsParam, statuses: statusesParam, - nodes: nodesParam, + nodes: mergedNodesParam, }; if (dateMode && customStart && customEnd) { params.start_date = customStart; @@ -953,7 +1110,7 @@ export default function AdminTrafficUsage() { sortDesc, tariffsParam, statusesParam, - nodesParam, + mergedNodesParam, dateMode, customStart, customEnd, @@ -1045,6 +1202,8 @@ export default function AdminTrafficUsage() { tariffs?: string; statuses?: string; nodes?: string; + total_threshold_gb?: number; + node_threshold_gb?: number; } = { period }; if (dateMode && customStart && customEnd) { exportData.start_date = customStart; @@ -1052,7 +1211,11 @@ export default function AdminTrafficUsage() { } if (tariffsParam) exportData.tariffs = tariffsParam; if (statusesParam) exportData.statuses = statusesParam; - if (nodesParam) exportData.nodes = nodesParam; + 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 { @@ -1116,15 +1279,36 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleCountryChange = (next: Set) => { + setSelectedCountries(next); + setOffset(0); + }; + const handleRefresh = () => { loadData(true); }; - // When node filter is active, show only selected node columns - const displayNodes = useMemo( - () => (selectedNodes.size > 0 ? nodes.filter((n) => selectedNodes.has(n.node_uuid)) : nodes), - [nodes, selectedNodes], - ); + 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; @@ -1383,6 +1567,11 @@ export default function AdminTrafficUsage() { onChange={handleTariffChange} /> + Date: Sat, 7 Feb 2026 13:42:15 +0300 Subject: [PATCH 13/13] fix: enforce column maxWidth for proper shrinking on mobile + country dropdown positioning --- src/pages/AdminTrafficUsage.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index aff28b4..33ff729 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -919,7 +919,7 @@ function CountryFilter({ {open && ( -
+