sto-k-odnomu/admin/src/pages/UsersPage.tsx
2026-01-06 23:12:36 +03:00

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