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)