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:
@@ -183,7 +183,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
||||
style={{
|
||||
animationDelay: `${i * 50}ms`,
|
||||
animationDuration: '400ms',
|
||||
@@ -288,7 +288,7 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
||||
style={{
|
||||
animationDelay: `${i * 75}ms`,
|
||||
animationDuration: '400ms',
|
||||
@@ -355,7 +355,7 @@ export default function AdminPage() {
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
} else if (tab === "management") {
|
||||
// Prefetch management components
|
||||
Promise.all([
|
||||
@@ -368,7 +368,7 @@ export default function AdminPage() {
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
setPrefetchedTabs(prev => new Set(prev).add(tab));
|
||||
@@ -392,12 +392,12 @@ export default function AdminPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between animate-in fade-in slide-in-from-top-2 duration-300">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Admin Dashboard</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Admin Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button asChild variant="outline" size="sm" className="border-border/50 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all">
|
||||
<Link href="/dashboard">Back to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users..."
|
||||
className="pl-8 w-64"
|
||||
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>
|
||||
|
||||
329
app/dashboard/admin/vendors/page.tsx
vendored
329
app/dashboard/admin/vendors/page.tsx
vendored
@@ -6,9 +6,10 @@ 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 } from "lucide-react";
|
||||
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;
|
||||
@@ -69,9 +70,9 @@ export default function AdminVendorsPage() {
|
||||
|
||||
const filteredVendors = searchQuery.trim()
|
||||
? vendors.filter(v =>
|
||||
v.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
v.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
)
|
||||
: vendors;
|
||||
|
||||
const activeVendors = vendors.filter(v => v.isActive);
|
||||
@@ -79,174 +80,214 @@ export default function AdminVendorsPage() {
|
||||
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">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">All Vendors</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</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">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">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalVendors}</div>
|
||||
<p className="text-xs text-muted-foreground">Registered vendors</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Vendors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeVendors.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{vendors.length > 0 ? Math.round((activeVendors.length / vendors.length) * 100) : 0}% active 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">Suspended</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{suspendedVendors.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{vendors.length > 0 ? Math.round((suspendedVendors.length / vendors.length) * 100) : 0}% suspension 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">Admin Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{adminVendors.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Administrative access</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>
|
||||
<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>Vendor Management</CardTitle>
|
||||
<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 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search vendors..."
|
||||
className="pl-8 w-64"
|
||||
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">
|
||||
<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" />
|
||||
Send Message
|
||||
Message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredVendors.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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>
|
||||
{filteredVendors.map((vendor) => (
|
||||
<TableRow key={vendor._id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{vendor.username}</div>
|
||||
</TableCell>
|
||||
<TableCell>{vendor.storeId || 'No store'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Badge
|
||||
variant={vendor.isActive ? "default" : "destructive"}
|
||||
>
|
||||
{vendor.isActive ? "active" : "suspended"}
|
||||
</Badge>
|
||||
{vendor.isAdmin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vendor.createdAt ? new Date(vendor.createdAt).toLocaleDateString() : 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vendor.lastLogin ? new Date(vendor.lastLogin).toLocaleDateString() : 'Never'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<UserX className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={!pagination.hasPrevPage || loading}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={!pagination.hasNextPage || loading}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : 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>
|
||||
|
||||
@@ -54,7 +54,7 @@ const ShippingTable = dynamic(() => import("@/components/tables/shipping-table")
|
||||
// Loading skeleton for shipping table
|
||||
function ShippingTableSkeleton() {
|
||||
return (
|
||||
<Card className="animate-in fade-in duration-500 relative">
|
||||
<Card className="animate-in fade-in duration-500 relative border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
{/* Subtle loading indicator */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
||||
<div className="h-full bg-primary w-1/3"
|
||||
|
||||
@@ -7,11 +7,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
@@ -30,12 +34,13 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Product } from "@/models/products";
|
||||
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar } from "lucide-react";
|
||||
import { Package, RefreshCw, ChevronDown, CheckSquare, XSquare, Boxes, Download, Calendar, Search, Filter, Save, X, Edit2 } from "lucide-react";
|
||||
import { clientFetch } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface StockData {
|
||||
currentStock: number;
|
||||
@@ -358,9 +363,9 @@ export default function StockManagementPage() {
|
||||
document.body.removeChild(link);
|
||||
|
||||
const periodText = reportType === 'daily' ? exportDate :
|
||||
reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` :
|
||||
reportType === 'monthly' ? `${response.monthName || 'current month'}` :
|
||||
`${format(exportDateRange?.from || new Date(), 'MMM dd')} to ${format(exportDateRange?.to || new Date(), 'MMM dd')}`;
|
||||
reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` :
|
||||
reportType === 'monthly' ? `${response.monthName || 'current month'}` :
|
||||
`${format(exportDateRange?.from || new Date(), 'MMM dd')} to ${format(exportDateRange?.to || new Date(), 'MMM dd')}`;
|
||||
|
||||
toast.success(`${reportType.charAt(0).toUpperCase() + reportType.slice(1)} stock report for ${periodText} exported successfully`);
|
||||
} catch (error) {
|
||||
@@ -379,6 +384,26 @@ export default function StockManagementPage() {
|
||||
return 'In stock';
|
||||
};
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Out of stock': return 'destructive';
|
||||
case 'Low stock': return 'warning'; // Custom variant or use secondary/outline
|
||||
case 'In stock': return 'default'; // often maps to primary which might be blue/black
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper for badging - if your Badge component doesn't support 'warning' directly, use className overrides
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
let styles = "font-medium border-transparent shadow-none";
|
||||
if (status === 'Out of stock') styles += " bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400";
|
||||
else if (status === 'Low stock') styles += " bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400";
|
||||
else if (status === 'In stock') styles += " bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400";
|
||||
else styles += " bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
|
||||
|
||||
return <Badge className={styles} variant="outline">{status}</Badge>;
|
||||
};
|
||||
|
||||
const filteredProducts = products.filter(product => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
@@ -392,31 +417,39 @@ export default function StockManagementPage() {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<Boxes className="mr-2 h-6 w-6" />
|
||||
Stock Management
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search products..."
|
||||
className="w-64"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="space-y-6 animate-in fade-in duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Boxes className="h-6 w-6 text-primary" />
|
||||
Stock Management
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Track inventory levels and manage stock status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search products..."
|
||||
className="pl-9 w-full sm:w-64 bg-background/50 border-border/50 focus:bg-background transition-colors"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Report Type Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{reportType.charAt(0).toUpperCase() + reportType.slice(1)} Report
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<Button variant="outline" size="icon" className="h-10 w-10 border-border/50 bg-background/50">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Filter Reports</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setReportType('daily')}>
|
||||
Daily Report
|
||||
</DropdownMenuItem>
|
||||
@@ -433,51 +466,53 @@ export default function StockManagementPage() {
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Date Selection based on report type */}
|
||||
{reportType === 'daily' && (
|
||||
<DatePicker
|
||||
date={exportDate ? new Date(exportDate) : undefined}
|
||||
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
placeholder="Select export date"
|
||||
className="w-auto"
|
||||
/>
|
||||
)}
|
||||
<div className="hidden sm:block">
|
||||
{reportType === 'daily' && (
|
||||
<DatePicker
|
||||
date={exportDate ? new Date(exportDate) : undefined}
|
||||
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
|
||||
placeholder="Select export date"
|
||||
className="w-auto border-border/50 bg-background/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{(reportType === 'weekly' || reportType === 'custom') && (
|
||||
<DateRangePicker
|
||||
dateRange={exportDateRange}
|
||||
onDateRangeChange={setExportDateRange}
|
||||
placeholder="Select date range"
|
||||
className="w-auto"
|
||||
/>
|
||||
)}
|
||||
{(reportType === 'weekly' || reportType === 'custom') && (
|
||||
<DateRangePicker
|
||||
dateRange={exportDateRange}
|
||||
onDateRangeChange={setExportDateRange}
|
||||
placeholder="Select date range"
|
||||
className="w-auto border-border/50 bg-background/50"
|
||||
/>
|
||||
)}
|
||||
|
||||
{reportType === 'monthly' && (
|
||||
<MonthPicker
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={(date) => setSelectedMonth(date || new Date())}
|
||||
placeholder="Select month"
|
||||
className="w-auto"
|
||||
/>
|
||||
)}
|
||||
{reportType === 'monthly' && (
|
||||
<MonthPicker
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={(date) => setSelectedMonth(date || new Date())}
|
||||
placeholder="Select month"
|
||||
className="w-auto border-border/50 bg-background/50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExportStock}
|
||||
disabled={isExporting}
|
||||
className="gap-2"
|
||||
className="gap-2 border-border/50 bg-background/50 hover:bg-background transition-colors"
|
||||
>
|
||||
{isExporting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{isExporting ? 'Exporting...' : 'Export CSV'}
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{selectedProducts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Button variant="default" className="gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Bulk Actions
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@@ -486,11 +521,11 @@ export default function StockManagementPage() {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleBulkAction('enable')}>
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Enable Stock Tracking
|
||||
Enable Tracking
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleBulkAction('disable')}>
|
||||
<XSquare className="h-4 w-4 mr-2" />
|
||||
Disable Stock Tracking
|
||||
Disable Tracking
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -498,90 +533,140 @@ export default function StockManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.length === products.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Stock Status</TableHead>
|
||||
<TableHead>Current Stock</TableHead>
|
||||
<TableHead>Track Stock</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<RefreshCw className="h-6 w-6 animate-spin inline-block" />
|
||||
<span className="ml-2">Loading products...</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
No products found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredProducts.map((product) => (
|
||||
<TableRow key={product._id}>
|
||||
<TableCell>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-medium">Inventory Data</CardTitle>
|
||||
<CardDescription>Manage stock levels and tracking for {products.length} products</CardDescription>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground bg-background/50 px-3 py-1 rounded-full border border-border/50">
|
||||
{filteredProducts.length} items
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="border-border/50 hover:bg-transparent">
|
||||
<TableHead className="w-12 pl-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.includes(product._id || '')}
|
||||
onChange={() => toggleSelectProduct(product._id || '')}
|
||||
className="rounded border-gray-300"
|
||||
checked={selectedProducts.length === products.length && products.length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{product.name}</TableCell>
|
||||
<TableCell>{getStockStatus(product)}</TableCell>
|
||||
<TableCell>
|
||||
{editingStock[product._id || ''] ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={stockValues[product._id || ''] || 0}
|
||||
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button size="sm" onClick={() => handleSaveStock(product)}>Save</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span>{product.currentStock || 0}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={product.stockTracking || false}
|
||||
onCheckedChange={() => handleToggleStockTracking(product)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!editingStock[product._id || ''] && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditStock(product._id || '')}
|
||||
>
|
||||
Edit Stock
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Current Stock</TableHead>
|
||||
<TableHead>Tracking</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="h-8 w-8 animate-spin opacity-20" />
|
||||
<p>Loading products...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredProducts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Boxes className="h-10 w-10 opacity-20" />
|
||||
<p>No products found matching your search</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredProducts.map((product, index) => (
|
||||
<motion.tr
|
||||
key={product._id}
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.03 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell className="pl-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedProducts.includes(product._id || '')}
|
||||
onChange={() => toggleSelectProduct(product._id || '')}
|
||||
className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{product.name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={getStockStatus(product)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editingStock[product._id || ''] ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={stockValues[product._id || ''] || 0}
|
||||
onChange={(e) => handleStockChange(product._id || '', parseInt(e.target.value) || 0)}
|
||||
className="w-20 h-8 font-mono bg-background"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-sm">{product.currentStock || 0}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={product.stockTracking || false}
|
||||
onCheckedChange={() => handleToggleStockTracking(product)}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex justify-end gap-1">
|
||||
{editingStock[product._id || ''] ? (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-100 dark:hover:bg-green-900/20"
|
||||
onClick={() => handleSaveStock(product)}
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setEditingStock({ ...editingStock, [product._id || '']: false })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10"
|
||||
onClick={() => handleEditStock(product._id || '')}
|
||||
>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
|
||||
@@ -589,12 +674,14 @@ export default function StockManagementPage() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to {bulkAction} stock tracking for {selectedProducts.length} selected products?
|
||||
Are you sure you want to {bulkAction} stock tracking for <span className="font-medium text-foreground">{selectedProducts.length}</span> selected products?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={executeBulkAction}>Continue</AlertDialogAction>
|
||||
<AlertDialogAction onClick={executeBulkAction} className="bg-primary text-primary-foreground">
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Edit, Trash } from "lucide-react";
|
||||
|
||||
import { Edit, Trash, Truck, PackageX } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ShippingMethod } from "@/lib/types";
|
||||
|
||||
interface ShippingTableProps {
|
||||
@@ -26,61 +27,90 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
|
||||
onDeleteShipping,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden">
|
||||
<Table className="relative">
|
||||
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[60%]">Name</TableHead>
|
||||
<TableHead className="text-center">Price</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center">
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : shippingMethods.length > 0 ? (
|
||||
shippingMethods.map((method) => (
|
||||
<TableRow
|
||||
key={method._id}
|
||||
className="transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70"
|
||||
>
|
||||
<TableCell className="font-medium">{method.name}</TableCell>
|
||||
<TableCell className="text-center">£{method.price}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
onClick={() => onEditShipping(method)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
onClick={() => onDeleteShipping(method._id ?? "")}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
|
||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2">
|
||||
<Truck className="h-5 w-5 text-primary" />
|
||||
Available Methods
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50 sticky top-0 z-10">
|
||||
<TableRow className="hover:bg-transparent border-border/50">
|
||||
<TableHead className="w-[60%] pl-6">Method Name</TableHead>
|
||||
<TableHead className="text-center">Price</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
No shipping methods found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-24 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
Loading methods...
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : shippingMethods.length > 0 ? (
|
||||
shippingMethods.map((method, index) => (
|
||||
<motion.tr
|
||||
key={method._id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
|
||||
>
|
||||
<TableCell className="font-medium pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded bg-primary/10 flex items-center justify-center">
|
||||
<Truck className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
{method.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-mono">£{method.price}</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
onClick={() => onEditShipping(method)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
onClick={() => onDeleteShipping(method._id ?? "")}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<PackageX className="h-8 w-8 opacity-50" />
|
||||
<p>No shipping methods found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user