create modal for add ingredient
This commit is contained in:
parent
af49388593
commit
4fbc8003a0
@ -91,15 +91,25 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body = await request.json();
|
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 });
|
return NextResponse.json({ error: 'All fields are required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
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 now = new Date();
|
||||||
const doc = {
|
const doc: Record<string, unknown> = {
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
categoryId: new ObjectId(categoryId),
|
categoryId: new ObjectId(categoryId),
|
||||||
@ -113,6 +123,19 @@ export async function POST(request: Request) {
|
|||||||
updatedAt: now,
|
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);
|
const result = await db.collection('ingredients').insertOne(doc);
|
||||||
|
|
||||||
return NextResponse.json({ _id: result.insertedId, ...doc }, { status: 201 });
|
return NextResponse.json({ _id: result.insertedId, ...doc }, { status: 201 });
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import Sidebar from '@/components/Sidebar';
|
import Sidebar from '@/components/Sidebar';
|
||||||
import IngredientTable, { Ingredient } from '@/components/IngredientTable';
|
import IngredientTable, { Ingredient } from '@/components/IngredientTable';
|
||||||
|
import AddIngredientModal from '@/components/AddIngredientModal';
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -34,6 +35,7 @@ export default function IngredientsPage() {
|
|||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [allSubcategories, setAllSubcategories] = useState<Subcategory[]>([]);
|
const [allSubcategories, setAllSubcategories] = useState<Subcategory[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
@ -141,7 +143,10 @@ export default function IngredientsPage() {
|
|||||||
<button className="flex-1 sm:flex-none px-4 sm:px-5 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 font-medium hover:bg-gray-50 transition-colors text-sm sm:text-base">
|
<button className="flex-1 sm:flex-none px-4 sm:px-5 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 font-medium hover:bg-gray-50 transition-colors text-sm sm:text-base">
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
<button className="flex-1 sm:flex-none px-4 sm:px-5 py-2.5 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 transition-colors text-sm sm:text-base whitespace-nowrap">
|
<button
|
||||||
|
onClick={() => setIsAddModalOpen(true)}
|
||||||
|
className="flex-1 sm:flex-none px-4 sm:px-5 py-2.5 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 transition-colors text-sm sm:text-base whitespace-nowrap"
|
||||||
|
>
|
||||||
+ Add Ingredient
|
+ Add Ingredient
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -301,6 +306,12 @@ export default function IngredientsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AddIngredientModal
|
||||||
|
open={isAddModalOpen}
|
||||||
|
onClose={() => setIsAddModalOpen(false)}
|
||||||
|
onSaved={fetchIngredients}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
618
components/AddIngredientModal.tsx
Normal file
618
components/AddIngredientModal.tsx
Normal file
@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user