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())}
{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' + }`} + />