mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
feat(clients): filter the client list by clicking a summary stat card
Each card on the Clients page now toggles its status bucket as the sole filter, and the Clients card clears it. The bucket filters used to be wider than the card counts: "active" still included clients near depletion and "deactive" included disabled clients that had run out, so a filtered list could disagree with the number on the card. Both filters now reuse the summary expressions, and a test pins each card's count to the size of its filtered list.
This commit is contained in:
@@ -61,6 +61,23 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-stat {
|
||||||
|
margin: -4px -8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat:hover,
|
||||||
|
.summary-stat:focus-visible {
|
||||||
|
background: var(--ant-color-fill-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-stat.selected {
|
||||||
|
background: var(--ant-color-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { useLocation, useSearchParams } from 'react-router';
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SummaryStatProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
prefix: ReactNode;
|
||||||
|
emails?: string[];
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
|
||||||
|
const stat = (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={selected ? 'summary-stat selected' : 'summary-stat'}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={activateOnKey(onSelect)}
|
||||||
|
>
|
||||||
|
<Statistic title={title} value={String(value)} prefix={prefix} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!emails) return stat;
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
title={title}
|
||||||
|
open={value ? undefined : false}
|
||||||
|
content={<ClientEmailList emails={emails} total={value} />}
|
||||||
|
>
|
||||||
|
{stat}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||||
|
|
||||||
interface PersistedFilterState {
|
interface PersistedFilterState {
|
||||||
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
|
|||||||
const someSelected =
|
const someSelected =
|
||||||
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||||
|
|
||||||
|
const isOnlyBucket = (bucket: string) =>
|
||||||
|
filters.buckets.length === 1 && filters.buckets[0] === bucket;
|
||||||
|
|
||||||
|
// Clicking the card that is already the sole status filter clears it again.
|
||||||
|
function selectBucket(bucket: string | null) {
|
||||||
|
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
|
||||||
|
setFilters({ ...filters, buckets });
|
||||||
|
}
|
||||||
|
|
||||||
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
||||||
if (key === 'expiryFrom' || key === 'expiryTo') {
|
if (key === 'expiryFrom' || key === 'expiryTo') {
|
||||||
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
||||||
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
|
|||||||
<Card size="small" hoverable className="summary-card">
|
<Card size="small" hoverable className="summary-card">
|
||||||
<Row gutter={[16, 12]}>
|
<Row gutter={[16, 12]}>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Statistic
|
<SummaryStat
|
||||||
title={t('clients')}
|
title={t('clients')}
|
||||||
value={String(summary.total)}
|
value={summary.total}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined />}
|
||||||
|
onSelect={() => selectBucket(null)}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('online')}
|
title={t('online')}
|
||||||
open={summary.onlineCount ? undefined : false}
|
value={summary.onlineCount}
|
||||||
content={
|
emails={summary.online}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-blue" />}
|
||||||
emails={summary.online}
|
selected={isOnlyBucket('online')}
|
||||||
total={summary.onlineCount}
|
onSelect={() => selectBucket('online')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('online')}
|
|
||||||
value={String(summary.onlineCount)}
|
|
||||||
prefix={<span className="dot dot-blue" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('depleted')}
|
title={t('depleted')}
|
||||||
open={summary.depletedCount ? undefined : false}
|
value={summary.depletedCount}
|
||||||
content={
|
emails={summary.depleted}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-red" />}
|
||||||
emails={summary.depleted}
|
selected={isOnlyBucket('depleted')}
|
||||||
total={summary.depletedCount}
|
onSelect={() => selectBucket('depleted')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('depleted')}
|
|
||||||
value={String(summary.depletedCount)}
|
|
||||||
prefix={<span className="dot dot-red" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('depletingSoon')}
|
title={t('depletingSoon')}
|
||||||
open={summary.expiringCount ? undefined : false}
|
value={summary.expiringCount}
|
||||||
content={
|
emails={summary.expiring}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-orange" />}
|
||||||
emails={summary.expiring}
|
selected={isOnlyBucket('expiring')}
|
||||||
total={summary.expiringCount}
|
onSelect={() => selectBucket('expiring')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('depletingSoon')}
|
|
||||||
value={String(summary.expiringCount)}
|
|
||||||
prefix={<span className="dot dot-orange" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Popover
|
<SummaryStat
|
||||||
title={t('disabled')}
|
title={t('disabled')}
|
||||||
open={summary.deactiveCount ? undefined : false}
|
value={summary.deactiveCount}
|
||||||
content={
|
emails={summary.deactive}
|
||||||
<ClientEmailList
|
prefix={<span className="dot dot-gray" />}
|
||||||
emails={summary.deactive}
|
selected={isOnlyBucket('deactive')}
|
||||||
total={summary.deactiveCount}
|
onSelect={() => selectBucket('deactive')}
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={t('disabled')}
|
|
||||||
value={String(summary.deactiveCount)}
|
|
||||||
prefix={<span className="dot dot-gray" />}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={4}>
|
<Col xs={12} sm={8} md={4}>
|
||||||
<Statistic
|
<SummaryStat
|
||||||
title={t('subscription.active')}
|
title={t('subscription.active')}
|
||||||
value={String(summary.active)}
|
value={summary.active}
|
||||||
prefix={<span className="dot dot-green" />}
|
prefix={<span className="dot dot-green" />}
|
||||||
|
selected={isOnlyBucket('active')}
|
||||||
|
onSelect={() => selectBucket('active')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -195,10 +195,9 @@ func (q clientQuery) activeExpr() string {
|
|||||||
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
|
// deactiveExpr leaves a disabled client that also ran out to depleted, so the
|
||||||
// client that also ran out counts once, under depleted, so the stat cards add
|
// stat cards add up to the total and each card's filter lists what it counts.
|
||||||
// up to the client total.
|
func (q clientQuery) deactiveExpr() string {
|
||||||
func (q clientQuery) summaryDeactiveExpr() string {
|
|
||||||
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,9 +274,9 @@ func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
|
|||||||
for _, b := range buckets {
|
for _, b := range buckets {
|
||||||
switch b {
|
switch b {
|
||||||
case "active":
|
case "active":
|
||||||
conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
|
conds = append(conds, q.activeExpr())
|
||||||
case "deactive":
|
case "deactive":
|
||||||
conds = append(conds, "(NOT "+sqlClientEnabled+")")
|
conds = append(conds, q.deactiveExpr())
|
||||||
case "depleted":
|
case "depleted":
|
||||||
conds = append(conds, q.depletedExpr())
|
conds = append(conds, q.depletedExpr())
|
||||||
case "expiring":
|
case "expiring":
|
||||||
@@ -490,7 +489,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
|
|||||||
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
"COALESCE(SUM(CASE WHEN " + q.activeExpr() + " THEN 1 ELSE 0 END), 0) AS active," +
|
||||||
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
" COALESCE(SUM(CASE WHEN " + q.depletedExpr() + " THEN 1 ELSE 0 END), 0) AS depleted," +
|
||||||
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
" COALESCE(SUM(CASE WHEN " + q.expiringExpr() + " THEN 1 ELSE 0 END), 0) AS expiring," +
|
||||||
" COALESCE(SUM(CASE WHEN " + q.summaryDeactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
" COALESCE(SUM(CASE WHEN " + q.deactiveExpr() + " THEN 1 ELSE 0 END), 0) AS deactive",
|
||||||
).Scan(&counts).Error; err != nil {
|
).Scan(&counts).Error; err != nil {
|
||||||
return s, err
|
return s, err
|
||||||
}
|
}
|
||||||
@@ -506,7 +505,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
|
|||||||
}{
|
}{
|
||||||
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
|
||||||
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
|
||||||
{q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
|
{q.deactiveExpr(), s.DeactiveCount, &s.Deactive},
|
||||||
}
|
}
|
||||||
for _, b := range buckets {
|
for _, b := range buckets {
|
||||||
// The counter already says the bucket is empty, so skip the scan that
|
// The counter already says the bucket is empty, so skip the scan that
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ func TestListPagedFilters(t *testing.T) {
|
|||||||
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
|
want: []string{"charlie@x", "delta@x", "foxtrot@x"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deactive bucket is every disabled client",
|
name: "deactive bucket leaves a disabled client that ran out to depleted",
|
||||||
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
params: ClientPageParams{PageSize: 50, Filter: "deactive"},
|
||||||
want: []string{"echo@x", "foxtrot@x"},
|
want: []string{"echo@x"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "expiring bucket covers near expiry and near quota",
|
name: "expiring bucket covers near expiry and near quota",
|
||||||
@@ -167,9 +167,9 @@ func TestListPagedFilters(t *testing.T) {
|
|||||||
want: []string{"golf@x", "hotel@x"},
|
want: []string{"golf@x", "hotel@x"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "active bucket keeps enabled clients that still have room",
|
name: "active bucket leaves clients near depletion to expiring",
|
||||||
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
params: ClientPageParams{PageSize: 50, Filter: "active"},
|
||||||
want: []string{"alpha@x", "bravo@x", "golf@x", "hotel@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
want: []string{"alpha@x", "bravo@x", "india@x", "juliet@x", "kilo_1@x", "kilo1@x"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "buckets are ORed",
|
name: "buckets are ORed",
|
||||||
@@ -477,6 +477,19 @@ func TestListPagedSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("clicking a stat card filters to exactly the clients it counts", func(t *testing.T) {
|
||||||
|
cards := map[string]int{"active": s.Active, "depleted": s.DepletedCount, "expiring": s.ExpiringCount, "deactive": s.DeactiveCount}
|
||||||
|
for bucket, count := range cards {
|
||||||
|
page, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{PageSize: 50, Filter: bucket})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPaged(%s): %v", bucket, err)
|
||||||
|
}
|
||||||
|
if page.Filtered != count {
|
||||||
|
t.Fatalf("filter %q matched %d clients, card counts %d", bucket, page.Filtered, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
|
t.Run("bucket lists carry the matching emails", func(t *testing.T) {
|
||||||
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
|
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
|
||||||
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
|
t.Fatalf("depleted = %v, want %v", s.Depleted, want)
|
||||||
|
|||||||
Reference in New Issue
Block a user