Change product modal
This commit is contained in:
@@ -19,25 +19,40 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Product } from "@/models/products";
|
import { Product } from "@/models/products";
|
||||||
|
import { Trash, PlusCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
_id: string;
|
_id: string;
|
||||||
name: 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;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProductModalProps {
|
interface ProductModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (productData: Product) => void;
|
onSave: (productData: ProductData) => void;
|
||||||
productData: Product;
|
productData: ProductData;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
handleChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
handleChange: (
|
||||||
setProductData: React.Dispatch<React.SetStateAction<Product>>;
|
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
handleTieredPricingChange: (
|
|
||||||
e: ChangeEvent<HTMLInputElement>,
|
|
||||||
index: number
|
|
||||||
) => void;
|
) => void;
|
||||||
|
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductModal = ({
|
export const ProductModal = ({
|
||||||
@@ -48,12 +63,24 @@ export const ProductModal = ({
|
|||||||
categories,
|
categories,
|
||||||
editing,
|
editing,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleTieredPricingChange,
|
|
||||||
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (productData?.pricing) {
|
||||||
|
setProductData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tieredPricing: productData.pricing,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [productData.pricing]);
|
||||||
|
|
||||||
// Update image preview when product data changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (productData.image && typeof productData.image === "string") {
|
if (productData.image && typeof productData.image === "string") {
|
||||||
setImagePreview(productData.image);
|
setImagePreview(productData.image);
|
||||||
@@ -63,88 +90,282 @@ export const ProductModal = ({
|
|||||||
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
setProductData({ ...productData, image: file });
|
const image = new Image();
|
||||||
setImagePreview(URL.createObjectURL(file));
|
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 {
|
} else {
|
||||||
setProductData({ ...productData, image: null });
|
setProductData({ ...productData, image: null });
|
||||||
setImagePreview(null);
|
setImagePreview(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ FIXED: Moved Inside the Component & Used Type Assertion
|
const handleSave = () => {
|
||||||
const handleTieredPricingChangeInternal = (
|
onSave(productData);
|
||||||
|
toast.success(
|
||||||
|
editing ? "Product updated successfully!" : "Product added successfully!"
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTieredPricingChange = (
|
||||||
e: ChangeEvent<HTMLInputElement>,
|
e: ChangeEvent<HTMLInputElement>,
|
||||||
index: number
|
index: number
|
||||||
) => {
|
) => {
|
||||||
const updatedPricing = [...productData.tieredPricing];
|
const { name, valueAsNumber } = e.target;
|
||||||
const field = e.target.name as keyof (typeof productData.tieredPricing)[number];
|
|
||||||
|
if (!productData.pricing) return;
|
||||||
|
const updatedPricing = [...productData.pricing];
|
||||||
|
|
||||||
updatedPricing[index] = {
|
updatedPricing[index] = {
|
||||||
...updatedPricing[index],
|
...updatedPricing[index],
|
||||||
[field]: e.target.valueAsNumber || 0,
|
[name]: isNaN(valueAsNumber) ? 0 : valueAsNumber, // Ensure valid numbers
|
||||||
};
|
};
|
||||||
|
|
||||||
setProductData({
|
setProductData((prev) => ({
|
||||||
...productData,
|
...prev,
|
||||||
tieredPricing: updatedPricing,
|
tieredPricing: updatedPricing,
|
||||||
});
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="max-w-6xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-lg">
|
<DialogTitle className="text-base">
|
||||||
{editing ? "Edit Product" : "Add Product"}
|
{editing ? "Edit Product" : "Add Product"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-8 py-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-6">
|
||||||
<label className="text-sm font-medium">Product Name</label>
|
<div>
|
||||||
<Input name="name" placeholder="Product Name" value={productData.name} onChange={handleChange} />
|
<label className="text-sm font-medium">Product Name</label>
|
||||||
</div>
|
<Input
|
||||||
|
name="name"
|
||||||
|
placeholder="Product Name"
|
||||||
|
value={productData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="text-sm h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Description */}
|
||||||
<label className="text-sm font-medium">Description</label>
|
<div>
|
||||||
<Textarea name="description" placeholder="Product Description" value={productData.description} onChange={handleChange} rows={3} />
|
<label className="text-sm font-medium">Description</label>
|
||||||
</div>
|
<Textarea
|
||||||
|
name="description"
|
||||||
|
placeholder="Product Description"
|
||||||
|
value={productData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={4}
|
||||||
|
className="text-sm h-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Category and Unit Type */}
|
||||||
<label className="text-sm font-medium">Category</label>
|
<div>
|
||||||
<Select value={productData.category} onValueChange={(value) => setProductData({ ...productData, category: value })}>
|
<label className="text-sm font-medium">Category</label>
|
||||||
<SelectTrigger>
|
<div className="flex items-center gap-2">
|
||||||
<SelectValue placeholder="Select a category" />
|
<Select
|
||||||
</SelectTrigger>
|
value={productData.category || "placeholder"}
|
||||||
<SelectContent>
|
onValueChange={(value) =>
|
||||||
{categories.map((cat) => (
|
setProductData((prev) => ({
|
||||||
<SelectItem key={cat._id} value={cat._id}>
|
...prev,
|
||||||
{cat.name}
|
category: value === "placeholder" ? "" : value,
|
||||||
</SelectItem>
|
}))
|
||||||
))}
|
}
|
||||||
</SelectContent>
|
>
|
||||||
</Select>
|
<SelectTrigger className="flex-1 h-9 text-sm">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{productData.category === "placeholder" && (
|
||||||
<h3 className="text-sm font-medium">Tiered Pricing</h3>
|
<Button
|
||||||
{productData.tieredPricing.map((tier, index) => (
|
variant="outline"
|
||||||
<div key={index} className="flex gap-2">
|
size="sm"
|
||||||
<Input name="minQuantity" type="number" min="1" placeholder="Quantity" value={tier.minQuantity} onChange={(e) => handleTieredPricingChangeInternal(e, index)} />
|
className="h-9 text-xs"
|
||||||
<Input name="pricePerUnit" type="number" step="0.01" placeholder="Price" value={tier.pricePerUnit} onChange={(e) => handleTieredPricingChangeInternal(e, index)} />
|
onClick={() => {
|
||||||
|
// Handle adding a category (e.g., open modal, show input field, etc.)
|
||||||
|
console.log("Open category creation modal");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setProductData({ ...productData, tieredPricing: [...productData.tieredPricing, { minQuantity: 1, pricePerUnit: 0 }] })}>
|
|
||||||
+ Add Tier
|
<div>
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
|
{/* Column 2: Image and Tiered Pricing */}
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex justify-end gap-2">
|
<DialogFooter className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
<Button
|
||||||
<Button onClick={() => onSave(productData)}>{editing ? "Update Product" : "Create Product"}</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"}
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { ChangeEvent } from "react";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { 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 { ShippingData } from "@/lib/types";
|
import { ShippingData } from "@/lib/types";
|
||||||
|
|
||||||
interface ShippingModalProps {
|
interface ShippingModalProps {
|
||||||
|
|||||||
Reference in New Issue
Block a user