IcostPro/components/AddIngredientModal.tsx
2026-02-11 21:36:58 +01:00

619 lines
23 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo } from 'react';
interface Category {
_id: string;
name: string;
}
interface Subcategory {
_id: string;
name: string;
categoryId: string;
}
interface Supplier {
_id: string;
name: string;
}
interface AddIngredientModalProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
}
const CREATE_NEW = '__create_new__';
export default function AddIngredientModal({ open, onClose, onSaved }: AddIngredientModalProps) {
// Reference data
const [categories, setCategories] = useState<Category[]>([]);
const [subcategories, setSubcategories] = useState<Subcategory[]>([]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
// Form fields
const [name, setName] = useState('');
const [categoryId, setCategoryId] = useState('');
const [subcategoryId, setSubcategoryId] = useState('');
const [quantity, setQuantity] = useState('');
const [unit, setUnit] = useState('');
const [grossPrice, setGrossPrice] = useState('');
const [vat, setVat] = useState('9');
const [supplierId, setSupplierId] = useState('');
// Discount
const [discountType, setDiscountType] = useState<'value' | 'percent'>('value');
const [discountValue, setDiscountValue] = useState('');
const [applyDiscountToNet, setApplyDiscountToNet] = useState(false);
// Advanced
const [showAdvanced, setShowAdvanced] = useState(false);
const [minStockLevel, setMinStockLevel] = useState('');
const [storageInstructions, setStorageInstructions] = useState('');
const [shelfLifeDays, setShelfLifeDays] = useState('');
const [notes, setNotes] = useState('');
// Create-new inline states
const [newCategoryName, setNewCategoryName] = useState('');
const [newSubcategoryName, setNewSubcategoryName] = useState('');
const [newSupplierName, setNewSupplierName] = useState('');
const [creatingCategory, setCreatingCategory] = useState(false);
const [creatingSubcategory, setCreatingSubcategory] = useState(false);
const [creatingSupplier, setCreatingSupplier] = useState(false);
// Save state
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Fetch reference data
useEffect(() => {
if (!open) return;
Promise.all([
fetch('/api/categories').then(r => r.json()),
fetch('/api/subcategories').then(r => r.json()),
fetch('/api/suppliers').then(r => r.json()),
]).then(([cats, subs, supps]) => {
setCategories(cats);
setSubcategories(subs);
setSuppliers(supps);
});
}, [open]);
// Reset form when modal opens
useEffect(() => {
if (open) {
setName('');
setCategoryId('');
setSubcategoryId('');
setQuantity('');
setUnit('');
setGrossPrice('');
setVat('9');
setSupplierId('');
setDiscountType('value');
setDiscountValue('');
setApplyDiscountToNet(false);
setShowAdvanced(false);
setMinStockLevel('');
setStorageInstructions('');
setShelfLifeDays('');
setNotes('');
setNewCategoryName('');
setNewSubcategoryName('');
setNewSupplierName('');
setCreatingCategory(false);
setCreatingSubcategory(false);
setCreatingSupplier(false);
setSaving(false);
setError('');
}
}, [open]);
// Clear subcategory when category changes
useEffect(() => {
setSubcategoryId('');
}, [categoryId]);
const filteredSubcategories = categoryId && categoryId !== CREATE_NEW
? subcategories.filter(s => s.categoryId === categoryId)
: subcategories;
// Price calculations
const gross = parseFloat(grossPrice) || 0;
const vatRate = parseFloat(vat) || 0;
const netPrice = gross * (1 + vatRate / 100);
const discount = parseFloat(discountValue) || 0;
const { effectiveNet, effectiveGross } = useMemo(() => {
if (discount <= 0) return { effectiveNet: netPrice, effectiveGross: gross };
if (applyDiscountToNet) {
const discountAmount = discountType === 'value' ? discount : netPrice * discount / 100;
const effNet = netPrice - discountAmount;
const effGross = vatRate > 0 ? effNet / (1 + vatRate / 100) : effNet;
return { effectiveNet: effNet, effectiveGross: effGross };
} else {
const discountAmount = discountType === 'value' ? discount : gross * discount / 100;
const effGross = gross - discountAmount;
const effNet = effGross * (1 + vatRate / 100);
return { effectiveNet: effNet, effectiveGross: effGross };
}
}, [gross, vatRate, netPrice, discount, discountType, applyDiscountToNet]);
// Create-new handlers
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return;
setCreatingCategory(true);
const res = await fetch('/api/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newCategoryName.trim() }),
});
const created = await res.json();
setCategories(prev => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
setCategoryId(created._id);
setNewCategoryName('');
setCreatingCategory(false);
};
const handleCreateSubcategory = async () => {
if (!newSubcategoryName.trim() || !categoryId || categoryId === CREATE_NEW) return;
setCreatingSubcategory(true);
const res = await fetch('/api/subcategories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newSubcategoryName.trim(), categoryId }),
});
const created = await res.json();
setSubcategories(prev => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
setSubcategoryId(created._id);
setNewSubcategoryName('');
setCreatingSubcategory(false);
};
const handleCreateSupplier = async () => {
if (!newSupplierName.trim()) return;
setCreatingSupplier(true);
const res = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newSupplierName.trim() }),
});
const created = await res.json();
setSuppliers(prev => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
setSupplierId(created._id);
setNewSupplierName('');
setCreatingSupplier(false);
};
const handleSave = async () => {
setError('');
if (!name.trim()) { setError('Ingredient name is required.'); return; }
if (!categoryId || categoryId === CREATE_NEW) { setError('Please select a category.'); return; }
if (!subcategoryId || subcategoryId === CREATE_NEW) { setError('Please select a subcategory.'); return; }
if (!quantity || parseFloat(quantity) <= 0) { setError('Quantity must be greater than 0.'); return; }
if (!unit.trim()) { setError('Unit is required.'); return; }
if (!grossPrice || gross <= 0) { setError('Gross price must be greater than 0.'); return; }
if (!supplierId || supplierId === CREATE_NEW) { setError('Please select a supplier.'); return; }
setSaving(true);
const payload: Record<string, unknown> = {
name: name.trim(),
categoryId,
subcategoryId,
quantity: parseFloat(quantity),
unit: unit.trim(),
unitPrice: gross,
vat: vatRate,
supplierId,
};
if (discount > 0) {
payload.discountType = discountType;
payload.discountValue = discount;
payload.applyDiscountToNet = applyDiscountToNet;
}
if (showAdvanced) {
if (minStockLevel) payload.minStockLevel = parseFloat(minStockLevel);
if (storageInstructions.trim()) payload.storageInstructions = storageInstructions.trim();
if (shelfLifeDays) payload.shelfLifeDays = parseInt(shelfLifeDays);
if (notes.trim()) payload.notes = notes.trim();
}
const res = await fetch('/api/ingredients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Failed to save ingredient.');
setSaving(false);
return;
}
setSaving(false);
onSaved();
onClose();
};
if (!open) return null;
const inputClass = 'w-full px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-900 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
const selectClass = 'w-full px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-900 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors';
const fmt = (n: number) => n < 0 ? `-€${Math.abs(n).toFixed(2)}` : `${n.toFixed(2)}`;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 my-8 sm:my-16">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Add Ingredient</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{error && (
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
{/* Ingredient Name */}
<div>
<label className={labelClass}>Ingredient Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Organic Butter"
className={inputClass}
autoFocus
/>
</div>
{/* Category & Subcategory */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Category */}
<div>
<label className={labelClass}>Category</label>
<select
value={categoryId}
onChange={e => setCategoryId(e.target.value)}
className={selectClass}
>
<option value="">Select category</option>
<option value={CREATE_NEW}>+ Create new</option>
{categories.map(c => (
<option key={c._id} value={c._id}>{c.name}</option>
))}
</select>
{categoryId === CREATE_NEW && (
<div className="flex gap-2 mt-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder="Category name"
className={inputClass}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleCreateCategory())}
/>
<button
onClick={handleCreateCategory}
disabled={creatingCategory || !newCategoryName.trim()}
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap transition-colors"
>
{creatingCategory ? '…' : 'Add'}
</button>
</div>
)}
</div>
{/* Subcategory */}
<div>
<label className={labelClass}>Subcategory</label>
<select
value={subcategoryId}
onChange={e => setSubcategoryId(e.target.value)}
className={selectClass}
disabled={!categoryId || categoryId === CREATE_NEW}
>
<option value="">Select subcategory</option>
<option value={CREATE_NEW}>+ Create new</option>
{filteredSubcategories.map(s => (
<option key={s._id} value={s._id}>{s.name}</option>
))}
</select>
{subcategoryId === CREATE_NEW && categoryId && categoryId !== CREATE_NEW && (
<div className="flex gap-2 mt-2">
<input
type="text"
value={newSubcategoryName}
onChange={e => setNewSubcategoryName(e.target.value)}
placeholder="Subcategory name"
className={inputClass}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleCreateSubcategory())}
/>
<button
onClick={handleCreateSubcategory}
disabled={creatingSubcategory || !newSubcategoryName.trim()}
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap transition-colors"
>
{creatingSubcategory ? '…' : 'Add'}
</button>
</div>
)}
</div>
</div>
{/* Quantity, Unit & Supplier */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<label className={labelClass}>Quantity</label>
<input
type="number"
value={quantity}
onChange={e => setQuantity(e.target.value)}
placeholder="0"
min="0"
step="any"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Unit</label>
<input
type="text"
value={unit}
onChange={e => setUnit(e.target.value)}
placeholder="kg, L, pcs…"
className={inputClass}
/>
</div>
<div className="col-span-2">
<label className={labelClass}>Supplier</label>
<select
value={supplierId}
onChange={e => setSupplierId(e.target.value)}
className={selectClass}
>
<option value="">Select supplier</option>
<option value={CREATE_NEW}>+ Create new</option>
{suppliers.map(s => (
<option key={s._id} value={s._id}>{s.name}</option>
))}
</select>
{supplierId === CREATE_NEW && (
<div className="flex gap-2 mt-2">
<input
type="text"
value={newSupplierName}
onChange={e => setNewSupplierName(e.target.value)}
placeholder="Supplier name"
className={inputClass}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleCreateSupplier())}
/>
<button
onClick={handleCreateSupplier}
disabled={creatingSupplier || !newSupplierName.trim()}
className="px-3 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap transition-colors"
>
{creatingSupplier ? '…' : 'Add'}
</button>
</div>
)}
</div>
</div>
{/* Pricing */}
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Pricing</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className={labelClass}>Gross Price ()</label>
<input
type="number"
value={grossPrice}
onChange={e => setGrossPrice(e.target.value)}
placeholder="0.00"
min="0"
step="0.01"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>VAT (%)</label>
<input
type="number"
value={vat}
onChange={e => setVat(e.target.value)}
placeholder="0"
min="0"
step="0.1"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Net Price</label>
<div className="px-3 py-2 rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-700 font-medium">
{fmt(netPrice)}
</div>
</div>
</div>
</div>
{/* Discount */}
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Discount</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelClass}>Type</label>
<div className="flex items-center gap-4 mt-1">
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="radio"
name="discountType"
checked={discountType === 'value'}
onChange={() => setDiscountType('value')}
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
Fixed value ()
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="radio"
name="discountType"
checked={discountType === 'percent'}
onChange={() => setDiscountType('percent')}
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
Percentage (%)
</label>
</div>
</div>
<div>
<label className={labelClass}>
{discountType === 'value' ? 'Discount Amount (€)' : 'Discount Rate (%)'}
</label>
<input
type="number"
value={discountValue}
onChange={e => setDiscountValue(e.target.value)}
placeholder="0"
min="0"
step={discountType === 'value' ? '0.01' : '0.1'}
className={inputClass}
/>
</div>
</div>
<label className="flex items-center gap-2 mt-3 text-sm text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={applyDiscountToNet}
onChange={e => setApplyDiscountToNet(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Apply discount to net price
</label>
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-4 rounded-lg bg-gray-50 border border-gray-200 p-4">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Effective Gross</p>
<p className="text-xl font-bold text-gray-900 mt-1">{fmt(effectiveGross)}</p>
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Effective Net</p>
<p className="text-xl font-bold text-gray-900 mt-1">{fmt(effectiveNet)}</p>
</div>
</div>
{/* Advanced Options */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors"
>
<svg
className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
Advanced options
</button>
{showAdvanced && (
<div className="mt-3 space-y-4 pl-6 border-l-2 border-gray-200">
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass}>Min Stock Level</label>
<input
type="number"
value={minStockLevel}
onChange={e => setMinStockLevel(e.target.value)}
placeholder="0"
min="0"
step="any"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Shelf Life (days)</label>
<input
type="number"
value={shelfLifeDays}
onChange={e => setShelfLifeDays(e.target.value)}
placeholder="0"
min="0"
className={inputClass}
/>
</div>
</div>
<div>
<label className={labelClass}>Storage Instructions</label>
<input
type="text"
value={storageInstructions}
onChange={e => setStorageInstructions(e.target.value)}
placeholder="e.g. Keep refrigerated at 2-8°C"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Notes</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Additional notes…"
rows={2}
className={inputClass + ' resize-none'}
/>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
<button
onClick={onClose}
disabled={saving}
className="px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 text-sm font-medium hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2.5 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}