Some checks failed
Build Frontend / build (push) Failing after 7s
Replaces imports from 'components/ui' with 'components/common' across the app and dashboard pages, and updates model and API imports to use new paths under 'lib'. Removes redundant authentication checks from several dashboard pages. Adds new dashboard components and utility files, and reorganizes hooks and services into the 'lib' directory for improved structure.
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/common/dialog";
|
|
import { Button } from "@/components/common/button";
|
|
import { Input } from "@/components/common/input";
|
|
import { Textarea } from "@/components/common/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/common/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/common/switch";
|
|
import { Badge } from "@/components/common/badge";
|
|
|
|
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-8">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-2">
|
|
<label htmlFor="name" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
|
Product Name
|
|
</label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
value={productData.name}
|
|
onChange={handleChange}
|
|
placeholder="e.g. Premium Wireless Headphones"
|
|
className="border-border/50 bg-background/50 focus:bg-background transition-colors"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label htmlFor="description" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
value={productData.description}
|
|
onChange={handleChange}
|
|
placeholder="Describe your product features and benefits..."
|
|
className="flex w-full rounded-md border border-border/50 bg-background/50 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 min-h-[120px] resize-y focus:bg-background transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium leading-none">Category</label>
|
|
<CategorySelect
|
|
categories={categories}
|
|
value={productData.category}
|
|
setProductData={setProductData}
|
|
onAddCategory={onAddCategory}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium leading-none">Unit Type</label>
|
|
<UnitTypeSelect
|
|
value={productData.unitType}
|
|
setProductData={setProductData}
|
|
categories={categories}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-muted/20 rounded-xl border border-border/40 overflow-hidden">
|
|
<div className="p-4 border-b border-border/40 bg-muted/30">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
Inventory Management
|
|
</h3>
|
|
</div>
|
|
<div className="p-5 space-y-6">
|
|
<div className="flex items-center justify-between p-3 rounded-lg border border-border/40 bg-background/40">
|
|
<div className="space-y-0.5">
|
|
<label htmlFor="stockTracking" className="text-sm font-medium cursor-pointer">Track Stock Quantity</label>
|
|
<p className="text-xs text-muted-foreground">Automatically update stock when orders are placed</p>
|
|
</div>
|
|
<Switch
|
|
id="stockTracking"
|
|
checked={productData.stockTracking !== false}
|
|
onCheckedChange={(checked) => {
|
|
setProductData({
|
|
...productData,
|
|
stockTracking: checked
|
|
});
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{productData.stockTracking !== false && (
|
|
<div className="grid grid-cols-2 gap-5 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
<div className="space-y-2">
|
|
<label htmlFor="currentStock" className="text-sm font-medium text-muted-foreground">
|
|
Current Quantity
|
|
</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"
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label htmlFor="lowStockThreshold" className="text-sm font-medium text-muted-foreground">
|
|
Low Stock Alert At
|
|
</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"
|
|
className="font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-muted/20 rounded-xl border border-border/40 overflow-hidden">
|
|
<div className="p-4 border-b border-border/40 bg-muted/30 flex justify-between items-center">
|
|
<h3 className="text-sm font-semibold">Cost Analysis</h3>
|
|
<Badge variant="outline" className="text-[10px] font-normal">Optional</Badge>
|
|
</div>
|
|
<div className="p-5">
|
|
<div className="space-y-2">
|
|
<label htmlFor="costPerUnit" className="text-sm font-medium">
|
|
Cost Per Unit
|
|
</label>
|
|
<div className="relative">
|
|
<span className="absolute left-3 top-2.5 text-muted-foreground text-sm">$</span>
|
|
<Input
|
|
id="costPerUnit"
|
|
name="costPerUnit"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={productData.costPerUnit || ''}
|
|
onChange={handleChange}
|
|
placeholder="0.00"
|
|
className="pl-7 font-mono"
|
|
/>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-1.5">
|
|
Enter your cost to calculate profit margins automatically. This is never shown to customers.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-4 rounded-xl border border-border/40 bg-blue-500/5">
|
|
<div className="space-y-0.5">
|
|
<label htmlFor="enabled" className="text-sm font-medium text-foreground">Product Visibility</label>
|
|
<p className="text-xs text-muted-foreground">Make this product visible in your store</p>
|
|
</div>
|
|
<Switch
|
|
id="enabled"
|
|
checked={productData.enabled !== false}
|
|
onCheckedChange={(checked) => {
|
|
setProductData({
|
|
...productData,
|
|
enabled: checked
|
|
});
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
|