mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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.
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
11
src/App.tsx
11
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() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/traffic-usage"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminTrafficUsage />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payment-methods"
|
||||
element={
|
||||
|
||||
53
src/api/adminTraffic.ts
Normal file
53
src/api/adminTraffic.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export interface TrafficNodeInfo {
|
||||
node_uuid: string;
|
||||
node_name: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
export interface UserTrafficItem {
|
||||
user_id: number;
|
||||
telegram_id: number | null;
|
||||
username: string | null;
|
||||
full_name: string;
|
||||
tariff_name: string | null;
|
||||
subscription_status: string | null;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
node_traffic: Record<string, number>;
|
||||
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<TrafficUsageResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/traffic', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
exportCsv: async (data: { period: number }): Promise<ExportCsvResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "قالبهای ایمیل",
|
||||
|
||||
@@ -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-шаблоны",
|
||||
|
||||
@@ -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": "支付方法",
|
||||
|
||||
@@ -216,6 +216,16 @@ const EnvelopeIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowsUpDownIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
@@ -317,6 +327,12 @@ export default function AdminPanel() {
|
||||
title: t('admin.nav.payments'),
|
||||
description: t('admin.panel.paymentsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/traffic-usage',
|
||||
icon: <ArrowsUpDownIcon />,
|
||||
title: t('admin.nav.trafficUsage'),
|
||||
description: t('admin.panel.trafficUsageDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
513
src/pages/AdminTrafficUsage.tsx
Normal file
513
src/pages/AdminTrafficUsage.tsx
Normal file
@@ -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<TData extends RowData, TValue> {
|
||||
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 = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronLeftIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => (
|
||||
<svg
|
||||
className="ml-1 inline h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{direction === 'asc' ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||
) : direction === 'desc' ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-dark-400">{label}</span>
|
||||
<div className="flex gap-1">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onChange(p)}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
value === p
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
{t('admin.trafficUsage.days')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminTrafficUsage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { capabilities } = usePlatform();
|
||||
|
||||
const [items, setItems] = useState<UserTrafficItem[]>([]);
|
||||
const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]);
|
||||
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<SortingState>([{ 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<ColumnDef<UserTrafficItem>[]>(() => {
|
||||
const cols: ColumnDef<UserTrafficItem>[] = [
|
||||
{
|
||||
id: 'user',
|
||||
accessorFn: (row) => row.full_name,
|
||||
header: t('admin.trafficUsage.user'),
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-xs font-medium text-white">
|
||||
{item.full_name?.[0] || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-dark-100">{item.full_name}</div>
|
||||
{item.username && (
|
||||
<div className="truncate text-xs text-dark-500">@{item.username}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { sticky: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'tariff_name',
|
||||
header: t('admin.trafficUsage.tariff'),
|
||||
enableSorting: true,
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-dark-300">
|
||||
{(getValue() as string | null) || t('admin.trafficUsage.noTariff')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'device_limit',
|
||||
header: t('admin.trafficUsage.devices'),
|
||||
enableSorting: true,
|
||||
meta: { align: 'center' as const },
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-xs text-dark-300">{getValue() as number}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'traffic_limit_gb',
|
||||
header: t('admin.trafficUsage.trafficLimit'),
|
||||
enableSorting: true,
|
||||
meta: { align: 'center' as const },
|
||||
cell: ({ getValue }) => {
|
||||
const gb = getValue() as number;
|
||||
return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>;
|
||||
},
|
||||
},
|
||||
// Dynamic node columns
|
||||
...nodes.map(
|
||||
(node): ColumnDef<UserTrafficItem> => ({
|
||||
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 (
|
||||
<span className="text-xs text-dark-300">
|
||||
{bytes > 0 ? formatBytes(bytes) : '\u2014'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
{
|
||||
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 (
|
||||
<span className="text-xs font-semibold text-dark-100">
|
||||
{bytes > 0 ? formatBytes(bytes) : '\u2014'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
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 (
|
||||
<div className="animate-fade-in">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div
|
||||
className={`fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-xl border px-4 py-2 text-sm shadow-lg ${
|
||||
toast.type === 'success'
|
||||
? 'border-success-500/30 bg-success-500/20 text-success-400'
|
||||
: 'border-error-500/30 bg-error-500/20 text-error-400'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.trafficUsage.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.trafficUsage.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={loadData} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<RefreshIcon className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls: period + export + search */}
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<PeriodSelector
|
||||
value={period}
|
||||
onChange={handlePeriodChange}
|
||||
label={t('admin.trafficUsage.period')}
|
||||
/>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-xs font-medium text-dark-200 transition-colors hover:border-dark-600 hover:bg-dark-700 disabled:opacity-50"
|
||||
>
|
||||
<DownloadIcon />
|
||||
{t('admin.trafficUsage.exportCsv')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch}>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-12 text-center text-dark-400">{t('admin.trafficUsage.noData')}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-dark-700">
|
||||
<table className="w-full min-w-[600px] text-left text-sm">
|
||||
<thead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
|
||||
{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 (
|
||||
<th
|
||||
key={header.id}
|
||||
className={`px-3 py-2 text-xs font-medium ${
|
||||
isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
|
||||
} ${align} ${
|
||||
isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
|
||||
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<SortIcon direction={header.column.getIsSorted()} />
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="cursor-pointer border-b border-dark-700/50 transition-colors hover:bg-dark-800/50"
|
||||
onClick={() => 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 (
|
||||
<td
|
||||
key={cell.id}
|
||||
className={`px-3 py-2 ${align} ${
|
||||
isSticky ? 'sticky left-0 z-10 bg-dark-900' : ''
|
||||
}`}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-dark-400">
|
||||
{offset + 1}
|
||||
{'\u2013'}
|
||||
{Math.min(offset + limit, total)} / {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
disabled={offset === 0}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-dark-300">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
disabled={offset + limit >= total}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user