Files
ember-market-frontend/components/modals/product-modal.tsx
g 0176f89cb7 Add CSV export for orders and update UI symbols
Introduces an exportOrdersToCSV function in lib/api-client.ts to allow exporting orders by status as a CSV file. Updates various UI components to use the '•' (bullet) symbol instead of '·' (middle dot) and replaces some emoji/unicode characters for improved consistency and compatibility. Also normalizes the 'use client' directive to include a BOM in many files.
2025-12-15 17:57:18 +00:00

478 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
// tempId ensures stable identity before backend assigns _id
// Using crypto.randomUUID if available, otherwise a timestamp fallback
tempId:
(typeof crypto !== "undefined" && (crypto as any).randomUUID
? (crypto as any).randomUUID()
: `${Date.now()}-${Math.random()}`),
},
],
}));
};
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 : parseFloat(value) || 0 }
: 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 xl:grid-cols-[2fr_1fr] gap-6 lg: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>
);