Introduces a Profit Analysis modal for products, allowing users to view profit, margin, and markup calculations based on cost per unit and pricing tiers. Adds cost per unit input to the product modal, updates product types, and integrates the analysis modal into the products page and product table. This enhances product management with profit tracking and analysis features.
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
"use client";
|
|
|
|
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,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { ImageUpload } from "@/components/forms/image-upload";
|
|
import { PricingTiers } from "@/components/forms/pricing-tiers";
|
|
import type { ProductModalProps, ProductData } from "@/lib/types";
|
|
import { toast } from "sonner";
|
|
import type React from "react";
|
|
import { Plus } from "lucide-react";
|
|
import { apiRequest } from "@/lib/api";
|
|
import { Switch } from "@/components/ui/switch";
|
|
|
|
type CategorySelectProps = {
|
|
categories: { _id: string; name: string; parentId?: string }[];
|
|
value: string;
|
|
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
|
|
onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void;
|
|
};
|
|
|
|
export const ProductModal: React.FC<ProductModalProps> = ({
|
|
open,
|
|
onClose,
|
|
onSave,
|
|
productData,
|
|
categories,
|
|
editing,
|
|
handleChange,
|
|
handleRemoveTier,
|
|
setProductData,
|
|
}) => {
|
|
/**
|
|
* 1) Store the selected file *separately* from productData.image
|
|
*/
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
|
const [imageDimensions, setImageDimensions] = useState({ width: 300, height: 200 });
|
|
const [localCategories, setLocalCategories] = useState(categories);
|
|
|
|
// If productData.image is a *URL* (string), show it as a default preview
|
|
useEffect(() => {
|
|
if (productData.image && typeof productData.image === "string" && productData._id) {
|
|
setImagePreview(`${process.env.NEXT_PUBLIC_API_URL}/products/${productData._id}/image`);
|
|
} else if (productData.image && typeof productData.image === "string") {
|
|
// Image exists but no ID, this is probably a new product
|
|
setImagePreview(null);
|
|
}
|
|
}, [productData.image, productData._id]);
|
|
|
|
useEffect(() => {
|
|
setLocalCategories(categories);
|
|
}, [categories]);
|
|
|
|
// Reset image state when modal is closed
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setSelectedFile(null);
|
|
setImagePreview(null);
|
|
}
|
|
}, [open]);
|
|
|
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) {
|
|
// no file selected
|
|
setSelectedFile(null);
|
|
setImagePreview(null);
|
|
return;
|
|
}
|
|
|
|
// For preview
|
|
const objectUrl = URL.createObjectURL(file);
|
|
setSelectedFile(file);
|
|
setImagePreview(objectUrl);
|
|
|
|
const image = new Image();
|
|
image.onload = () => {
|
|
const aspectRatio = image.naturalWidth / image.naturalHeight;
|
|
const width = aspectRatio > 1 ? 300 : 200 * aspectRatio;
|
|
const height = aspectRatio > 1 ? 300 / aspectRatio : 200;
|
|
setImageDimensions({ width, height });
|
|
};
|
|
image.src = objectUrl;
|
|
};
|
|
|
|
const handleAddTier = () => {
|
|
setProductData((prev) => ({
|
|
...prev,
|
|
pricing: [...prev.pricing, { minQuantity: 0, pricePerUnit: 0 }],
|
|
}));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
// Validate required fields
|
|
if (!productData.name || !productData.category || !productData.unitType) {
|
|
toast.error("Please fill in all required fields");
|
|
return;
|
|
}
|
|
|
|
// Validate pricing tiers
|
|
if (!productData.pricing || productData.pricing.length === 0) {
|
|
toast.error("At least one pricing tier is required");
|
|
return;
|
|
}
|
|
|
|
// Make sure stock values are numbers
|
|
let stockData = { ...productData };
|
|
if (stockData.stockTracking) {
|
|
stockData.currentStock = Number(stockData.currentStock) || 0;
|
|
stockData.lowStockThreshold = Number(stockData.lowStockThreshold) || 10;
|
|
}
|
|
|
|
await onSave(stockData, selectedFile);
|
|
onClose();
|
|
} catch (error) {
|
|
console.error("Error saving product:", error);
|
|
toast.error("Failed to save product");
|
|
}
|
|
};
|
|
|
|
const handleAddCategory = (newCategory: { _id: string; name: string; parentId?: string }) => {
|
|
setLocalCategories((prev) => [...prev, newCategory]);
|
|
};
|
|
|
|
const handleTierChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
|
const { name, value } = e.target;
|
|
|
|
setProductData((prev) => ({
|
|
...prev,
|
|
pricing: prev.pricing.map((tier, i) =>
|
|
i === index
|
|
? { ...tier, [name]: value === "" ? 0 : Number(value) }
|
|
: tier
|
|
),
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onClose}>
|
|
<DialogContent className="max-w-[95vw] lg:max-w-6xl w-full overflow-y-auto max-h-[90vh] z-[80]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base">
|
|
{editing ? "Edit Product" : "Add Product"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-8 py-4">
|
|
<ProductBasicInfo
|
|
productData={productData}
|
|
handleChange={handleChange}
|
|
categories={localCategories}
|
|
setProductData={setProductData}
|
|
onAddCategory={handleAddCategory}
|
|
/>
|
|
|
|
<div className="space-y-6">
|
|
<ImageUpload
|
|
imagePreview={imagePreview}
|
|
handleImageChange={handleImageChange}
|
|
imageDimensions={imageDimensions}
|
|
/>
|
|
<PricingTiers
|
|
pricing={productData.pricing}
|
|
handleTierChange={handleTierChange}
|
|
handleRemoveTier={handleRemoveTier}
|
|
handleAddTier={handleAddTier}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
{editing ? "Update" : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const ProductBasicInfo: React.FC<{
|
|
productData: ProductData;
|
|
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
|
categories: { _id: string; name: string; parentId?: string }[];
|
|
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
|
|
onAddCategory: (newCategory: { _id: string; name: string; parentId?: string }) => void;
|
|
}> = ({ productData, handleChange, categories, setProductData, onAddCategory }) => (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label htmlFor="name" className="text-sm font-medium">
|
|
Product Name
|
|
</label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
value={productData.name}
|
|
onChange={handleChange}
|
|
placeholder="Enter product name"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="description" className="text-sm font-medium">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
value={productData.description}
|
|
onChange={handleChange}
|
|
placeholder="Enter product description"
|
|
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
</div>
|
|
|
|
<div className="bg-background rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium mb-4">Product Status</h3>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
id="enabled"
|
|
checked={productData.enabled !== false}
|
|
onCheckedChange={(checked) => {
|
|
setProductData({
|
|
...productData,
|
|
enabled: checked
|
|
});
|
|
}}
|
|
/>
|
|
<label htmlFor="enabled" className="text-sm">
|
|
Enable Product
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-background rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium mb-4">Stock Management</h3>
|
|
|
|
<div className="flex items-center space-x-2 mb-4">
|
|
<input
|
|
id="stockTracking"
|
|
name="stockTracking"
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
checked={productData.stockTracking !== false}
|
|
onChange={(e) => {
|
|
setProductData({
|
|
...productData,
|
|
stockTracking: e.target.checked
|
|
});
|
|
}}
|
|
/>
|
|
<label htmlFor="stockTracking" className="text-sm">
|
|
Enable Stock Tracking
|
|
</label>
|
|
</div>
|
|
|
|
{productData.stockTracking !== false && (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label htmlFor="lowStockThreshold" className="text-sm font-medium">
|
|
Low Stock Threshold
|
|
</label>
|
|
<Input
|
|
id="lowStockThreshold"
|
|
name="lowStockThreshold"
|
|
type="number"
|
|
min="1"
|
|
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
|
|
value={productData.lowStockThreshold || 10}
|
|
onChange={handleChange}
|
|
placeholder="10"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="currentStock" className="text-sm font-medium">
|
|
Current Stock
|
|
</label>
|
|
<Input
|
|
id="currentStock"
|
|
name="currentStock"
|
|
type="number"
|
|
min="0"
|
|
step={productData.unitType === 'gr' || productData.unitType === 'ml' ? '0.1' : '1'}
|
|
value={productData.currentStock || 0}
|
|
onChange={handleChange}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="bg-background rounded-lg border border-border p-4">
|
|
<h3 className="text-sm font-medium mb-4">💰 Cost & Profit Tracking</h3>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
Track your costs to automatically calculate profit margins and markup percentages.
|
|
</p>
|
|
|
|
<div>
|
|
<label htmlFor="costPerUnit" className="text-sm font-medium">
|
|
Cost Per Unit (Optional)
|
|
</label>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
How much you paid for each unit of this product
|
|
</p>
|
|
<Input
|
|
id="costPerUnit"
|
|
name="costPerUnit"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={productData.costPerUnit || ''}
|
|
onChange={handleChange}
|
|
placeholder="0.00"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="text-sm font-medium">Category</label>
|
|
<CategorySelect
|
|
categories={categories}
|
|
value={productData.category}
|
|
setProductData={setProductData}
|
|
onAddCategory={onAddCategory}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Unit Type</label>
|
|
<UnitTypeSelect
|
|
value={productData.unitType}
|
|
setProductData={setProductData}
|
|
categories={categories}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const CategorySelect: React.FC<CategorySelectProps> = ({
|
|
categories,
|
|
value,
|
|
setProductData,
|
|
}) => {
|
|
const [selectedMainCategory, setSelectedMainCategory] = useState<string>("");
|
|
|
|
// Get root categories (those without parentId)
|
|
const rootCategories = categories.filter(cat => !cat.parentId);
|
|
|
|
// Get subcategories for a given parent
|
|
const getSubcategories = (parentId: string) =>
|
|
categories.filter(cat => cat.parentId === parentId);
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Category</label>
|
|
<div className="space-y-2">
|
|
{/* Main Category Select */}
|
|
<Select
|
|
value={selectedMainCategory}
|
|
onValueChange={(val) => {
|
|
setSelectedMainCategory(val);
|
|
setProductData((prev) => ({ ...prev, category: "" }));
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="Select main category..." />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[100]">
|
|
<SelectItem value="uncategorized">Select main category...</SelectItem>
|
|
{rootCategories.map((cat) => (
|
|
<SelectItem key={cat._id} value={cat._id}>
|
|
{cat.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* Subcategory Select */}
|
|
{selectedMainCategory && selectedMainCategory !== "uncategorized" && (
|
|
<Select
|
|
value={value || "uncategorized"}
|
|
onValueChange={(val) =>
|
|
setProductData((prev) => ({
|
|
...prev,
|
|
category: val === "uncategorized" ? "" : val,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="Select subcategory (optional)..." />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[100]">
|
|
<SelectItem value="uncategorized">No subcategory</SelectItem>
|
|
{getSubcategories(selectedMainCategory).map((subCat) => (
|
|
<SelectItem key={subCat._id} value={subCat._id}>
|
|
{subCat.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UnitTypeSelect: React.FC<{
|
|
value: string;
|
|
setProductData: React.Dispatch<React.SetStateAction<ProductData>>;
|
|
categories: { _id: string; name: string; parentId?: string }[];
|
|
}> = ({ value, setProductData, categories }) => (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Unit Type</label>
|
|
<div className="flex items-center space-x-2">
|
|
<Select
|
|
value={value || "placeholder"}
|
|
onValueChange={(val) => {
|
|
if (val === "placeholder") return;
|
|
setProductData((prev) => ({
|
|
...prev,
|
|
unitType: val as 'pcs' | 'gr' | 'kg' | 'ml' | 'oz' | 'lb',
|
|
}));
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="Select unit type" />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[100]">
|
|
<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>
|
|
<SelectItem value="ml">Milliliters (ml)</SelectItem>
|
|
<SelectItem value="oz">Ounces (oz)</SelectItem>
|
|
<SelectItem value="lb">Pounds (lb)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button type="button" variant="outline" size="sm" disabled>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
); |