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:
Fringg
2026-02-07 09:04:55 +03:00
parent 280f4aef0d
commit a034a6068c
10 changed files with 716 additions and 8 deletions

34
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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;
},
};

View File

@@ -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",

View File

@@ -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": "قالب‌های ایمیل",

View File

@@ -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-шаблоны",

View File

@@ -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": "支付方法",

View File

@@ -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'),
},
],
},
{

View 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>
);
}