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", unitType: "pcs",
category: "", category: "",
pricing: [{ minQuantity: 1, pricePerUnit: 0 }], pricing: [{ minQuantity: 1, pricePerUnit: 0 }],
image: null image: null,
}); });
// Fetch products and categories // Fetch products and categories
@@ -55,15 +55,15 @@ export default function ProductsPage() {
authToken authToken
), ),
]); ]);
console.log("Fetched Products:", fetchedProducts); console.log("Fetched Products:", fetchedProducts);
// Ensure all products have tieredPricing // Ensure all products have tieredPricing
const processedProducts = fetchedProducts.map((product: Product) => ({ const processedProducts = fetchedProducts.map((product: Product) => ({
...product, ...product,
pricing: product.pricing || [{ minQuantity: 1, pricePerUnit: 0 }], pricing: product.pricing || [{ minQuantity: 1, pricePerUnit: 0 }],
})); }));
setProducts(processedProducts); setProducts(processedProducts);
setCategories(fetchedCategories); setCategories(fetchedCategories);
} catch (error) { } catch (error) {
@@ -75,6 +75,20 @@ export default function ProductsPage() {
fetchDataAsync(); 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 // Handle input changes
const handleChange = ( const handleChange = (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
@@ -100,26 +114,26 @@ export default function ProductsPage() {
? parseFloat(tier.pricePerUnit) ? parseFloat(tier.pricePerUnit)
: tier.pricePerUnit, : tier.pricePerUnit,
})); }));
const productToSave: Product = { const productToSave: Product = {
...data, ...data,
pricing: adjustedPricing, pricing: adjustedPricing,
image: data.image ?? "", // ✅ Prevents undefined error image: data.image ?? "", // ✅ Prevents undefined error
}; };
try { try {
const authToken = document.cookie.split("Authorization=")[1]; const authToken = document.cookie.split("Authorization=")[1];
const apiUrl = editing const apiUrl = editing
? `${process.env.NEXT_PUBLIC_API_URL}/products/${data._id}` ? `${process.env.NEXT_PUBLIC_API_URL}/products/${data._id}`
: `${process.env.NEXT_PUBLIC_API_URL}/products`; : `${process.env.NEXT_PUBLIC_API_URL}/products`;
const savedProduct = await saveProductData( const savedProduct = await saveProductData(
apiUrl, apiUrl,
productToSave, productToSave,
authToken, authToken,
editing ? "PUT" : "POST" editing ? "PUT" : "POST"
); );
setProducts((prevProducts) => { setProducts((prevProducts) => {
if (editing) { if (editing) {
return prevProducts.map((product) => return prevProducts.map((product) =>
@@ -129,7 +143,7 @@ export default function ProductsPage() {
return [...prevProducts, savedProduct]; return [...prevProducts, savedProduct];
} }
}); });
setModalOpen(false); setModalOpen(false);
} catch (error) { } catch (error) {
console.error("Error saving product:", error); console.error("Error saving product:", error);
@@ -159,9 +173,9 @@ export default function ProductsPage() {
setProductData({ setProductData({
...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 }], // Fallback if undefined : [{ minQuantity: 1, pricePerUnit: 0 }], // Fallback if undefined
}); });
@@ -219,6 +233,8 @@ export default function ProductsPage() {
editing={editing} editing={editing}
handleChange={handleChange} handleChange={handleChange}
handleTieredPricingChange={handleTieredPricingChange} handleTieredPricingChange={handleTieredPricingChange}
handleAddTier={handleAddTier} // ✅ Ensure this is passed
handleRemoveTier={handleRemoveTier} // ✅ Ensure this is passed
setProductData={setProductData} setProductData={setProductData}
/> />
</div> </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"; "use client";
import { ChangeEvent, useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { ImageUpload } from "@/components/forms/image-upload";
SelectContent, import { PricingTiers } from "@/components/forms/pricing-tiers";
SelectItem, import { ProductModalProps, ProductData } from "@/lib/types";
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Trash, PlusCircle } from "lucide-react";
import { toast } from "sonner"; 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 = ({ export const ProductModal = ({
open, open,
@@ -65,11 +22,7 @@ export const ProductModal = ({
setProductData, setProductData,
}: ProductModalProps) => { }: ProductModalProps) => {
const [imagePreview, setImagePreview] = useState<string | null>(null); 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(() => { useEffect(() => {
if (productData.image && typeof productData.image === "string") { if (productData.image && typeof productData.image === "string") {
@@ -77,71 +30,60 @@ export const ProductModal = ({
} }
}, [productData.image]); }, [productData.image]);
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { 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 {
setProductData({ ...productData, image: null }); setProductData({ ...productData, image: null });
setImagePreview(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 = () => { const handleSave = () => {
onSave(productData); onSave(productData);
toast.success( toast.success(editing ? "Product updated!" : "Product added!");
editing ? "Product updated successfully!" : "Product added successfully!"
);
onClose(); 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 ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-6xl"> <DialogContent className="max-w-6xl">
@@ -152,216 +94,155 @@ export const ProductModal = ({
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-8 py-4"> <div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-8 py-4">
{/* Left Column */}
<div className="space-y-6"> <div className="space-y-6">
<div> <ProductBasicInfo
<label className="text-sm font-medium">Product Name</label> productData={productData}
<Input handleChange={handleChange}
name="name" categories={categories}
placeholder="Product Name" setProductData={setProductData}
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>
</div> </div>
{/* Column 2: Image and Tiered Pricing */} {/* Right Column */}
<div className="space-y-6"> <div className="space-y-6">
{/* Product Image */} <ImageUpload
<div> imagePreview={imagePreview}
<label className="text-sm font-medium">Product Image</label> handleImageChange={handleImageChange}
<div className="relative border rounded-md flex items-center justify-center bg-gray-50 dark:bg-gray-800 w-full h-48"> imageDimensions={imageDimensions}
{imagePreview ? ( />
<img <PricingTiers
src={imagePreview} pricing={productData.pricing}
alt="Preview" handleTierChange={handleTierChange}
className="object-contain w-full h-full rounded-md" handleRemoveTier={handleRemoveTier}
/> handleAddTier={handleAddTier}
) : ( />
<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>
</div> </div>
</div> </div>
<DialogFooter className="flex justify-end gap-2"> <DialogFooter>
<Button <Button variant="outline" onClick={onClose}>Cancel</Button>
variant="outline" <Button onClick={handleSave}>
size="sm" {editing ? "Update" : "Create"}
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"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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 // lib/types.ts
export interface ShippingMethod { export interface ShippingMethod {
_id?: string; // Optional before saving, required after fetching _id?: string; // Optional before saving, required after fetching
@@ -11,19 +27,43 @@ export interface ShippingData {
price: number; 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> = { export type ApiResponse<T> = {
data?: T; data?: T;
error?: string; error?: string;
total?: number; 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;
}