From 90b38e3ef2815300ee4b50a4d3da0b1422d21092 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Feb 2026 11:53:09 +0300 Subject: [PATCH] feat: add node/status filters and custom date range to traffic page - Add NodeFilter component with flag emojis and checkbox dropdown - Add StatusFilter component with colored status dots - Add custom date range picker (calendar toggle, from/to date inputs) - Wire all filters to backend params (nodes, statuses, start_date, end_date) - CSV export passes all active filters and date range - Add displayNodes memo for table column filtering - Add i18n keys for nodes, statuses, dates in ru/en/zh/fa --- src/api/adminTraffic.ts | 32 +- src/locales/en.json | 13 +- src/locales/fa.json | 13 +- src/locales/ru.json | 13 +- src/locales/zh.json | 13 +- src/pages/AdminTrafficUsage.tsx | 508 ++++++++++++++++++++++++++++++-- 6 files changed, 554 insertions(+), 38 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index e00cdeb..4ecf16c 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -27,6 +27,7 @@ export interface TrafficUsageResponse { limit: number; period_days: number; available_tariffs: string[]; + available_statuses: string[]; } export interface ExportCsvResponse { @@ -42,6 +43,10 @@ export type TrafficParams = { sort_by?: string; sort_desc?: boolean; tariffs?: string; + statuses?: string; + nodes?: string; + start_date?: string; + end_date?: string; }; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -50,13 +55,17 @@ const trafficCache = new Map => { + exportCsv: async (data: { + period: number; + start_date?: string; + end_date?: string; + tariffs?: string; + statuses?: string; + nodes?: 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..d73e8b6 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": "Custom dates", + "dateFrom": "From", + "dateTo": "To" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2bf6548..22be7de 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..b535256 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..5a96fa1 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..3636860 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -13,6 +13,7 @@ import { adminTrafficApi, type UserTrafficItem, type TrafficNodeInfo, + type TrafficUsageResponse, type TrafficParams, } from '../api/adminTraffic'; import { usePlatform } from '../platform/hooks/usePlatform'; @@ -134,6 +135,42 @@ const ChevronDownIcon = () => ( ); +const ServerIcon = () => ( + + + +); + +const CalendarIcon = () => ( + + + +); + +const XIcon = () => ( + + + +); + +const StatusIcon = () => ( + + + +); + // ============ Progress Bar ============ function ProgressBar({ loading }: { loading: boolean }) { @@ -189,12 +226,62 @@ function PeriodSelector({ value, onChange, label, + dateMode, + customStart, + customEnd, + onToggleDateMode, + onCustomStartChange, + onCustomEndChange, }: { value: number; onChange: (v: number) => void; label: string; + dateMode: boolean; + customStart: string; + customEnd: string; + onToggleDateMode: () => void; + onCustomStartChange: (v: string) => void; + onCustomEndChange: (v: string) => void; }) { const { t } = useTranslation(); + + // Limit: last 31 days + const today = new Date().toISOString().split('T')[0]; + const minDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + if (dateMode) { + return ( +
+ + {t('admin.trafficUsage.dateFrom')} + onCustomStartChange(e.target.value)} + 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" + /> + {t('admin.trafficUsage.dateTo')} + onCustomEndChange(e.target.value)} + 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" + /> + +
+ ); + } + 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} /> + +