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:
c0mrade
2026-05-27 09:45:29 +03:00
parent c94bd3d593
commit 0aaf58c8b8
6 changed files with 654 additions and 625 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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)