Files
ember-market-frontend/components/modals/product-modal.tsx
NotII be746664c5 Add profit analysis modal and cost tracking for products
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.
2025-08-26 20:52:38 +01:00

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>
);