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
This commit is contained in:
Fringg
2026-02-07 09:30:23 +03:00
parent 8c8fa407f5
commit cfb7ce72f2
6 changed files with 205 additions and 14 deletions

View File

@@ -26,6 +26,7 @@ export interface TrafficUsageResponse {
offset: number; offset: number;
limit: number; limit: number;
period_days: number; period_days: number;
available_tariffs: string[];
} }
export interface ExportCsvResponse { export interface ExportCsvResponse {
@@ -41,6 +42,7 @@ export const adminTrafficApi = {
search?: string; search?: string;
sort_by?: string; sort_by?: string;
sort_desc?: boolean; sort_desc?: boolean;
tariffs?: string;
}): Promise<TrafficUsageResponse> => { }): Promise<TrafficUsageResponse> => {
const response = await apiClient.get('/cabinet/admin/traffic', { params }); const response = await apiClient.get('/cabinet/admin/traffic', { params });
return response.data; return response.data;

View File

@@ -803,7 +803,8 @@
"noData": "No traffic data", "noData": "No traffic data",
"loading": "Loading traffic data...", "loading": "Loading traffic data...",
"noTariff": "\u2014", "noTariff": "\u2014",
"search": "Search by name or username" "search": "Search by name or username",
"allTariffs": "All tariffs"
}, },
"emailTemplates": { "emailTemplates": {
"title": "Email Templates", "title": "Email Templates",

View File

@@ -684,7 +684,8 @@
"noData": "داده ترافیکی موجود نیست", "noData": "داده ترافیکی موجود نیست",
"loading": "در حال بارگذاری داده‌های ترافیک...", "loading": "در حال بارگذاری داده‌های ترافیک...",
"noTariff": "\u2014", "noTariff": "\u2014",
"search": "جستجو بر اساس نام یا نام کاربری" "search": "جستجو بر اساس نام یا نام کاربری",
"allTariffs": "همه تعرفه‌ها"
}, },
"emailTemplates": { "emailTemplates": {
"title": "قالب‌های ایمیل", "title": "قالب‌های ایمیل",

View File

@@ -824,7 +824,8 @@
"noData": "Нет данных о трафике", "noData": "Нет данных о трафике",
"loading": "Загрузка данных о трафике...", "loading": "Загрузка данных о трафике...",
"noTariff": "\u2014", "noTariff": "\u2014",
"search": "Поиск по имени или юзернейму" "search": "Поиск по имени или юзернейму",
"allTariffs": "Все тарифы"
}, },
"emailTemplates": { "emailTemplates": {
"title": "Email-шаблоны", "title": "Email-шаблоны",

View File

@@ -684,7 +684,8 @@
"noData": "无流量数据", "noData": "无流量数据",
"loading": "正在加载流量数据...", "loading": "正在加载流量数据...",
"noTariff": "\u2014", "noTariff": "\u2014",
"search": "按名称或用户名搜索" "search": "按名称或用户名搜索",
"allTariffs": "所有套餐"
}, },
"paymentMethods": { "paymentMethods": {
"title": "支付方法", "title": "支付方法",

View File

@@ -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 { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -42,7 +42,6 @@ const getFlagEmoji = (countryCode: string): string => {
return String.fromCodePoint(...codePoints); return String.fromCodePoint(...codePoints);
}; };
/** Map TanStack column IDs to backend sort_by field names. */
const toBackendSortField = (columnId: string): string => { const toBackendSortField = (columnId: string): string => {
if (columnId === 'user') return 'full_name'; if (columnId === 'user') return 'full_name';
return columnId; return columnId;
@@ -114,6 +113,22 @@ const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => (
</svg> </svg>
); );
const FilterIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
// ============ Components ============ // ============ Components ============
const PERIODS = [1, 3, 7, 14, 30] as const; const PERIODS = [1, 3, 7, 14, 30] as const;
@@ -151,6 +166,135 @@ function PeriodSelector({
); );
} }
function TariffFilter({
available,
selected,
onChange,
}: {
available: string[];
selected: Set<string>;
onChange: (next: Set<string>) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors ${
activeCount > 0
? 'border-accent-500/50 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`}
>
<FilterIcon />
{t('admin.trafficUsage.tariff')}
{activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount}
</span>
)}
<ChevronDownIcon />
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
<button
onClick={selectAll}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-dark-700 ${
allSelected ? 'text-accent-400' : 'text-dark-300'
}`}
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{allSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span>
{t('admin.trafficUsage.allTariffs')}
</button>
<div className="mx-2 border-t border-dark-700" />
<div className="max-h-48 overflow-y-auto">
{available.map((tariff) => {
const checked = selected.has(tariff);
return (
<button
key={tariff}
onClick={() => toggle(tariff)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-dark-300 transition-colors hover:bg-dark-700"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`}
>
{checked && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span>
{tariff}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
// ============ Main Page ============ // ============ Main Page ============
export default function AdminTrafficUsage() { export default function AdminTrafficUsage() {
@@ -160,20 +304,24 @@ export default function AdminTrafficUsage() {
const [items, setItems] = useState<UserTrafficItem[]>([]); const [items, setItems] = useState<UserTrafficItem[]>([]);
const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]); const [nodes, setNodes] = useState<TrafficNodeInfo[]>([]);
const [availableTariffs, setAvailableTariffs] = useState<string[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState(30); const [period, setPeriod] = useState(30);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [committedSearch, setCommittedSearch] = useState(''); const [committedSearch, setCommittedSearch] = useState('');
const [selectedTariffs, setSelectedTariffs] = useState<Set<string>>(new Set());
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]); const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
const [columnSizing, setColumnSizing] = useState<Record<string, number>>({});
const limit = 50; const limit = 50;
const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes'; const sortBy = sorting[0] ? toBackendSortField(sorting[0].id) : 'total_bytes';
const sortDesc = sorting[0]?.desc ?? true; const sortDesc = sorting[0]?.desc ?? true;
const tariffsParam = selectedTariffs.size > 0 ? [...selectedTariffs].join(',') : undefined;
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
@@ -185,16 +333,18 @@ export default function AdminTrafficUsage() {
search: committedSearch || undefined, search: committedSearch || undefined,
sort_by: sortBy, sort_by: sortBy,
sort_desc: sortDesc, sort_desc: sortDesc,
tariffs: tariffsParam,
}); });
setItems(data.items); setItems(data.items);
setNodes(data.nodes); setNodes(data.nodes);
setTotal(data.total); setTotal(data.total);
setAvailableTariffs(data.available_tariffs);
} catch { } catch {
// silently fail — toast could be added if needed // silently fail
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [period, offset, committedSearch, sortBy, sortDesc]); }, [period, offset, committedSearch, sortBy, sortDesc, tariffsParam]);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -236,7 +386,11 @@ export default function AdminTrafficUsage() {
setOffset(0); setOffset(0);
}; };
// Build columns dynamically based on nodes const handleTariffChange = (next: Set<string>) => {
setSelectedTariffs(next);
setOffset(0);
};
const columns = useMemo<ColumnDef<UserTrafficItem>[]>(() => { const columns = useMemo<ColumnDef<UserTrafficItem>[]>(() => {
const cols: ColumnDef<UserTrafficItem>[] = [ const cols: ColumnDef<UserTrafficItem>[] = [
{ {
@@ -244,6 +398,8 @@ export default function AdminTrafficUsage() {
accessorFn: (row) => row.full_name, accessorFn: (row) => row.full_name,
header: t('admin.trafficUsage.user'), header: t('admin.trafficUsage.user'),
enableSorting: true, enableSorting: true,
size: 200,
minSize: 140,
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original; const item = row.original;
return ( return (
@@ -266,6 +422,8 @@ export default function AdminTrafficUsage() {
accessorKey: 'tariff_name', accessorKey: 'tariff_name',
header: t('admin.trafficUsage.tariff'), header: t('admin.trafficUsage.tariff'),
enableSorting: true, enableSorting: true,
size: 120,
minSize: 80,
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<span className="text-xs text-dark-300"> <span className="text-xs text-dark-300">
{(getValue() as string | null) || t('admin.trafficUsage.noTariff')} {(getValue() as string | null) || t('admin.trafficUsage.noTariff')}
@@ -276,6 +434,8 @@ export default function AdminTrafficUsage() {
accessorKey: 'device_limit', accessorKey: 'device_limit',
header: t('admin.trafficUsage.devices'), header: t('admin.trafficUsage.devices'),
enableSorting: true, enableSorting: true,
size: 80,
minSize: 60,
meta: { align: 'center' as const }, meta: { align: 'center' as const },
cell: ({ getValue }) => ( cell: ({ getValue }) => (
<span className="text-xs text-dark-300">{getValue() as number}</span> <span className="text-xs text-dark-300">{getValue() as number}</span>
@@ -285,19 +445,22 @@ export default function AdminTrafficUsage() {
accessorKey: 'traffic_limit_gb', accessorKey: 'traffic_limit_gb',
header: t('admin.trafficUsage.trafficLimit'), header: t('admin.trafficUsage.trafficLimit'),
enableSorting: true, enableSorting: true,
size: 80,
minSize: 60,
meta: { align: 'center' as const }, meta: { align: 'center' as const },
cell: ({ getValue }) => { cell: ({ getValue }) => {
const gb = getValue() as number; const gb = getValue() as number;
return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>; return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>;
}, },
}, },
// Dynamic node columns
...nodes.map( ...nodes.map(
(node): ColumnDef<UserTrafficItem> => ({ (node): ColumnDef<UserTrafficItem> => ({
id: `node_${node.node_uuid}`, id: `node_${node.node_uuid}`,
accessorFn: (row) => row.node_traffic[node.node_uuid] || 0, accessorFn: (row) => row.node_traffic[node.node_uuid] || 0,
header: `${getFlagEmoji(node.country_code)} ${node.node_name}`, header: `${getFlagEmoji(node.country_code)} ${node.node_name}`,
enableSorting: true, enableSorting: true,
size: 110,
minSize: 80,
meta: { align: 'center' as const }, meta: { align: 'center' as const },
cell: ({ getValue }) => { cell: ({ getValue }) => {
const bytes = getValue() as number; const bytes = getValue() as number;
@@ -313,6 +476,8 @@ export default function AdminTrafficUsage() {
accessorKey: 'total_bytes', accessorKey: 'total_bytes',
header: t('admin.trafficUsage.total'), header: t('admin.trafficUsage.total'),
enableSorting: true, enableSorting: true,
size: 110,
minSize: 80,
meta: { align: 'center' as const, bold: true }, meta: { align: 'center' as const, bold: true },
cell: ({ getValue }) => { cell: ({ getValue }) => {
const bytes = getValue() as number; const bytes = getValue() as number;
@@ -330,11 +495,14 @@ export default function AdminTrafficUsage() {
const table = useReactTable({ const table = useReactTable({
data: items, data: items,
columns, columns,
state: { sorting }, state: { sorting, columnSizing },
onSortingChange: handleSortingChange, onSortingChange: handleSortingChange,
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
manualSorting: true, manualSorting: true,
enableSortingRemoval: false, enableSortingRemoval: false,
enableColumnResizing: true,
columnResizeMode: 'onChange',
}); });
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
@@ -376,7 +544,7 @@ export default function AdminTrafficUsage() {
</button> </button>
</div> </div>
{/* Controls: period + export + search */} {/* Controls */}
<div className="mb-4 flex flex-col gap-3"> <div className="mb-4 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<PeriodSelector <PeriodSelector
@@ -384,6 +552,11 @@ export default function AdminTrafficUsage() {
onChange={handlePeriodChange} onChange={handlePeriodChange}
label={t('admin.trafficUsage.period')} label={t('admin.trafficUsage.period')}
/> />
<TariffFilter
available={availableTariffs}
selected={selectedTariffs}
onChange={handleTariffChange}
/>
<button <button
onClick={handleExport} onClick={handleExport}
disabled={exporting} disabled={exporting}
@@ -419,7 +592,7 @@ export default function AdminTrafficUsage() {
<div className="py-12 text-center text-dark-400">{t('admin.trafficUsage.noData')}</div> <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"> <div className="overflow-x-auto rounded-xl border border-dark-700">
<table className="w-full min-w-[600px] text-left text-sm"> <table className="text-left text-sm" style={{ width: table.getCenterTotalSize() }}>
<thead> <thead>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80"> <tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
@@ -432,17 +605,28 @@ export default function AdminTrafficUsage() {
return ( return (
<th <th
key={header.id} key={header.id}
className={`px-3 py-2 text-xs font-medium ${ className={`relative px-3 py-2 text-xs font-medium ${
isBold ? 'font-semibold text-dark-200' : 'text-dark-400' isBold ? 'font-semibold text-dark-200' : 'text-dark-400'
} ${align} ${ } ${align} ${
isSticky ? 'sticky left-0 z-10 bg-dark-800' : '' isSticky ? 'sticky left-0 z-10 bg-dark-800' : ''
} ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`} } ${header.column.getCanSort() ? 'cursor-pointer select-none hover:text-dark-200' : ''}`}
style={{ width: header.getSize() }}
onClick={header.column.getToggleSortingHandler()} onClick={header.column.getToggleSortingHandler()}
> >
{flexRender(header.column.columnDef.header, header.getContext())} {flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && ( {header.column.getCanSort() && (
<SortIcon direction={header.column.getIsSorted()} /> <SortIcon direction={header.column.getIsSorted()} />
)} )}
<div
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'
}`}
/>
</th> </th>
); );
})} })}
@@ -467,6 +651,7 @@ export default function AdminTrafficUsage() {
className={`px-3 py-2 ${align} ${ className={`px-3 py-2 ${align} ${
isSticky ? 'sticky left-0 z-10 bg-dark-900' : '' isSticky ? 'sticky left-0 z-10 bg-dark-900' : ''
}`} }`}
style={{ width: cell.column.getSize() }}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</td> </td>