feat: add user management components including header, loading states, and table with pagination

This commit is contained in:
JSC
2025-08-17 11:44:08 +02:00
parent 75ecd26e06
commit 46bfcad271
5 changed files with 515 additions and 130 deletions

View File

@@ -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 */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage user accounts and permissions
</p>
</div>
<div className="flex items-center gap-4">
{!loading && !error && (
<div className="text-sm text-muted-foreground">
{statusFilter !== 'all'
? `${userCount} ${statusFilter} user${userCount !== 1 ? 's' : ''}`
: `${userCount} user${userCount !== 1 ? 's' : ''}`
}
</div>
)}
<Button onClick={onRefresh} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
{/* Search and Sort Controls */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users..."
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
className="pl-9 pr-9"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
onClick={() => 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"
>
<X className="h-3 w-3" />
</Button>
)}
</div>
</div>
<div className="flex gap-2">
<Select
value={sortBy}
onValueChange={value => onSortByChange(value as UserSortField)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="role">Role</SelectItem>
<SelectItem value="credits">Credits</SelectItem>
<SelectItem value="created_at">Created Date</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
title={sortOrder === 'asc' ? 'Sort ascending' : 'Sort descending'}
>
{sortOrder === 'asc' ? (
<SortAsc className="h-4 w-4" />
) : (
<SortDesc className="h-4 w-4" />
)}
</Button>
<Select
value={statusFilter}
onValueChange={value => onStatusFilterChange(value as UserStatus)}
>
<SelectTrigger className="w-[120px]">
<Filter className="h-4 w-4 mr-2" />
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
onClick={onRefresh}
disabled={loading}
title="Refresh users"
>
<RefreshCw
className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</div>
</>
)
}

View File

@@ -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 (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8" />
<Skeleton className="h-8 w-8" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export function UsersError({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16 space-y-4">
<div className="rounded-full p-3 bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">Failed to load users</h3>
<p className="text-muted-foreground max-w-md">
{error}
</p>
</div>
<Button onClick={onRetry} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Try again
</Button>
</div>
)
}
export function UsersEmpty({ searchQuery, statusFilter }: {
searchQuery: string;
statusFilter: string;
}) {
return (
<div className="flex flex-col items-center justify-center py-16 space-y-4">
<div className="rounded-full p-3 bg-muted">
<Users className="h-8 w-8 text-muted-foreground" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-semibold">
{searchQuery || statusFilter !== 'all'
? 'No users found'
: 'No users yet'
}
</h3>
<p className="text-muted-foreground max-w-md">
{searchQuery
? `No users match "${searchQuery}"`
: statusFilter !== 'all'
? `No ${statusFilter} users found`
: 'Users will appear here once they are added to the system'
}
</p>
</div>
</div>
)
}

View File

@@ -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 (
<Badge variant={role === 'admin' ? 'destructive' : 'secondary'}>
{role}
</Badge>
)
}
const getStatusBadge = (isActive: boolean) => {
return (
<Badge variant={isActive ? 'default' : 'secondary'}>
{isActive ? 'Active' : 'Inactive'}
</Badge>
)
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>{user.plan.name}</TableCell>
<TableCell>{user.credits.toLocaleString()}</TableCell>
<TableCell>{getStatusBadge(user.is_active)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(user)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onToggleStatus(user)}
>
{user.is_active ? (
<UserX className="h-4 w-4" />
) : (
<UserCheck className="h-4 w-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -23,6 +23,23 @@ export interface MessageResponse {
message: string 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 { export interface ScanResults {
added: number added: number
updated: number updated: number
@@ -54,10 +71,32 @@ export interface NormalizationResponse {
} }
export class AdminService { export class AdminService {
async listUsers(limit = 100, offset = 0): Promise<User[]> { async listUsers(params?: GetUsersParams): Promise<GetUsersResponse> {
return apiClient.get<User[]>(`/api/v1/admin/users/`, { const searchParams = new URLSearchParams()
params: { limit, offset },
}) 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<GetUsersResponse>(url)
} }
async getUser(userId: number): Promise<User> { async getUser(userId: number): Promise<User> {

View File

@@ -1,7 +1,14 @@
import { AppLayout } from '@/components/AppLayout' 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 { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { import {
@@ -12,20 +19,10 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Sheet, SheetContent } from '@/components/ui/sheet' import { Sheet, SheetContent } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch' 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 Plan, adminService } from '@/lib/api/services/admin'
import type { User } from '@/types/auth' import type { User } from '@/types/auth'
import { formatDate } from '@/utils/format-date' import { formatDate } from '@/utils/format-date'
import { Edit, UserCheck, UserX } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -40,6 +37,21 @@ export function UsersPage() {
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [plans, setPlans] = useState<Plan[]>([]) const [plans, setPlans] = useState<Plan[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and filtering state
const [searchQuery, setSearchQuery] = useState('')
const [sortBy, setSortBy] = useState<UserSortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const [statusFilter, setStatusFilter] = useState<UserStatus>('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<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null)
const [editData, setEditData] = useState<EditUserData>({ const [editData, setEditData] = useState<EditUserData>({
name: '', name: '',
@@ -49,26 +61,65 @@ export function UsersPage() {
}) })
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { // Debounce search query
loadData() const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
}, [])
const loadData = async () => { useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchUsers = async () => {
try { try {
const [usersData, plansData] = await Promise.all([ setLoading(true)
adminService.listUsers(), 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(), adminService.listPlans(),
]) ])
setUsers(usersData) setUsers(usersResponse.users)
setTotalPages(usersResponse.total_pages)
setTotalCount(usersResponse.total)
setPlans(plansData) setPlans(plansData)
} catch (error) { } catch (err) {
toast.error('Failed to load data') const errorMessage = err instanceof Error ? err.message : 'Failed to load users'
console.error('Error loading data:', error) setError(errorMessage)
toast.error(errorMessage)
} finally { } finally {
setLoading(false) 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) => { const handleEditUser = (user: User) => {
setEditingUser(user) setEditingUser(user)
setEditData({ setEditData({
@@ -111,7 +162,7 @@ export function UsersPage() {
toast.success('User enabled successfully') toast.success('User enabled successfully')
} }
// Reload data to get updated user status // Reload data to get updated user status
loadData() fetchUsers()
} catch (error) { } catch (error) {
toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`) toast.error(`Failed to ${user.is_active ? 'disable' : 'enable'} user`)
console.error('Error toggling user status:', error) console.error('Error toggling user status:', error)
@@ -126,47 +177,36 @@ export function UsersPage() {
) )
} }
const getStatusBadge = (isActive: boolean) => { const renderContent = () => {
return (
<Badge variant={isActive ? 'default' : 'secondary'}>
{isActive ? 'Active' : 'Inactive'}
</Badge>
)
}
if (loading) { if (loading) {
return <UsersLoading />
}
if (error) {
return <UsersError error={error} onRetry={fetchUsers} />
}
if (users.length === 0) {
return <UsersEmpty searchQuery={searchQuery} statusFilter={statusFilter} />
}
return ( return (
<AppLayout <div className="space-y-4">
breadcrumb={{ <UsersTable
items: [ users={users}
{ label: 'Dashboard', href: '/' }, onEdit={handleEditUser}
{ label: 'Admin' }, onToggleStatus={handleToggleUserStatus}
{ label: 'Users' }, />
], <AppPagination
}} currentPage={currentPage}
> totalPages={totalPages}
<div className="flex-1 rounded-xl bg-muted/50 p-4"> totalCount={totalCount}
<div className="flex items-center justify-between mb-6"> pageSize={pageSize}
<div> onPageChange={handlePageChange}
<Skeleton className="h-8 w-48" /> onPageSizeChange={handlePageSizeChange}
<Skeleton className="h-4 w-64 mt-1" /> itemName="users"
/>
</div> </div>
<Skeleton className="h-10 w-32" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
</AppLayout>
) )
} }
@@ -181,72 +221,22 @@ export function UsersPage() {
}} }}
> >
<div className="flex-1 rounded-xl bg-muted/50 p-4"> <div className="flex-1 rounded-xl bg-muted/50 p-4">
<div className="flex items-center justify-between mb-6"> <UsersHeader
<div> searchQuery={searchQuery}
<h1 className="text-2xl font-bold">User Management</h1> onSearchChange={setSearchQuery}
<p className="text-muted-foreground"> sortBy={sortBy}
Manage user accounts and permissions onSortByChange={setSortBy}
</p> sortOrder={sortOrder}
</div> onSortOrderChange={setSortOrder}
<Button onClick={loadData} disabled={loading}> statusFilter={statusFilter}
Refresh onStatusFilterChange={setStatusFilter}
</Button> onRefresh={fetchUsers}
</div> loading={loading}
error={error}
userCount={totalCount}
/>
<Card> {renderContent()}
<CardHeader>
<CardTitle>Users ({users.length})</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Credits</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{getRoleBadge(user.role)}</TableCell>
<TableCell>{user.plan.name}</TableCell>
<TableCell>{user.credits.toLocaleString()}</TableCell>
<TableCell>{getStatusBadge(user.is_active)}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleToggleUserStatus(user)}
>
{user.is_active ? (
<UserX className="h-4 w-4" />
) : (
<UserCheck className="h-4 w-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div> </div>
{/* Edit User Sheet */} {/* Edit User Sheet */}