diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index e00cdeb..0d39a35 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -15,6 +15,7 @@ export interface UserTrafficItem { subscription_status: string | null; traffic_limit_gb: number; device_limit: number; + connected_devices: number; node_traffic: Record; total_bytes: number; } @@ -27,6 +28,7 @@ export interface TrafficUsageResponse { limit: number; period_days: number; available_tariffs: string[]; + available_statuses: string[]; } export interface ExportCsvResponse { @@ -42,6 +44,10 @@ export type TrafficParams = { sort_by?: string; sort_desc?: boolean; tariffs?: string; + nodes?: string; + statuses?: string; + start_date?: string; + end_date?: string; }; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -57,6 +63,10 @@ function buildCacheKey(params: TrafficParams): string { sb: params.sort_by ?? 'total_bytes', sd: params.sort_desc ?? true, t: params.tariffs ?? '', + n: params.nodes ?? '', + st: params.statuses ?? '', + sd2: params.start_date ?? '', + ed: params.end_date ?? '', }); } @@ -95,7 +105,15 @@ export const adminTrafficApi = { trafficCache.clear(); }, - exportCsv: async (data: { period: number }): Promise => { + exportCsv: async (data: { + period: number; + start_date?: string; + end_date?: string; + tariffs?: string; + nodes?: string; + statuses?: string; + search?: 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..c5d3152 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": "Dates", + "dateFrom": "From", + "dateTo": "To" }, "emailTemplates": { "title": "Email Templates", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2bf6548..aace454 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..7b04e20 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..e2bc9e5 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..27e3f03 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -52,6 +52,13 @@ const toBackendSortField = (columnId: string): string => { return columnId; }; +const formatDateForInput = (date: Date): string => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +}; + // ============ Icons ============ const SearchIcon = () => ( @@ -134,6 +141,32 @@ const ChevronDownIcon = () => ( ); +const CalendarIcon = () => ( + + + +); + +const ServerIcon = () => ( + + + +); + +const XIcon = () => ( + + + +); + // ============ Progress Bar ============ function ProgressBar({ loading }: { loading: boolean }) { @@ -181,6 +214,47 @@ function ProgressBar({ loading }: { loading: boolean }) { ); } +// ============ Checkbox helper ============ + +function Checkbox({ checked }: { checked: boolean }) { + return ( + + {checked && ( + + + + )} + + ); +} + +// ============ Status dot helper ============ + +const STATUS_COLORS: Record = { + active: 'bg-green-500', + trial: 'bg-yellow-500', + expired: 'bg-red-500', + disabled: 'bg-gray-500', +}; + +function StatusDot({ status }: { status: string }) { + return ( + + ); +} + // ============ Components ============ const PERIODS = [1, 3, 7, 14, 30] as const; @@ -189,31 +263,96 @@ function PeriodSelector({ value, onChange, label, + dateMode, + onToggleDateMode, }: { value: number; onChange: (v: number) => void; label: string; + dateMode: boolean; + onToggleDateMode: () => void; }) { const { t } = useTranslation(); return (
{label} -
- {PERIODS.map((p) => ( - - ))} -
+ {!dateMode && ( +
+ {PERIODS.map((p) => ( + + ))} +
+ )} + +
+ ); +} + +function DateRangePicker({ + startDate, + endDate, + onStartChange, + onEndChange, + onClose, +}: { + startDate: string; + endDate: string; + onStartChange: (v: string) => void; + onEndChange: (v: string) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const today = formatDateForInput(new Date()); + const minDate = formatDateForInput(new Date(Date.now() - 31 * 24 * 60 * 60 * 1000)); + + return ( +
+ + + onStartChange(e.target.value)} + min={minDate} + max={endDate || today} + 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" + /> + + onEndChange(e.target.value)} + min={startDate || minDate} + max={today} + 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" + /> +
); } @@ -284,62 +423,211 @@ function TariffFilter({ allSelected ? 'text-accent-400' : 'text-dark-300' }`} > - - {allSelected && ( - - - - )} - + {t('admin.trafficUsage.allTariffs')}
- {available.map((tariff) => { - const checked = selected.has(tariff); - return ( - - ); - })} + {available.map((tariff) => ( + + ))} +
+
+ )} + + ); +} + +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 = (nodeUuid: string) => { + const next = new Set(selected); + if (next.has(nodeUuid)) { + next.delete(nodeUuid); + } else { + next.add(nodeUuid); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((node) => ( + + ))} +
+
+ )} +
+ ); +} + +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 statusLabels: Record = { + active: t('admin.trafficUsage.statusActive'), + trial: t('admin.trafficUsage.statusTrial'), + expired: t('admin.trafficUsage.statusExpired'), + disabled: t('admin.trafficUsage.statusDisabled'), + }; + + 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()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((status) => ( + + ))}
)} @@ -356,13 +644,20 @@ export default function AdminTrafficUsage() { const [items, setItems] = useState([]); const [nodes, setNodes] = useState([]); + const [allNodes, setAllNodes] = 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 [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [selectedTariffs, setSelectedTariffs] = useState>(new Set()); + const [selectedNodes, setSelectedNodes] = useState>(new Set()); + const [selectedStatuses, setSelectedStatuses] = useState>(new Set()); + const [dateMode, setDateMode] = useState(false); + const [customStart, setCustomStart] = useState(''); + const [customEnd, setCustomEnd] = useState(''); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); const [exporting, setExporting] = useState(false); @@ -376,18 +671,36 @@ 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 nodesParam = selectedNodes.size > 0 ? [...selectedNodes].join(',') : undefined; + const statusesParam = selectedStatuses.size > 0 ? [...selectedStatuses].join(',') : undefined; const buildParams = useCallback( (): TrafficParams => ({ - period, + period: dateMode ? undefined : period, limit, offset, search: committedSearch || undefined, sort_by: sortBy, sort_desc: sortDesc, tariffs: tariffsParam, + nodes: nodesParam, + statuses: statusesParam, + start_date: dateMode && customStart ? customStart : undefined, + end_date: dateMode && customEnd ? customEnd : undefined, }), - [period, offset, committedSearch, sortBy, sortDesc, tariffsParam], + [ + period, + offset, + committedSearch, + sortBy, + sortDesc, + tariffsParam, + nodesParam, + statusesParam, + dateMode, + customStart, + customEnd, + ], ); const applyData = useCallback( @@ -396,11 +709,13 @@ export default function AdminTrafficUsage() { nodes: TrafficNodeInfo[]; total: number; available_tariffs: string[]; + available_statuses: string[]; }) => { setItems(data.items); setNodes(data.nodes); setTotal(data.total); setAvailableTariffs(data.available_tariffs); + setAvailableStatuses(data.available_statuses); }, [], ); @@ -409,6 +724,9 @@ export default function AdminTrafficUsage() { async (skipCache = false) => { const params = buildParams(); + // In date mode, require both dates + if (dateMode && (!customStart || !customEnd)) return; + // Check cache first — apply instantly without any loading state if (!skipCache) { const cached = adminTrafficApi.getCached(params); @@ -430,7 +748,7 @@ export default function AdminTrafficUsage() { setInitialLoading(false); } }, - [buildParams, applyData], + [buildParams, applyData, dateMode, customStart, customEnd], ); // Load on param change @@ -438,8 +756,36 @@ export default function AdminTrafficUsage() { loadData(); }, [loadData]); - // Prefetch adjacent periods in background + // Track all available nodes (before node filtering) — fetch once on mount with default params useEffect(() => { + const fetchAllNodes = async () => { + const params: TrafficParams = { + period: 30, + limit: 1, + offset: 0, + sort_by: 'total_bytes', + sort_desc: true, + }; + try { + const data = await adminTrafficApi.getTrafficUsage(params); + setAllNodes(data.nodes); + } catch { + // ignore + } + }; + fetchAllNodes(); + }, []); + + // Update allNodes when we get unfiltered response + useEffect(() => { + if (selectedNodes.size === 0 && nodes.length > 0) { + setAllNodes(nodes); + } + }, [nodes, selectedNodes.size]); + + // 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 +802,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 +820,15 @@ export default function AdminTrafficUsage() { const handleExport = async () => { try { setExporting(true); - await adminTrafficApi.exportCsv({ period }); + await adminTrafficApi.exportCsv({ + period, + start_date: dateMode && customStart ? customStart : undefined, + end_date: dateMode && customEnd ? customEnd : undefined, + tariffs: tariffsParam, + nodes: nodesParam, + statuses: statusesParam, + search: committedSearch || undefined, + }); setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); } catch { setToast({ message: t('admin.trafficUsage.exportError'), type: 'error' }); @@ -500,10 +853,41 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleNodeChange = (next: Set) => { + setSelectedNodes(next); + setOffset(0); + }; + + const handleStatusChange = (next: Set) => { + setSelectedStatuses(next); + setOffset(0); + }; + const handleRefresh = () => { loadData(true); }; + const handleToggleDateMode = () => { + if (!dateMode) { + const today = new Date(); + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + setCustomStart(formatDateForInput(weekAgo)); + setCustomEnd(formatDateForInput(today)); + } + setDateMode(!dateMode); + setOffset(0); + }; + + const handleCustomStartChange = (v: string) => { + setCustomStart(v); + setOffset(0); + }; + + const handleCustomEndChange = (v: string) => { + setCustomEnd(v); + setOffset(0); + }; + const columns = useMemo[]>(() => { const cols: ColumnDef[] = [ { @@ -544,15 +928,26 @@ export default function AdminTrafficUsage() { ), }, { - accessorKey: 'device_limit', + id: 'device_limit', + accessorFn: (row) => row.device_limit, header: t('admin.trafficUsage.devices'), enableSorting: true, size: 80, minSize: 60, meta: { align: 'center' as const }, - cell: ({ getValue }) => ( - {getValue() as number} - ), + cell: ({ row }) => { + const item = row.original; + const connected = item.connected_devices; + const limit = item.device_limit; + return ( + + 0 ? 'text-accent-400' : 'text-dark-500'}> + {connected} + + /{limit} + + ); + }, }, { accessorKey: 'traffic_limit_gb', @@ -667,16 +1062,34 @@ export default function AdminTrafficUsage() { {/* Controls */}
- + {dateMode ? ( + + ) : ( + + )} + +