feat(clients): advanced filter drawer with multi-select state/protocol/inbound + expiry/usage ranges + auto-renew/tg/comment

The old toolbar exposed a single-value Search box, a single bucket
radio, and one Protocol + Inbound dropdown. Real panels with hundreds
of clients across mixed protocols need to slice by combinations
(active + expiring, two specific inbounds, expiring within a window,
high-usage subset, etc.), which the old shape couldn't express.

Backend ClientPageParams now accepts comma-separated multi values for
Filter / Protocol / Inbound and three new structured fields each:
expiry/usage ranges (ms / bytes), and three trinary toggles
(AutoRenew / HasTgID / HasComment with on/off, yes/no). The free-text
search predicate also picks up UUID / Password / Auth, which were
previously invisible to search.

Frontend introduces a dedicated FilterDrawer (multi-select for
state/protocol/inbound, DatePicker.RangePicker for expiry, paired
InputNumbers for usage, radio buttons for the trinary toggles) opened
from a single Filter button with a badge for the active count. Active
filters render as closable chips above the table so the user can drop
them one at a time, with a Clear-all next to the Filter button. The
search box stays inline and always visible.
This commit is contained in:
MHSanaei
2026-05-27 12:54:06 +02:00
parent 5eb80eca8e
commit 3675f88caf
7 changed files with 642 additions and 122 deletions
+25 -3
View File
@@ -42,11 +42,19 @@ export interface ClientQueryParams {
page: number;
pageSize: number;
search?: string;
// CSV strings — frontend joins arrays on ',', backend splits the same way.
filter?: string;
protocol?: string;
inbound?: number;
inbound?: string;
sort?: string;
order?: 'ascend' | 'descend';
expiryFrom?: number;
expiryTo?: number;
usageFrom?: number;
usageTo?: number;
autoRenew?: 'on' | 'off' | '';
hasTgId?: 'yes' | 'no' | '';
hasComment?: 'yes' | 'no' | '';
}
const DEFAULT_QUERY: ClientQueryParams = { page: 1, pageSize: 25 };
@@ -61,9 +69,16 @@ function buildQS(p: ClientQueryParams): string {
if (p.search) sp.set('search', p.search);
if (p.filter) sp.set('filter', p.filter);
if (p.protocol) sp.set('protocol', p.protocol);
if (p.inbound && p.inbound > 0) sp.set('inbound', String(p.inbound));
if (p.inbound) sp.set('inbound', p.inbound);
if (p.sort) sp.set('sort', p.sort);
if (p.order) sp.set('order', p.order);
if (p.expiryFrom && p.expiryFrom > 0) sp.set('expiryFrom', String(p.expiryFrom));
if (p.expiryTo && p.expiryTo > 0) sp.set('expiryTo', String(p.expiryTo));
if (p.usageFrom && p.usageFrom > 0) sp.set('usageFrom', String(p.usageFrom));
if (p.usageTo && p.usageTo > 0) sp.set('usageTo', String(p.usageTo));
if (p.autoRenew) sp.set('autoRenew', p.autoRenew);
if (p.hasTgId) sp.set('hasTgId', p.hasTgId);
if (p.hasComment) sp.set('hasComment', p.hasComment);
return sp.toString();
}
@@ -105,9 +120,16 @@ export function useClients() {
&& (prev.search ?? '') === (next.search ?? '')
&& (prev.filter ?? '') === (next.filter ?? '')
&& (prev.protocol ?? '') === (next.protocol ?? '')
&& (prev.inbound ?? 0) === (next.inbound ?? 0)
&& (prev.inbound ?? '') === (next.inbound ?? '')
&& (prev.sort ?? '') === (next.sort ?? '')
&& (prev.order ?? '') === (next.order ?? '')
&& (prev.expiryFrom ?? 0) === (next.expiryFrom ?? 0)
&& (prev.expiryTo ?? 0) === (next.expiryTo ?? 0)
&& (prev.usageFrom ?? 0) === (next.usageFrom ?? 0)
&& (prev.usageTo ?? 0) === (next.usageTo ?? 0)
&& (prev.autoRenew ?? '') === (next.autoRenew ?? '')
&& (prev.hasTgId ?? '') === (next.hasTgId ?? '')
&& (prev.hasComment ?? '') === (next.hasComment ?? '')
) return prev;
return next;
});
@@ -33,6 +33,20 @@
flex: 0 0 auto;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0 0 12px;
padding: 6px 8px;
background: var(--ant-color-fill-quaternary);
border-radius: 8px;
}
.filter-chips .ant-tag {
margin: 0;
}
.dot {
display: inline-block;
width: 8px;
+155 -93
View File
@@ -13,9 +13,7 @@ import {
Modal,
Pagination,
Popover,
Radio,
Row,
Select,
Space,
Spin,
Statistic,
@@ -58,18 +56,18 @@ const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
const ClientQrModal = lazy(() => import('./ClientQrModal'));
const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
const FilterDrawer = lazy(() => import('./FilterDrawer'));
import { emptyFilters, activeFilterCount } from './filters';
import type { ClientFilters } from './filters';
import './ClientsPage.css';
const FILTER_STATE_KEY = 'clientsFilterState';
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface FilterState {
enableFilter: boolean;
interface PersistedFilterState {
searchKey: string;
filterBy: string;
protocolFilter?: string;
inboundFilter?: number;
filters: ClientFilters;
}
const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
@@ -86,22 +84,30 @@ const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
};
const INBOUND_CHIP_LIMIT = 1;
function readFilterState(): FilterState {
function readFilterState(): PersistedFilterState {
try {
const raw = JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
const inb = typeof raw.inboundFilter === 'number' && raw.inboundFilter > 0 ? raw.inboundFilter : undefined;
const fromRaw = (raw.filters ?? {}) as Partial<ClientFilters>;
return {
enableFilter: !!raw.enableFilter,
searchKey: raw.searchKey || '',
filterBy: raw.filterBy || '',
protocolFilter: raw.protocolFilter,
inboundFilter: inb,
searchKey: typeof raw.searchKey === 'string' ? raw.searchKey : '',
filters: {
...emptyFilters(),
...fromRaw,
buckets: Array.isArray(fromRaw.buckets) ? fromRaw.buckets : [],
protocols: Array.isArray(fromRaw.protocols) ? fromRaw.protocols : [],
inboundIds: Array.isArray(fromRaw.inboundIds) ? fromRaw.inboundIds : [],
},
};
} catch {
return { enableFilter: false, searchKey: '', filterBy: '', protocolFilter: undefined, inboundFilter: undefined };
return { searchKey: '', filters: emptyFilters() };
}
}
function gbToBytes(gb: number | undefined): number {
if (!gb || gb <= 0) return 0;
return Math.round(gb * 1024 * 1024 * 1024);
}
export default function ClientsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -142,11 +148,9 @@ export default function ClientsPage() {
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const initial = readFilterState();
const [enableFilter, setEnableFilter] = useState(initial.enableFilter);
const [searchKey, setSearchKey] = useState(initial.searchKey);
const [filterBy, setFilterBy] = useState(initial.filterBy);
const [protocolFilter, setProtocolFilter] = useState<string | undefined>(initial.protocolFilter);
const [inboundFilter, setInboundFilter] = useState<number | undefined>(initial.inboundFilter);
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
@@ -157,10 +161,8 @@ export default function ClientsPage() {
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
useEffect(() => {
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
enableFilter, searchKey, filterBy, protocolFilter, inboundFilter,
}));
}, [enableFilter, searchKey, filterBy, protocolFilter, inboundFilter]);
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({ searchKey, filters }));
}, [searchKey, filters]);
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(searchKey), 300);
@@ -171,20 +173,29 @@ export default function ClientsPage() {
// Reset to page 1 whenever a filter or sort changes — otherwise an empty
// result set on a high page number leaves the user staring at "no clients".
setCurrentPage(1);
}, [debouncedSearch, enableFilter, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
}, [debouncedSearch, filters, sortColumn, sortOrder]);
useEffect(() => {
setQuery({
page: currentPage,
pageSize: tablePageSize,
search: enableFilter ? '' : debouncedSearch,
filter: enableFilter ? (filterBy || '') : '',
protocol: protocolFilter || '',
inbound: inboundFilter,
search: debouncedSearch,
filter: filters.buckets.join(','),
protocol: filters.protocols.join(','),
inbound: filters.inboundIds.join(','),
expiryFrom: filters.expiryFrom,
expiryTo: filters.expiryTo,
usageFrom: gbToBytes(filters.usageFromGB),
usageTo: gbToBytes(filters.usageToGB),
autoRenew: filters.autoRenew || undefined,
hasTgId: filters.hasTgId || undefined,
hasComment: filters.hasComment || undefined,
sort: sortColumn || undefined,
order: sortOrder || undefined,
});
}, [setQuery, currentPage, tablePageSize, enableFilter, debouncedSearch, filterBy, protocolFilter, inboundFilter, sortColumn, sortOrder]);
}, [setQuery, currentPage, tablePageSize, debouncedSearch, filters, sortColumn, sortOrder]);
const activeCount = activeFilterCount(filters);
useEffect(() => {
if (pageSize > 0) {
@@ -640,10 +651,16 @@ export default function ClientsPage() {
const allSelected = filteredClients.length > 0 && selectedRowKeys.length === filteredClients.length;
const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
function onToggleFilter(checked: boolean) {
setEnableFilter(checked);
if (checked) setSearchKey('');
else setFilterBy('');
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
if (key === 'expiryFrom' || key === 'expiryTo') {
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
return;
}
if (key === 'usageFromGB' || key === 'usageToGB') {
setFilters({ ...filters, usageFromGB: undefined, usageToGB: undefined });
return;
}
setFilters({ ...filters, [key]: emptyFilters()[key] });
}
return (
@@ -741,72 +758,96 @@ export default function ClientsPage() {
}
>
<div className={isMobile ? 'filter-bar mobile' : 'filter-bar'}>
<Switch
checked={enableFilter}
onChange={onToggleFilter}
checkedChildren={<SearchOutlined />}
unCheckedChildren={<FilterOutlined />}
<Input
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('pages.clients.searchPlaceholder')}
allowClear
prefix={<SearchOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }}
/>
{!enableFilter && (
<Input
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder={t('search')}
autoFocus
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 300 }}
/>
)}
{enableFilter && (
<Radio.Group
value={filterBy}
onChange={(e) => setFilterBy(e.target.value)}
optionType="button"
buttonStyle="solid"
<Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button
icon={<FilterOutlined />}
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'}
>
<Radio.Button value="">{t('none')}</Radio.Button>
<Radio.Button value="active">{t('subscription.active')}</Radio.Button>
<Radio.Button value="deactive">{t('disabled')}</Radio.Button>
<Radio.Button value="depleted">{t('depleted')}</Radio.Button>
<Radio.Button value="expiring">{t('depletingSoon')}</Radio.Button>
<Radio.Button value="online">{t('online')}</Radio.Button>
</Radio.Group>
{!isMobile && t('filter')}
</Button>
</Badge>
{activeCount > 0 && (
<Button
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilters(emptyFilters())}
>
{t('pages.clients.clearAllFilters')}
</Button>
)}
<Select
value={protocolFilter}
onChange={(v) => {
setProtocolFilter(v);
if (v && inboundFilter) {
const ib = inbounds.find((x) => x.id === inboundFilter);
if (!ib || ib.protocol !== v) setInboundFilter(undefined);
}
}}
allowClear
placeholder={t('pages.inbounds.protocol')}
size={isMobile ? 'small' : 'middle'}
style={{ width: 150 }}
options={protocolOptions.map((p) => ({ value: p, label: p }))}
/>
<Select
value={inboundFilter}
onChange={(v) => setInboundFilter(v)}
allowClear
showSearch={{ optionFilterProp: 'label' }}
placeholder={t('inbounds')}
size={isMobile ? 'small' : 'middle'}
style={{ minWidth: 160, maxWidth: 240 }}
options={inbounds
.filter((ib) => !protocolFilter || ib.protocol === protocolFilter)
.map((ib) => ({
value: ib.id,
label: ib.remark
? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
: `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
}))}
/>
</div>
{activeCount > 0 && (
<div className="filter-chips">
{filters.buckets.map((b) => (
<Tag
key={`b-${b}`}
closable
onClose={() => setFilters({ ...filters, buckets: filters.buckets.filter((x) => x !== b) })}
>
{bucketChipLabel(b, t)}
</Tag>
))}
{filters.protocols.map((p) => (
<Tag
key={`p-${p}`}
closable
color="blue"
onClose={() => setFilters({ ...filters, protocols: filters.protocols.filter((x) => x !== p) })}
>
{p}
</Tag>
))}
{filters.inboundIds.map((id) => (
<Tag
key={`i-${id}`}
closable
color="cyan"
onClose={() => setFilters({ ...filters, inboundIds: filters.inboundIds.filter((x) => x !== id) })}
>
{inboundLabel(id)}
</Tag>
))}
{(filters.expiryFrom || filters.expiryTo) && (
<Tag closable color="purple" onClose={() => clearOneFilter('expiryFrom')}>
{t('pages.clients.expiryTime')}: {filters.expiryFrom ? IntlUtil.formatDate(filters.expiryFrom, datepicker) : '…'}
{' → '}
{filters.expiryTo ? IntlUtil.formatDate(filters.expiryTo, datepicker) : '…'}
</Tag>
)}
{(filters.usageFromGB || filters.usageToGB) && (
<Tag closable color="orange" onClose={() => clearOneFilter('usageFromGB')}>
{t('pages.clients.traffic')}: {filters.usageFromGB ?? 0}{filters.usageToGB ? `${filters.usageToGB}` : '+'} GB
</Tag>
)}
{filters.autoRenew && (
<Tag closable color="gold" onClose={() => clearOneFilter('autoRenew')}>
{t('pages.clients.renew')}: {filters.autoRenew === 'on' ? t('enabled') : t('disabled')}
</Tag>
)}
{filters.hasTgId && (
<Tag closable onClose={() => clearOneFilter('hasTgId')}>
{t('pages.clients.telegramId')}: {filters.hasTgId === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
</Tag>
)}
{filters.hasComment && (
<Tag closable onClose={() => clearOneFilter('hasComment')}>
{t('pages.clients.comment')}: {filters.hasComment === 'yes' ? t('pages.clients.has') : t('pages.clients.hasNot')}
</Tag>
)}
</div>
)}
{!isMobile ? (
<Table<ClientRecord>
columns={columns}
@@ -993,7 +1034,28 @@ export default function ClientsPage() {
}}
/>
</LazyMount>
<LazyMount when={filterDrawerOpen}>
<FilterDrawer
open={filterDrawerOpen}
onOpenChange={setFilterDrawerOpen}
filters={filters}
onChange={setFilters}
inbounds={inbounds}
protocols={protocolOptions}
/>
</LazyMount>
</Layout>
</ConfigProvider>
);
}
function bucketChipLabel(b: string, t: (k: string) => string): string {
switch (b) {
case 'active': return t('subscription.active');
case 'expiring': return t('depletingSoon');
case 'depleted': return t('depleted');
case 'deactive': return t('disabled');
case 'online': return t('online');
default: return b;
}
}
+222
View File
@@ -0,0 +1,222 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Checkbox,
Col,
DatePicker,
Drawer,
Form,
InputNumber,
Radio,
Row,
Select,
Space,
Typography,
} from 'antd';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import type { InboundOption } from '@/hooks/useClients';
import { emptyFilters, type ClientFilters } from './filters';
interface FilterDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: ClientFilters;
onChange: (next: ClientFilters) => void;
inbounds: InboundOption[];
protocols: string[];
}
const BUCKET_KEYS = ['active', 'expiring', 'depleted', 'deactive', 'online'] as const;
export default function FilterDrawer({
open,
onOpenChange,
filters,
onChange,
inbounds,
protocols,
}: FilterDrawerProps) {
const { t } = useTranslation();
function patch<K extends keyof ClientFilters>(key: K, value: ClientFilters[K]) {
onChange({ ...filters, [key]: value });
}
const inboundOptions = useMemo(
() => inbounds.map((ib) => ({
value: ib.id,
label: ib.remark
? `${ib.remark} (${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''})`
: `#${ib.id} ${ib.protocol || ''}${ib.port ? `:${ib.port}` : ''}`,
})),
[inbounds],
);
const protocolOptions = useMemo(
() => protocols.map((p) => ({ value: p, label: p })),
[protocols],
);
const dateRange: [Dayjs | null, Dayjs | null] = [
filters.expiryFrom ? dayjs(filters.expiryFrom) : null,
filters.expiryTo ? dayjs(filters.expiryTo) : null,
];
return (
<Drawer
title={t('pages.clients.filterTitle')}
open={open}
onClose={() => onOpenChange(false)}
width={420}
destroyOnHidden
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button onClick={() => onChange(emptyFilters())} danger>
{t('pages.clients.clearAllFilters')}
</Button>
<Button type="primary" onClick={() => onOpenChange(false)}>
{t('done')}
</Button>
</div>
}
>
<Form layout="vertical">
<Form.Item label={<Typography.Text strong>{t('status')}</Typography.Text>}>
<Checkbox.Group
value={filters.buckets}
onChange={(v) => patch('buckets', v as string[])}
>
<Space direction="vertical">
{BUCKET_KEYS.map((k) => (
<Checkbox key={k} value={k}>
{bucketLabel(k, t)}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</Form.Item>
<Form.Item label={t('pages.inbounds.protocol')}>
<Select
mode="multiple"
value={filters.protocols}
onChange={(v) => patch('protocols', v as string[])}
options={protocolOptions}
placeholder={t('pages.inbounds.protocol')}
maxTagCount="responsive"
allowClear
/>
</Form.Item>
<Form.Item label={t('inbounds')}>
<Select
mode="multiple"
value={filters.inboundIds}
onChange={(v) => patch('inboundIds', v as number[])}
options={inboundOptions}
placeholder={t('inbounds')}
maxTagCount="responsive"
allowClear
showSearch
optionFilterProp="label"
listHeight={220}
/>
</Form.Item>
<Form.Item label={t('pages.clients.expiryTime')}>
<DatePicker.RangePicker
value={dateRange}
onChange={(range) => {
const from = range?.[0]?.startOf('day').valueOf();
const to = range?.[1]?.endOf('day').valueOf();
onChange({ ...filters, expiryFrom: from || undefined, expiryTo: to || undefined });
}}
style={{ width: '100%' }}
allowEmpty={[true, true]}
/>
</Form.Item>
<Form.Item label={`${t('pages.clients.traffic')} (GB)`}>
<Row gutter={8}>
<Col span={12}>
<InputNumber
value={filters.usageFromGB}
min={0}
step={1}
placeholder={t('from')}
style={{ width: '100%' }}
onChange={(v) => patch('usageFromGB', typeof v === 'number' ? v : undefined)}
/>
</Col>
<Col span={12}>
<InputNumber
value={filters.usageToGB}
min={0}
step={1}
placeholder={t('to')}
style={{ width: '100%' }}
onChange={(v) => patch('usageToGB', typeof v === 'number' ? v : undefined)}
/>
</Col>
</Row>
</Form.Item>
<Form.Item label={t('pages.clients.renew')}>
<Radio.Group
value={filters.autoRenew}
onChange={(e) => patch('autoRenew', e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{ value: '', label: t('all') },
{ value: 'on', label: t('enabled') },
{ value: 'off', label: t('disabled') },
]}
/>
</Form.Item>
<Form.Item label={t('pages.clients.telegramId')}>
<Radio.Group
value={filters.hasTgId}
onChange={(e) => patch('hasTgId', e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{ value: '', label: t('all') },
{ value: 'yes', label: t('pages.clients.has') },
{ value: 'no', label: t('pages.clients.hasNot') },
]}
/>
</Form.Item>
<Form.Item label={t('pages.clients.comment')}>
<Radio.Group
value={filters.hasComment}
onChange={(e) => patch('hasComment', e.target.value)}
optionType="button"
buttonStyle="solid"
options={[
{ value: '', label: t('all') },
{ value: 'yes', label: t('pages.clients.has') },
{ value: 'no', label: t('pages.clients.hasNot') },
]}
/>
</Form.Item>
</Form>
</Drawer>
);
}
function bucketLabel(key: string, t: (k: string) => string): string {
switch (key) {
case 'active': return t('subscription.active');
case 'expiring': return t('depletingSoon');
case 'depleted': return t('depleted');
case 'deactive': return t('disabled');
case 'online': return t('online');
default: return key;
}
}
+36
View File
@@ -0,0 +1,36 @@
export interface ClientFilters {
buckets: string[];
protocols: string[];
inboundIds: number[];
expiryFrom?: number;
expiryTo?: number;
usageFromGB?: number;
usageToGB?: number;
autoRenew: '' | 'on' | 'off';
hasTgId: '' | 'yes' | 'no';
hasComment: '' | 'yes' | 'no';
}
export function emptyFilters(): ClientFilters {
return {
buckets: [],
protocols: [],
inboundIds: [],
autoRenew: '',
hasTgId: '',
hasComment: '',
};
}
export function activeFilterCount(f: ClientFilters): number {
let n = 0;
if (f.buckets.length) n++;
if (f.protocols.length) n++;
if (f.inboundIds.length) n++;
if (f.expiryFrom || f.expiryTo) n++;
if (f.usageFromGB || f.usageToGB) n++;
if (f.autoRenew) n++;
if (f.hasTgId) n++;
if (f.hasComment) n++;
return n;
}