Enhance admin dashboard UI and tables with new styles
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:
g
2026-01-12 07:16:33 +00:00
parent 63c833b510
commit 73adbe5d07
6 changed files with 757 additions and 611 deletions

View File

@@ -37,7 +37,7 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`Error loading ${this.props.componentName || 'component'}:`, error, errorInfo); console.error(`Error loading ${this.props.componentName || 'component'}:`, error, errorInfo);
// Log to error tracking service if available // Log to error tracking service if available
if (typeof window !== 'undefined' && (window as any).gtag) { if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('event', 'exception', { (window as any).gtag('event', 'exception', {
@@ -105,31 +105,31 @@ class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
} }
// Lazy load admin components with error handling // Lazy load admin components with error handling
const AdminAnalytics = lazy(() => const AdminAnalytics = lazy(() =>
import("@/components/admin/AdminAnalytics").catch((err) => { import("@/components/admin/AdminAnalytics").catch((err) => {
console.error("Failed to load AdminAnalytics:", err); console.error("Failed to load AdminAnalytics:", err);
throw err; throw err;
}) })
); );
const InviteVendorCard = lazy(() => const InviteVendorCard = lazy(() =>
import("@/components/admin/InviteVendorCard").catch((err) => { import("@/components/admin/InviteVendorCard").catch((err) => {
console.error("Failed to load InviteVendorCard:", err); console.error("Failed to load InviteVendorCard:", err);
throw err; throw err;
}) })
); );
const BanUserCard = lazy(() => const BanUserCard = lazy(() =>
import("@/components/admin/BanUserCard").catch((err) => { import("@/components/admin/BanUserCard").catch((err) => {
console.error("Failed to load BanUserCard:", err); console.error("Failed to load BanUserCard:", err);
throw err; throw err;
}) })
); );
const InvitationsListCard = lazy(() => const InvitationsListCard = lazy(() =>
import("@/components/admin/InvitationsListCard").catch((err) => { import("@/components/admin/InvitationsListCard").catch((err) => {
console.error("Failed to load InvitationsListCard:", err); console.error("Failed to load InvitationsListCard:", err);
throw err; throw err;
}) })
); );
const VendorsCard = lazy(() => const VendorsCard = lazy(() =>
import("@/components/admin/VendorsCard").catch((err) => { import("@/components/admin/VendorsCard").catch((err) => {
console.error("Failed to load VendorsCard:", err); console.error("Failed to load VendorsCard:", err);
throw err; throw err;
@@ -139,7 +139,7 @@ const VendorsCard = lazy(() =>
// Loading skeleton with timeout warning // Loading skeleton with timeout warning
function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) { function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) {
return ( return (
<div <div
className="space-y-6 animate-in fade-in duration-500 relative" className="space-y-6 animate-in fade-in duration-500 relative"
role="status" role="status"
aria-label="Loading analytics dashboard" aria-label="Loading analytics dashboard"
@@ -156,7 +156,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
)} )}
{/* Subtle loading indicator */} {/* Subtle loading indicator */}
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full"> <div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full">
<div className="h-full bg-primary w-1/3 animate-[shimmer_2s_ease-in-out_infinite]" <div className="h-full bg-primary w-1/3 animate-[shimmer_2s_ease-in-out_infinite]"
style={{ style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)', background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%', backgroundSize: '200% 100%',
@@ -164,7 +164,7 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
}} }}
/> />
</div> </div>
{/* Header skeleton */} {/* Header skeleton */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="space-y-2"> <div className="space-y-2">
@@ -181,9 +181,9 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
{/* Metric cards grid skeleton */} {/* Metric cards grid skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<Card <Card
key={i} 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={{ style={{
animationDelay: `${i * 50}ms`, animationDelay: `${i * 50}ms`,
animationDuration: '400ms', animationDuration: '400ms',
@@ -227,14 +227,14 @@ function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?:
} }
// Suspense wrapper with timeout // Suspense wrapper with timeout
function SuspenseWithTimeout({ function SuspenseWithTimeout({
children, children,
fallback, fallback,
timeout = 5000, timeout = 5000,
timeoutFallback timeoutFallback
}: { }: {
children: ReactNode; children: ReactNode;
fallback: ReactNode; fallback: ReactNode;
timeout?: number; timeout?: number;
timeoutFallback?: ReactNode; timeoutFallback?: ReactNode;
}) { }) {
@@ -258,7 +258,7 @@ function SuspenseWithTimeout({
// Loading skeleton for management cards // Loading skeleton for management cards
function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) { function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) {
return ( return (
<div <div
className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch relative" className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch relative"
role="status" role="status"
aria-label="Loading management tools" aria-label="Loading management tools"
@@ -277,7 +277,7 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
)} )}
{/* Subtle loading indicator */} {/* Subtle loading indicator */}
<div className="absolute -top-6 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full"> <div className="absolute -top-6 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full">
<div className="h-full bg-primary w-1/3" <div className="h-full bg-primary w-1/3"
style={{ style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)', background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%', backgroundSize: '200% 100%',
@@ -286,9 +286,9 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
/> />
</div> </div>
{[1, 2, 3, 4].map((i) => ( {[1, 2, 3, 4].map((i) => (
<Card <Card
key={i} 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={{ style={{
animationDelay: `${i * 75}ms`, animationDelay: `${i * 75}ms`,
animationDuration: '400ms', animationDuration: '400ms',
@@ -311,8 +311,8 @@ function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?
/* List items skeleton for list cards */ /* List items skeleton for list cards */
<> <>
{[1, 2, 3].map((j) => ( {[1, 2, 3].map((j) => (
<div <div
key={j} key={j}
className="space-y-2 p-3 rounded border border-border/50 animate-in fade-in" className="space-y-2 p-3 rounded border border-border/50 animate-in fade-in"
style={{ style={{
animationDelay: `${(i - 2) * 75 + j * 50}ms`, animationDelay: `${(i - 2) * 75 + j * 50}ms`,
@@ -345,9 +345,9 @@ export default function AdminPage() {
const prefetchTabComponents = (tab: string) => { const prefetchTabComponents = (tab: string) => {
// Avoid prefetching if already done // Avoid prefetching if already done
if (prefetchedTabs.has(tab)) return; if (prefetchedTabs.has(tab)) return;
const startTime = performance.now(); const startTime = performance.now();
if (tab === "analytics") { if (tab === "analytics") {
// Prefetch analytics component // Prefetch analytics component
import("@/components/admin/AdminAnalytics") import("@/components/admin/AdminAnalytics")
@@ -355,7 +355,7 @@ export default function AdminPage() {
const loadTime = performance.now() - startTime; const loadTime = performance.now() - startTime;
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`); console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
}) })
.catch(() => {}); .catch(() => { });
} else if (tab === "management") { } else if (tab === "management") {
// Prefetch management components // Prefetch management components
Promise.all([ Promise.all([
@@ -368,9 +368,9 @@ export default function AdminPage() {
const loadTime = performance.now() - startTime; const loadTime = performance.now() - startTime;
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`); console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
}) })
.catch(() => {}); .catch(() => { });
} }
setPrefetchedTabs(prev => new Set(prev).add(tab)); setPrefetchedTabs(prev => new Set(prev).add(tab));
}; };
@@ -392,26 +392,26 @@ export default function AdminPage() {
return ( return (
<div className="space-y-6"> <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> <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> <p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
</div> </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> <Link href="/dashboard">Back to Dashboard</Link>
</Button> </Button>
</div> </div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList> <TabsList>
<TabsTrigger <TabsTrigger
value="analytics" value="analytics"
onMouseEnter={() => handleTabHover("analytics")} onMouseEnter={() => handleTabHover("analytics")}
onFocus={() => handleTabFocus("analytics")} onFocus={() => handleTabFocus("analytics")}
> >
Analytics Analytics
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="management" value="management"
onMouseEnter={() => handleTabHover("management")} onMouseEnter={() => handleTabHover("management")}
onFocus={() => handleTabFocus("management")} onFocus={() => handleTabFocus("management")}
@@ -422,7 +422,7 @@ export default function AdminPage() {
<TabsContent value="analytics" className="space-y-6 relative"> <TabsContent value="analytics" className="space-y-6 relative">
<ErrorBoundary componentName="Analytics Dashboard"> <ErrorBoundary componentName="Analytics Dashboard">
<SuspenseWithTimeout <SuspenseWithTimeout
fallback={<AdminComponentSkeleton />} fallback={<AdminComponentSkeleton />}
timeout={5000} timeout={5000}
timeoutFallback={<AdminComponentSkeleton showSlowWarning={true} />} timeoutFallback={<AdminComponentSkeleton showSlowWarning={true} />}
@@ -436,7 +436,7 @@ export default function AdminPage() {
<TabsContent value="management" className="space-y-6 relative"> <TabsContent value="management" className="space-y-6 relative">
<ErrorBoundary componentName="Management Tools"> <ErrorBoundary componentName="Management Tools">
<SuspenseWithTimeout <SuspenseWithTimeout
fallback={<ManagementCardsSkeleton />} fallback={<ManagementCardsSkeleton />}
timeout={5000} timeout={5000}
timeoutFallback={<ManagementCardsSkeleton showSlowWarning={true} />} timeoutFallback={<ManagementCardsSkeleton showSlowWarning={true} />}

View File

@@ -7,9 +7,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; 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 { fetchClient } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { motion, AnimatePresence } from "framer-motion";
interface TelegramUser { interface TelegramUser {
telegramUserId: string; telegramUserId: string;
@@ -88,123 +89,86 @@ export default function AdminUsersPage() {
const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0); const totalSpent = users.reduce((sum, u) => sum + u.totalSpent, 0);
const totalOrders = users.reduce((sum, u) => sum + u.totalOrders, 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 ( return (
<div className="space-y-6"> <div className="space-y-6 animate-in fade-in duration-500">
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Telegram Users</h1> <div>
<p className="text-sm text-muted-foreground mt-1">Manage Telegram user accounts and view statistics</p> <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> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-5"> <div className="grid gap-4 md:grid-cols-5">
<Card> {stats.map((stat, i) => (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardTitle className="text-sm font-medium">Total Users</CardTitle> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</CardHeader> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<CardContent> <stat.icon className="h-4 w-4 text-muted-foreground" />
{loading ? ( </CardHeader>
<div className="flex items-center justify-center py-4"> <CardContent>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> {loading ? (
</div> <div className="h-12 flex items-center">
) : ( <Loader2 className="h-4 w-4 animate-spin text-muted-foreground/50" />
<> </div>
<div className="text-2xl font-bold">{users.length}</div> ) : (
<p className="text-xs text-muted-foreground">Registered users</p> <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>
</CardContent> </div>
</Card> )}
<Card> </CardContent>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </Card>
<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>
</div> </div>
{/* Search and Filters */} {/* Search and Filters */}
<Card> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<CardTitle>User Management</CardTitle> <CardTitle className="text-lg font-medium">User Management</CardTitle>
<CardDescription>View and manage all Telegram user accounts</CardDescription> <CardDescription>View and manage all Telegram user accounts</CardDescription>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative"> <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 <Input
placeholder="Search users..." 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} value={searchQuery}
onChange={(e) => { onChange={(e) => {
setSearchQuery(e.target.value); setSearchQuery(e.target.value);
@@ -216,19 +180,11 @@ export default function AdminUsersPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading ? ( <div className="rounded-md border border-border/50 overflow-hidden">
<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>
) : (
<Table> <Table>
<TableHeader> <TableHeader className="bg-muted/30">
<TableRow> <TableRow className="border-border/50 hover:bg-transparent">
<TableHead>User ID</TableHead> <TableHead className="w-[100px]">User ID</TableHead>
<TableHead>Username</TableHead> <TableHead>Username</TableHead>
<TableHead>Orders</TableHead> <TableHead>Orders</TableHead>
<TableHead>Total Spent</TableHead> <TableHead>Total Spent</TableHead>
@@ -239,88 +195,118 @@ export default function AdminUsersPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((user) => ( <AnimatePresence mode="popLayout">
<TableRow key={user.telegramUserId}> {loading ? (
<TableCell> <TableRow>
<div className="font-mono text-sm">{user.telegramUserId}</div> <TableCell colSpan={8} className="h-32 text-center">
</TableCell> <div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<TableCell> <Loader2 className="h-8 w-8 animate-spin opacity-25" />
<div className="font-medium"> <p>Loading users...</p>
{user.telegramUsername !== "Unknown" ? `@${user.telegramUsername}` : "Unknown"} </div>
</div> </TableCell>
</TableCell> </TableRow>
<TableCell> ) : users.length === 0 ? (
<div className="flex items-center space-x-2"> <TableRow>
<Package className="h-4 w-4 text-muted-foreground" /> <TableCell colSpan={8} className="h-32 text-center text-muted-foreground">
<span>{user.totalOrders}</span> {searchQuery ? "No users found matching your search" : "No users found"}
{user.completedOrders > 0 && ( </TableCell>
<Badge variant="secondary" className="text-xs"> </TableRow>
{user.completedOrders} completed ) : (
</Badge> users.map((user, index) => (
)} <motion.tr
</div> key={user.telegramUserId}
</TableCell> initial={{ opacity: 0, y: 10 }}
<TableCell> animate={{ opacity: 1, y: 0 }}
<div className="flex items-center space-x-1"> exit={{ opacity: 0 }}
<DollarSign className="h-4 w-4 text-muted-foreground" /> transition={{ duration: 0.2, delay: index * 0.03 }}
<span className="font-medium">{formatCurrency(user.totalSpent)}</span> className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
</div> >
</TableCell> <TableCell>
<TableCell> <div className="font-mono text-xs text-muted-foreground/80">{user.telegramUserId}</div>
{user.isBlocked ? ( </TableCell>
<TooltipProvider> <TableCell>
<Tooltip> <div className="font-medium flex items-center gap-2">
<TooltipTrigger asChild> {user.telegramUsername !== "Unknown" ? (
<Badge variant="destructive"> <>
<Ban className="h-3 w-3 mr-1" /> <span className="text-blue-500/80">@</span>
Blocked {user.telegramUsername}
</Badge> </>
</TooltipTrigger> ) : (
{user.blockedReason && ( <span className="text-muted-foreground italic">Unknown</span>
<TooltipContent>
<p className="max-w-xs">{user.blockedReason}</p>
</TooltipContent>
)} )}
</Tooltip> </div>
</TooltipProvider> </TableCell>
) : user.totalOrders > 0 ? ( <TableCell>
<Badge variant="default">Active</Badge> <div className="flex items-center gap-2">
) : ( <span className="font-medium">{user.totalOrders}</span>
<Badge variant="secondary">No Orders</Badge> {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">
</TableCell> {user.completedOrders} done
<TableCell> </Badge>
{user.firstOrderDate )}
? new Date(user.firstOrderDate).toLocaleDateString() </div>
: 'N/A'} </TableCell>
</TableCell> <TableCell>
<TableCell> <span className="font-medium tabular-nums">{formatCurrency(user.totalSpent)}</span>
{user.lastOrderDate </TableCell>
? new Date(user.lastOrderDate).toLocaleDateString() <TableCell>
: 'N/A'} {user.isBlocked ? (
</TableCell> <TooltipProvider>
<TableCell className="text-right"> <Tooltip>
<div className="flex items-center justify-end space-x-2"> <TooltipTrigger asChild>
{!user.isBlocked ? ( <Badge variant="destructive" className="items-center gap-1">
<Button variant="outline" size="sm"> <Ban className="h-3 w-3" />
<Ban className="h-4 w-4" /> Blocked
</Button> </Badge>
) : ( </TooltipTrigger>
<Button variant="outline" size="sm"> {user.blockedReason && (
<UserCheck className="h-4 w-4" /> <TooltipContent>
</Button> <p className="max-w-xs">{user.blockedReason}</p>
)} </TooltipContent>
</div> )}
</TableCell> </Tooltip>
</TableRow> </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> </TableBody>
</Table> </Table>
)} </div>
{pagination && pagination.totalPages > 1 && ( {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"> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -328,6 +314,7 @@ export default function AdminUsersPage() {
size="sm" size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))} onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={!pagination.hasPrevPage} disabled={!pagination.hasPrevPage}
className="h-8"
> >
Previous Previous
</Button> </Button>
@@ -336,6 +323,7 @@ export default function AdminUsersPage() {
size="sm" size="sm"
onClick={() => setPage(p => p + 1)} onClick={() => setPage(p => p + 1)}
disabled={!pagination.hasNextPage} disabled={!pagination.hasNextPage}
className="h-8"
> >
Next Next
</Button> </Button>

View File

@@ -6,9 +6,10 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; 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 { fetchClient } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { motion, AnimatePresence } from "framer-motion";
interface Vendor { interface Vendor {
_id: string; _id: string;
@@ -68,10 +69,10 @@ export default function AdminVendorsPage() {
}, [fetchVendors]); }, [fetchVendors]);
const filteredVendors = searchQuery.trim() const filteredVendors = searchQuery.trim()
? vendors.filter(v => ? vendors.filter(v =>
v.username.toLowerCase().includes(searchQuery.toLowerCase()) || v.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
(v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase())) (v.storeId && v.storeId.toString().toLowerCase().includes(searchQuery.toLowerCase()))
) )
: vendors; : vendors;
const activeVendors = vendors.filter(v => v.isActive); const activeVendors = vendors.filter(v => v.isActive);
@@ -79,174 +80,214 @@ export default function AdminVendorsPage() {
const adminVendors = vendors.filter(v => v.isAdmin); const adminVendors = vendors.filter(v => v.isAdmin);
const totalVendors = pagination?.total || vendors.length; 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 ( return (
<div className="space-y-6"> <div className="space-y-6 animate-in fade-in duration-500">
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight">All Vendors</h1> <div>
<p className="text-sm text-muted-foreground mt-1">Manage vendor accounts and permissions</p> <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> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<Card> {stats.map((stat, i) => (
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <Card key={i} className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
</CardHeader> <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<CardContent> <stat.icon className="h-4 w-4 text-muted-foreground" />
<div className="text-2xl font-bold">{totalVendors}</div> </CardHeader>
<p className="text-xs text-muted-foreground">Registered vendors</p> <CardContent>
</CardContent> <div className="animate-in fade-in slide-in-from-bottom-2 duration-500" style={{ animationDelay: `${i * 100}ms` }}>
</Card> <div className="text-2xl font-bold">{stat.value}</div>
<Card> <p className="text-xs text-muted-foreground">{stat.description}</p>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> </div>
<CardTitle className="text-sm font-medium">Active Vendors</CardTitle> </CardContent>
</CardHeader> </Card>
<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>
</div> </div>
{/* Search and Filters */} {/* Search and Filters */}
<Card> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
<CardHeader> <CardHeader className="pb-4">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<CardTitle>Vendor Management</CardTitle> <CardTitle className="text-lg font-medium">Vendor Management</CardTitle>
<CardDescription>View and manage all vendor accounts</CardDescription> <CardDescription>View and manage all vendor accounts</CardDescription>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative"> <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 <Input
placeholder="Search vendors..." 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} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </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" /> <Mail className="h-4 w-4 mr-2" />
Send Message Message
</Button> </Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loading ? ( <div className="rounded-md border border-border/50 overflow-hidden">
<div className="flex items-center justify-center py-8"> <Table>
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <TableHeader className="bg-muted/30">
</div> <TableRow className="border-border/50 hover:bg-transparent">
) : filteredVendors.length === 0 ? ( <TableHead>Vendor</TableHead>
<div className="text-center py-8 text-muted-foreground"> <TableHead>Store</TableHead>
{searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"} <TableHead>Status</TableHead>
</div> <TableHead>Join Date</TableHead>
) : ( <TableHead>Last Login</TableHead>
<> <TableHead className="text-right">Actions</TableHead>
<Table> </TableRow>
<TableHeader> </TableHeader>
<TableRow> <TableBody>
<TableHead>Vendor</TableHead> <AnimatePresence mode="popLayout">
<TableHead>Store</TableHead> {loading ? (
<TableHead>Status</TableHead> <TableRow>
<TableHead>Join Date</TableHead> <TableCell colSpan={6} className="h-32 text-center">
<TableHead>Last Login</TableHead> <div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<TableHead className="text-right">Actions</TableHead> <Loader2 className="h-8 w-8 animate-spin opacity-25" />
</TableRow> <p>Loading vendors...</p>
</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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ) : filteredVendors.length === 0 ? (
</TableBody> <TableRow>
</Table> <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
{pagination && pagination.totalPages > 1 && ( {searchQuery.trim() ? "No vendors found matching your search" : "No vendors found"}
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground"> </TableCell>
<span> </TableRow>
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total) ) : (
</span> filteredVendors.map((vendor, index) => (
<div className="flex gap-2"> <motion.tr
<Button key={vendor._id}
variant="outline" initial={{ opacity: 0, y: 10 }}
size="sm" animate={{ opacity: 1, y: 0 }}
onClick={() => setPage(p => Math.max(1, p - 1))} exit={{ opacity: 0 }}
disabled={!pagination.hasPrevPage || loading} transition={{ duration: 0.2, delay: index * 0.03 }}
> className="group hover:bg-muted/40 border-b border-border/50 transition-colors"
Previous >
</Button> <TableCell>
<Button <div className="font-medium flex items-center gap-2">
variant="outline" <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
size="sm" {vendor.username.substring(0, 2).toUpperCase()}
onClick={() => setPage(p => p + 1)} </div>
disabled={!pagination.hasNextPage || loading} {vendor.username}
> </div>
Next </TableCell>
</Button> <TableCell>
</div> {vendor.storeId ? (
</div> <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> </CardContent>
</Card> </Card>

View File

@@ -54,10 +54,10 @@ const ShippingTable = dynamic(() => import("@/components/tables/shipping-table")
// Loading skeleton for shipping table // Loading skeleton for shipping table
function ShippingTableSkeleton() { function ShippingTableSkeleton() {
return ( 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 */} {/* Subtle loading indicator */}
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg"> <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" <div className="h-full bg-primary w-1/3"
style={{ style={{
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)', background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
backgroundSize: '200% 100%', backgroundSize: '200% 100%',
@@ -65,7 +65,7 @@ function ShippingTableSkeleton() {
}} }}
/> />
</div> </div>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
@@ -77,8 +77,8 @@ function ShippingTableSkeleton() {
<div className="border-b p-4"> <div className="border-b p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{['Method Name', 'Price', 'Actions'].map((header, i) => ( {['Method Name', 'Price', 'Actions'].map((header, i) => (
<Skeleton <Skeleton
key={i} key={i}
className="h-4 w-20 flex-1 animate-in fade-in" className="h-4 w-20 flex-1 animate-in fade-in"
style={{ style={{
animationDelay: `${i * 50}ms`, animationDelay: `${i * 50}ms`,
@@ -89,10 +89,10 @@ function ShippingTableSkeleton() {
))} ))}
</div> </div>
</div> </div>
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<div <div
key={i} key={i}
className="border-b last:border-b-0 p-4 animate-in fade-in" className="border-b last:border-b-0 p-4 animate-in fade-in"
style={{ style={{
animationDelay: `${200 + i * 50}ms`, animationDelay: `${200 + i * 50}ms`,
@@ -180,12 +180,12 @@ export default function ShippingPage() {
.split("; ") .split("; ")
.find((row) => row.startsWith("Authorization=")) .find((row) => row.startsWith("Authorization="))
?.split("=")[1]; ?.split("=")[1];
if (!authToken) { if (!authToken) {
console.error("No auth token found"); console.error("No auth token found");
return; return;
} }
console.log("Sending request to add shipping method:", newShipping); console.log("Sending request to add shipping method:", newShipping);
const response = await addShippingMethod( const response = await addShippingMethod(
newShipping, newShipping,
@@ -196,10 +196,10 @@ export default function ShippingPage() {
// Close modal and reset form before refreshing to avoid UI delays // Close modal and reset form before refreshing to avoid UI delays
setModalOpen(false); setModalOpen(false);
setNewShipping({ name: "", price: 0 }); setNewShipping({ name: "", price: 0 });
// Refresh the list after adding // Refresh the list after adding
refreshShippingMethods(); refreshShippingMethods();
console.log("Shipping method added successfully"); console.log("Shipping method added successfully");
} catch (error) { } catch (error) {
console.error("Error adding shipping method:", error); console.error("Error adding shipping method:", error);
@@ -218,12 +218,12 @@ export default function ShippingPage() {
.split("; ") .split("; ")
.find((row) => row.startsWith("Authorization=")) .find((row) => row.startsWith("Authorization="))
?.split("=")[1]; ?.split("=")[1];
if (!authToken) { if (!authToken) {
console.error("No auth token found"); console.error("No auth token found");
return; return;
} }
await updateShippingMethod( await updateShippingMethod(
newShipping._id, newShipping._id,
newShipping, newShipping,
@@ -234,10 +234,10 @@ export default function ShippingPage() {
setModalOpen(false); setModalOpen(false);
setNewShipping({ name: "", price: 0 }); setNewShipping({ name: "", price: 0 });
setEditing(false); setEditing(false);
// Refresh the list after updating // Refresh the list after updating
refreshShippingMethods(); refreshShippingMethods();
console.log("Shipping method updated successfully"); console.log("Shipping method updated successfully");
} catch (error) { } catch (error) {
console.error("Error updating shipping method:", error); console.error("Error updating shipping method:", error);

View File

@@ -7,18 +7,22 @@ import { Button } from "@/components/ui/button";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
DropdownMenu, import { Badge } from "@/components/ui/badge";
DropdownMenuContent, import {
DropdownMenuItem, DropdownMenu,
DropdownMenuTrigger DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuSeparator
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
@@ -30,12 +34,13 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Product } from "@/models/products"; 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 { clientFetch } from "@/lib/api";
import { toast } from "sonner"; import { toast } from "sonner";
import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker"; import { DatePicker, DateRangePicker, DateRangeDisplay, MonthPicker } from "@/components/ui/date-picker";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns"; import { addDays, startOfDay, endOfDay, format, isSameDay } from "date-fns";
import { motion, AnimatePresence } from "framer-motion";
interface StockData { interface StockData {
currentStock: number; currentStock: number;
@@ -55,7 +60,7 @@ export default function StockManagementPage() {
const [selectedProducts, setSelectedProducts] = useState<string[]>([]); const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [bulkAction, setBulkAction] = useState<'enable' | 'disable' | null>(null); const [bulkAction, setBulkAction] = useState<'enable' | 'disable' | null>(null);
// Export state // Export state
const [exportDate, setExportDate] = useState<string>(new Date().toISOString().split('T')[0]); const [exportDate, setExportDate] = useState<string>(new Date().toISOString().split('T')[0]);
const [exportDateRange, setExportDateRange] = useState<DateRange | undefined>({ const [exportDateRange, setExportDateRange] = useState<DateRange | undefined>({
@@ -82,7 +87,7 @@ export default function StockManagementPage() {
const response = await clientFetch<Product[]>('api/products'); const response = await clientFetch<Product[]>('api/products');
const fetchedProducts = response || []; const fetchedProducts = response || [];
setProducts(fetchedProducts); setProducts(fetchedProducts);
// Initialize stock values // Initialize stock values
const initialStockValues: Record<string, number> = {}; const initialStockValues: Record<string, number> = {};
fetchedProducts.forEach((product: Product) => { fetchedProducts.forEach((product: Product) => {
@@ -91,7 +96,7 @@ export default function StockManagementPage() {
} }
}); });
setStockValues(initialStockValues); setStockValues(initialStockValues);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error("Error fetching products:", error); console.error("Error fetching products:", error);
@@ -114,7 +119,7 @@ export default function StockManagementPage() {
try { try {
const newStockValue = stockValues[product._id] || 0; const newStockValue = stockValues[product._id] || 0;
const stockData: StockData = { const stockData: StockData = {
currentStock: newStockValue, currentStock: newStockValue,
stockTracking: product.stockTracking || false, stockTracking: product.stockTracking || false,
@@ -165,14 +170,14 @@ export default function StockManagementPage() {
try { try {
// Toggle the stock tracking status // Toggle the stock tracking status
const newTrackingStatus = !product.stockTracking; const newTrackingStatus = !product.stockTracking;
// For enabling tracking, we need to ensure there's a stock value // For enabling tracking, we need to ensure there's a stock value
const stockData: StockData = { const stockData: StockData = {
stockTracking: newTrackingStatus, stockTracking: newTrackingStatus,
currentStock: product.currentStock || 0, currentStock: product.currentStock || 0,
lowStockThreshold: product.lowStockThreshold || 10, lowStockThreshold: product.lowStockThreshold || 10,
}; };
// Update stock tracking status // Update stock tracking status
await clientFetch(`api/stock/${product._id}`, { await clientFetch(`api/stock/${product._id}`, {
method: 'PUT', method: 'PUT',
@@ -212,7 +217,7 @@ export default function StockManagementPage() {
try { try {
const productsToUpdate = products.filter(p => selectedProducts.includes(p._id || '')); const productsToUpdate = products.filter(p => selectedProducts.includes(p._id || ''));
await Promise.all(productsToUpdate.map(async (product) => { await Promise.all(productsToUpdate.map(async (product) => {
if (!product._id) return; if (!product._id) return;
@@ -254,7 +259,7 @@ export default function StockManagementPage() {
}; };
const toggleSelectProduct = (productId: string) => { const toggleSelectProduct = (productId: string) => {
setSelectedProducts(prev => setSelectedProducts(prev =>
prev.includes(productId) prev.includes(productId)
? prev.filter(id => id !== productId) ? prev.filter(id => id !== productId)
: [...prev, productId] : [...prev, productId]
@@ -262,7 +267,7 @@ export default function StockManagementPage() {
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
setSelectedProducts(prev => setSelectedProducts(prev =>
prev.length === products.length prev.length === products.length
? [] ? []
: products.map(p => p._id || '') : products.map(p => p._id || '')
@@ -280,7 +285,7 @@ export default function StockManagementPage() {
response = await clientFetch(`/api/analytics/daily-stock-report?date=${exportDate}`); response = await clientFetch(`/api/analytics/daily-stock-report?date=${exportDate}`);
filename = `daily-stock-report-${exportDate}.csv`; filename = `daily-stock-report-${exportDate}.csv`;
break; break;
case 'weekly': case 'weekly':
if (!exportDateRange?.from) { if (!exportDateRange?.from) {
toast.error('Please select a date range for weekly report'); toast.error('Please select a date range for weekly report');
@@ -290,14 +295,14 @@ export default function StockManagementPage() {
response = await clientFetch(`/api/analytics/weekly-stock-report?weekStart=${weekStart}`); response = await clientFetch(`/api/analytics/weekly-stock-report?weekStart=${weekStart}`);
filename = `weekly-stock-report-${weekStart}.csv`; filename = `weekly-stock-report-${weekStart}.csv`;
break; break;
case 'monthly': case 'monthly':
const year = selectedMonth.getFullYear(); const year = selectedMonth.getFullYear();
const month = selectedMonth.getMonth() + 1; const month = selectedMonth.getMonth() + 1;
response = await clientFetch(`/api/analytics/monthly-stock-report?year=${year}&month=${month}`); response = await clientFetch(`/api/analytics/monthly-stock-report?year=${year}&month=${month}`);
filename = `monthly-stock-report-${year}-${month.toString().padStart(2, '0')}.csv`; filename = `monthly-stock-report-${year}-${month.toString().padStart(2, '0')}.csv`;
break; break;
case 'custom': case 'custom':
if (!exportDateRange?.from || !exportDateRange?.to) { if (!exportDateRange?.from || !exportDateRange?.to) {
toast.error('Please select a date range for custom report'); toast.error('Please select a date range for custom report');
@@ -308,12 +313,12 @@ export default function StockManagementPage() {
response = await clientFetch(`/api/analytics/daily-stock-report?startDate=${startDate}&endDate=${endDate}`); response = await clientFetch(`/api/analytics/daily-stock-report?startDate=${startDate}&endDate=${endDate}`);
filename = `custom-stock-report-${startDate}-to-${endDate}.csv`; filename = `custom-stock-report-${startDate}-to-${endDate}.csv`;
break; break;
default: default:
toast.error('Invalid report type'); toast.error('Invalid report type');
return; return;
} }
if (!response || !response.products) { if (!response || !response.products) {
throw new Error('No data received from server'); throw new Error('No data received from server');
} }
@@ -348,19 +353,19 @@ export default function StockManagementPage() {
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a'); const link = document.createElement('a');
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
link.setAttribute('href', url); link.setAttribute('href', url);
link.setAttribute('download', filename); link.setAttribute('download', filename);
link.style.visibility = 'hidden'; link.style.visibility = 'hidden';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
const periodText = reportType === 'daily' ? exportDate : const periodText = reportType === 'daily' ? exportDate :
reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` : reportType === 'weekly' ? `week starting ${format(exportDateRange?.from || new Date(), 'MMM dd')}` :
reportType === 'monthly' ? `${response.monthName || 'current month'}` : reportType === 'monthly' ? `${response.monthName || 'current month'}` :
`${format(exportDateRange?.from || new Date(), 'MMM dd')} to ${format(exportDateRange?.to || new Date(), 'MMM dd')}`; `${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`); toast.success(`${reportType.charAt(0).toUpperCase() + reportType.slice(1)} stock report for ${periodText} exported successfully`);
} catch (error) { } catch (error) {
@@ -379,9 +384,29 @@ export default function StockManagementPage() {
return 'In stock'; 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 => { const filteredProducts = products.filter(product => {
if (!searchTerm) return true; if (!searchTerm) return true;
const searchLower = searchTerm.toLowerCase(); const searchLower = searchTerm.toLowerCase();
return ( return (
product.name.toLowerCase().includes(searchLower) || product.name.toLowerCase().includes(searchLower) ||
@@ -392,31 +417,39 @@ export default function StockManagementPage() {
return ( return (
<Layout> <Layout>
<div className="space-y-6"> <div className="space-y-6 animate-in fade-in duration-500">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white flex items-center"> <div>
<Boxes className="mr-2 h-6 w-6" /> <h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
Stock Management <Boxes className="h-6 w-6 text-primary" />
</h1> Stock Management
<div className="flex items-center gap-3"> </h1>
<Input <p className="text-muted-foreground text-sm mt-1">
type="search" Track inventory levels and manage stock status
placeholder="Search products..." </p>
className="w-64" </div>
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} <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" />
{/* Report Type Selector */} <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>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="outline" size="icon" className="h-10 w-10 border-border/50 bg-background/50">
<Calendar className="h-4 w-4" /> <Filter className="h-4 w-4 text-muted-foreground" />
{reportType.charAt(0).toUpperCase() + reportType.slice(1)} Report
<ChevronDown className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent align="end">
<DropdownMenuLabel>Filter Reports</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setReportType('daily')}> <DropdownMenuItem onClick={() => setReportType('daily')}>
Daily Report Daily Report
</DropdownMenuItem> </DropdownMenuItem>
@@ -433,51 +466,53 @@ export default function StockManagementPage() {
</DropdownMenu> </DropdownMenu>
{/* Date Selection based on report type */} {/* Date Selection based on report type */}
{reportType === 'daily' && ( <div className="hidden sm:block">
<DatePicker {reportType === 'daily' && (
date={exportDate ? new Date(exportDate) : undefined} <DatePicker
onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')} date={exportDate ? new Date(exportDate) : undefined}
placeholder="Select export date" onDateChange={(date) => setExportDate(date ? date.toISOString().split('T')[0] : '')}
className="w-auto" placeholder="Select export date"
/> className="w-auto border-border/50 bg-background/50"
)} />
)}
{(reportType === 'weekly' || reportType === 'custom') && ( {(reportType === 'weekly' || reportType === 'custom') && (
<DateRangePicker <DateRangePicker
dateRange={exportDateRange} dateRange={exportDateRange}
onDateRangeChange={setExportDateRange} onDateRangeChange={setExportDateRange}
placeholder="Select date range" placeholder="Select date range"
className="w-auto" className="w-auto border-border/50 bg-background/50"
/> />
)} )}
{reportType === 'monthly' && ( {reportType === 'monthly' && (
<MonthPicker <MonthPicker
selectedMonth={selectedMonth} selectedMonth={selectedMonth}
onMonthChange={(date) => setSelectedMonth(date || new Date())} onMonthChange={(date) => setSelectedMonth(date || new Date())}
placeholder="Select month" placeholder="Select month"
className="w-auto" className="w-auto border-border/50 bg-background/50"
/> />
)} )}
</div>
<Button <Button
variant="outline" variant="outline"
onClick={handleExportStock} onClick={handleExportStock}
disabled={isExporting} disabled={isExporting}
className="gap-2" className="gap-2 border-border/50 bg-background/50 hover:bg-background transition-colors"
> >
{isExporting ? ( {isExporting ? (
<RefreshCw className="h-4 w-4 animate-spin" /> <RefreshCw className="h-4 w-4 animate-spin" />
) : ( ) : (
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
)} )}
{isExporting ? 'Exporting...' : 'Export CSV'} Export
</Button> </Button>
{selectedProducts.length > 0 && ( {selectedProducts.length > 0 && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2"> <Button variant="default" className="gap-2">
<Package className="h-4 w-4" /> <Package className="h-4 w-4" />
Bulk Actions Bulk Actions
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
@@ -486,11 +521,11 @@ export default function StockManagementPage() {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleBulkAction('enable')}> <DropdownMenuItem onClick={() => handleBulkAction('enable')}>
<CheckSquare className="h-4 w-4 mr-2" /> <CheckSquare className="h-4 w-4 mr-2" />
Enable Stock Tracking Enable Tracking
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleBulkAction('disable')}> <DropdownMenuItem onClick={() => handleBulkAction('disable')}>
<XSquare className="h-4 w-4 mr-2" /> <XSquare className="h-4 w-4 mr-2" />
Disable Stock Tracking Disable Tracking
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -498,90 +533,140 @@ export default function StockManagementPage() {
</div> </div>
</div> </div>
<div className="rounded-md border"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<Table> <CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30 flex flex-row items-center justify-between">
<TableHeader> <div>
<TableRow> <CardTitle className="text-lg font-medium">Inventory Data</CardTitle>
<TableHead className="w-12"> <CardDescription>Manage stock levels and tracking for {products.length} products</CardDescription>
<input </div>
type="checkbox" <div className="text-xs text-muted-foreground bg-background/50 px-3 py-1 rounded-full border border-border/50">
checked={selectedProducts.length === products.length} {filteredProducts.length} items
onChange={toggleSelectAll} </div>
className="rounded border-gray-300" </CardHeader>
/> <CardContent className="p-0">
</TableHead> <div className="overflow-x-auto">
<TableHead>Product</TableHead> <Table>
<TableHead>Stock Status</TableHead> <TableHeader className="bg-muted/50">
<TableHead>Current Stock</TableHead> <TableRow className="border-border/50 hover:bg-transparent">
<TableHead>Track Stock</TableHead> <TableHead className="w-12 pl-6">
<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>
<input <input
type="checkbox" type="checkbox"
checked={selectedProducts.includes(product._id || '')} checked={selectedProducts.length === products.length && products.length > 0}
onChange={() => toggleSelectProduct(product._id || '')} onChange={toggleSelectAll}
className="rounded border-gray-300" className="rounded border-gray-300 dark:border-zinc-700 bg-background focus:ring-primary/20"
/> />
</TableCell> </TableHead>
<TableCell>{product.name}</TableCell> <TableHead>Product</TableHead>
<TableCell>{getStockStatus(product)}</TableCell> <TableHead>Status</TableHead>
<TableCell> <TableHead>Current Stock</TableHead>
{editingStock[product._id || ''] ? ( <TableHead>Tracking</TableHead>
<div className="flex items-center gap-2"> <TableHead className="text-right pr-6">Actions</TableHead>
<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>
</TableRow> </TableRow>
)) </TableHeader>
)} <TableBody>
</TableBody> <AnimatePresence mode="popLayout">
</Table> {loading ? (
</div> <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> </div>
<AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}> <AlertDialog open={isConfirmDialogOpen} onOpenChange={setIsConfirmDialogOpen}>
@@ -589,12 +674,14 @@ export default function StockManagementPage() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle> <AlertDialogTitle>Confirm Bulk Action</AlertDialogTitle>
<AlertDialogDescription> <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> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel> <AlertDialogCancel onClick={() => setBulkAction(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={executeBulkAction}>Continue</AlertDialogAction> <AlertDialogAction onClick={executeBulkAction} className="bg-primary text-primary-foreground">
Continue
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -8,8 +8,9 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@/components/ui/button"; 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"; import { ShippingMethod } from "@/lib/types";
interface ShippingTableProps { interface ShippingTableProps {
@@ -26,61 +27,90 @@ export const ShippingTable: React.FC<ShippingTableProps> = ({
onDeleteShipping, onDeleteShipping,
}) => { }) => {
return ( return (
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden"> <Card className="border-border/40 bg-background/50 backdrop-blur-sm shadow-sm overflow-hidden">
<Table className="relative"> <CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/30">
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50"> <CardTitle className="text-lg font-medium flex items-center gap-2">
<TableRow className="hover:bg-transparent"> <Truck className="h-5 w-5 text-primary" />
<TableHead className="w-[60%]">Name</TableHead> Available Methods
<TableHead className="text-center">Price</TableHead> </CardTitle>
<TableHead className="text-right">Actions</TableHead> </CardHeader>
</TableRow> <CardContent className="p-0">
</TableHeader> <div className="overflow-auto">
<TableBody> <Table>
{loading ? ( <TableHeader className="bg-muted/50 sticky top-0 z-10">
<TableRow> <TableRow className="hover:bg-transparent border-border/50">
<TableCell colSpan={3} className="text-center"> <TableHead className="w-[60%] pl-6">Method Name</TableHead>
<Skeleton className="h-4 w-[200px]" /> <TableHead className="text-center">Price</TableHead>
</TableCell> <TableHead className="text-right pr-6">Actions</TableHead>
</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>
</TableRow> </TableRow>
)) </TableHeader>
) : ( <TableBody>
<TableRow> <AnimatePresence mode="popLayout">
<TableCell colSpan={3} className="h-24 text-center"> {loading ? (
No shipping methods found <TableRow>
</TableCell> <TableCell colSpan={3} className="h-24 text-center">
</TableRow> <div className="flex items-center justify-center gap-2 text-muted-foreground">
)} <Skeleton className="h-4 w-4 rounded-full" />
</TableBody> Loading methods...
</Table> </div>
</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>
); );
}; };