Some checks failed
Build Frontend / build (push) Failing after 7s
Introduces a modular dashboard system with draggable, configurable widgets including revenue, low stock, recent customers, and pending chats. Adds a dashboard editor for layout customization, widget visibility, and settings. Refactors dashboard content to use the new widget system and improves UI consistency and interactivity.
332 lines
13 KiB
TypeScript
332 lines
13 KiB
TypeScript
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import Image from "next/image";
|
|
import {
|
|
Edit,
|
|
Trash,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Calculator,
|
|
Copy,
|
|
PackageX,
|
|
Archive
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import React, { useState, useEffect } from "react";
|
|
import { Product } from "@/models/products";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
const getProductImageUrl = (product: Product) => {
|
|
if (!product.image) return null;
|
|
if (typeof product.image === 'string' && product.image.startsWith('http')) return product.image;
|
|
// Use the API endpoint to serve the image
|
|
return `${process.env.NEXT_PUBLIC_API_URL}/products/${product._id}/image`;
|
|
};
|
|
|
|
interface ProductTableProps {
|
|
products: Product[];
|
|
loading: boolean;
|
|
onEdit: (product: Product) => void;
|
|
onClone?: (product: Product) => void;
|
|
onDelete: (productId: string) => void;
|
|
onToggleEnabled: (productId: string, enabled: boolean) => void;
|
|
onProfitAnalysis?: (productId: string, productName: string) => void;
|
|
getCategoryNameById: (categoryId: string) => string;
|
|
}
|
|
|
|
const ProductTable = ({
|
|
products,
|
|
loading,
|
|
onEdit,
|
|
onClone,
|
|
onDelete,
|
|
onToggleEnabled,
|
|
onProfitAnalysis,
|
|
getCategoryNameById,
|
|
}: ProductTableProps) => {
|
|
// Separate enabled and disabled products
|
|
const enabledProducts = products.filter((p) => p.enabled !== false);
|
|
const disabledProducts = products.filter((p) => p.enabled === false);
|
|
|
|
const sortByCategory = (productList: Product[]) => {
|
|
return [...productList].sort((a, b) => {
|
|
const categoryNameA = getCategoryNameById(a.category);
|
|
const categoryNameB = getCategoryNameById(b.category);
|
|
return categoryNameA.localeCompare(categoryNameB);
|
|
});
|
|
};
|
|
|
|
const sortedEnabledProducts = sortByCategory(enabledProducts);
|
|
const sortedDisabledProducts = sortByCategory(disabledProducts);
|
|
|
|
const getStockIcon = (product: Product) => {
|
|
if (!product.stockTracking) return null;
|
|
|
|
if (product.stockStatus === "out_of_stock") {
|
|
return <AlertTriangle className="h-4 w-4 text-red-500" />;
|
|
} else if (product.stockStatus === "low_stock") {
|
|
return <AlertCircle className="h-4 w-4 text-amber-500" />;
|
|
} else {
|
|
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
|
}
|
|
};
|
|
|
|
const renderProductRow = (product: Product, index: number, isDisabled: boolean = false) => (
|
|
<motion.tr
|
|
key={product._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 ${isDisabled ? "opacity-60 bg-muted/20" : ""}`}
|
|
>
|
|
<TableCell className="font-medium">
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
|
|
{getProductImageUrl(product) ? (
|
|
<Image
|
|
src={getProductImageUrl(product)!}
|
|
alt={product.name}
|
|
width={32}
|
|
height={32}
|
|
className="h-full w-full object-cover"
|
|
unoptimized={getProductImageUrl(product)?.startsWith('data:')}
|
|
/>
|
|
) : (
|
|
<span className="text-xs font-bold">{product.name.charAt(0).toUpperCase()}</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="truncate max-w-[180px]">{product.name}</div>
|
|
<div className="sm:hidden text-xs text-muted-foreground">
|
|
{getCategoryNameById(product.category)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="hidden sm:table-cell text-center">
|
|
<Badge variant="outline" className="font-normal bg-background/50">
|
|
{getCategoryNameById(product.category)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="hidden md:table-cell text-center text-muted-foreground text-sm">
|
|
{product.unitType}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
{product.stockTracking ? (
|
|
<div className="flex items-center justify-center gap-1.5">
|
|
{getStockIcon(product)}
|
|
<span className={`text-sm font-medium ${product.stockStatus === 'out_of_stock' ? 'text-destructive' :
|
|
product.stockStatus === 'low_stock' ? 'text-amber-500' : 'text-foreground'
|
|
}`}>
|
|
{product.currentStock !== undefined ? product.currentStock : 0}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-muted-foreground bg-muted/50">
|
|
Unlimited
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="hidden lg:table-cell text-center">
|
|
<Switch
|
|
checked={product.enabled !== false}
|
|
onCheckedChange={(checked) =>
|
|
onToggleEnabled(product._id as string, checked)
|
|
}
|
|
className="data-[state=checked]:bg-primary"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{onProfitAnalysis && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() =>
|
|
onProfitAnalysis(product._id as string, product.name)
|
|
}
|
|
className="h-8 w-8 text-emerald-500 hover:text-emerald-600 hover:bg-emerald-500/10"
|
|
title="Profit Analysis"
|
|
>
|
|
<Calculator className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{onClone && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onClone(product)}
|
|
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-500/10"
|
|
title="Clone Listing"
|
|
>
|
|
<Copy className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onEdit(product)}
|
|
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
|
title="Edit Product"
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onDelete(product._id as string)}
|
|
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
|
title="Delete Product"
|
|
>
|
|
<Trash className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</motion.tr>
|
|
);
|
|
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
// Browser detection
|
|
const [isFirefox, setIsFirefox] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const ua = navigator.userAgent.toLowerCase();
|
|
setIsFirefox(ua.includes("firefox") && !ua.includes("chrome"));
|
|
}, []);
|
|
|
|
const renderTableHeader = () => (
|
|
<TableHeader className="bg-muted/50 sticky top-0 z-10">
|
|
<TableRow className="hover:bg-transparent border-border/50">
|
|
<TableHead className="w-[200px]">Product</TableHead>
|
|
<TableHead className="hidden sm:table-cell text-center">
|
|
Category
|
|
</TableHead>
|
|
<TableHead className="hidden md:table-cell text-center">Unit</TableHead>
|
|
<TableHead className="text-center">Stock</TableHead>
|
|
<TableHead className="hidden lg:table-cell text-center">
|
|
Enabled
|
|
</TableHead>
|
|
<TableHead className="text-right pr-6">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* Enabled Products Table */}
|
|
<Card className="border-white/10 bg-black/40 backdrop-blur-xl shadow-2xl overflow-hidden rounded-xl">
|
|
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.02]">
|
|
<CardTitle className="text-lg font-bold flex items-center gap-3 text-white">
|
|
<div className="p-2 rounded-lg bg-indigo-500/10 border border-indigo-500/20">
|
|
<CheckCircle className="h-5 w-5 text-indigo-400" />
|
|
</div>
|
|
Active Products
|
|
<Badge variant="secondary" className="ml-2 bg-indigo-500/10 text-indigo-300 border-indigo-500/20 hover:bg-indigo-500/20">
|
|
{sortedEnabledProducts.length}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="max-h-[600px] overflow-auto">
|
|
<Table>
|
|
{renderTableHeader()}
|
|
<TableBody>
|
|
{isFirefox ? (
|
|
loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
|
<span>Loading products...</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : sortedEnabledProducts.length > 0 ? (
|
|
sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6} 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-20" />
|
|
<p>No active products found</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)
|
|
) : (
|
|
<AnimatePresence>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
|
|
<span>Loading products...</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : sortedEnabledProducts.length > 0 ? (
|
|
sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
|
|
) : (
|
|
<TableRow>
|
|
<TableCell colSpan={6} 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-20" />
|
|
<p>No active products found</p>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</AnimatePresence>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Disabled Products Section */}
|
|
{!loading && disabledProducts.length > 0 && (
|
|
<Card className="border-white/5 bg-black/20 backdrop-blur-sm shadow-none overflow-hidden opacity-80 hover:opacity-100 transition-opacity">
|
|
<CardHeader className="py-4 px-6 border-b border-white/5 bg-white/[0.01]">
|
|
<CardTitle className="text-lg font-medium flex items-center gap-2 text-zinc-400">
|
|
<Archive className="h-5 w-5" />
|
|
Archived / Disabled
|
|
<Badge variant="outline" className="ml-2 border-white/10 text-zinc-500">
|
|
{sortedDisabledProducts.length}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="max-h-[400px] overflow-auto">
|
|
<Table>
|
|
{renderTableHeader()}
|
|
<TableBody>
|
|
<AnimatePresence>
|
|
{sortedDisabledProducts.map((product, index) =>
|
|
renderProductRow(product, index, true),
|
|
)}
|
|
</AnimatePresence>
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
};
|
|
|
|
export default ProductTable;
|