From 46bfcad2718d44ea3dbfa01fbddeb888ef59c07a Mon Sep 17 00:00:00 2001 From: JSC Date: Sun, 17 Aug 2025 11:44:08 +0200 Subject: [PATCH] feat: add user management components including header, loading states, and table with pagination --- src/components/admin/UsersHeader.tsx | 156 +++++++++++++ src/components/admin/UsersLoadingStates.tsx | 112 +++++++++ src/components/admin/UsersTable.tsx | 88 +++++++ src/lib/api/services/admin.ts | 47 +++- src/pages/admin/UsersPage.tsx | 242 ++++++++++---------- 5 files changed, 515 insertions(+), 130 deletions(-) create mode 100644 src/components/admin/UsersHeader.tsx create mode 100644 src/components/admin/UsersLoadingStates.tsx create mode 100644 src/components/admin/UsersTable.tsx 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' : ''}` + } +
+ )} + +
+
+ + {/* Search and Sort Controls */} +
+
+
+ + onSearchChange(e.target.value)} + className="pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+
+ +
+ + + + + + + +
+
+ + ) +} \ 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} +

+
+ +
+ ) +} + +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)} + +
+ + +
+
+
+ ))} +
+
+
+ ) +} \ 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 -

-
- -
+ - - - 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)} - -
- - -
-
-
- ))} -
-
-
-
+ {renderContent()}
{/* Edit User Sheet */}