mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor(admin-traffic): extract 5 filter components
Third and final step in the AdminTrafficUsage decomp. Move the
filter dropdowns + PeriodSelector into
src/components/admin/trafficUsage/filters/:
- PeriodSelector.tsx (103 lines): fixed period tabs (1/3/7/14/30
days) + custom date-range mode. Exports the PERIODS constant
so the parent's prefetch effect iterates the same canonical
list.
- TariffFilter.tsx (133 lines): pop-over multi-select for tariff
names.
- StatusFilter.tsx (147 lines): pop-over multi-select for
subscription statuses. Exports STATUS_COLORS alongside.
- NodeFilter.tsx (135 lines): pop-over multi-select for traffic
nodes (with flag emoji + traffic-share numbers).
- CountryFilter.tsx (132 lines): pop-over multi-select for
source countries.
All five are pure controlled components — parent owns every
Set<string> filter state and feeds them via {available, selected,
onChange}. The parent imports them and drops 9 now-unused icon
imports (FilterIcon, ChevronDownIcon, ServerIcon, CalendarIcon,
StatusIcon, GlobeIcon — they live inside the filter files now).
AdminTrafficUsage.tsx: 1593 → 977 lines (-616).
Cumulative across the 3-step admin-traffic decomp:
1904 → 977 (-927, -49%).
This commit is contained in:
131
src/components/admin/trafficUsage/filters/CountryFilter.tsx
Normal file
131
src/components/admin/trafficUsage/filters/CountryFilter.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { ChevronDownIcon, GlobeIcon } from '../TrafficIcons';
|
||||||
|
import { getFlagEmoji } from '../trafficUsageHelpers';
|
||||||
|
|
||||||
|
export function CountryFilter({
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
available: { code: string; count: number }[];
|
||||||
|
selected: Set<string>;
|
||||||
|
onChange: (next: Set<string>) => void;
|
||||||
|
}) {
|
||||||
|
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 = (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 (
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GlobeIcon />
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
||||||
|
{activeCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-30 mt-1 w-48 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl sm:left-0 sm:right-auto">
|
||||||
|
<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>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mx-2 border-t border-dark-700" />
|
||||||
|
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{available.map(({ code, count }) => {
|
||||||
|
const checked = selected.has(code);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
onClick={() => toggle(code)}
|
||||||
|
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>
|
||||||
|
{getFlagEmoji(code)} {code.toUpperCase()}
|
||||||
|
<span className="ml-auto text-dark-500">{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/admin/trafficUsage/filters/NodeFilter.tsx
Normal file
134
src/components/admin/trafficUsage/filters/NodeFilter.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronDownIcon, ServerIcon } from '../TrafficIcons';
|
||||||
|
import { getFlagEmoji } from '../trafficUsageHelpers';
|
||||||
|
import type { TrafficNodeInfo } from '../../../../api/adminTraffic';
|
||||||
|
|
||||||
|
export function NodeFilter({
|
||||||
|
available,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
available: TrafficNodeInfo[];
|
||||||
|
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 = (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 (
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ServerIcon />
|
||||||
|
{t('admin.trafficUsage.nodes')}
|
||||||
|
{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-64 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.allNodes')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mx-2 border-t border-dark-700" />
|
||||||
|
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{available.map((node) => {
|
||||||
|
const checked = selected.has(node.node_uuid);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={node.node_uuid}
|
||||||
|
onClick={() => toggle(node.node_uuid)}
|
||||||
|
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>
|
||||||
|
{getFlagEmoji(node.country_code)} {node.node_name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/admin/trafficUsage/filters/PeriodSelector.tsx
Normal file
103
src/components/admin/trafficUsage/filters/PeriodSelector.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { CalendarIcon, XIcon } from '../TrafficIcons';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// PeriodSelector — switches between fixed period tabs (1/3/7/14/30
|
||||||
|
// days) and a free custom-date-range mode. Parent owns all state;
|
||||||
|
// component is fully controlled.
|
||||||
|
//
|
||||||
|
// PERIODS is re-exported so the parent's prefetch effect iterates
|
||||||
|
// the same canonical list.
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PERIODS = [1, 3, 7, 14, 30] as const;
|
||||||
|
|
||||||
|
export 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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CalendarIcon />
|
||||||
|
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customStart}
|
||||||
|
min={minDate}
|
||||||
|
max={customEnd || today}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateTo')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customEnd}
|
||||||
|
min={customStart || minDate}
|
||||||
|
max={today}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onToggleDateMode}
|
||||||
|
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
title={t('admin.trafficUsage.period')}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-dark-400">{label}</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onChange(p)}
|
||||||
|
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||||
|
value === p
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-800 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
{t('admin.trafficUsage.days')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggleDateMode}
|
||||||
|
className="rounded-lg border border-dark-700 bg-dark-800 p-1.5 text-dark-400 transition-colors hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
|
||||||
|
title={t('admin.trafficUsage.customDates')}
|
||||||
|
>
|
||||||
|
<CalendarIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/admin/trafficUsage/filters/StatusFilter.tsx
Normal file
146
src/components/admin/trafficUsage/filters/StatusFilter.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronDownIcon, StatusIcon } from '../TrafficIcons';
|
||||||
|
|
||||||
|
// Status colour pills shared with the StatusFilter dropdown.
|
||||||
|
export const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: 'bg-success-500',
|
||||||
|
trial: 'bg-warning-500',
|
||||||
|
expired: 'bg-error-500',
|
||||||
|
disabled: 'bg-dark-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusFilter({
|
||||||
|
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 = (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 (
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<StatusIcon />
|
||||||
|
{t('admin.trafficUsage.status')}
|
||||||
|
{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.allStatuses')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mx-2 border-t border-dark-700" />
|
||||||
|
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{available.map((s) => {
|
||||||
|
const checked = selected.has(s);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => toggle(s)}
|
||||||
|
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>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${STATUS_COLORS[s] || 'bg-dark-500'}`} />
|
||||||
|
{statusLabel(s)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/components/admin/trafficUsage/filters/TariffFilter.tsx
Normal file
132
src/components/admin/trafficUsage/filters/TariffFilter.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ChevronDownIcon, FilterIcon } from '../TrafficIcons';
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ import {
|
|||||||
formatGbPerDay,
|
formatGbPerDay,
|
||||||
} from '../components/admin/trafficUsage/trafficUsageHelpers';
|
} from '../components/admin/trafficUsage/trafficUsageHelpers';
|
||||||
import { RiskBadge } from '../components/admin/trafficUsage/RiskBadge';
|
import { RiskBadge } from '../components/admin/trafficUsage/RiskBadge';
|
||||||
|
import { PeriodSelector, PERIODS } from '../components/admin/trafficUsage/filters/PeriodSelector';
|
||||||
|
import { TariffFilter } from '../components/admin/trafficUsage/filters/TariffFilter';
|
||||||
|
import { StatusFilter } from '../components/admin/trafficUsage/filters/StatusFilter';
|
||||||
|
import { NodeFilter } from '../components/admin/trafficUsage/filters/NodeFilter';
|
||||||
|
import { CountryFilter } from '../components/admin/trafficUsage/filters/CountryFilter';
|
||||||
import {
|
import {
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
@@ -39,13 +44,7 @@ import {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
SortIcon,
|
SortIcon,
|
||||||
FilterIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ServerIcon,
|
|
||||||
CalendarIcon,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
StatusIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
ServerSmallIcon,
|
ServerSmallIcon,
|
||||||
} from '../components/admin/trafficUsage/TrafficIcons';
|
} from '../components/admin/trafficUsage/TrafficIcons';
|
||||||
@@ -103,625 +102,9 @@ function ProgressBar({ loading }: { loading: boolean }) {
|
|||||||
|
|
||||||
// ============ Components ============
|
// ============ Components ============
|
||||||
|
|
||||||
const PERIODS = [1, 3, 7, 14, 30] as const;
|
// (Filter components + PERIODS / STATUS_COLORS constants moved into
|
||||||
|
// ./trafficUsage/filters/{PeriodSelector,TariffFilter,StatusFilter,
|
||||||
function PeriodSelector({
|
// NodeFilter,CountryFilter}.tsx)
|
||||||
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 (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon />
|
|
||||||
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={customStart}
|
|
||||||
min={minDate}
|
|
||||||
max={customEnd || today}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateTo')}</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={customEnd}
|
|
||||||
min={customStart || minDate}
|
|
||||||
max={today}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={onToggleDateMode}
|
|
||||||
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
|
||||||
title={t('admin.trafficUsage.period')}
|
|
||||||
>
|
|
||||||
<XIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-dark-400">{label}</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{PERIODS.map((p) => (
|
|
||||||
<button
|
|
||||||
key={p}
|
|
||||||
onClick={() => onChange(p)}
|
|
||||||
className={`rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
|
|
||||||
value === p
|
|
||||||
? 'bg-accent-500 text-white'
|
|
||||||
: 'bg-dark-800 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
{t('admin.trafficUsage.days')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onToggleDateMode}
|
|
||||||
className="rounded-lg border border-dark-700 bg-dark-800 p-1.5 text-dark-400 transition-colors hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200"
|
|
||||||
title={t('admin.trafficUsage.customDates')}
|
|
||||||
>
|
|
||||||
<CalendarIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
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<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 = (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 (
|
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<StatusIcon />
|
|
||||||
{t('admin.trafficUsage.status')}
|
|
||||||
{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.allStatuses')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mx-2 border-t border-dark-700" />
|
|
||||||
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
{available.map((s) => {
|
|
||||||
const checked = selected.has(s);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={s}
|
|
||||||
onClick={() => toggle(s)}
|
|
||||||
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>
|
|
||||||
<span className={`h-2 w-2 rounded-full ${STATUS_COLORS[s] || 'bg-dark-500'}`} />
|
|
||||||
{statusLabel(s)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeFilter({
|
|
||||||
available,
|
|
||||||
selected,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
available: TrafficNodeInfo[];
|
|
||||||
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 = (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 (
|
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ServerIcon />
|
|
||||||
{t('admin.trafficUsage.nodes')}
|
|
||||||
{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-64 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.allNodes')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mx-2 border-t border-dark-700" />
|
|
||||||
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
{available.map((node) => {
|
|
||||||
const checked = selected.has(node.node_uuid);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={node.node_uuid}
|
|
||||||
onClick={() => toggle(node.node_uuid)}
|
|
||||||
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>
|
|
||||||
{getFlagEmoji(node.country_code)} {node.node_name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CountryFilter({
|
|
||||||
available,
|
|
||||||
selected,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
available: { code: string; count: number }[];
|
|
||||||
selected: Set<string>;
|
|
||||||
onChange: (next: Set<string>) => void;
|
|
||||||
}) {
|
|
||||||
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 = (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 (
|
|
||||||
<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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<GlobeIcon />
|
|
||||||
{activeCount > 0 && (
|
|
||||||
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
|
|
||||||
{activeCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ChevronDownIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="absolute right-0 top-full z-30 mt-1 w-48 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl sm:left-0 sm:right-auto">
|
|
||||||
<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>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mx-2 border-t border-dark-700" />
|
|
||||||
|
|
||||||
<div className="max-h-48 overflow-y-auto">
|
|
||||||
{available.map(({ code, count }) => {
|
|
||||||
const checked = selected.has(code);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={code}
|
|
||||||
onClick={() => toggle(code)}
|
|
||||||
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>
|
|
||||||
{getFlagEmoji(code)} {code.toUpperCase()}
|
|
||||||
<span className="ml-auto text-dark-500">{count}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// (RiskBadge moved into ./trafficUsage/RiskBadge.tsx)
|
// (RiskBadge moved into ./trafficUsage/RiskBadge.tsx)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user