548 lines
20 KiB
TypeScript
548 lines
20 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { usersApi } from '@/api/users'
|
|
import type { UserDto, PaginatedResponse, PaymentDto } from '@/types/models'
|
|
import type { AxiosError } from 'axios'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { packsApi } from '@/api/packs'
|
|
import { Search, Edit, Trash2, ChevronLeft, ChevronRight, CreditCard } from 'lucide-react'
|
|
|
|
export default function UsersPage() {
|
|
const queryClient = useQueryClient()
|
|
const [page, setPage] = useState(1)
|
|
const [search, setSearch] = useState('')
|
|
const [selectedUser, setSelectedUser] = useState<UserDto | null>(null)
|
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
|
const [isPurchasesDialogOpen, setIsPurchasesDialogOpen] = useState(false)
|
|
const [userToDelete, setUserToDelete] = useState<UserDto | null>(null)
|
|
const [userPurchases, setUserPurchases] = useState<PaymentDto[]>([])
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
email: '',
|
|
admin: false,
|
|
subscription: false,
|
|
packs: [] as string[],
|
|
subscriptionFeatures: [] as string[],
|
|
})
|
|
|
|
const limit = 20
|
|
|
|
// Fetch users
|
|
const { data, isLoading, error } = useQuery<PaginatedResponse<UserDto>>({
|
|
queryKey: ['users', page],
|
|
queryFn: () => usersApi.getUsers({ page, limit }),
|
|
})
|
|
|
|
// Fetch packs for selection
|
|
const { data: packsData } = useQuery({
|
|
queryKey: ['packs', 1, 100, ''],
|
|
queryFn: () => packsApi.getPacks({ page: 1, limit: 100, search: '', showDisabled: true }),
|
|
})
|
|
|
|
// Mutations
|
|
const updateMutation = useMutation({
|
|
mutationFn: (user: UserDto) => usersApi.upsertUser(user),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
toast.success('User updated successfully')
|
|
closeEditDialog()
|
|
},
|
|
onError: (error: unknown) => {
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
toast.error(axiosError.response?.data?.message || 'Failed to update user')
|
|
},
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (userId: string) => usersApi.deleteUser(userId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['users'] })
|
|
toast.success('User deleted successfully')
|
|
setIsDeleteDialogOpen(false)
|
|
setUserToDelete(null)
|
|
},
|
|
onError: (error: unknown) => {
|
|
const axiosError = error as AxiosError<{ message?: string }>
|
|
toast.error(axiosError.response?.data?.message || 'Failed to delete user')
|
|
},
|
|
})
|
|
|
|
const openEditDialog = (user: UserDto) => {
|
|
setSelectedUser(user)
|
|
setFormData({
|
|
name: user.name || '',
|
|
email: user.email || '',
|
|
admin: user.admin,
|
|
subscription: user.subscription || false,
|
|
packs: [...user.packs],
|
|
subscriptionFeatures: [...user.subscriptionFeatures],
|
|
})
|
|
setIsEditDialogOpen(true)
|
|
}
|
|
|
|
const closeEditDialog = () => {
|
|
setIsEditDialogOpen(false)
|
|
setSelectedUser(null)
|
|
}
|
|
|
|
const openPurchasesDialog = async (user: UserDto) => {
|
|
if (!user.id) return
|
|
|
|
try {
|
|
const purchases = await usersApi.getUserPurchases(user.id.toString())
|
|
setUserPurchases(purchases)
|
|
setSelectedUser(user)
|
|
setIsPurchasesDialogOpen(true)
|
|
} catch {
|
|
toast.error('Failed to load user purchases')
|
|
}
|
|
}
|
|
|
|
const closePurchasesDialog = () => {
|
|
setIsPurchasesDialogOpen(false)
|
|
setSelectedUser(null)
|
|
setUserPurchases([])
|
|
}
|
|
|
|
const handleDelete = (user: UserDto) => {
|
|
setUserToDelete(user)
|
|
setIsDeleteDialogOpen(true)
|
|
}
|
|
|
|
const confirmDelete = () => {
|
|
if (userToDelete?.id) {
|
|
deleteMutation.mutate(userToDelete.id.toString())
|
|
}
|
|
}
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
if (!selectedUser?.id) {
|
|
toast.error('User ID is required')
|
|
return
|
|
}
|
|
|
|
const userData: UserDto = {
|
|
id: selectedUser.id,
|
|
name: formData.name.trim() || undefined,
|
|
email: formData.email.trim() || undefined,
|
|
admin: formData.admin,
|
|
subscription: formData.subscription,
|
|
packs: formData.packs,
|
|
purchases: selectedUser.purchases,
|
|
subscriptionFeatures: formData.subscriptionFeatures,
|
|
}
|
|
|
|
updateMutation.mutate(userData)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Users Management</h1>
|
|
<p className="text-muted-foreground">Error loading users</p>
|
|
</div>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-red-500">Failed to load users. Please try again later.</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Users Management</h1>
|
|
<p className="text-muted-foreground">
|
|
View and manage user accounts
|
|
</p>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search users by name or ID..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="max-w-sm"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Users Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Users ({data?.total || 0})</CardTitle>
|
|
<CardDescription>
|
|
Manage user accounts and permissions
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="text-center py-8">Loading users...</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Email</TableHead>
|
|
<TableHead>Admin</TableHead>
|
|
<TableHead>Packs</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data?.items
|
|
.filter(user =>
|
|
search === '' ||
|
|
user.name?.toLowerCase().includes(search.toLowerCase()) ||
|
|
user.id?.toString().includes(search)
|
|
)
|
|
.map((user) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell className="font-mono text-sm">{user.id}</TableCell>
|
|
<TableCell className="font-medium">{user.name || 'No name'}</TableCell>
|
|
<TableCell>{user.email || 'No email'}</TableCell>
|
|
<TableCell>
|
|
{user.admin ? (
|
|
<Badge variant="destructive">Admin</Badge>
|
|
) : (
|
|
<Badge variant="secondary">User</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>{user.packs.length} packs</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openEditDialog(user)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openPurchasesDialog(user)}
|
|
>
|
|
<CreditCard className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(user)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* Pagination */}
|
|
{data && data.totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} users
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page <= 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm">
|
|
Page {page} of {data.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= data.totalPages}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Edit User Dialog */}
|
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Edit User</DialogTitle>
|
|
<DialogDescription>
|
|
Update user information and permissions
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="userId">User ID</Label>
|
|
<Input
|
|
id="userId"
|
|
value={selectedUser?.id || ''}
|
|
disabled
|
|
className="bg-muted"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Name</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
placeholder="User name"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={formData.email}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
|
placeholder="user@example.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="admin"
|
|
checked={formData.admin}
|
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, admin: checked as boolean }))}
|
|
/>
|
|
<Label htmlFor="admin">Administrator</Label>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="subscription"
|
|
checked={formData.subscription}
|
|
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, subscription: checked as boolean }))}
|
|
/>
|
|
<Label htmlFor="subscription">Subscription</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Packs Owned</Label>
|
|
<div className="border rounded-lg max-h-[200px] overflow-y-auto p-2">
|
|
{!packsData?.items || packsData.items.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground">No packs available</div>
|
|
) : (
|
|
packsData.items.map((pack) => (
|
|
<div key={pack.id} className="flex items-center space-x-2 py-1">
|
|
<Checkbox
|
|
id={`pack-${pack.id}`}
|
|
checked={formData.packs.includes(pack.id)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
packs: [...prev.packs, pack.id],
|
|
}))
|
|
} else {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
packs: prev.packs.filter(id => id !== pack.id),
|
|
}))
|
|
}
|
|
}}
|
|
/>
|
|
<Label htmlFor={`pack-${pack.id}`} className="text-sm font-normal cursor-pointer">
|
|
{pack.title}
|
|
</Label>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Selected: {formData.packs.length} pack(s)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Subscription Features</Label>
|
|
<div className="space-y-2">
|
|
{['premium', 'unlimited', 'ad_free', 'early_access', 'priority_support'].map((feature) => (
|
|
<div key={feature} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`feature-${feature}`}
|
|
checked={formData.subscriptionFeatures.includes(feature)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
subscriptionFeatures: [...prev.subscriptionFeatures, feature],
|
|
}))
|
|
} else {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
subscriptionFeatures: prev.subscriptionFeatures.filter(f => f !== feature),
|
|
}))
|
|
}
|
|
}}
|
|
/>
|
|
<Label htmlFor={`feature-${feature}`} className="text-sm font-normal cursor-pointer capitalize">
|
|
{feature.replace(/_/g, ' ')}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Selected: {formData.subscriptionFeatures.length} feature(s)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={closeEditDialog}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={updateMutation.isPending}>
|
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* User Purchases Dialog */}
|
|
<Dialog open={isPurchasesDialogOpen} onOpenChange={setIsPurchasesDialogOpen}>
|
|
<DialogContent className="sm:max-w-[700px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Purchase History</DialogTitle>
|
|
<DialogDescription>
|
|
Payment history for user: {selectedUser?.name || selectedUser?.id}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-[400px] overflow-y-auto">
|
|
{userPurchases.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No purchases found for this user
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Amount</TableHead>
|
|
<TableHead>Currency</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Date</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{userPurchases.map((payment) => (
|
|
<TableRow key={payment.id}>
|
|
<TableCell className="font-mono text-sm">{payment.id}</TableCell>
|
|
<TableCell>{payment.amount}</TableCell>
|
|
<TableCell>{payment.currency}</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
payment.status === 'completed' ? 'default' :
|
|
payment.status === 'pending' ? 'secondary' : 'destructive'
|
|
}
|
|
>
|
|
{payment.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
{new Date(payment.createdAt).toLocaleDateString()}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={closePurchasesDialog}>Close</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete User</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete the user "{userToDelete?.name || userToDelete?.id}"? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDelete}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|