diff --git a/app/api/ingredients/route.ts b/app/api/ingredients/route.ts index 77d5c8d..bb6b4f3 100644 --- a/app/api/ingredients/route.ts +++ b/app/api/ingredients/route.ts @@ -91,15 +91,25 @@ export async function GET(request: NextRequest) { export async function POST(request: Request) { const body = await request.json(); - const { code, name, categoryId, subcategoryId, quantity, unit, unitPrice, vat, supplierId } = body; + const { name, categoryId, subcategoryId, quantity, unit, unitPrice, vat, supplierId } = body; - if (!code || !name || !categoryId || !subcategoryId || !quantity || !unit || unitPrice == null || vat == null || !supplierId) { + if (!name || !categoryId || !subcategoryId || !quantity || !unit || unitPrice == null || vat == null || !supplierId) { return NextResponse.json({ error: 'All fields are required' }, { status: 400 }); } const db = await getDb(); + + // Auto-generate code + const last = await db.collection('ingredients') + .find({}, { projection: { code: 1 } }) + .sort({ code: -1 }) + .limit(1) + .next(); + const lastNum = last?.code ? parseInt(last.code.replace('ING-', '')) : 0; + const code = `ING-${String(lastNum + 1).padStart(3, '0')}`; + const now = new Date(); - const doc = { + const doc: Record = { code, name, categoryId: new ObjectId(categoryId), @@ -113,6 +123,19 @@ export async function POST(request: Request) { updatedAt: now, }; + // Optional discount fields + if (body.discountType) { + doc.discountType = body.discountType; + doc.discountValue = body.discountValue ?? 0; + doc.applyDiscountToNet = body.applyDiscountToNet ?? false; + } + + // Optional advanced fields + if (body.minStockLevel != null) doc.minStockLevel = body.minStockLevel; + if (body.storageInstructions) doc.storageInstructions = body.storageInstructions; + if (body.shelfLifeDays != null) doc.shelfLifeDays = body.shelfLifeDays; + if (body.notes) doc.notes = body.notes; + const result = await db.collection('ingredients').insertOne(doc); return NextResponse.json({ _id: result.insertedId, ...doc }, { status: 201 }); diff --git a/app/dashboard/ingredients/page.tsx b/app/dashboard/ingredients/page.tsx index 102f1d1..843e04b 100644 --- a/app/dashboard/ingredients/page.tsx +++ b/app/dashboard/ingredients/page.tsx @@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import Sidebar from '@/components/Sidebar'; import IngredientTable, { Ingredient } from '@/components/IngredientTable'; +import AddIngredientModal from '@/components/AddIngredientModal'; interface Category { _id: string; @@ -34,6 +35,7 @@ export default function IngredientsPage() { const [categories, setCategories] = useState([]); const [allSubcategories, setAllSubcategories] = useState([]); const [loading, setLoading] = useState(true); + const [isAddModalOpen, setIsAddModalOpen] = useState(false); const limit = 10; const totalPages = Math.ceil(total / limit); @@ -141,7 +143,10 @@ export default function IngredientsPage() { - @@ -301,6 +306,12 @@ export default function IngredientsPage() { )} + + setIsAddModalOpen(false)} + onSaved={fetchIngredients} + /> ); } diff --git a/components/AddIngredientModal.tsx b/components/AddIngredientModal.tsx new file mode 100644 index 0000000..861bdcc --- /dev/null +++ b/components/AddIngredientModal.tsx @@ -0,0 +1,618 @@ +'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([]); + const [subcategories, setSubcategories] = useState([]); + const [suppliers, setSuppliers] = useState([]); + + // 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 = { + 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 ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

Add Ingredient

+ +
+ + {/* Body */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Ingredient Name */} +
+ + setName(e.target.value)} + placeholder="e.g. Organic Butter" + className={inputClass} + autoFocus + /> +
+ + {/* Category & Subcategory */} +
+ {/* Category */} +
+ + + {categoryId === CREATE_NEW && ( +
+ setNewCategoryName(e.target.value)} + placeholder="Category name" + className={inputClass} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleCreateCategory())} + /> + +
+ )} +
+ + {/* Subcategory */} +
+ + + {subcategoryId === CREATE_NEW && categoryId && categoryId !== CREATE_NEW && ( +
+ setNewSubcategoryName(e.target.value)} + placeholder="Subcategory name" + className={inputClass} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleCreateSubcategory())} + /> + +
+ )} +
+
+ + {/* Quantity, Unit & Supplier */} +
+
+ + setQuantity(e.target.value)} + placeholder="0" + min="0" + step="any" + className={inputClass} + /> +
+
+ + setUnit(e.target.value)} + placeholder="kg, L, pcs…" + className={inputClass} + /> +
+
+ + + {supplierId === CREATE_NEW && ( +
+ setNewSupplierName(e.target.value)} + placeholder="Supplier name" + className={inputClass} + onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleCreateSupplier())} + /> + +
+ )} +
+
+ + {/* Pricing */} +
+

Pricing

+
+
+ + setGrossPrice(e.target.value)} + placeholder="0.00" + min="0" + step="0.01" + className={inputClass} + /> +
+
+ + setVat(e.target.value)} + placeholder="0" + min="0" + step="0.1" + className={inputClass} + /> +
+
+ +
+ {fmt(netPrice)} +
+
+
+
+ + {/* Discount */} +
+

Discount

+
+
+ +
+ + +
+
+
+ + setDiscountValue(e.target.value)} + placeholder="0" + min="0" + step={discountType === 'value' ? '0.01' : '0.1'} + className={inputClass} + /> +
+
+ +
+ + {/* Summary */} +
+
+

Effective Gross

+

{fmt(effectiveGross)}

+
+
+

Effective Net

+

{fmt(effectiveNet)}

+
+
+ + {/* Advanced Options */} +
+ + + {showAdvanced && ( +
+
+
+ + setMinStockLevel(e.target.value)} + placeholder="0" + min="0" + step="any" + className={inputClass} + /> +
+
+ + setShelfLifeDays(e.target.value)} + placeholder="0" + min="0" + className={inputClass} + /> +
+
+
+ + setStorageInstructions(e.target.value)} + placeholder="e.g. Keep refrigerated at 2-8°C" + className={inputClass} + /> +
+
+ +