From 0aaf58c8b8b30edfb1793881f4ed23bff28114f3 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Wed, 27 May 2026 09:45:29 +0300 Subject: [PATCH] refactor(admin-traffic): extract 5 filter components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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%). --- .../trafficUsage/filters/CountryFilter.tsx | 131 ++++ .../admin/trafficUsage/filters/NodeFilter.tsx | 134 ++++ .../trafficUsage/filters/PeriodSelector.tsx | 103 +++ .../trafficUsage/filters/StatusFilter.tsx | 146 ++++ .../trafficUsage/filters/TariffFilter.tsx | 132 ++++ src/pages/AdminTrafficUsage.tsx | 633 +----------------- 6 files changed, 654 insertions(+), 625 deletions(-) create mode 100644 src/components/admin/trafficUsage/filters/CountryFilter.tsx create mode 100644 src/components/admin/trafficUsage/filters/NodeFilter.tsx create mode 100644 src/components/admin/trafficUsage/filters/PeriodSelector.tsx create mode 100644 src/components/admin/trafficUsage/filters/StatusFilter.tsx create mode 100644 src/components/admin/trafficUsage/filters/TariffFilter.tsx diff --git a/src/components/admin/trafficUsage/filters/CountryFilter.tsx b/src/components/admin/trafficUsage/filters/CountryFilter.tsx new file mode 100644 index 0000000..10977f7 --- /dev/null +++ b/src/components/admin/trafficUsage/filters/CountryFilter.tsx @@ -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; + 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 ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/trafficUsage/filters/NodeFilter.tsx b/src/components/admin/trafficUsage/filters/NodeFilter.tsx new file mode 100644 index 0000000..c3cb2b4 --- /dev/null +++ b/src/components/admin/trafficUsage/filters/NodeFilter.tsx @@ -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; + onChange: (next: Set) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (available.length === 0) return null; + + const allSelected = selected.size === 0; + const activeCount = selected.size; + + const toggle = (uuid: string) => { + const next = new Set(selected); + if (next.has(uuid)) { + next.delete(uuid); + } else { + next.add(uuid); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((node) => { + const checked = selected.has(node.node_uuid); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx new file mode 100644 index 0000000..7b2c0c5 --- /dev/null +++ b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx @@ -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 ( +
+ + {t('admin.trafficUsage.dateFrom')} + onCustomStartChange(e.target.value)} + className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" + /> + {t('admin.trafficUsage.dateTo')} + onCustomEndChange(e.target.value)} + className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" + /> + +
+ ); + } + + return ( +
+ {label} +
+ {PERIODS.map((p) => ( + + ))} +
+ +
+ ); +} diff --git a/src/components/admin/trafficUsage/filters/StatusFilter.tsx b/src/components/admin/trafficUsage/filters/StatusFilter.tsx new file mode 100644 index 0000000..f87df56 --- /dev/null +++ b/src/components/admin/trafficUsage/filters/StatusFilter.tsx @@ -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 = { + 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; + onChange: (next: Set) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + if (available.length === 0) return null; + + const allSelected = selected.size === 0; + const activeCount = selected.size; + + const toggle = (status: string) => { + const next = new Set(selected); + if (next.has(status)) { + next.delete(status); + } else { + next.add(status); + } + onChange(next); + }; + + const selectAll = () => onChange(new Set()); + + const statusLabel = (s: string) => { + const key = `admin.trafficUsage.status${s.charAt(0).toUpperCase() + s.slice(1)}`; + return t(key, s); + }; + + return ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((s) => { + const checked = selected.has(s); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/trafficUsage/filters/TariffFilter.tsx b/src/components/admin/trafficUsage/filters/TariffFilter.tsx new file mode 100644 index 0000000..5f4173c --- /dev/null +++ b/src/components/admin/trafficUsage/filters/TariffFilter.tsx @@ -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; + 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 = (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 ( +
+ + + {open && ( +
+ + +
+ +
+ {available.map((tariff) => { + const checked = selected.has(tariff); + return ( + + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index f82a31b..84d90e9 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -32,6 +32,11 @@ import { formatGbPerDay, } from '../components/admin/trafficUsage/trafficUsageHelpers'; 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 { SearchIcon, ChevronLeftIcon, @@ -39,13 +44,7 @@ import { RefreshIcon, DownloadIcon, SortIcon, - FilterIcon, - ChevronDownIcon, - ServerIcon, - CalendarIcon, XIcon, - StatusIcon, - GlobeIcon, ShieldIcon, ServerSmallIcon, } from '../components/admin/trafficUsage/TrafficIcons'; @@ -103,625 +102,9 @@ function ProgressBar({ loading }: { loading: boolean }) { // ============ Components ============ -const PERIODS = [1, 3, 7, 14, 30] as const; - -function PeriodSelector({ - value, - onChange, - label, - dateMode, - customStart, - customEnd, - onToggleDateMode, - onCustomStartChange, - onCustomEndChange, -}: { - value: number; - onChange: (v: number) => void; - label: string; - dateMode: boolean; - customStart: string; - customEnd: string; - onToggleDateMode: () => void; - onCustomStartChange: (v: string) => void; - onCustomEndChange: (v: string) => void; -}) { - const { t } = useTranslation(); - - // Limit: last 31 days - const today = new Date().toISOString().split('T')[0]; - const minDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - - if (dateMode) { - return ( -
- - {t('admin.trafficUsage.dateFrom')} - onCustomStartChange(e.target.value)} - className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" - /> - {t('admin.trafficUsage.dateTo')} - onCustomEndChange(e.target.value)} - className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1 text-xs text-dark-200 focus:border-dark-600 focus:outline-none" - /> - -
- ); - } - - return ( -
- {label} -
- {PERIODS.map((p) => ( - - ))} -
- -
- ); -} - -function TariffFilter({ - available, - selected, - onChange, -}: { - available: string[]; - selected: Set; - onChange: (next: Set) => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - if (available.length === 0) return null; - - const allSelected = selected.size === 0; - const activeCount = selected.size; - - const toggle = (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 ( -
- - - {open && ( -
- - -
- -
- {available.map((tariff) => { - const checked = selected.has(tariff); - return ( - - ); - })} -
-
- )} -
- ); -} - -const STATUS_COLORS: Record = { - active: 'bg-success-500', - trial: 'bg-warning-500', - expired: 'bg-error-500', - disabled: 'bg-dark-500', -}; - -function StatusFilter({ - available, - selected, - onChange, -}: { - available: string[]; - selected: Set; - onChange: (next: Set) => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - if (available.length === 0) return null; - - const allSelected = selected.size === 0; - const activeCount = selected.size; - - const toggle = (status: string) => { - const next = new Set(selected); - if (next.has(status)) { - next.delete(status); - } else { - next.add(status); - } - onChange(next); - }; - - const selectAll = () => onChange(new Set()); - - const statusLabel = (s: string) => { - const key = `admin.trafficUsage.status${s.charAt(0).toUpperCase() + s.slice(1)}`; - return t(key, s); - }; - - return ( -
- - - {open && ( -
- - -
- -
- {available.map((s) => { - const checked = selected.has(s); - return ( - - ); - })} -
-
- )} -
- ); -} - -function NodeFilter({ - available, - selected, - onChange, -}: { - available: TrafficNodeInfo[]; - selected: Set; - onChange: (next: Set) => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); - - if (available.length === 0) return null; - - const allSelected = selected.size === 0; - const activeCount = selected.size; - - const toggle = (uuid: string) => { - const next = new Set(selected); - if (next.has(uuid)) { - next.delete(uuid); - } else { - next.add(uuid); - } - onChange(next); - }; - - const selectAll = () => onChange(new Set()); - - return ( -
- - - {open && ( -
- - -
- -
- {available.map((node) => { - const checked = selected.has(node.node_uuid); - return ( - - ); - })} -
-
- )} -
- ); -} - -function CountryFilter({ - available, - selected, - onChange, -}: { - available: { code: string; count: number }[]; - selected: Set; - 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 ( - - ); - })} -
-
- )} -
- ); -} +// (Filter components + PERIODS / STATUS_COLORS constants moved into +// ./trafficUsage/filters/{PeriodSelector,TariffFilter,StatusFilter, +// NodeFilter,CountryFilter}.tsx) // (RiskBadge moved into ./trafficUsage/RiskBadge.tsx)