All checks were successful
Build Frontend / build (push) Successful in 1m7s
Enhanced the admin dashboard tab styling for better clarity. Refactored InviteVendorCard with improved UI, feedback, and clipboard copy functionality. Fixed vendor store ID update to send raw object instead of JSON string. Ensured product price formatting is robust against non-numeric values.
389 lines
16 KiB
TypeScript
389 lines
16 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, Pencil, Plus } from "lucide-react";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
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 [isEditStoreOpen, setIsEditStoreOpen] = useState(false);
|
|
const [editingVendor, setEditingVendor] = useState<Vendor | null>(null);
|
|
const [newStoreId, setNewStoreId] = useState("");
|
|
const [updating, setUpdating] = useState(false);
|
|
|
|
const handleEditStore = (vendor: Vendor) => {
|
|
setEditingVendor(vendor);
|
|
setNewStoreId(vendor.storeId || "");
|
|
setIsEditStoreOpen(true);
|
|
};
|
|
|
|
const saveStoreId = async () => {
|
|
if (!editingVendor) return;
|
|
|
|
try {
|
|
setUpdating(true);
|
|
await fetchClient(`/admin/vendors/${editingVendor._id}/store-id`, {
|
|
method: 'PUT',
|
|
body: { storeId: newStoreId }
|
|
});
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: "Store ID updated successfully",
|
|
});
|
|
setIsEditStoreOpen(false);
|
|
fetchVendors(); // Refresh list
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || "Failed to update store ID",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setUpdating(false);
|
|
}
|
|
};
|
|
|
|
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 ? (
|
|
<div className="flex items-center gap-2 group/store">
|
|
<span className="font-mono text-xs">{vendor.storeId}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 opacity-0 group-hover/store:opacity-100 transition-opacity"
|
|
onClick={() => handleEditStore(vendor)}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground italic text-xs">No store</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 text-muted-foreground hover:text-primary"
|
|
onClick={() => handleEditStore(vendor)}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</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>
|
|
|
|
<Dialog open={isEditStoreOpen} onOpenChange={setIsEditStoreOpen}>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>Update Vendor Store</DialogTitle>
|
|
<DialogDescription>
|
|
Enter the Store ID to assign to vendor <span className="font-semibold text-foreground">{editingVendor?.username}</span>.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="storeId">Store ID</Label>
|
|
<Input
|
|
id="storeId"
|
|
value={newStoreId}
|
|
onChange={(e) => setNewStoreId(e.target.value)}
|
|
placeholder="Enter 24-character Store ID"
|
|
className="col-span-3 font-mono"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Ensure the Store ID corresponds to an existing store in the system.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsEditStoreOpen(false)} disabled={updating}>Cancel</Button>
|
|
<Button onClick={saveStoreId} disabled={updating || !newStoreId || newStoreId.length < 24}>
|
|
{updating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Save Changes
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div >
|
|
);
|
|
}
|