diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 4ecf16c..8af4dcd 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -111,6 +111,8 @@ export const adminTrafficApi = { tariffs?: string; statuses?: string; nodes?: string; + total_threshold_gb?: number; + node_threshold_gb?: number; }): Promise => { const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data); return response.data; diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 5a3266c..aff28b4 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -279,6 +279,16 @@ const StatusIcon = () => ( ); +const GlobeIcon = () => ( + + + +); + const ShieldIcon = () => ( ; + onChange: (next: Set) => void; +}) { + 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 = (code: string) => { + const next = new Set(selected); + if (next.has(code)) { + next.delete(code); + } else { + next.add(code); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map(({ code, count }) => { + const checked = selected.has(code); + return ( + + ); + })} +
+
+ )} +
+ ); +} + // ============ Risk Badge ============ function RiskBadge({ @@ -908,6 +1046,7 @@ export default function AdminTrafficUsage() { const [selectedTariffs, setSelectedTariffs] = useState>(new Set()); const [selectedStatuses, setSelectedStatuses] = useState>(new Set()); const [selectedNodes, setSelectedNodes] = useState>(new Set()); + const [selectedCountries, setSelectedCountries] = useState>(new Set()); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); const [exporting, setExporting] = useState(false); @@ -925,7 +1064,25 @@ export default function AdminTrafficUsage() { 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; + + // Merge country filter into node UUIDs so backend filters data consistently + const mergedNodesParam = useMemo(() => { + const countryUuids = + selectedCountries.size > 0 + ? new Set( + nodes.filter((n) => selectedCountries.has(n.country_code)).map((n) => n.node_uuid), + ) + : null; + const nodeUuids = selectedNodes.size > 0 ? new Set(selectedNodes) : null; + + let merged: Set | null = null; + if (countryUuids && nodeUuids) { + merged = new Set([...countryUuids].filter((id) => nodeUuids.has(id))); + } else { + merged = countryUuids || nodeUuids; + } + return merged && merged.size > 0 ? [...merged].join(',') : undefined; + }, [nodes, selectedCountries, selectedNodes]); const buildParams = useCallback((): TrafficParams => { const params: TrafficParams = { @@ -936,7 +1093,7 @@ export default function AdminTrafficUsage() { sort_desc: sortDesc, tariffs: tariffsParam, statuses: statusesParam, - nodes: nodesParam, + nodes: mergedNodesParam, }; if (dateMode && customStart && customEnd) { params.start_date = customStart; @@ -953,7 +1110,7 @@ export default function AdminTrafficUsage() { sortDesc, tariffsParam, statusesParam, - nodesParam, + mergedNodesParam, dateMode, customStart, customEnd, @@ -1045,6 +1202,8 @@ export default function AdminTrafficUsage() { tariffs?: string; statuses?: string; nodes?: string; + total_threshold_gb?: number; + node_threshold_gb?: number; } = { period }; if (dateMode && customStart && customEnd) { exportData.start_date = customStart; @@ -1052,7 +1211,11 @@ export default function AdminTrafficUsage() { } if (tariffsParam) exportData.tariffs = tariffsParam; if (statusesParam) exportData.statuses = statusesParam; - if (nodesParam) exportData.nodes = nodesParam; + if (mergedNodesParam) exportData.nodes = mergedNodesParam; + + if (totalThresholdNum > 0) exportData.total_threshold_gb = totalThresholdNum; + if (nodeThresholdNum > 0) exportData.node_threshold_gb = nodeThresholdNum; + await adminTrafficApi.exportCsv(exportData); setToast({ message: t('admin.trafficUsage.exportSuccess'), type: 'success' }); } catch { @@ -1116,15 +1279,36 @@ export default function AdminTrafficUsage() { setOffset(0); }; + const handleCountryChange = (next: Set) => { + setSelectedCountries(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 availableCountries = useMemo(() => { + const map = new Map(); + for (const n of nodes) { + if (n.country_code) map.set(n.country_code, (map.get(n.country_code) || 0) + 1); + } + return Array.from(map.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, count]) => ({ code, count })); + }, [nodes]); + + // When country/node filter is active, show only matching node columns + const displayNodes = useMemo(() => { + let filtered = nodes; + if (selectedCountries.size > 0) { + filtered = filtered.filter((n) => selectedCountries.has(n.country_code)); + } + if (selectedNodes.size > 0) { + filtered = filtered.filter((n) => selectedNodes.has(n.node_uuid)); + } + return filtered; + }, [nodes, selectedCountries, selectedNodes]); const totalThresholdNum = Math.max(0, parseFloat(totalThreshold) || 0); const hasTotalThreshold = totalThresholdNum > 0; @@ -1383,6 +1567,11 @@ export default function AdminTrafficUsage() { onChange={handleTariffChange} /> +