Compare commits
2 Commits
211cdc71f9
...
f7e768f6d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7e768f6d6 | ||
|
|
7c7db0fc09 |
@@ -45,7 +45,7 @@ const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-ana
|
||||
|
||||
function ProductTableSkeleton() {
|
||||
return (
|
||||
<Card className="animate-in fade-in duration-500">
|
||||
<Card className="animate-in fade-in duration-500 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
@@ -60,8 +60,8 @@ function ProductTableSkeleton() {
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{['Product', 'Category', 'Price', 'Stock', 'Status', 'Actions'].map((header, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||
style={{
|
||||
animationDelay: `${i * 50}ms`,
|
||||
@@ -72,10 +72,10 @@ function ProductTableSkeleton() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
<div
|
||||
key={i}
|
||||
className="border-b last:border-b-0 p-4 animate-in fade-in"
|
||||
style={{
|
||||
animationDelay: `${300 + i * 50}ms`,
|
||||
@@ -152,7 +152,7 @@ export default function ProductsPage() {
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [addProductOpen, setAddProductOpen] = useState(false);
|
||||
const [profitAnalysisOpen, setProfitAnalysisOpen] = useState(false);
|
||||
const [selectedProductForAnalysis, setSelectedProductForAnalysis] = useState<{id: string, name: string} | null>(null);
|
||||
const [selectedProductForAnalysis, setSelectedProductForAnalysis] = useState<{ id: string, name: string } | null>(null);
|
||||
|
||||
// Fetch products and categories
|
||||
useEffect(() => {
|
||||
@@ -169,7 +169,7 @@ export default function ProductsPage() {
|
||||
const fetchDataAsync = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
const [fetchedProducts, fetchedCategories] = await Promise.all([
|
||||
clientFetch('/products'),
|
||||
clientFetch('/categories'),
|
||||
@@ -210,7 +210,7 @@ export default function ProductsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle input changes
|
||||
// Handle input changes
|
||||
const handleChange = (
|
||||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => setProductData({ ...productData, [e.target.name]: e.target.value });
|
||||
@@ -226,7 +226,7 @@ export default function ProductsPage() {
|
||||
setProductData({ ...productData, pricing: updatedPricing });
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleSaveProduct = async (data: Product, file?: File | null) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -247,7 +247,7 @@ export default function ProductsPage() {
|
||||
// Save the product data
|
||||
const endpoint = editing ? `/products/${data._id}` : "/products";
|
||||
const method = editing ? "PUT" : "POST";
|
||||
|
||||
|
||||
const productResponse = await clientFetch(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -259,10 +259,10 @@ export default function ProductsPage() {
|
||||
// If there's a new image to upload
|
||||
if (file) {
|
||||
const imageEndpoint = `/products/${productResponse._id || data._id}/image`;
|
||||
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
|
||||
await fetch(`${process.env.NEXT_PUBLIC_API_URL}${imageEndpoint}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
@@ -279,10 +279,10 @@ export default function ProductsPage() {
|
||||
// Refresh products list
|
||||
const fetchedProducts = await clientFetch('/products');
|
||||
setProducts(fetchedProducts);
|
||||
|
||||
|
||||
setModalOpen(false);
|
||||
setLoading(false);
|
||||
|
||||
|
||||
toast.success(
|
||||
editing ? "Product updated successfully" : "Product added successfully"
|
||||
);
|
||||
@@ -296,18 +296,18 @@ export default function ProductsPage() {
|
||||
// Handle delete product
|
||||
const handleDeleteProduct = async (productId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this product?")) return;
|
||||
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
await clientFetch(`/products/${productId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
||||
// Refresh products list
|
||||
const fetchedProducts = await clientFetch('/products');
|
||||
setProducts(fetchedProducts);
|
||||
|
||||
|
||||
toast.success("Product deleted successfully");
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
@@ -323,9 +323,9 @@ export default function ProductsPage() {
|
||||
...product,
|
||||
pricing: product.pricing
|
||||
? product.pricing.map((tier) => ({
|
||||
minQuantity: tier.minQuantity,
|
||||
pricePerUnit: tier.pricePerUnit,
|
||||
}))
|
||||
minQuantity: tier.minQuantity,
|
||||
pricePerUnit: tier.pricePerUnit,
|
||||
}))
|
||||
: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||
costPerUnit: product.costPerUnit || 0,
|
||||
});
|
||||
@@ -343,16 +343,16 @@ export default function ProductsPage() {
|
||||
image: null, // Clear image so user can upload a new one
|
||||
pricing: product.pricing
|
||||
? product.pricing.map((tier) => ({
|
||||
minQuantity: tier.minQuantity,
|
||||
pricePerUnit: tier.pricePerUnit,
|
||||
}))
|
||||
minQuantity: tier.minQuantity,
|
||||
pricePerUnit: tier.pricePerUnit,
|
||||
}))
|
||||
: [{ minQuantity: 1, pricePerUnit: 0 }],
|
||||
costPerUnit: product.costPerUnit || 0,
|
||||
// Reset stock to defaults for cloned product
|
||||
currentStock: 0,
|
||||
stockStatus: 'out_of_stock' as const,
|
||||
};
|
||||
|
||||
|
||||
setProductData(clonedProduct);
|
||||
setEditing(false); // Set to false so it creates a new product
|
||||
setAddProductOpen(true);
|
||||
@@ -390,19 +390,19 @@ export default function ProductsPage() {
|
||||
// Filter products based on search term
|
||||
const filteredProducts = products.filter(product => {
|
||||
if (!searchTerm) return true;
|
||||
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
|
||||
|
||||
// Search in product name
|
||||
if (product.name.toLowerCase().includes(searchLower)) return true;
|
||||
|
||||
|
||||
// Search in product description if it exists
|
||||
if (product.description && product.description.toLowerCase().includes(searchLower)) return true;
|
||||
|
||||
|
||||
// Search in category name
|
||||
const categoryName = getCategoryNameById(product.category).toLowerCase();
|
||||
if (categoryName.includes(searchLower)) return true;
|
||||
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -437,19 +437,19 @@ export default function ProductsPage() {
|
||||
const handleToggleEnabled = async (productId: string, enabled: boolean) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
await clientFetch(`/products/${productId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
|
||||
// Update the local state
|
||||
setProducts(products.map(product =>
|
||||
product._id === productId
|
||||
? { ...product, enabled }
|
||||
setProducts(products.map(product =>
|
||||
product._id === productId
|
||||
? { ...product, enabled }
|
||||
: product
|
||||
));
|
||||
|
||||
|
||||
toast.success(`Product ${enabled ? 'enabled' : 'disabled'} successfully`);
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
@@ -489,9 +489,9 @@ export default function ProductsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
variant="outline"
|
||||
<Button
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
variant="outline"
|
||||
className="gap-2 flex-1 sm:flex-none"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
|
||||
@@ -14,11 +14,22 @@ import {
|
||||
AlertCircle,
|
||||
Calculator,
|
||||
Copy,
|
||||
PackageX,
|
||||
Archive
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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[];
|
||||
@@ -68,35 +79,57 @@ const ProductTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const renderProductRow = (product: Product, isDisabled: boolean = false) => (
|
||||
<TableRow
|
||||
const renderProductRow = (product: Product, index: number, isDisabled: boolean = false) => (
|
||||
<motion.tr
|
||||
key={product._id}
|
||||
className={`transition-colors hover:bg-gray-50 dark:hover:bg-zinc-800/70 ${isDisabled ? "opacity-60" : ""}`}
|
||||
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>
|
||||
<div className="font-medium truncate max-w-[180px]">{product.name}</div>
|
||||
<div className="hidden sm:block text-sm text-muted-foreground mt-1">
|
||||
{getCategoryNameById(product.category)}
|
||||
<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) ? (
|
||||
<img
|
||||
src={getProductImageUrl(product)!}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{getCategoryNameById(product.category)}
|
||||
<Badge variant="outline" className="font-normal bg-background/50">
|
||||
{getCategoryNameById(product.category)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-center">
|
||||
<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">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{getStockIcon(product)}
|
||||
<span className="text-sm">
|
||||
{product.currentStock !== undefined ? product.currentStock : 0}{" "}
|
||||
{product.unitType}
|
||||
<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="outline" className="text-xs">
|
||||
Not Tracked
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-muted-foreground bg-muted/50">
|
||||
Unlimited
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -106,57 +139,61 @@ const ProductTable = ({
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleEnabled(product._id as string, checked)
|
||||
}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right flex justify-end space-x-1">
|
||||
{onProfitAnalysis && (
|
||||
<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="sm"
|
||||
onClick={() =>
|
||||
onProfitAnalysis(product._id as string, product.name)
|
||||
}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
|
||||
title="Profit Analysis"
|
||||
size="icon"
|
||||
onClick={() => onEdit(product)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
title="Edit Product"
|
||||
>
|
||||
<Calculator className="h-4 w-4" />
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{onClone && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onClone(product)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20"
|
||||
title="Clone Listing"
|
||||
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"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(product)}
|
||||
title="Edit Product"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(product._id as string)}
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
title="Delete Product"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</motion.tr>
|
||||
);
|
||||
|
||||
const renderTableHeader = () => (
|
||||
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<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
|
||||
@@ -166,57 +203,86 @@ const ProductTable = ({
|
||||
<TableHead className="hidden lg:table-cell text-center">
|
||||
Enabled
|
||||
</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead className="text-right pr-6">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* Enabled Products Table */}
|
||||
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden">
|
||||
<Table className="relative">
|
||||
{renderTableHeader()}
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
Array.from({ length: 1 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
<TableCell>Loading...</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : sortedEnabledProducts.length > 0 ? (
|
||||
sortedEnabledProducts.map((product) => renderProductRow(product))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
No enabled products found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<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">
|
||||
<CheckCircle className="h-5 w-5 text-primary" />
|
||||
Active Products
|
||||
<Badge variant="secondary" className="ml-2 bg-background/80 backdrop-blur-sm">
|
||||
{sortedEnabledProducts.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[600px] overflow-auto">
|
||||
<Table>
|
||||
{renderTableHeader()}
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||
Loading products...
|
||||
</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-50" />
|
||||
<p>No active products found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disabled Products Section */}
|
||||
{!loading && disabledProducts.length > 0 && (
|
||||
<div className="rounded-lg border dark:border-zinc-700 shadow-sm overflow-hidden bg-gray-50/30 dark:bg-zinc-900/30">
|
||||
<Table className="relative">
|
||||
{renderTableHeader()}
|
||||
<TableBody>
|
||||
{sortedDisabledProducts.map((product) =>
|
||||
renderProductRow(product, true),
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Card className="border-border/40 bg-background/30 backdrop-blur-sm shadow-sm overflow-hidden opacity-90">
|
||||
<CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/20">
|
||||
<CardTitle className="text-lg font-medium flex items-center gap-2 text-muted-foreground">
|
||||
<Archive className="h-5 w-5" />
|
||||
Archived / Disabled
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{sortedDisabledProducts.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-[400px] overflow-auto">
|
||||
<Table>
|
||||
{renderTableHeader()}
|
||||
<TableBody>
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedDisabledProducts.map((product, index) =>
|
||||
renderProductRow(product, index, true),
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
export default ProductTable;
|
||||
|
||||
@@ -15,4 +15,5 @@ export interface Product {
|
||||
pricePerUnit: number;
|
||||
}>;
|
||||
image?: string | File | null | undefined;
|
||||
costPerUnit?: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user