Skip to content

Commit 5922181

Browse files
os-zhuangclaude
andcommitted
feat(console): server-side pagination + search pushdown on the approvals inbox
Companion to the framework's listRequests paging (closes the silent 500-row truncation on the All tab): - Submitted/All tabs fetch PAGE_SIZE=50 windows with a server total, a "Load more" appender, and a loaded-of-total footer. - Free-text search on those tabs debounces (350ms) into the server's q param — record titles match via the payload-snapshot pushdown, so results come from the whole dataset, not the loaded page. - The status filter pushes down with the page query. - My Pending keeps instant client-side matching over its bounded personal queue (no behavior change). - 3 new approvalsInbox i18n keys across all ten locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9765756 commit 5922181

12 files changed

Lines changed: 117 additions & 9 deletions

File tree

apps/console/src/pages/system/ApprovalsInboxPage.tsx

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ import {
104104

105105
type TabKey = 'pending' | 'submitted' | 'all';
106106

107+
/** Server page size for the paginated tabs (submitted / all). */
108+
const PAGE_SIZE = 50;
109+
107110
/**
108111
* Semantic status colors (green = approved, amber = waiting, red = rejected,
109112
* slate = recalled) — variant-based Badge colors read as monochrome chrome,
@@ -315,6 +318,9 @@ export function ApprovalsInboxPage() {
315318
const [tab, setTab] = useState<TabKey>('pending');
316319
const [rows, setRows] = useState<ApprovalRequestRow[]>([]);
317320
const [loading, setLoading] = useState(false);
321+
const [loadingMore, setLoadingMore] = useState(false);
322+
/** Unwindowed total on the paginated tabs (null = unpaginated tab). */
323+
const [total, setTotal] = useState<number | null>(null);
318324
const [error, setError] = useState<string | null>(null);
319325
/** "My pending" count, independent of the active tab (badge + bell parity). */
320326
const [myPendingCount, setMyPendingCount] = useState(0);
@@ -327,8 +333,11 @@ export function ApprovalsInboxPage() {
327333
const [actorOverride, setActorOverride] = useState('');
328334
const [submitting, setSubmitting] = useState<'approve' | 'reject' | 'recall' | null>(null);
329335

330-
// Search + filters (client-side; lists are capped server-side)
336+
// Search + filters. On the paginated tabs (submitted/all) the free-text
337+
// query is debounced and pushed to the server; the pending tab keeps
338+
// instant client-side matching over its (bounded) personal queue.
331339
const [query, setQuery] = useState('');
340+
const [serverQuery, setServerQuery] = useState('');
332341
const [processFilter, setProcessFilter] = useState<string>('all');
333342
const [objectFilter, setObjectFilter] = useState<string>('all');
334343
const [statusFilter, setStatusFilter] = useState<string>('all');
@@ -353,6 +362,12 @@ export function ApprovalsInboxPage() {
353362
// Keyboard row focus
354363
const [focusIndex, setFocusIndex] = useState<number>(-1);
355364

365+
useEffect(() => {
366+
if (tab === 'pending') return;
367+
const t = window.setTimeout(() => setServerQuery(query), 350);
368+
return () => window.clearTimeout(t);
369+
}, [query, tab]);
370+
356371
const load = useCallback(async () => {
357372
setLoading(true);
358373
setError(null);
@@ -364,14 +379,28 @@ export function ApprovalsInboxPage() {
364379
? (await approvalsApi.listRequests({ status: 'pending', approverId: identities })).data
365380
: [];
366381
setMyPendingCount(requests.length);
382+
setTotal(null);
367383
} else {
384+
const pageParams = {
385+
q: serverQuery || undefined,
386+
status: statusFilter !== 'all' ? statusFilter : undefined,
387+
limit: PAGE_SIZE,
388+
offset: 0,
389+
};
368390
if (tab === 'submitted') {
369391
const submitterId = user?.id;
370-
requests = submitterId
371-
? (await approvalsApi.listRequests({ submitterId })).data
372-
: [];
392+
if (submitterId) {
393+
const res = await approvalsApi.listRequests({ submitterId, ...pageParams });
394+
requests = res.data;
395+
setTotal(res.total ?? res.data.length);
396+
} else {
397+
requests = [];
398+
setTotal(0);
399+
}
373400
} else {
374-
requests = (await approvalsApi.listRequests({})).data;
401+
const res = await approvalsApi.listRequests(pageParams);
402+
requests = res.data;
403+
setTotal(res.total ?? res.data.length);
375404
}
376405
// Keep the badge honest while browsing other tabs.
377406
if (identities.length) {
@@ -389,10 +418,34 @@ export function ApprovalsInboxPage() {
389418
} finally {
390419
setLoading(false);
391420
}
392-
}, [tab, identities, user?.id]);
421+
}, [tab, identities, user?.id, serverQuery, statusFilter]);
393422

394423
useEffect(() => { void load(); }, [load]);
395424

425+
/** Append the next server page (paginated tabs only). */
426+
const loadMore = useCallback(async () => {
427+
if (tab === 'pending' || loadingMore) return;
428+
setLoadingMore(true);
429+
try {
430+
const res = await approvalsApi.listRequests({
431+
submitterId: tab === 'submitted' ? user?.id ?? undefined : undefined,
432+
q: serverQuery || undefined,
433+
status: statusFilter !== 'all' ? statusFilter : undefined,
434+
limit: PAGE_SIZE,
435+
offset: rows.length,
436+
});
437+
setRows(prev => {
438+
const seen = new Set(prev.map(r => r.id));
439+
return [...prev, ...res.data.filter(r => !seen.has(r.id))];
440+
});
441+
if (res.total != null) setTotal(res.total);
442+
} catch (err: any) {
443+
toast.error(humanizeError(err, tr('loadFailed', 'Failed to load request')));
444+
} finally {
445+
setLoadingMore(false);
446+
}
447+
}, [tab, loadingMore, user?.id, serverQuery, statusFilter, rows.length, humanizeError, tr]);
448+
396449
const openDrawer = useCallback(async (id: string) => {
397450
setSelectedId(id);
398451
setDrawerLoading(true);
@@ -615,6 +668,10 @@ export function ApprovalsInboxPage() {
615668
if (processFilter !== 'all' && processLabel(r) !== processFilter) return false;
616669
if (objectFilter !== 'all' && r.object_name !== objectFilter) return false;
617670
if (statusFilter !== 'all' && r.status !== statusFilter) return false;
671+
// Paginated tabs: the server already applied the free-text query
672+
// (incl. record titles via the payload snapshot) — re-filtering here
673+
// against a narrower client haystack would drop valid matches.
674+
if (tab !== 'pending') return true;
618675
if (!q) return true;
619676
const hay = [
620677
r.process_name, r.process_label, r.step_label, r.object_name,
@@ -623,7 +680,7 @@ export function ApprovalsInboxPage() {
623680
].filter(Boolean).join(' ').toLowerCase();
624681
return hay.includes(q);
625682
});
626-
}, [rows, query, processFilter, objectFilter, statusFilter]);
683+
}, [rows, query, processFilter, objectFilter, statusFilter, tab]);
627684
/** Position of the open request within the visible list (drawer prev/next). */
628685
const drawerIndex = useMemo(
629686
() => (selectedId ? filteredRows.findIndex(r => r.id === selectedId) : -1),
@@ -934,7 +991,7 @@ export function ApprovalsInboxPage() {
934991

935992
<TabsContent value={tab} className="mt-4 space-y-3">
936993
{/* Toolbar: search + filters */}
937-
{!loading && rows.length > 0 && (
994+
{!loading && (rows.length > 0 || (tab !== 'pending' && (serverQuery || statusFilter !== 'all'))) && (
938995
<div className="flex flex-wrap items-center gap-2">
939996
<div className="relative flex-1 min-w-[200px] max-w-sm">
940997
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
@@ -1228,6 +1285,19 @@ export function ApprovalsInboxPage() {
12281285
))}
12291286
</div>
12301287

1288+
{tab !== 'pending' && total != null && (
1289+
<div className="flex items-center justify-center gap-3 py-1">
1290+
<span className="text-xs text-muted-foreground">
1291+
{tr('loadedOf', 'Loaded {{loaded}} of {{total}}', { loaded: rows.length, total })}
1292+
</span>
1293+
{rows.length < total && (
1294+
<Button size="sm" variant="outline" disabled={loadingMore} onClick={() => void loadMore()}>
1295+
{loadingMore ? tr('loadingMore', 'Loading…') : tr('loadMore', 'Load more')}
1296+
</Button>
1297+
)}
1298+
</div>
1299+
)}
1300+
12311301
<div className="hidden md:block text-[11px] text-muted-foreground">
12321302
{tr('keyboardHint', 'Keyboard: j/k move · Enter open · x select · a approve · r reject')}
12331303
</div>

apps/console/src/services/approvalsApi.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export const approvalsApi = {
110110
*/
111111
approverId?: string | string[];
112112
submitterId?: string;
113+
/** Free-text search, matched server-side (incl. record titles via the payload snapshot). */
114+
q?: string;
115+
/** Page window; when `limit` is set the response carries `total`. */
116+
limit?: number;
117+
offset?: number;
113118
} = {}) {
114119
const qs = new URLSearchParams();
115120
if (params.status) qs.set('status', params.status);
@@ -120,8 +125,11 @@ export const approvalsApi = {
120125
: params.approverId;
121126
if (approver) qs.set('approverId', approver);
122127
if (params.submitterId) qs.set('submitterId', params.submitterId);
128+
if (params.q?.trim()) qs.set('q', params.q.trim());
129+
if (params.limit != null) qs.set('limit', String(params.limit));
130+
if (params.offset != null) qs.set('offset', String(params.offset));
123131
const q = qs.toString();
124-
return call<{ data: ApprovalRequestRow[] }>(`/approvals/requests${q ? `?${q}` : ''}`);
132+
return call<{ data: ApprovalRequestRow[]; total?: number }>(`/approvals/requests${q ? `?${q}` : ''}`);
125133
},
126134

127135
async getRequest(id: string) {

packages/i18n/src/locales/ar.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const ar = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: 'تحميل المزيد',
1754+
loadingMore: 'جارٍ التحميل…',
1755+
loadedOf: 'تم تحميل {{loaded}} من {{total}}',
17531756
actEscalate: 'تصعيد SLA',
17541757
systemSlaActor: 'النظام (SLA)',
17551758
reassignBtn: 'إعادة إسناد',

packages/i18n/src/locales/de.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const de = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: 'Mehr laden',
1754+
loadingMore: 'Lädt…',
1755+
loadedOf: '{{loaded}} von {{total}} geladen',
17531756
actEscalate: 'SLA eskaliert',
17541757
systemSlaActor: 'System (SLA)',
17551758
reassignBtn: 'Übergeben',

packages/i18n/src/locales/en.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1905,6 +1905,9 @@ const en = {
19051905
},
19061906
},
19071907
approvalsInbox: {
1908+
loadMore: 'Load more',
1909+
loadingMore: 'Loading…',
1910+
loadedOf: 'Loaded {{loaded}} of {{total}}',
19081911
actEscalate: 'SLA escalated',
19091912
systemSlaActor: 'System (SLA)',
19101913
reassignBtn: 'Reassign',

packages/i18n/src/locales/es.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const es = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: 'Cargar más',
1754+
loadingMore: 'Cargando…',
1755+
loadedOf: '{{loaded}} de {{total}} cargadas',
17531756
actEscalate: 'SLA escalado',
17541757
systemSlaActor: 'Sistema (SLA)',
17551758
reassignBtn: 'Reasignar',

packages/i18n/src/locales/fr.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const fr = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: 'Charger plus',
1754+
loadingMore: 'Chargement…',
1755+
loadedOf: '{{loaded}} sur {{total}} chargées',
17531756
actEscalate: 'SLA escaladé',
17541757
systemSlaActor: 'Système (SLA)',
17551758
reassignBtn: 'Réattribuer',

packages/i18n/src/locales/ja.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const ja = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: 'さらに読み込む',
1754+
loadingMore: '読み込み中…',
1755+
loadedOf: '{{total}} 件中 {{loaded}} 件を表示',
17531756
actEscalate: 'SLAエスカレーション',
17541757
systemSlaActor: 'システム(SLA)',
17551758
reassignBtn: '引き継ぎ',

packages/i18n/src/locales/ko.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const ko = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: '더 보기',
1754+
loadingMore: '불러오는 중…',
1755+
loadedOf: '{{total}}개 중 {{loaded}}개 로드됨',
17531756
actEscalate: 'SLA 에스컬레이션',
17541757
systemSlaActor: '시스템(SLA)',
17551758
reassignBtn: '재할당',

packages/i18n/src/locales/pt.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1750,6 +1750,9 @@ const pt = {
17501750
},
17511751
},
17521752
approvalsInbox: {
1753+
loadMore: 'Carregar mais',
1754+
loadingMore: 'Carregando…',
1755+
loadedOf: '{{loaded}} de {{total}} carregadas',
17531756
actEscalate: 'SLA escalonado',
17541757
systemSlaActor: 'Sistema (SLA)',
17551758
reassignBtn: 'Reatribuir',

0 commit comments

Comments
 (0)