Compare commits

..

2 Commits

Author SHA1 Message Date
g
f7e768f6d6 Improve product image handling and add costPerUnit
All checks were successful
Build Frontend / build (push) Successful in 1m10s
Added a utility to generate product image URLs, ensuring images are displayed correctly in the product table. Updated the Product model to include an optional costPerUnit field. Minor UI and code formatting improvements were made for consistency.
2026-01-12 06:59:21 +00:00
g
7c7db0fc09 Update product-table.tsx 2026-01-12 06:54:28 +00:00
3 changed files with 198 additions and 131 deletions

View File

@@ -45,7 +45,7 @@ const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-ana
function ProductTableSkeleton() { function ProductTableSkeleton() {
return ( 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> <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" />
@@ -60,8 +60,8 @@ function ProductTableSkeleton() {
<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">
{['Product', 'Category', 'Price', 'Stock', 'Status', 'Actions'].map((header, i) => ( {['Product', 'Category', 'Price', 'Stock', 'Status', '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`,
@@ -72,10 +72,10 @@ function ProductTableSkeleton() {
))} ))}
</div> </div>
</div> </div>
{[...Array(8)].map((_, i) => ( {[...Array(8)].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: `${300 + i * 50}ms`, animationDelay: `${300 + i * 50}ms`,
@@ -152,7 +152,7 @@ export default function ProductsPage() {
const [importModalOpen, setImportModalOpen] = useState(false); const [importModalOpen, setImportModalOpen] = useState(false);
const [addProductOpen, setAddProductOpen] = useState(false); const [addProductOpen, setAddProductOpen] = useState(false);
const [profitAnalysisOpen, setProfitAnalysisOpen] = 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 // Fetch products and categories
useEffect(() => { useEffect(() => {
@@ -169,7 +169,7 @@ export default function ProductsPage() {
const fetchDataAsync = async () => { const fetchDataAsync = async () => {
try { try {
setLoading(true); setLoading(true);
const [fetchedProducts, fetchedCategories] = await Promise.all([ const [fetchedProducts, fetchedCategories] = await Promise.all([
clientFetch('/products'), clientFetch('/products'),
clientFetch('/categories'), clientFetch('/categories'),
@@ -210,7 +210,7 @@ export default function ProductsPage() {
})); }));
}; };
// Handle input changes // Handle input changes
const handleChange = ( const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => setProductData({ ...productData, [e.target.name]: e.target.value }); ) => setProductData({ ...productData, [e.target.name]: e.target.value });
@@ -226,7 +226,7 @@ export default function ProductsPage() {
setProductData({ ...productData, pricing: updatedPricing }); setProductData({ ...productData, pricing: updatedPricing });
}; };
const handleSaveProduct = async (data: Product, file?: File | null) => { const handleSaveProduct = async (data: Product, file?: File | null) => {
try { try {
setLoading(true); setLoading(true);
@@ -247,7 +247,7 @@ export default function ProductsPage() {
// Save the product data // Save the product data
const endpoint = editing ? `/products/${data._id}` : "/products"; const endpoint = editing ? `/products/${data._id}` : "/products";
const method = editing ? "PUT" : "POST"; const method = editing ? "PUT" : "POST";
const productResponse = await clientFetch(endpoint, { const productResponse = await clientFetch(endpoint, {
method, method,
headers: { headers: {
@@ -259,10 +259,10 @@ export default function ProductsPage() {
// If there's a new image to upload // If there's a new image to upload
if (file) { if (file) {
const imageEndpoint = `/products/${productResponse._id || data._id}/image`; const imageEndpoint = `/products/${productResponse._id || data._id}/image`;
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
await fetch(`${process.env.NEXT_PUBLIC_API_URL}${imageEndpoint}`, { await fetch(`${process.env.NEXT_PUBLIC_API_URL}${imageEndpoint}`, {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -279,10 +279,10 @@ export default function ProductsPage() {
// Refresh products list // Refresh products list
const fetchedProducts = await clientFetch('/products'); const fetchedProducts = await clientFetch('/products');
setProducts(fetchedProducts); setProducts(fetchedProducts);
setModalOpen(false); setModalOpen(false);
setLoading(false); setLoading(false);
toast.success( toast.success(
editing ? "Product updated successfully" : "Product added successfully" editing ? "Product updated successfully" : "Product added successfully"
); );
@@ -296,18 +296,18 @@ export default function ProductsPage() {
// Handle delete product // Handle delete product
const handleDeleteProduct = async (productId: string) => { const handleDeleteProduct = async (productId: string) => {
if (!confirm("Are you sure you want to delete this product?")) return; if (!confirm("Are you sure you want to delete this product?")) return;
try { try {
setLoading(true); setLoading(true);
await clientFetch(`/products/${productId}`, { await clientFetch(`/products/${productId}`, {
method: "DELETE", method: "DELETE",
}); });
// Refresh products list // Refresh products list
const fetchedProducts = await clientFetch('/products'); const fetchedProducts = await clientFetch('/products');
setProducts(fetchedProducts); setProducts(fetchedProducts);
toast.success("Product deleted successfully"); toast.success("Product deleted successfully");
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
@@ -323,9 +323,9 @@ export default function ProductsPage() {
...product, ...product,
pricing: product.pricing pricing: product.pricing
? product.pricing.map((tier) => ({ ? product.pricing.map((tier) => ({
minQuantity: tier.minQuantity, minQuantity: tier.minQuantity,
pricePerUnit: tier.pricePerUnit, pricePerUnit: tier.pricePerUnit,
})) }))
: [{ minQuantity: 1, pricePerUnit: 0 }], : [{ minQuantity: 1, pricePerUnit: 0 }],
costPerUnit: product.costPerUnit || 0, costPerUnit: product.costPerUnit || 0,
}); });
@@ -343,16 +343,16 @@ export default function ProductsPage() {
image: null, // Clear image so user can upload a new one image: null, // Clear image so user can upload a new one
pricing: product.pricing pricing: product.pricing
? product.pricing.map((tier) => ({ ? product.pricing.map((tier) => ({
minQuantity: tier.minQuantity, minQuantity: tier.minQuantity,
pricePerUnit: tier.pricePerUnit, pricePerUnit: tier.pricePerUnit,
})) }))
: [{ minQuantity: 1, pricePerUnit: 0 }], : [{ minQuantity: 1, pricePerUnit: 0 }],
costPerUnit: product.costPerUnit || 0, costPerUnit: product.costPerUnit || 0,
// Reset stock to defaults for cloned product // Reset stock to defaults for cloned product
currentStock: 0, currentStock: 0,
stockStatus: 'out_of_stock' as const, stockStatus: 'out_of_stock' as const,
}; };
setProductData(clonedProduct); setProductData(clonedProduct);
setEditing(false); // Set to false so it creates a new product setEditing(false); // Set to false so it creates a new product
setAddProductOpen(true); setAddProductOpen(true);
@@ -390,19 +390,19 @@ export default function ProductsPage() {
// Filter products based on search term // Filter products based on search term
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();
// Search in product name // Search in product name
if (product.name.toLowerCase().includes(searchLower)) return true; if (product.name.toLowerCase().includes(searchLower)) return true;
// Search in product description if it exists // Search in product description if it exists
if (product.description && product.description.toLowerCase().includes(searchLower)) return true; if (product.description && product.description.toLowerCase().includes(searchLower)) return true;
// Search in category name // Search in category name
const categoryName = getCategoryNameById(product.category).toLowerCase(); const categoryName = getCategoryNameById(product.category).toLowerCase();
if (categoryName.includes(searchLower)) return true; if (categoryName.includes(searchLower)) return true;
return false; return false;
}); });
@@ -437,19 +437,19 @@ export default function ProductsPage() {
const handleToggleEnabled = async (productId: string, enabled: boolean) => { const handleToggleEnabled = async (productId: string, enabled: boolean) => {
try { try {
setLoading(true); setLoading(true);
await clientFetch(`/products/${productId}`, { await clientFetch(`/products/${productId}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ enabled }), body: JSON.stringify({ enabled }),
}); });
// Update the local state // Update the local state
setProducts(products.map(product => setProducts(products.map(product =>
product._id === productId product._id === productId
? { ...product, enabled } ? { ...product, enabled }
: product : product
)); ));
toast.success(`Product ${enabled ? 'enabled' : 'disabled'} successfully`); toast.success(`Product ${enabled ? 'enabled' : 'disabled'} successfully`);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
@@ -489,9 +489,9 @@ export default function ProductsPage() {
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => setImportModalOpen(true)} onClick={() => setImportModalOpen(true)}
variant="outline" variant="outline"
className="gap-2 flex-1 sm:flex-none" className="gap-2 flex-1 sm:flex-none"
> >
<Upload className="h-4 w-4" /> <Upload className="h-4 w-4" />

View File

@@ -14,11 +14,22 @@ import {
AlertCircle, AlertCircle,
Calculator, Calculator,
Copy, Copy,
PackageX,
Archive
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Product } from "@/models/products"; import { Product } from "@/models/products";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; 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 { interface ProductTableProps {
products: Product[]; products: Product[];
@@ -68,35 +79,57 @@ const ProductTable = ({
} }
}; };
const renderProductRow = (product: Product, isDisabled: boolean = false) => ( const renderProductRow = (product: Product, index: number, isDisabled: boolean = false) => (
<TableRow <motion.tr
key={product._id} 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> <TableCell className="font-medium">
<div className="font-medium truncate max-w-[180px]">{product.name}</div> <div className="flex items-center gap-2">
<div className="hidden sm:block text-sm text-muted-foreground mt-1"> <div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
{getCategoryNameById(product.category)} {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> </div>
</TableCell> </TableCell>
<TableCell className="hidden sm:table-cell text-center"> <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>
<TableCell className="hidden md:table-cell text-center"> <TableCell className="hidden md:table-cell text-center text-muted-foreground text-sm">
{product.unitType} {product.unitType}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{product.stockTracking ? ( {product.stockTracking ? (
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1.5">
{getStockIcon(product)} {getStockIcon(product)}
<span className="text-sm"> <span className={`text-sm font-medium ${product.stockStatus === 'out_of_stock' ? 'text-destructive' :
{product.currentStock !== undefined ? product.currentStock : 0}{" "} product.stockStatus === 'low_stock' ? 'text-amber-500' : 'text-foreground'
{product.unitType} }`}>
{product.currentStock !== undefined ? product.currentStock : 0}
</span> </span>
</div> </div>
) : ( ) : (
<Badge variant="outline" className="text-xs"> <Badge variant="secondary" className="text-[10px] h-5 px-1.5 text-muted-foreground bg-muted/50">
Not Tracked Unlimited
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
@@ -106,57 +139,61 @@ const ProductTable = ({
onCheckedChange={(checked) => onCheckedChange={(checked) =>
onToggleEnabled(product._id as string, checked) onToggleEnabled(product._id as string, checked)
} }
className="data-[state=checked]:bg-primary"
/> />
</TableCell> </TableCell>
<TableCell className="text-right flex justify-end space-x-1"> <TableCell className="text-right">
{onProfitAnalysis && ( <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 <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onClick={() => onEdit(product)}
onProfitAnalysis(product._id as string, product.name) className="h-8 w-8 text-muted-foreground hover:text-foreground"
} title="Edit Product"
className="text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950/20"
title="Profit Analysis"
> >
<Calculator className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
)}
{onClone && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => onClone(product)} onClick={() => onDelete(product._id as string)}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-950/20" className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Clone Listing" title="Delete Product"
> >
<Copy className="h-4 w-4" /> <Trash className="h-4 w-4" />
</Button> </Button>
)} </div>
<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>
</TableCell> </TableCell>
</TableRow> </motion.tr>
); );
const renderTableHeader = () => ( const renderTableHeader = () => (
<TableHeader className="bg-gray-50 dark:bg-zinc-800/50"> <TableHeader className="bg-muted/50 sticky top-0 z-10">
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent border-border/50">
<TableHead className="w-[200px]">Product</TableHead> <TableHead className="w-[200px]">Product</TableHead>
<TableHead className="hidden sm:table-cell text-center"> <TableHead className="hidden sm:table-cell text-center">
Category Category
@@ -166,57 +203,86 @@ const ProductTable = ({
<TableHead className="hidden lg:table-cell text-center"> <TableHead className="hidden lg:table-cell text-center">
Enabled Enabled
</TableHead> </TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right pr-6">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
); );
return ( return (
<div className="space-y-6"> <div className="space-y-8">
{/* Enabled Products Table */} {/* Enabled Products Table */}
<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">
{renderTableHeader()} <CardTitle className="text-lg font-medium flex items-center gap-2">
<TableBody> <CheckCircle className="h-5 w-5 text-primary" />
{loading ? ( Active Products
Array.from({ length: 1 }).map((_, index) => ( <Badge variant="secondary" className="ml-2 bg-background/80 backdrop-blur-sm">
<TableRow key={index}> {sortedEnabledProducts.length}
<TableCell>Loading...</TableCell> </Badge>
<TableCell>Loading...</TableCell> </CardTitle>
<TableCell>Loading...</TableCell> </CardHeader>
<TableCell>Loading...</TableCell> <CardContent className="p-0">
<TableCell>Loading...</TableCell> <div className="max-h-[600px] overflow-auto">
<TableCell>Loading...</TableCell> <Table>
</TableRow> {renderTableHeader()}
)) <TableBody>
) : sortedEnabledProducts.length > 0 ? ( <AnimatePresence mode="popLayout">
sortedEnabledProducts.map((product) => renderProductRow(product)) {loading ? (
) : ( <TableRow>
<TableRow> <TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
<TableCell colSpan={6} className="h-24 text-center"> Loading products...
No enabled products found. </TableCell>
</TableCell> </TableRow>
</TableRow> ) : sortedEnabledProducts.length > 0 ? (
)} sortedEnabledProducts.map((product, index) => renderProductRow(product, index))
</TableBody> ) : (
</Table> <TableRow>
</div> <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 */} {/* Disabled Products Section */}
{!loading && disabledProducts.length > 0 && ( {!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"> <Card className="border-border/40 bg-background/30 backdrop-blur-sm shadow-sm overflow-hidden opacity-90">
<Table className="relative"> <CardHeader className="py-4 px-6 border-b border-border/50 bg-muted/20">
{renderTableHeader()} <CardTitle className="text-lg font-medium flex items-center gap-2 text-muted-foreground">
<TableBody> <Archive className="h-5 w-5" />
{sortedDisabledProducts.map((product) => Archived / Disabled
renderProductRow(product, true), <Badge variant="outline" className="ml-2">
)} {sortedDisabledProducts.length}
</TableBody> </Badge>
</Table> </CardTitle>
</div> </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> </div>
); );
}; };
export default ProductTable; export default ProductTable;

View File

@@ -15,4 +15,5 @@ export interface Product {
pricePerUnit: number; pricePerUnit: number;
}>; }>;
image?: string | File | null | undefined; image?: string | File | null | undefined;
costPerUnit?: number;
} }