Przeglądaj źródła

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.
MHSanaei 9 godzin temu
rodzic
commit
3675f88caf

+ 25 - 3
frontend/src/hooks/useClients.ts

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

+ 14 - 0
frontend/src/pages/clients/ClientsPage.css

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

+ 154 - 92
frontend/src/pages/clients/ClientsPage.tsx

@@ -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
+                        <Badge count={activeCount} size="small" offset={[-4, 4]}>
+                          <Button
+                            icon={<FilterOutlined />}
                             size={isMobile ? 'small' : 'middle'}
-                            style={{ maxWidth: 300 }}
-                          />
-                        )}
-                        {enableFilter && (
-                          <Radio.Group
-                            value={filterBy}
-                            onChange={(e) => setFilterBy(e.target.value)}
-                            optionType="button"
-                            buttonStyle="solid"
+                            onClick={() => setFilterDrawerOpen(true)}
+                            type={activeCount > 0 ? 'primary' : 'default'}
+                          >
+                            {!isMobile && t('filter')}
+                          </Button>
+                        </Badge>
+                        {activeCount > 0 && (
+                          <Button
                             size={isMobile ? 'small' : 'middle'}
+                            onClick={() => setFilters(emptyFilters())}
                           >
-                            <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>
+                            {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 - 0
frontend/src/pages/clients/FilterDrawer.tsx

@@ -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 - 0
frontend/src/pages/clients/filters.ts

@@ -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;
+}

+ 174 - 19
web/service/client.go

@@ -8,6 +8,7 @@ import (
 	"fmt"
 	"slices"
 	"sort"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -864,15 +865,28 @@ type ClientSlim struct {
 
 // ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
 // All fields are optional — the empty value means "no filter" / defaults.
+//
+// Filter / Protocol / Inbound accept either a single value or a comma-separated
+// list; matching is OR within a field and AND across fields. The numeric range
+// fields treat 0 as "unset" on the lower bound and 0 (or negative) as
+// "unbounded" on the upper bound.
 type ClientPageParams struct {
 	Page     int    `form:"page"`
 	PageSize int    `form:"pageSize"`
 	Search   string `form:"search"`
 	Filter   string `form:"filter"`
 	Protocol string `form:"protocol"`
-	Inbound  int    `form:"inbound"`
+	Inbound  string `form:"inbound"`
 	Sort     string `form:"sort"`
 	Order    string `form:"order"`
+
+	ExpiryFrom int64  `form:"expiryFrom"`
+	ExpiryTo   int64  `form:"expiryTo"`
+	UsageFrom  int64  `form:"usageFrom"`
+	UsageTo    int64  `form:"usageTo"`
+	AutoRenew  string `form:"autoRenew"`
+	HasTgID    string `form:"hasTgId"`
+	HasComment string `form:"hasComment"`
 }
 
 // ClientPageResponse is the shape returned by ListPaged. `Total` is the
@@ -931,8 +945,12 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		page = 1
 	}
 
+	protocols := parseCSVStrings(params.Protocol)
+	inboundIDs := parseCSVInts(params.Inbound)
+	buckets := parseCSVStrings(params.Filter)
+
 	var protocolByInbound map[int]string
-	if params.Protocol != "" {
+	if len(protocols) > 0 {
 		inbounds, err := inboundSvc.GetAllInbounds()
 		if err == nil {
 			protocolByInbound = make(map[int]string, len(inbounds))
@@ -968,13 +986,28 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
 		if needle != "" && !clientMatchesSearch(c, needle) {
 			continue
 		}
-		if params.Protocol != "" && !clientMatchesProtocol(c, params.Protocol, protocolByInbound) {
+		if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
+			continue
+		}
+		if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
+			continue
+		}
+		if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
+			continue
+		}
+		if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
+			continue
+		}
+		if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
 			continue
 		}
-		if params.Inbound > 0 && !clientMatchesInbound(c, params.Inbound) {
+		if !clientMatchesAutoRenew(c, params.AutoRenew) {
 			continue
 		}
-		if params.Filter != "" && !clientMatchesBucket(c, params.Filter, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
+		if !clientMatchesHasTgID(c, params.HasTgID) {
+			continue
+		}
+		if !clientMatchesHasComment(c, params.HasComment) {
 			continue
 		}
 		filtered = append(filtered, c)
@@ -1068,35 +1101,157 @@ func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
 	if needle == "" {
 		return true
 	}
-	if strings.Contains(strings.ToLower(c.Email), needle) {
-		return true
+	candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
+	for _, v := range candidates {
+		if v != "" && strings.Contains(strings.ToLower(v), needle) {
+			return true
+		}
 	}
-	if strings.Contains(strings.ToLower(c.SubID), needle) {
-		return true
+	return false
+}
+
+// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
+// and drops blanks. Returns nil when the input has no usable entries — the
+// caller can then skip the predicate entirely.
+func parseCSVStrings(raw string) []string {
+	if raw == "" {
+		return nil
 	}
-	if strings.Contains(strings.ToLower(c.Comment), needle) {
-		return true
+	parts := strings.Split(raw, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		s := strings.ToLower(strings.TrimSpace(p))
+		if s != "" {
+			out = append(out, s)
+		}
 	}
-	return false
+	if len(out) == 0 {
+		return nil
+	}
+	return out
 }
 
-func clientMatchesProtocol(c ClientWithAttachments, protocol string, byInbound map[int]string) bool {
-	if protocol == "" {
-		return true
+// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
+// non-positive entries are silently dropped.
+func parseCSVInts(raw string) []int {
+	if raw == "" {
+		return nil
 	}
+	parts := strings.Split(raw, ",")
+	out := make([]int, 0, len(parts))
+	for _, p := range parts {
+		s := strings.TrimSpace(p)
+		if s == "" {
+			continue
+		}
+		if n, err := strconv.Atoi(s); err == nil && n > 0 {
+			out = append(out, n)
+		}
+	}
+	if len(out) == 0 {
+		return nil
+	}
+	return out
+}
+
+func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
 	for _, id := range c.InboundIds {
-		if byInbound[id] == protocol {
+		p := byInbound[id]
+		if p == "" {
+			continue
+		}
+		if slices.Contains(protocols, strings.ToLower(p)) {
 			return true
 		}
 	}
 	return false
 }
 
-func clientMatchesInbound(c ClientWithAttachments, inboundId int) bool {
-	if inboundId <= 0 {
+func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
+	for _, id := range c.InboundIds {
+		if slices.Contains(inboundIds, id) {
+			return true
+		}
+	}
+	return false
+}
+
+func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
+	for _, b := range buckets {
+		if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
+			return true
+		}
+	}
+	return false
+}
+
+func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
+	if fromMs <= 0 && toMs <= 0 {
 		return true
 	}
-	return slices.Contains(c.InboundIds, inboundId)
+	// expiryTime of 0 means "never expires"; treat it as outside any bounded
+	// range so users filtering by date see only clients with concrete expiries.
+	if c.ExpiryTime == 0 {
+		return false
+	}
+	// Negative expiry is the "delayed start" sentinel; same treatment as never.
+	if c.ExpiryTime < 0 {
+		return false
+	}
+	if fromMs > 0 && c.ExpiryTime < fromMs {
+		return false
+	}
+	if toMs > 0 && c.ExpiryTime > toMs {
+		return false
+	}
+	return true
+}
+
+func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
+	if fromBytes <= 0 && toBytes <= 0 {
+		return true
+	}
+	used := int64(0)
+	if c.Traffic != nil {
+		used = c.Traffic.Up + c.Traffic.Down
+	}
+	if fromBytes > 0 && used < fromBytes {
+		return false
+	}
+	if toBytes > 0 && used > toBytes {
+		return false
+	}
+	return true
+}
+
+func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "on":
+		return c.Reset > 0
+	case "off":
+		return c.Reset <= 0
+	}
+	return true
+}
+
+func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "yes":
+		return c.TgID != 0
+	case "no":
+		return c.TgID == 0
+	}
+	return true
+}
+
+func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
+	switch strings.ToLower(strings.TrimSpace(mode)) {
+	case "yes":
+		return strings.TrimSpace(c.Comment) != ""
+	case "no":
+		return strings.TrimSpace(c.Comment) == ""
+	}
+	return true
 }
 
 func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {

+ 9 - 0
web/translation/en-US.json

@@ -18,6 +18,10 @@
   "protocol": "Protocol",
   "search": "Search",
   "filter": "Filter",
+  "all": "All",
+  "from": "From",
+  "to": "To",
+  "done": "Done",
   "loading": "Loading...",
   "refresh": "Refresh",
   "clear": "Clear",
@@ -454,6 +458,11 @@
       "days": "Day(s)",
       "renew": "Auto Renew",
       "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)",
+      "searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
+      "filterTitle": "Filter clients",
+      "clearAllFilters": "Clear all",
+      "has": "Has",
+      "hasNot": "Doesn't have",
       "title": "Clients",
       "actions": "Actions",
       "totalGB": "Total Sent/Received (GB)",