@@ -104,6 +104,9 @@ import {
104104
105105type 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 >
0 commit comments