diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css
index b319214cd..17795ad03 100644
--- a/frontend/src/pages/clients/ClientsPage.css
+++ b/frontend/src/pages/clients/ClientsPage.css
@@ -61,6 +61,23 @@
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 {
display: inline-block;
width: 8px;
diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx
index 6b862c6ca..c53e1aff5 100644
--- a/frontend/src/pages/clients/ClientsPage.tsx
+++ b/frontend/src/pages/clients/ClientsPage.tsx
@@ -1,4 +1,5 @@
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { useTranslation } from 'react-i18next';
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 = (
+
+
+
+ );
+ if (!emails) return stat;
+ return (
+ }
+ >
+ {stat}
+
+ );
+}
+
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
interface PersistedFilterState {
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
const someSelected =
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(key: K) {
if (key === 'expiryFrom' || key === 'expiryTo') {
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
- }
+ onSelect={() => selectBucket(null)}
/>
-
- }
- >
- }
- />
-
+ value={summary.onlineCount}
+ emails={summary.online}
+ prefix={}
+ selected={isOnlyBucket('online')}
+ onSelect={() => selectBucket('online')}
+ />
-
- }
- >
- }
- />
-
+ value={summary.depletedCount}
+ emails={summary.depleted}
+ prefix={}
+ selected={isOnlyBucket('depleted')}
+ onSelect={() => selectBucket('depleted')}
+ />
-
- }
- >
- }
- />
-
+ value={summary.expiringCount}
+ emails={summary.expiring}
+ prefix={}
+ selected={isOnlyBucket('expiring')}
+ onSelect={() => selectBucket('expiring')}
+ />
-
- }
- >
- }
- />
-
+ value={summary.deactiveCount}
+ emails={summary.deactive}
+ prefix={}
+ selected={isOnlyBucket('deactive')}
+ onSelect={() => selectBucket('deactive')}
+ />
- }
+ selected={isOnlyBucket('active')}
+ onSelect={() => selectBucket('active')}
/>
diff --git a/internal/web/service/client_paging.go b/internal/web/service/client_paging.go
index 06fac9dd8..19ad394c3 100644
--- a/internal/web/service/client_paging.go
+++ b/internal/web/service/client_paging.go
@@ -195,10 +195,9 @@ func (q clientQuery) activeExpr() string {
return "(" + sqlClientEnabled + " AND NOT " + q.depletedExpr() + " AND NOT " + q.nearDepletionExpr() + ")"
}
-// summaryDeactiveExpr is narrower than the "deactive" bucket filter: a disabled
-// client that also ran out counts once, under depleted, so the stat cards add
-// up to the client total.
-func (q clientQuery) summaryDeactiveExpr() string {
+// deactiveExpr leaves a disabled client that also ran out to depleted, so the
+// stat cards add up to the total and each card's filter lists what it counts.
+func (q clientQuery) deactiveExpr() string {
return "(NOT " + sqlClientEnabled + " AND NOT " + q.depletedExpr() + ")"
}
@@ -275,9 +274,9 @@ func (q clientQuery) bucketCond(buckets, onlines []string) (string, []any) {
for _, b := range buckets {
switch b {
case "active":
- conds = append(conds, "("+sqlClientEnabled+" AND NOT "+q.depletedExpr()+")")
+ conds = append(conds, q.activeExpr())
case "deactive":
- conds = append(conds, "(NOT "+sqlClientEnabled+")")
+ conds = append(conds, q.deactiveExpr())
case "depleted":
conds = append(conds, q.depletedExpr())
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.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.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 {
return s, err
}
@@ -506,7 +505,7 @@ func (q clientQuery) summary(onlines []string, total int) (ClientsSummary, error
}{
{q.depletedExpr(), s.DepletedCount, &s.Depleted},
{q.expiringExpr(), s.ExpiringCount, &s.Expiring},
- {q.summaryDeactiveExpr(), s.DeactiveCount, &s.Deactive},
+ {q.deactiveExpr(), s.DeactiveCount, &s.Deactive},
}
for _, b := range buckets {
// The counter already says the bucket is empty, so skip the scan that
diff --git a/internal/web/service/client_paging_test.go b/internal/web/service/client_paging_test.go
index 310f028f2..aff9732e1 100644
--- a/internal/web/service/client_paging_test.go
+++ b/internal/web/service/client_paging_test.go
@@ -157,9 +157,9 @@ func TestListPagedFilters(t *testing.T) {
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"},
- want: []string{"echo@x", "foxtrot@x"},
+ want: []string{"echo@x"},
},
{
name: "expiring bucket covers near expiry and near quota",
@@ -167,9 +167,9 @@ func TestListPagedFilters(t *testing.T) {
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"},
- 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",
@@ -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) {
if want := []string{"charlie@x", "delta@x", "foxtrot@x"}; !slices.Equal(s.Depleted, want) {
t.Fatalf("depleted = %v, want %v", s.Depleted, want)