Improve product image handling and add costPerUnit
All checks were successful
Build Frontend / build (push) Successful in 1m10s
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.
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
Table,
|
import {
|
||||||
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Calculator,
|
Calculator,
|
||||||
Copy,
|
Copy,
|
||||||
PackageOffset,
|
PackageX,
|
||||||
Archive
|
Archive
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -23,6 +24,13 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -82,9 +90,13 @@ const ProductTable = ({
|
|||||||
>
|
>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="h-8 w-8 rounded bg-muted/50 flex items-center justify-center text-muted-foreground overflow-hidden relative">
|
||||||
{product.image ? (
|
{getProductImageUrl(product) ? (
|
||||||
<img src={product.image} alt={product.name} className="h-full w-full object-cover rounded" />
|
<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>
|
<span className="text-xs font-bold">{product.name.charAt(0).toUpperCase()}</span>
|
||||||
)}
|
)}
|
||||||
@@ -110,7 +122,7 @@ const ProductTable = ({
|
|||||||
<div className="flex items-center justify-center gap-1.5">
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
{getStockIcon(product)}
|
{getStockIcon(product)}
|
||||||
<span className={`text-sm font-medium ${product.stockStatus === 'out_of_stock' ? 'text-destructive' :
|
<span className={`text-sm font-medium ${product.stockStatus === 'out_of_stock' ? 'text-destructive' :
|
||||||
product.stockStatus === 'low_stock' ? 'text-amber-500' : 'text-foreground'
|
product.stockStatus === 'low_stock' ? 'text-amber-500' : 'text-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{product.currentStock !== undefined ? product.currentStock : 0}
|
{product.currentStock !== undefined ? product.currentStock : 0}
|
||||||
</span>
|
</span>
|
||||||
@@ -227,7 +239,7 @@ const ProductTable = ({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-2">
|
||||||
<PackageOffset className="h-8 w-8 opacity-50" />
|
<PackageX className="h-8 w-8 opacity-50" />
|
||||||
<p>No active products found</p>
|
<p>No active products found</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ export interface Product {
|
|||||||
pricePerUnit: number;
|
pricePerUnit: number;
|
||||||
}>;
|
}>;
|
||||||
image?: string | File | null | undefined;
|
image?: string | File | null | undefined;
|
||||||
|
costPerUnit?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user