This commit is contained in:
g
2025-02-08 00:54:11 +00:00
parent 30fb2aaaab
commit 468fd69cb5
5 changed files with 375 additions and 331 deletions

View File

@@ -28,7 +28,7 @@ export default function ProductsPage() {
unitType: "pcs",
category: "",
pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
image: null
image: null,
});
// Fetch products and categories
@@ -55,15 +55,15 @@ export default function ProductsPage() {
authToken
),
]);
console.log("Fetched Products:", fetchedProducts);
// Ensure all products have tieredPricing
const processedProducts = fetchedProducts.map((product: Product) => ({
...product,
pricing: product.pricing || [{ minQuantity: 1, pricePerUnit: 0 }],
}));
setProducts(processedProducts);
setCategories(fetchedCategories);
} catch (error) {
@@ -75,6 +75,20 @@ export default function ProductsPage() {
fetchDataAsync();
}, []);
const handleAddTier = () => {
setProductData((prev) => ({
...prev,
pricing: [...prev.pricing, { minQuantity: 1, pricePerUnit: 0 }],
}));
};
const handleRemoveTier = (index: number) => {
setProductData((prev) => ({
...prev,
pricing: prev.pricing.filter((_, i) => i !== index),
}));
};
// Handle input changes
const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
@@ -100,26 +114,26 @@ export default function ProductsPage() {
? parseFloat(tier.pricePerUnit)
: tier.pricePerUnit,
}));
const productToSave: Product = {
...data,
pricing: adjustedPricing,
image: data.image ?? "", // ✅ Prevents undefined error
};
try {
const authToken = document.cookie.split("Authorization=")[1];
const apiUrl = editing
? `${process.env.NEXT_PUBLIC_API_URL}/products/${data._id}`
: `${process.env.NEXT_PUBLIC_API_URL}/products`;
const savedProduct = await saveProductData(
apiUrl,
productToSave,
authToken,
editing ? "PUT" : "POST"
);
setProducts((prevProducts) => {
if (editing) {
return prevProducts.map((product) =>
@@ -129,7 +143,7 @@ export default function ProductsPage() {
return [...prevProducts, savedProduct];
}
});
setModalOpen(false);
} catch (error) {
console.error("Error saving product:", error);
@@ -159,9 +173,9 @@ export default function ProductsPage() {
setProductData({
...product,
pricing: product.pricing
? product.pricing.map(tier => ({
? product.pricing.map((tier) => ({
minQuantity: tier.minQuantity,
pricePerUnit: tier.pricePerUnit
pricePerUnit: tier.pricePerUnit,
}))
: [{ minQuantity: 1, pricePerUnit: 0 }], // Fallback if undefined
});
@@ -219,6 +233,8 @@ export default function ProductsPage() {
editing={editing}
handleChange={handleChange}
handleTieredPricingChange={handleTieredPricingChange}
handleAddTier={handleAddTier} // ✅ Ensure this is passed
handleRemoveTier={handleRemoveTier} // ✅ Ensure this is passed
setProductData={setProductData}
/>
</div>

View File

@@ -0,0 +1,40 @@
"use client";
import { ChangeEvent } from "react";
import { Input } from "@/components/ui/input";
interface ImageUploadProps {
imagePreview: string | null;
handleImageChange: (e: ChangeEvent<HTMLInputElement>) => void;
imageDimensions: { width: number; height: number };
}
export const ImageUpload = ({
imagePreview,
handleImageChange,
imageDimensions,
}: ImageUploadProps) => (
<div>
<label className="text-sm font-medium">Product Image</label>
<div
className="relative border rounded-md flex items-center justify-center bg-gray-50 dark:bg-gray-800 w-full h-48"
style={{ width: imageDimensions.width, height: imageDimensions.height }}
>
{imagePreview ? (
<img
src={imagePreview}
alt="Preview"
className="object-contain w-full h-full rounded-md"
/>
) : (
<span className="text-gray-400 text-sm">No Image Selected</span>
)}
</div>
<Input
type="file"
accept="image/*"
onChange={handleImageChange}
className="mt-2 h-9 text-sm"
/>
</div>
);

View File

@@ -0,0 +1,67 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Trash, PlusCircle } from "lucide-react";
interface PricingTiersProps {
pricing: any[];
handleTierChange: (e: React.ChangeEvent<HTMLInputElement>, index: number) => void;
handleRemoveTier: (index: number) => void;
handleAddTier: () => void;
}
export const PricingTiers = ({
pricing,
handleTierChange,
handleRemoveTier,
handleAddTier,
}: PricingTiersProps) => (
<div>
<h3 className="text-sm font-medium">Tiered Pricing</h3>
{pricing?.length > 0 ? (
pricing.map((tier, index) => (
<div key={tier._id || index} className="flex items-center gap-2 mt-2">
<Input
name="minQuantity"
type="number"
min="1"
placeholder="Min Quantity"
value={tier.minQuantity}
onChange={(e) => handleTierChange(e, index)}
className="h-8 text-sm px-2 flex-1"
/>
<Input
name="pricePerUnit"
type="number"
placeholder="Price per unit"
value={tier.pricePerUnit}
onChange={(e) => handleTierChange(e, index)}
className="h-8 text-sm px-2 flex-1"
/>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:bg-red-100"
onClick={() => handleRemoveTier(index)}
>
<Trash className="h-5 w-5" />
</Button>
</div>
))
) : (
<p className="text-sm text-gray-500 mt-2">No pricing tiers added.</p>
)}
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={handleAddTier}
>
<PlusCircle className="w-4 h-4 mr-1" />
Add Tier
</Button>
</div>
);

View File

@@ -1,58 +1,15 @@
"use client";
import { ChangeEvent, useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Trash, PlusCircle } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImageUpload } from "@/components/forms/image-upload";
import { PricingTiers } from "@/components/forms/pricing-tiers";
import { ProductModalProps, ProductData } from "@/lib/types";
import { toast } from "sonner";
import { Product } from "@/models/products";
interface Category {
_id: string;
name: string;
}
interface PricingTier {
minQuantity: number;
pricePerUnit: number;
_id?: string;
}
interface ProductData {
name: string;
description: string;
unitType: string;
category: string;
pricing: PricingTier[];
image?: string | File | null | undefined;
}
interface ProductModalProps {
open: boolean;
onClose: () => void;
onSave: (productData: ProductData) => void;
productData: ProductData;
categories: any[];
editing: boolean;
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
handleTieredPricingChange: (e: ChangeEvent<HTMLInputElement>, index: number) => void; // ✅ Added this
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
}
export const ProductModal = ({
open,
@@ -65,11 +22,7 @@ export const ProductModal = ({
setProductData,
}: ProductModalProps) => {
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [newCategory, setNewCategory] = useState("");
const [imageDimensions, setImageDimensions] = useState({
width: 300,
height: 200,
});
const [imageDimensions, setImageDimensions] = useState({ width: 300, height: 200 });
useEffect(() => {
if (productData.image && typeof productData.image === "string") {
@@ -77,71 +30,60 @@ export const ProductModal = ({
}
}, [productData.image]);
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const image = new Image();
const objectUrl = URL.createObjectURL(file);
image.onload = () => {
const aspectRatio = image.naturalWidth / image.naturalHeight;
let width = 300;
let height = 200;
if (aspectRatio > 1) {
width = 300;
height = 300 / aspectRatio;
} else {
height = 200;
width = 200 * aspectRatio;
}
setProductData({ ...productData, image: file });
setImagePreview(objectUrl);
setImageDimensions({ width, height });
};
image.src = objectUrl;
} else {
if (!file) {
setProductData({ ...productData, image: null });
setImagePreview(null);
return;
}
const image = new Image();
const objectUrl = URL.createObjectURL(file);
image.onload = () => {
const aspectRatio = image.naturalWidth / image.naturalHeight;
const width = aspectRatio > 1 ? 300 : 200 * aspectRatio;
const height = aspectRatio > 1 ? 300 / aspectRatio : 200;
setProductData({ ...productData, image: file });
setImagePreview(objectUrl);
setImageDimensions({ width, height });
};
image.src = objectUrl;
};
const handleTierChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
const { name, valueAsNumber } = e.target;
if (!["minQuantity", "pricePerUnit"].includes(name)) return; // ✅ Ensure valid keys
const updatedPricing = [...productData.pricing];
(updatedPricing[index] as any)[name] = isNaN(valueAsNumber) ? 0 : valueAsNumber;
setProductData({ ...productData, pricing: updatedPricing });
};
const handleAddTier = () => {
setProductData(prev => ({
...prev,
pricing: [...prev.pricing, { minQuantity: 1, pricePerUnit: 0 }]
}));
};
const handleRemoveTier = (index: number) => {
const updatedPricing = productData.pricing.filter((_, i) => i !== index);
setProductData({ ...productData, pricing: updatedPricing });
};
const handleSave = () => {
onSave(productData);
toast.success(
editing ? "Product updated successfully!" : "Product added successfully!"
);
toast.success(editing ? "Product updated!" : "Product added!");
onClose();
};
const handleTieredPricingChange = (
e: ChangeEvent<HTMLInputElement>,
index: number
) => {
const { name, valueAsNumber } = e.target;
if (!productData.pricing) return;
const updatedPricing = [...productData.pricing];
updatedPricing[index] = {
...updatedPricing[index],
[name]: isNaN(valueAsNumber) ? 0 : valueAsNumber,
};
setProductData((prev) => ({
...prev,
tieredPricing: updatedPricing,
image: prev.image ?? null,
}));
const convertToProductData = (product: Product): ProductData => ({
...product,
image: product.image ?? null, // ✅ Ensures the type is correct
});
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl">
@@ -152,216 +94,155 @@ export const ProductModal = ({
</DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-8 py-4">
{/* Left Column */}
<div className="space-y-6">
<div>
<label className="text-sm font-medium">Product Name</label>
<Input
name="name"
placeholder="Product Name"
value={productData.name}
onChange={handleChange}
className="text-sm h-9"
/>
</div>
{/* Description */}
<div>
<label className="text-sm font-medium">Description</label>
<Textarea
name="description"
placeholder="Product Description"
value={productData.description}
onChange={handleChange}
rows={4}
className="text-sm h-24"
/>
</div>
{/* Category and Unit Type */}
<div>
<label className="text-sm font-medium">Category</label>
<div className="flex items-center gap-2">
<Select
value={productData.category || "placeholder"}
onValueChange={(value) =>
setProductData((prev) => ({
...prev,
category: value === "placeholder" ? "" : value,
}))
}
>
<SelectTrigger className="flex-1 h-9 text-sm">
<SelectValue
placeholder={
productData.category === "placeholder"
? "Select category..."
: "Select a category"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder">
Select category...
</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat._id} value={cat._id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
{productData.category === "placeholder" && (
<Button
variant="outline"
size="sm"
className="h-9 text-xs"
onClick={() => {
// Handle adding a category (e.g., open modal, show input field, etc.)
console.log("Open category creation modal");
}}
>
+ Add
</Button>
)}
</div>
</div>
<div>
<label className="text-sm font-medium">Unit Type</label>
<Select
value={productData.unitType || "placeholder"}
onValueChange={(value) =>
setProductData({
...productData,
unitType: value === "placeholder" ? "" : value,
})
}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select a unit type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder" disabled>
Select a unit type
</SelectItem>
<SelectItem value="pcs">Pieces (pcs)</SelectItem>
<SelectItem value="gr">Grams (gr)</SelectItem>
<SelectItem value="kg">Kilograms (kg)</SelectItem>
</SelectContent>
</Select>
</div>
<ProductBasicInfo
productData={productData}
handleChange={handleChange}
categories={categories}
setProductData={setProductData}
/>
</div>
{/* Column 2: Image and Tiered Pricing */}
{/* Right Column */}
<div className="space-y-6">
{/* Product Image */}
<div>
<label className="text-sm font-medium">Product Image</label>
<div className="relative border rounded-md flex items-center justify-center bg-gray-50 dark:bg-gray-800 w-full h-48">
{imagePreview ? (
<img
src={imagePreview}
alt="Preview"
className="object-contain w-full h-full rounded-md"
/>
) : (
<span className="text-gray-400 text-sm">
No Image Selected
</span>
)}
</div>
<Input
type="file"
accept="image/*"
onChange={handleImageChange}
className="mt-2 h-9 text-sm"
/>
</div>
{/* Tiered Pricing */}
<div>
<h3 className="text-sm font-medium">Tiered Pricing</h3>
{productData.pricing?.length > 0 ? (
productData.pricing.map((tier, index) => (
<div
key={tier._id || index}
className="flex items-center gap-2"
>
<Input
name="minQuantity"
type="number"
min="1"
placeholder="Min Quantity"
value={tier.minQuantity}
onChange={(e) => handleTieredPricingChange(e, index)}
className="h-8 text-sm px-2 flex-1"
/>
<Input
name="pricePerUnit"
type="number"
placeholder="Price per unit"
value={tier.pricePerUnit}
onChange={(e) => handleTieredPricingChange(e, index)}
className="h-8 text-sm px-2 flex-1"
/>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:bg-red-100"
onClick={() => {
const updatedPricing = [...productData.pricing];
updatedPricing.splice(index, 1);
setProductData({
...productData,
pricing: updatedPricing, // Update `pricing`
});
}}
>
<Trash className="h-5 w-5" />
</Button>
</div>
))
) : (
<p className="text-sm text-gray-500">No pricing tiers added.</p>
)}
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() =>
setProductData((prev) => ({
...prev,
pricing: [
...(prev.pricing || []),
{ minQuantity: 1, pricePerUnit: 0 },
],
}))
}
>
<PlusCircle className="w-4 h-4 mr-1" />
Add Tier
</Button>
</div>
<ImageUpload
imagePreview={imagePreview}
handleImageChange={handleImageChange}
imageDimensions={imageDimensions}
/>
<PricingTiers
pricing={productData.pricing}
handleTierChange={handleTierChange}
handleRemoveTier={handleRemoveTier}
handleAddTier={handleAddTier}
/>
</div>
</div>
<DialogFooter className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
className="h-9 text-sm px-4"
onClick={onClose}
>
Cancel
</Button>
<Button size="sm" className="h-9 text-sm px-4" onClick={handleSave}>
{editing ? "Update Product" : "Create Product"}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={handleSave}>
{editing ? "Update" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const ProductBasicInfo = ({
productData,
handleChange,
categories,
setProductData,
}: {
productData: ProductData;
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
categories: { _id: string; name: string }[];
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
}) => (
<>
<div>
<label className="text-sm font-medium">Product Name</label>
<Input
name="name"
placeholder="Product Name"
value={productData.name}
onChange={handleChange}
className="text-sm h-9"
/>
</div>
<div>
<label className="text-sm font-medium">Description</label>
<Textarea
name="description"
placeholder="Product Description"
value={productData.description}
onChange={handleChange}
rows={4}
className="text-sm h-24"
/>
</div>
<CategorySelect
categories={categories}
value={productData.category}
setProductData={setProductData}
/>
<UnitTypeSelect
value={productData.unitType}
setProductData={setProductData}
/>
</>
);
const CategorySelect = ({
categories,
value,
setProductData,
}: {
categories: { _id: string; name: string }[];
value: string;
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
}) => (
<div>
<label className="text-sm font-medium">Category</label>
<Select
value={value || "placeholder"}
onValueChange={(val) =>
setProductData((prev) => ({
...prev,
category: val === "placeholder" ? "" : val,
}))
}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder">Select category...</SelectItem>
{categories.map((cat) => (
<SelectItem key={cat._id} value={cat._id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
const UnitTypeSelect = ({
value,
setProductData,
}: {
value: string;
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
}) => (
<div>
<label className="text-sm font-medium">Unit Type</label>
<Select
value={value || "placeholder"}
onValueChange={(val) =>
setProductData((prev: ProductData) => ({
...prev,
unitType: val === "placeholder" ? "" : val,
}))
}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select unit type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder" disabled>
Select unit type
</SelectItem>
<SelectItem value="pcs">Pieces (pcs)</SelectItem>
<SelectItem value="gr">Grams (gr)</SelectItem>
<SelectItem value="kg">Kilograms (kg)</SelectItem>
</SelectContent>
</Select>
</div>
);

View File

@@ -1,3 +1,19 @@
import { ChangeEvent, Dispatch, SetStateAction } from "react";
export interface ProductModalProps {
open: boolean;
onClose: () => void;
onSave: (productData: ProductData) => void;
productData: ProductData;
categories: Category[];
editing: boolean;
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
handleTieredPricingChange: (e: React.ChangeEvent<HTMLInputElement>, index: number) => void;
handleAddTier: () => void; // ✅ ADDED
handleRemoveTier: (index: number) => void; // ✅ ADDED
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
}
// lib/types.ts
export interface ShippingMethod {
_id?: string; // Optional before saving, required after fetching
@@ -11,19 +27,43 @@ export interface ShippingData {
price: number;
}
export interface Product {
_id?: string;
name: string;
price: number;
description?: string;
sku?: string;
stock: number;
createdAt?: Date;
updatedAt?: Date;
}
export type ApiResponse<T> = {
data?: T;
error?: string;
total?: number;
};
};
export interface Product {
_id?: string;
name: string;
description: string;
stock?: number;
unitType: string;
category: string;
pricing: PricingTier[];
image?: string | File | null;
}
export interface ProductData {
_id?: string;
name: string;
description: string;
stock?: number;
unitType: string;
category: string;
pricing: PricingTier[];
image?: string | File | null;
}
export interface PricingTier {
minQuantity: number;
pricePerUnit: number;
_id?: string;
}
export interface Category {
_id: string;
name: string;
}