Enhance admin dashboard UI and tables with new styles
All checks were successful
Build Frontend / build (push) Successful in 1m4s
All checks were successful
Build Frontend / build (push) Successful in 1m4s
Refactors admin dashboard, users, vendors, shipping, and stock pages to improve UI consistency and visual clarity. Adds new icons, animated transitions, and card styles for stats and tables. Updates table row rendering with framer-motion for smooth animations, improves badge and button styling, and enhances search/filter inputs. Refines loading skeletons and overall layout for a more modern, accessible admin experience.
This commit is contained in:
@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat } from "lucide-react";
|
||||
import { Search, Ban, UserCheck, Package, DollarSign, Loader2, Repeat, Users, ShoppingBag, CreditCard, UserX } from "lucide-react";
|
||||
import { fetchClient } from "@/lib/api-client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface TelegramUser {
|
||||
telegramUserId: string;
|
||||
@@ -88,123 +89,86 @@ export default function AdminUsersPage() {
|
||||
const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
|
||||
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 0);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Total Users",
|
||||
value: users.length,
|
||||
description: "Registered users",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "Users with Orders",
|
||||
value: usersWithOrders.length,
|
||||
description: `${users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate`,
|
||||
icon: ShoppingBag,
|
||||
},
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: formatCurrency(totalSpent),
|
||||
description: `${totalOrders} total orders`,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: "Returning",
|
||||
value: returningCustomers.length,
|
||||
description: `${usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers`,
|
||||
icon: Repeat,
|
||||
},
|
||||
{
|
||||
title: "Blocked",
|
||||
value: blockedUsers.length,
|
||||
description: `${users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate`,
|
||||
icon: UserX,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Telegram Users</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Telegram Users</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{users.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered users</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Users with Orders</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{usersWithOrders.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.length > 0 ? Math.round((usersWithOrders.length / users.length) * 100) : 0}% conversion rate
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{formatCurrency(totalSpent)}</div>
|
||||
<p className="text-xs text-muted-foreground">{totalOrders} total orders</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Returning Customers</CardTitle>
|
||||
<Repeat className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{returningCustomers.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{usersWithOrders.length > 0 ? Math.round((returningCustomers.length / usersWithOrders.length) * 100) : 0}% of customers
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Blocked Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{blockedUsers.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.length > 0 ? Math.round((blockedUsers.length / users.length) * 100) : 0}% blocked rate
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-12 flex items-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">{stat.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>User Management</CardTitle>
|
||||
<CardTitle className="text-lg font-medium">User Management</CardTitle>
|
||||
<CardDescription>View and manage all Telegram user accounts</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
className="pl-8 w-64"
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -216,19 +180,11 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery ? "No users found matching your search" : "No users found"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User ID</TableHead>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow className="border-border/50 hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">User ID</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Orders</TableHead>
|
||||
<TableHead>Total Spent</TableHead>
|
||||
@@ -239,88 +195,118 @@ export default function AdminUsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.telegramUserId}>
|
||||
<TableCell>
|
||||
<div className="font-mono text-sm">{user.telegramUserId}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">
|
||||
{user.telegramUsername !== "Unknown" ? `@${user.telegramUsername}` : "Unknown"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{user.totalOrders}</span>
|
||||
{user.completedOrders > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{user.completedOrders} completed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{formatCurrency(user.totalSpent)}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.isBlocked ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive">
|
||||
<Ban className="h-3 w-3 mr-1" />
|
||||
Blocked
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{user.blockedReason && (
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">{user.blockedReason}</p>
|
||||
</TooltipContent>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin opacity-25" />
|
||||
<p>Loading users...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
|
||||
{searchQuery ? "No users found matching your search" : "No users found"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user, index) => (
|
||||
<motion.tr
|
||||
key={user.telegramUserId}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-mono text-xs text-muted-foreground/80">{user.telegramUserId}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{user.telegramUsername !== "Unknown" ? (
|
||||
<>
|
||||
<span className="text-blue-500/80">@</span>
|
||||
{user.telegramUsername}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">Unknown</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : user.totalOrders > 0 ? (
|
||||
<Badge variant="default">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Orders</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.firstOrderDate
|
||||
? new Date(user.firstOrderDate).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.lastOrderDate
|
||||
? new Date(user.lastOrderDate).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
{!user.isBlocked ? (
|
||||
<Button variant="outline" size="sm">
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{user.totalOrders}</span>
|
||||
{user.completedOrders > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] h-5 px-1.5 bg-green-500/10 text-green-600 border-green-200 dark:border-green-900">
|
||||
{user.completedOrders} done
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium tabular-nums">{formatCurrency(user.totalSpent)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.isBlocked ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="destructive" className="items-center gap-1">
|
||||
<Ban className="h-3 w-3" />
|
||||
Blocked
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{user.blockedReason && (
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">{user.blockedReason}</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : user.totalOrders > 0 ? (
|
||||
<Badge variant="default" className="bg-green-600 hover:bg-green-700">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">No Orders</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{user.firstOrderDate
|
||||
? new Date(user.firstOrderDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{user.lastOrderDate
|
||||
? new Date(user.lastOrderDate).toLocaleDateString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{!user.isBlocked ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
|
||||
<Ban className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-green-600 hover:bg-green-500/10">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border/50 pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing page {pagination.page} of {pagination.totalPages} ({pagination.total} total users)
|
||||
Showing page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -328,6 +314,7 @@ export default function AdminUsersPage() {
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={!pagination.hasPrevPage}
|
||||
className="h-8"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
@@ -336,6 +323,7 @@ export default function AdminUsersPage() {
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!pagination.hasNextPage}
|
||||
className="h-8"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user