@@ -4,17 +4,49 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
44import { Select , SelectContent , SelectGroup , SelectItem , SelectTrigger , SelectValue } from '@/components/ui/select'
55import { Badge } from '@/components/ui/badge'
66import { Popover , PopoverContent , PopoverTrigger } from '@/components/ui/popover'
7+ import { DropdownMenu , DropdownMenuContent , DropdownMenuItem , DropdownMenuTrigger , DropdownMenuSeparator , DropdownMenuLabel } from '@/components/ui/dropdown-menu'
78import useDirDetection from '@/hooks/use-dir-detection'
89import { cn } from '@/lib/utils'
910import { debounce } from 'es-toolkit'
10- import { RefreshCw , SearchIcon , Filter , X } from 'lucide-react'
11- import { useCallback , useState } from 'react'
11+ import { RefreshCw , SearchIcon , Filter , X , ArrowUpDown , User , Calendar , ChartPie , ChevronDown } from 'lucide-react'
12+ import { useState , useMemo } from 'react'
1213import { useTranslation } from 'react-i18next'
1314import { useGetUsers , UserStatus } from '@/service/api'
1415import { RefetchOptions } from '@tanstack/react-query'
1516import { LoaderCircle } from 'lucide-react'
1617import { UseFormReturn } from 'react-hook-form'
1718
19+ // Sort configuration to eliminate duplication
20+ const sortSections = [
21+ {
22+ key : 'username' ,
23+ icon : User ,
24+ label : 'username' ,
25+ items : [
26+ { value : 'username' , label : 'sort.username.asc' } ,
27+ { value : '-username' , label : 'sort.username.desc' } ,
28+ ] ,
29+ } ,
30+ {
31+ key : 'expire' ,
32+ icon : Calendar ,
33+ label : 'expireDate' ,
34+ items : [
35+ { value : 'expire' , label : 'sort.expire.oldest' } ,
36+ { value : '-expire' , label : 'sort.expire.newest' } ,
37+ ] ,
38+ } ,
39+ {
40+ key : 'usage' ,
41+ icon : ChartPie ,
42+ label : 'dataUsage' ,
43+ items : [
44+ { value : 'used_traffic' , label : 'sort.usage.low' } ,
45+ { value : '-used_traffic' , label : 'sort.usage.high' } ,
46+ ] ,
47+ } ,
48+ ] as const
49+
1850interface FiltersProps {
1951 filters : {
2052 search ?: string
@@ -25,13 +57,14 @@ interface FiltersProps {
2557 load_sub : boolean
2658 }
2759 onFilterChange : ( filters : Partial < FiltersProps [ 'filters' ] > ) => void
28- refetch ?: ( options ?: RefetchOptions ) => Promise < any >
60+ refetch ?: ( options ?: RefetchOptions ) => Promise < unknown >
2961 advanceSearchOnOpen : ( status : boolean ) => void
30- advanceSearchForm ?: UseFormReturn < any >
62+ advanceSearchForm ?: UseFormReturn < Record < string , unknown > >
3163 onClearAdvanceSearch ?: ( ) => void
64+ handleSort ?: ( column : string ) => void
3265}
3366
34- export const Filters = ( { filters, onFilterChange, refetch, advanceSearchOnOpen, advanceSearchForm, onClearAdvanceSearch } : FiltersProps ) => {
67+ export const Filters = ( { filters, onFilterChange, refetch, advanceSearchOnOpen, advanceSearchForm, onClearAdvanceSearch, handleSort } : FiltersProps ) => {
3568 const { t } = useTranslation ( )
3669 const dir = useDirDetection ( )
3770 const [ search , setSearch ] = useState ( filters . search || '' )
@@ -40,20 +73,22 @@ export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen,
4073 const handleRefetch = refetch || userQuery . refetch
4174
4275 // Ultra-fast debounced search function
43- const setSearchField = useCallback (
44- debounce ( ( value : string ) => {
45- onFilterChange ( {
46- search : value ,
47- offset : 0 , // Reset to first page when search is updated
48- } )
49- } , 25 ) , // Ultra-fast debounce
50- [ onFilterChange ] , // Recreate the debounced function when onFilterChange changes
76+ const debouncedFilterChange = useMemo (
77+ ( ) =>
78+ debounce ( ( value : string ) => {
79+ onFilterChange ( {
80+ search : value ,
81+ offset : 0 , // Reset to first page when search is updated
82+ } )
83+ } , 25 ) , // Ultra-fast debounce
84+ [ onFilterChange ] ,
5185 )
5286
5387 // Handle input change
5488 const handleSearchChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
55- setSearch ( e . target . value )
56- setSearchField ( e . target . value )
89+ const value = e . target . value
90+ setSearch ( value )
91+ debouncedFilterChange ( value )
5792 }
5893
5994 // Clear search field
@@ -83,18 +118,24 @@ export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen,
83118 // Check if any advance search filters are active
84119 const hasActiveAdvanceFilters = ( ) => {
85120 if ( ! advanceSearchForm ) return false
86- const values = advanceSearchForm . getValues ( )
87- return ( values . admin && values . admin . length > 0 ) || ( values . group && values . group . length > 0 ) || values . status !== '0'
121+ const values = advanceSearchForm . getValues ( ) as Record < string , unknown >
122+ const admin = values . admin as string [ ] | undefined
123+ const group = values . group as string [ ] | undefined
124+ const status = values . status as string | undefined
125+ return ( admin && admin . length > 0 ) || ( group && group . length > 0 ) || status !== '0'
88126 }
89127
90128 // Get the count of active advance filters
91129 const getActiveFiltersCount = ( ) => {
92130 if ( ! advanceSearchForm ) return 0
93- const values = advanceSearchForm . getValues ( )
131+ const values = advanceSearchForm . getValues ( ) as Record < string , unknown >
132+ const admin = values . admin as string [ ] | undefined
133+ const group = values . group as string [ ] | undefined
134+ const status = values . status as string | undefined
94135 let count = 0
95- if ( values . admin && values . admin . length > 0 ) count ++
96- if ( values . group && values . group . length > 0 ) count ++
97- if ( values . status !== '0' ) count ++
136+ if ( admin && admin . length > 0 ) count ++
137+ if ( group && group . length > 0 ) count ++
138+ if ( status !== '0' ) count ++
98139 return count
99140 }
100141
@@ -132,6 +173,46 @@ export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen,
132173 </ Popover >
133174 ) }
134175 </ div >
176+ { /* Sort Button */ }
177+ { handleSort && (
178+ < div className = "flex h-full items-center gap-1" >
179+ < DropdownMenu >
180+ < DropdownMenuTrigger asChild >
181+ < Button size = "icon-md" variant = "ghost" className = "relative flex items-center gap-2 border" aria-label = { t ( 'sortOptions' , { defaultValue : 'Sort Options' } ) } >
182+ < ArrowUpDown className = "h-4 w-4" />
183+ { filters . sort && filters . sort !== '-created_at' && < div className = "absolute -right-1 -top-1 h-2 w-2 rounded-full bg-primary" /> }
184+ </ Button >
185+ </ DropdownMenuTrigger >
186+ < DropdownMenuContent align = "end" className = "w-52 md:w-56" >
187+ { sortSections . map ( ( section , sectionIndex ) => (
188+ < div key = { section . key } >
189+ { /* Section Label */ }
190+ < DropdownMenuLabel className = "flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground md:gap-2 md:px-3 md:py-2" >
191+ < section . icon className = "h-3 w-3" />
192+ < span className = "text-xs md:text-sm" > { t ( section . label ) } </ span >
193+ </ DropdownMenuLabel >
194+
195+ { /* Section Items */ }
196+ { section . items . map ( item => (
197+ < DropdownMenuItem
198+ key = { item . value }
199+ onClick = { ( ) => handleSort && handleSort ( item . value ) }
200+ className = { `whitespace-nowrap px-2 py-1.5 text-xs md:px-3 md:py-2 ${ filters . sort === item . value ? 'bg-accent' : '' } ` }
201+ >
202+ < section . icon className = "mr-1.5 h-3 w-3 flex-shrink-0 md:mr-2 md:h-4 md:w-4" />
203+ < span className = "truncate" > { t ( item . label ) } </ span >
204+ { filters . sort === item . value && < ChevronDown className = { `ml-auto h-3 w-3 flex-shrink-0 md:h-4 md:w-4 ${ item . value . startsWith ( '-' ) ? '' : 'rotate-180' } ` } /> }
205+ </ DropdownMenuItem >
206+ ) ) }
207+
208+ { /* Add separator except for last section */ }
209+ { sectionIndex < sortSections . length - 1 && < DropdownMenuSeparator /> }
210+ </ div >
211+ ) ) }
212+ </ DropdownMenuContent >
213+ </ DropdownMenu >
214+ </ div >
215+ ) }
135216 { /* Refresh Button */ }
136217 < div className = "flex h-full items-center gap-2" >
137218 < Button size = "icon-md" onClick = { handleRefreshClick } variant = "ghost" className = "flex items-center gap-2 border" disabled = { isRefreshing } >
0 commit comments