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.
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
import { Search, MoreHorizontal, UserCheck, UserX, Mail, Loader2, Store, Shield, ShieldAlert, Clock, Calendar } from "lucide-react";
|
|
import { fetchClient } from "@/lib/api-client";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
interface Vendor {
|
|
_id: string;
|
|
username: string;
|
|
storeId?: string;
|
|
createdAt?: string;
|
|
lastLogin?: string;
|
|
isAdmin?: boolean;
|
|
isActive: boolean;
|
|
}
|
|
|
|
interface PaginationResponse {
|
|
success: boolean;
|
|
vendors: Vendor[];
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasNextPage: boolean;
|
|
hasPrevPage: boolean;
|
|
};
|
|
}
|
|
|
|
export default function AdminVendorsPage() {
|
|
const { toast } = useToast();
|
|
const [loading, setLoading] = useState(true);
|
|
const [vendors, setVendors] = useState<Vendor[]>([]);
|
|
const [page, setPage] = useState(1);
|
|
const [pagination, setPagination] = useState<PaginationResponse['pagination'] | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
const fetchVendors = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const params = new URLSearchParams({
|
|
page: page.toString(),
|
|
limit: '25'
|
|
});
|
|
const data = await fetchClient<PaginationResponse>(`/admin/vendors?${params.toString()}`);
|
|
setVendors(data.vendors);
|
|
setPagination(data.pagination);
|
|
} catch (error: any) {
|
|
console.error("Failed to fetch vendors:", error);
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || "Failed to load vendors",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, toast]);
|
|
|
|
useEffect(() => {
|
|
fetchVendors();
|
|
}, [fetchVendors]);
|
|
|
|
const filteredVendors = searchQuery.trim()
|
|
? vendors.filter(v =>
|
|
v.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase()))
|
|
)
|
|
: vendors;
|
|
|
|
const activeVendors = vendors.filter(v => v.isActive);
|
|
const suspendedVendors = vendors.filter(v => !v.isActive);
|
|
const adminVendors = vendors.filter(v => v.isAdmin);
|
|
const totalVendors = pagination?.total || vendors.length;
|
|
|
|
const stats = [
|
|
{
|
|
title: "Total Vendors",
|
|
value: totalVendors,
|
|
description: "Registered vendors",
|
|
icon: Store,
|
|
},
|
|
{
|
|
title: "Active Vendors",
|
|
value: activeVendors.length,
|
|
description: `${vendors.length > 0 ? Math.round((activeVendors.length / vendors.length) * 100) : 0}% active rate`,
|
|
icon: UserCheck,
|
|
},
|
|
{
|
|
title: "Suspended",
|
|
value: suspendedVendors.length,
|
|
description: `${vendors.length > 0 ? Math.round((suspendedVendors.length / vendors.length) * 100) : 0}% suspension rate`,
|
|
icon: UserX,
|
|
},
|
|
{
|
|
title: "Admin Users",
|
|
value: adminVendors.length,
|
|
description: "Administrative access",
|
|
icon: ShieldAlert,
|
|
},
|
|
];
|
|
|
|
return (
|
|
<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">All Vendors</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
{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>
|
|
<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 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 className="text-lg font-medium">Vendor Management</CardTitle>
|
|
<CardDescription>View and manage all vendor accounts</CardDescription>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search vendors..."
|
|
className="pl-9 w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button variant="outline" size="sm" className="bg-background/50 border-border/50 hover:bg-background transition-colors">
|
|
<Mail className="h-4 w-4 mr-2" />
|
|
Message
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="rounded-md border border-border/50 overflow-hidden">
|
|
<Table>
|
|
<TableHeader className="bg-muted/30">
|
|
<TableRow className="border-border/50 hover:bg-transparent">
|
|
<TableHead>Vendor</TableHead>
|
|
<TableHead>Store</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Join Date</TableHead>
|
|
<TableHead>Last Login</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
<AnimatePresence mode="popLayout">
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} 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 vendors...</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : filteredVendors.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
|
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredVendors.map((vendor, index) => (
|
|
<motion.tr
|
|
key={vendor._id}
|
|
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-medium flex items-center gap-2">
|
|
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
|
{vendor.username.substring(0, 2).toUpperCase()}
|
|
</div>
|
|
{vendor.username}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{vendor.storeId ? (
|
|
<span className="font-mono text-xs">{vendor.storeId}</span>
|
|
) : (
|
|
<span className="text-muted-foreground italic text-xs">No store</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
<Badge
|
|
variant={vendor.isActive ? "default" : "destructive"}
|
|
className={vendor.isActive ? "bg-green-600 hover:bg-green-700" : ""}
|
|
>
|
|
{vendor.isActive ? "Active" : "Suspended"}
|
|
</Badge>
|
|
{vendor.isAdmin && (
|
|
<Badge variant="outline" className="border-blue-200 text-blue-700 bg-blue-50 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-900">
|
|
<Shield className="h-3 w-3 mr-1" />
|
|
Admin
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-1.5">
|
|
<Calendar className="h-3.5 w-3.5 opacity-70" />
|
|
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
<div className="flex items-center gap-1.5">
|
|
<Clock className="h-3.5 w-3.5 opacity-70" />
|
|
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end space-x-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary">
|
|
<UserCheck className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10">
|
|
<UserX className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
|
|
<MoreHorizontal 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 border-t border-border/50 pt-4 text-sm text-muted-foreground">
|
|
<span>
|
|
Page <span className="font-medium text-foreground">{pagination.page}</span> of {pagination.totalPages}
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={!pagination.hasPrevPage || loading}
|
|
className="h-8"
|
|
>
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(p => p + 1)}
|
|
disabled={!pagination.hasNextPage || loading}
|
|
className="h-8"
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|