diff --git a/src/components/admin/UsersHeader.tsx b/src/components/admin/UsersHeader.tsx
new file mode 100644
index 0000000..5027fda
--- /dev/null
+++ b/src/components/admin/UsersHeader.tsx
@@ -0,0 +1,156 @@
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { Filter, RefreshCw, Search, SortAsc, SortDesc, X } from 'lucide-react'
+
+export type UserSortField = 'name' | 'email' | 'role' | 'credits' | 'created_at'
+export type SortOrder = 'asc' | 'desc'
+export type UserStatus = 'all' | 'active' | 'inactive'
+
+interface UsersHeaderProps {
+ searchQuery: string
+ onSearchChange: (query: string) => void
+ sortBy: UserSortField
+ onSortByChange: (sortBy: UserSortField) => void
+ sortOrder: SortOrder
+ onSortOrderChange: (order: SortOrder) => void
+ statusFilter: UserStatus
+ onStatusFilterChange: (status: UserStatus) => void
+ onRefresh: () => void
+ loading: boolean
+ error: string | null
+ userCount: number
+}
+
+export function UsersHeader({
+ searchQuery,
+ onSearchChange,
+ sortBy,
+ onSortByChange,
+ sortOrder,
+ onSortOrderChange,
+ statusFilter,
+ onStatusFilterChange,
+ onRefresh,
+ loading,
+ error,
+ userCount,
+}: UsersHeaderProps) {
+ return (
+ <>
+ {/* Header */}
+
+
+
User Management
+
+ Manage user accounts and permissions
+
+
+
+ {!loading && !error && (
+
+ {statusFilter !== 'all'
+ ? `${userCount} ${statusFilter} user${userCount !== 1 ? 's' : ''}`
+ : `${userCount} user${userCount !== 1 ? 's' : ''}`
+ }
+
+ )}
+
+
+ Refresh
+
+
+
+
+ {/* Search and Sort Controls */}
+
+
+
+
+ onSearchChange(e.target.value)}
+ className="pl-9 pr-9"
+ />
+ {searchQuery && (
+ onSearchChange('')}
+ className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
+ title="Clear search"
+ >
+
+
+ )}
+
+
+
+
+ onSortByChange(value as UserSortField)}
+ >
+
+
+
+
+ Name
+ Email
+ Role
+ Credits
+ Created Date
+
+
+
+ onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
+ title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
+ >
+ {sortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
+
+ onStatusFilterChange(value as UserStatus)}
+ >
+
+
+
+
+
+ All Status
+ Active
+ Inactive
+
+
+
+
+
+
+
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/src/components/admin/UsersLoadingStates.tsx b/src/components/admin/UsersLoadingStates.tsx
new file mode 100644
index 0000000..07d2805
--- /dev/null
+++ b/src/components/admin/UsersLoadingStates.tsx
@@ -0,0 +1,112 @@
+import { Button } from '@/components/ui/button'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { AlertCircle, RefreshCw, Users } from 'lucide-react'
+
+export function UsersLoading() {
+ return (
+
+
+
+
+
+ Name
+ Email
+ Role
+ Plan
+ Credits
+ Status
+ Actions
+
+
+
+ {Array.from({ length: 10 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+export function UsersError({ error, onRetry }: { error: string; onRetry: () => void }) {
+ return (
+
+
+
+
Failed to load users
+
+ {error}
+
+
+
+
+ Try again
+
+
+ )
+}
+
+export function UsersEmpty({ searchQuery, statusFilter }: {
+ searchQuery: string;
+ statusFilter: string;
+}) {
+ return (
+
+
+
+
+
+
+ {searchQuery || statusFilter !== 'all'
+ ? 'No users found'
+ : 'No users yet'
+ }
+
+
+ {searchQuery
+ ? `No users match "${searchQuery}"`
+ : statusFilter !== 'all'
+ ? `No ${statusFilter} users found`
+ : 'Users will appear here once they are added to the system'
+ }
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/admin/UsersTable.tsx b/src/components/admin/UsersTable.tsx
new file mode 100644
index 0000000..13cd5ee
--- /dev/null
+++ b/src/components/admin/UsersTable.tsx
@@ -0,0 +1,88 @@
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import type { User } from '@/types/auth'
+import { Edit, UserCheck, UserX } from 'lucide-react'
+
+interface UsersTableProps {
+ users: User[]
+ onEdit: (user: User) => void
+ onToggleStatus: (user: User) => void
+}
+
+export function UsersTable({ users, onEdit, onToggleStatus }: UsersTableProps) {
+ const getRoleBadge = (role: string) => {
+ return (
+
+ {role}
+
+ )
+ }
+
+ const getStatusBadge = (isActive: boolean) => {
+ return (
+
+ {isActive ? 'Active' : 'Inactive'}
+
+ )
+ }
+
+ return (
+
+
+
+
+ Name
+ Email
+ Role
+ Plan
+ Credits
+ Status
+ Actions
+
+
+
+ {users.map(user => (
+
+ {user.name}
+ {user.email}
+ {getRoleBadge(user.role)}
+ {user.plan.name}
+ {user.credits.toLocaleString()}
+ {getStatusBadge(user.is_active)}
+
+
+ onEdit(user)}
+ >
+
+
+ onToggleStatus(user)}
+ >
+ {user.is_active ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/lib/api/services/admin.ts b/src/lib/api/services/admin.ts
index 958ee8e..08271c6 100644
--- a/src/lib/api/services/admin.ts
+++ b/src/lib/api/services/admin.ts
@@ -23,6 +23,23 @@ export interface MessageResponse {
message: string
}
+export interface GetUsersParams {
+ page?: number
+ limit?: number
+ search?: string
+ sort_by?: string
+ sort_order?: string
+ status_filter?: string
+}
+
+export interface GetUsersResponse {
+ users: User[]
+ total: number
+ page: number
+ limit: number
+ total_pages: number
+}
+
export interface ScanResults {
added: number
updated: number
@@ -54,10 +71,32 @@ export interface NormalizationResponse {
}
export class AdminService {
- async listUsers(limit = 100, offset = 0): Promise {
- return apiClient.get(`/api/v1/admin/users/`, {
- params: { limit, offset },
- })
+ async listUsers(params?: GetUsersParams): Promise {
+ const searchParams = new URLSearchParams()
+
+ if (params?.page) {
+ searchParams.append('page', params.page.toString())
+ }
+ if (params?.limit) {
+ searchParams.append('limit', params.limit.toString())
+ }
+ if (params?.search) {
+ searchParams.append('search', params.search)
+ }
+ if (params?.sort_by) {
+ searchParams.append('sort_by', params.sort_by)
+ }
+ if (params?.sort_order) {
+ searchParams.append('sort_order', params.sort_order)
+ }
+ if (params?.status_filter) {
+ searchParams.append('status_filter', params.status_filter)
+ }
+
+ const url = searchParams.toString()
+ ? `/api/v1/admin/users/?${searchParams.toString()}`
+ : '/api/v1/admin/users/'
+ return apiClient.get(url)
}
async getUser(userId: number): Promise {
diff --git a/src/pages/admin/UsersPage.tsx b/src/pages/admin/UsersPage.tsx
index 02e03a4..46203cc 100644
--- a/src/pages/admin/UsersPage.tsx
+++ b/src/pages/admin/UsersPage.tsx
@@ -1,7 +1,14 @@
import { AppLayout } from '@/components/AppLayout'
+import { AppPagination } from '@/components/AppPagination'
+import { UsersHeader, type UserSortField, type SortOrder, type UserStatus } from '@/components/admin/UsersHeader'
+import {
+ UsersEmpty,
+ UsersError,
+ UsersLoading,
+} from '@/components/admin/UsersLoadingStates'
+import { UsersTable } from '@/components/admin/UsersTable'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -12,20 +19,10 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Sheet, SheetContent } from '@/components/ui/sheet'
-import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
import { type Plan, adminService } from '@/lib/api/services/admin'
import type { User } from '@/types/auth'
import { formatDate } from '@/utils/format-date'
-import { Edit, UserCheck, UserX } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -40,6 +37,21 @@ export function UsersPage() {
const [users, setUsers] = useState([])
const [plans, setPlans] = useState([])
const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // Search and filtering state
+ const [searchQuery, setSearchQuery] = useState('')
+ const [sortBy, setSortBy] = useState('name')
+ const [sortOrder, setSortOrder] = useState('asc')
+ const [statusFilter, setStatusFilter] = useState('all')
+
+ // Pagination state
+ const [currentPage, setCurrentPage] = useState(1)
+ const [totalPages, setTotalPages] = useState(1)
+ const [totalCount, setTotalCount] = useState(0)
+ const [pageSize, setPageSize] = useState(10)
+
+ // Edit user state
const [editingUser, setEditingUser] = useState(null)
const [editData, setEditData] = useState({
name: '',
@@ -49,26 +61,65 @@ export function UsersPage() {
})
const [saving, setSaving] = useState(false)
- useEffect(() => {
- loadData()
- }, [])
+ // Debounce search query
+ const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
- const loadData = async () => {
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedSearchQuery(searchQuery)
+ }, 300)
+
+ return () => clearTimeout(handler)
+ }, [searchQuery])
+
+ const fetchUsers = async () => {
try {
- const [usersData, plansData] = await Promise.all([
- adminService.listUsers(),
+ setLoading(true)
+ setError(null)
+ const [usersResponse, plansData] = await Promise.all([
+ adminService.listUsers({
+ page: currentPage,
+ limit: pageSize,
+ search: debouncedSearchQuery.trim() || undefined,
+ sort_by: sortBy,
+ sort_order: sortOrder,
+ status_filter: statusFilter !== 'all' ? statusFilter : undefined,
+ }),
adminService.listPlans(),
])
- setUsers(usersData)
+ setUsers(usersResponse.users)
+ setTotalPages(usersResponse.total_pages)
+ setTotalCount(usersResponse.total)
setPlans(plansData)
- } catch (error) {
- toast.error('Failed to load data')
- console.error('Error loading data:', error)
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Failed to load users'
+ setError(errorMessage)
+ toast.error(errorMessage)
} finally {
setLoading(false)
}
}
+ useEffect(() => {
+ fetchUsers()
+ }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, currentPage, pageSize])
+
+ // Reset to page 1 when filters change
+ useEffect(() => {
+ if (currentPage !== 1) {
+ setCurrentPage(1)
+ }
+ }, [debouncedSearchQuery, sortBy, sortOrder, statusFilter, pageSize])
+
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page)
+ }
+
+ const handlePageSizeChange = (size: number) => {
+ setPageSize(size)
+ setCurrentPage(1) // Reset to first page when changing page size
+ }
+
const handleEditUser = (user: User) => {
setEditingUser(user)
setEditData({
@@ -111,7 +162,7 @@ export function UsersPage() {
toast.success('User enabled successfully')
}
// Reload data to get updated user status
- loadData()
+ fetchUsers()
} catch (error) {
toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`)
console.error('Error toggling user status:', error)
@@ -126,47 +177,36 @@ export function UsersPage() {
)
}
- const getStatusBadge = (isActive: boolean) => {
- return (
-
- {isActive ? 'Active' : 'Inactive'}
-
- )
- }
+ const renderContent = () => {
+ if (loading) {
+ return
+ }
+
+ if (error) {
+ return
+ }
+
+ if (users.length === 0) {
+ return
+ }
- if (loading) {
return (
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
-
-
-
+
)
}
@@ -181,72 +221,22 @@ export function UsersPage() {
}}
>
-
-
-
User Management
-
- Manage user accounts and permissions
-
-
-
- Refresh
-
-
+
-
-
- Users ({users.length})
-
-
-
-
-
- Name
- Email
- Role
- Plan
- Credits
- Status
- Actions
-
-
-
- {users.map(user => (
-
- {user.name}
- {user.email}
- {getRoleBadge(user.role)}
- {user.plan.name}
- {user.credits.toLocaleString()}
- {getStatusBadge(user.is_active)}
-
-
- handleEditUser(user)}
- >
-
-
- handleToggleUserStatus(user)}
- >
- {user.is_active ? (
-
- ) : (
-
- )}
-
-
-
-
- ))}
-
-
-
-
+ {renderContent()}
{/* Edit User Sheet */}