fix click dosent do nothing
This commit is contained in:
parent
4fbc8003a0
commit
2ad1edfea9
@ -45,11 +45,21 @@ export async function GET(
|
|||||||
name: 1,
|
name: 1,
|
||||||
category: { $arrayElemAt: ['$categoryDoc.name', 0] },
|
category: { $arrayElemAt: ['$categoryDoc.name', 0] },
|
||||||
subcategory: { $arrayElemAt: ['$subcategoryDoc.name', 0] },
|
subcategory: { $arrayElemAt: ['$subcategoryDoc.name', 0] },
|
||||||
|
categoryId: 1,
|
||||||
|
subcategoryId: 1,
|
||||||
|
supplierId: 1,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: 1,
|
unit: 1,
|
||||||
unitPrice: 1,
|
unitPrice: 1,
|
||||||
vat: 1,
|
vat: 1,
|
||||||
supplier: { $arrayElemAt: ['$supplierDoc.name', 0] },
|
supplier: { $arrayElemAt: ['$supplierDoc.name', 0] },
|
||||||
|
discountType: 1,
|
||||||
|
discountValue: 1,
|
||||||
|
applyDiscountToNet: 1,
|
||||||
|
minStockLevel: 1,
|
||||||
|
storageInstructions: 1,
|
||||||
|
shelfLifeDays: 1,
|
||||||
|
notes: 1,
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
},
|
},
|
||||||
@ -85,6 +95,13 @@ export async function PUT(
|
|||||||
if (body.unitPrice !== undefined) update.unitPrice = body.unitPrice;
|
if (body.unitPrice !== undefined) update.unitPrice = body.unitPrice;
|
||||||
if (body.vat !== undefined) update.vat = body.vat;
|
if (body.vat !== undefined) update.vat = body.vat;
|
||||||
if (body.supplierId !== undefined) update.supplierId = new ObjectId(body.supplierId);
|
if (body.supplierId !== undefined) update.supplierId = new ObjectId(body.supplierId);
|
||||||
|
if (body.discountType !== undefined) update.discountType = body.discountType;
|
||||||
|
if (body.discountValue !== undefined) update.discountValue = body.discountValue;
|
||||||
|
if (body.applyDiscountToNet !== undefined) update.applyDiscountToNet = body.applyDiscountToNet;
|
||||||
|
if (body.minStockLevel !== undefined) update.minStockLevel = body.minStockLevel;
|
||||||
|
if (body.storageInstructions !== undefined) update.storageInstructions = body.storageInstructions;
|
||||||
|
if (body.shelfLifeDays !== undefined) update.shelfLifeDays = body.shelfLifeDays;
|
||||||
|
if (body.notes !== undefined) update.notes = body.notes;
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const result = await db.collection('ingredients').findOneAndUpdate(
|
const result = await db.collection('ingredients').findOneAndUpdate(
|
||||||
|
|||||||
@ -4,6 +4,8 @@ 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';
|
import AddIngredientModal from '@/components/AddIngredientModal';
|
||||||
|
import ViewIngredientModal from '@/components/ViewIngredientModal';
|
||||||
|
import DeleteConfirmModal from '@/components/DeleteConfirmModal';
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -36,6 +38,9 @@ export default function IngredientsPage() {
|
|||||||
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 [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
const [editIngredientId, setEditIngredientId] = useState<string | null>(null);
|
||||||
|
const [viewIngredientId, setViewIngredientId] = useState<string | null>(null);
|
||||||
|
const [deleteIngredient, setDeleteIngredient] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
const limit = 10;
|
const limit = 10;
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
@ -252,7 +257,12 @@ export default function IngredientsPage() {
|
|||||||
<p className="text-gray-500">No ingredients found.</p>
|
<p className="text-gray-500">No ingredients found.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<IngredientTable ingredients={ingredients} />
|
<IngredientTable
|
||||||
|
ingredients={ingredients}
|
||||||
|
onView={(id) => setViewIngredientId(id)}
|
||||||
|
onEdit={(id) => setEditIngredientId(id)}
|
||||||
|
onDelete={(id, name) => setDeleteIngredient({ id, name })}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@ -308,9 +318,28 @@ export default function IngredientsPage() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddIngredientModal
|
<AddIngredientModal
|
||||||
open={isAddModalOpen}
|
open={isAddModalOpen || !!editIngredientId}
|
||||||
onClose={() => setIsAddModalOpen(false)}
|
onClose={() => { setIsAddModalOpen(false); setEditIngredientId(null); }}
|
||||||
onSaved={fetchIngredients}
|
onSaved={fetchIngredients}
|
||||||
|
ingredientId={editIngredientId ?? undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ViewIngredientModal
|
||||||
|
open={!!viewIngredientId}
|
||||||
|
ingredientId={viewIngredientId}
|
||||||
|
onClose={() => setViewIngredientId(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={!!deleteIngredient}
|
||||||
|
ingredientName={deleteIngredient?.name ?? ''}
|
||||||
|
onClose={() => setDeleteIngredient(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!deleteIngredient) return;
|
||||||
|
await fetch(`/api/ingredients/${deleteIngredient.id}`, { method: 'DELETE' });
|
||||||
|
setDeleteIngredient(null);
|
||||||
|
fetchIngredients();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -22,11 +22,14 @@ interface AddIngredientModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
ingredientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CREATE_NEW = '__create_new__';
|
const CREATE_NEW = '__create_new__';
|
||||||
|
|
||||||
export default function AddIngredientModal({ open, onClose, onSaved }: AddIngredientModalProps) {
|
export default function AddIngredientModal({ open, onClose, onSaved, ingredientId }: AddIngredientModalProps) {
|
||||||
|
const isEditMode = !!ingredientId;
|
||||||
|
const [loadingData, setLoadingData] = useState(false);
|
||||||
// Reference data
|
// Reference data
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [subcategories, setSubcategories] = useState<Subcategory[]>([]);
|
const [subcategories, setSubcategories] = useState<Subcategory[]>([]);
|
||||||
@ -80,9 +83,48 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
});
|
});
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Reset form when modal opens
|
// Reset form when modal opens (add mode) or fetch data (edit mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (!open) return;
|
||||||
|
|
||||||
|
// Always reset transient state
|
||||||
|
setNewCategoryName('');
|
||||||
|
setNewSubcategoryName('');
|
||||||
|
setNewSupplierName('');
|
||||||
|
setCreatingCategory(false);
|
||||||
|
setCreatingSubcategory(false);
|
||||||
|
setCreatingSupplier(false);
|
||||||
|
setSaving(false);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (ingredientId) {
|
||||||
|
// Edit mode: fetch ingredient data after reference data loads
|
||||||
|
setLoadingData(true);
|
||||||
|
fetch(`/api/ingredients/${ingredientId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setName(data.name || '');
|
||||||
|
setCategoryId(data.categoryId || '');
|
||||||
|
setSubcategoryId(data.subcategoryId || '');
|
||||||
|
setQuantity(String(data.quantity ?? ''));
|
||||||
|
setUnit(data.unit || '');
|
||||||
|
setGrossPrice(String(data.unitPrice ?? ''));
|
||||||
|
setVat(String(data.vat ?? '9'));
|
||||||
|
setSupplierId(data.supplierId || '');
|
||||||
|
setDiscountType(data.discountType || 'value');
|
||||||
|
setDiscountValue(data.discountValue ? String(data.discountValue) : '');
|
||||||
|
setApplyDiscountToNet(data.applyDiscountToNet || false);
|
||||||
|
const hasAdvanced = data.minStockLevel || data.storageInstructions || data.shelfLifeDays || data.notes;
|
||||||
|
setShowAdvanced(!!hasAdvanced);
|
||||||
|
setMinStockLevel(data.minStockLevel ? String(data.minStockLevel) : '');
|
||||||
|
setStorageInstructions(data.storageInstructions || '');
|
||||||
|
setShelfLifeDays(data.shelfLifeDays ? String(data.shelfLifeDays) : '');
|
||||||
|
setNotes(data.notes || '');
|
||||||
|
setLoadingData(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add mode: reset all fields
|
||||||
|
setLoadingData(false);
|
||||||
setName('');
|
setName('');
|
||||||
setCategoryId('');
|
setCategoryId('');
|
||||||
setSubcategoryId('');
|
setSubcategoryId('');
|
||||||
@ -99,21 +141,17 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
setStorageInstructions('');
|
setStorageInstructions('');
|
||||||
setShelfLifeDays('');
|
setShelfLifeDays('');
|
||||||
setNotes('');
|
setNotes('');
|
||||||
setNewCategoryName('');
|
|
||||||
setNewSubcategoryName('');
|
|
||||||
setNewSupplierName('');
|
|
||||||
setCreatingCategory(false);
|
|
||||||
setCreatingSubcategory(false);
|
|
||||||
setCreatingSupplier(false);
|
|
||||||
setSaving(false);
|
|
||||||
setError('');
|
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open, ingredientId]);
|
||||||
|
|
||||||
// Clear subcategory when category changes
|
// Clear subcategory when category changes (only in add mode to avoid overwriting fetched data)
|
||||||
|
const [categoryUserChanged, setCategoryUserChanged] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (categoryUserChanged) {
|
||||||
setSubcategoryId('');
|
setSubcategoryId('');
|
||||||
}, [categoryId]);
|
setCategoryUserChanged(false);
|
||||||
|
}
|
||||||
|
}, [categoryId, categoryUserChanged]);
|
||||||
|
|
||||||
const filteredSubcategories = categoryId && categoryId !== CREATE_NEW
|
const filteredSubcategories = categoryId && categoryId !== CREATE_NEW
|
||||||
? subcategories.filter(s => s.categoryId === categoryId)
|
? subcategories.filter(s => s.categoryId === categoryId)
|
||||||
@ -224,8 +262,11 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
if (notes.trim()) payload.notes = notes.trim();
|
if (notes.trim()) payload.notes = notes.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/ingredients', {
|
const url = isEditMode ? `/api/ingredients/${ingredientId}` : '/api/ingredients';
|
||||||
method: 'POST',
|
const method = isEditMode ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@ -259,7 +300,7 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 my-8 sm:my-16">
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 my-8 sm:my-16">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<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>
|
<h2 className="text-lg font-semibold text-gray-900">{isEditMode ? 'Edit Ingredient' : 'Add Ingredient'}</h2>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@ -269,12 +310,22 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-6 py-5 space-y-5">
|
<div className="px-6 py-5 space-y-5">
|
||||||
{error && (
|
{loadingData && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<svg className="animate-spin h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<span className="ml-2 text-sm text-gray-500">Loading ingredient data...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingData && error && (
|
||||||
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loadingData && <>
|
||||||
{/* Ingredient Name */}
|
{/* Ingredient Name */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Ingredient Name</label>
|
<label className={labelClass}>Ingredient Name</label>
|
||||||
@ -295,7 +346,7 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
<label className={labelClass}>Category</label>
|
<label className={labelClass}>Category</label>
|
||||||
<select
|
<select
|
||||||
value={categoryId}
|
value={categoryId}
|
||||||
onChange={e => setCategoryId(e.target.value)}
|
onChange={e => { setCategoryId(e.target.value); setCategoryUserChanged(true); }}
|
||||||
className={selectClass}
|
className={selectClass}
|
||||||
>
|
>
|
||||||
<option value="">Select category…</option>
|
<option value="">Select category…</option>
|
||||||
@ -593,6 +644,7 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
@ -606,7 +658,7 @@ export default function AddIngredientModal({ open, onClose, onSaved }: AddIngred
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving || loadingData}
|
||||||
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"
|
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'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
|||||||
61
components/DeleteConfirmModal.tsx
Normal file
61
components/DeleteConfirmModal.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
open: boolean;
|
||||||
|
ingredientName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteConfirmModal({ open, ingredientName, onClose, onConfirm }: DeleteConfirmModalProps) {
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
await onConfirm();
|
||||||
|
setDeleting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-4">
|
||||||
|
<div className="px-6 py-5">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Delete Ingredient</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Are you sure you want to delete <span className="font-semibold text-gray-900">{ingredientName}</span>? This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={deleting}
|
||||||
|
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={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-4 py-2.5 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
export interface Ingredient {
|
export interface Ingredient {
|
||||||
_id: string;
|
_id: string;
|
||||||
@ -19,10 +19,33 @@ export interface Ingredient {
|
|||||||
|
|
||||||
interface IngredientTableProps {
|
interface IngredientTableProps {
|
||||||
ingredients: Ingredient[];
|
ingredients: Ingredient[];
|
||||||
|
onView: (id: string) => void;
|
||||||
|
onEdit: (id: string) => void;
|
||||||
|
onDelete: (id: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IngredientTable({ ingredients }: IngredientTableProps) {
|
export default function IngredientTable({ ingredients, onView, onEdit, onDelete }: IngredientTableProps) {
|
||||||
const [selectedIngredients, setSelectedIngredients] = useState<Set<string>>(new Set());
|
const [selectedIngredients, setSelectedIngredients] = useState<Set<string>>(new Set());
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close menu on outside click or scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openMenuId) return;
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleScroll = () => setOpenMenuId(null);
|
||||||
|
document.addEventListener('mousedown', handleClick);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClick);
|
||||||
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
};
|
||||||
|
}, [openMenuId]);
|
||||||
|
|
||||||
const toggleIngredient = (id: string) => {
|
const toggleIngredient = (id: string) => {
|
||||||
const newSelected = new Set(selectedIngredients);
|
const newSelected = new Set(selectedIngredients);
|
||||||
@ -46,10 +69,41 @@ export default function IngredientTable({ ingredients }: IngredientTableProps) {
|
|||||||
return unitPrice * (1 + vat / 100);
|
return unitPrice * (1 + vat / 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMenuAction = useCallback((action: 'view' | 'edit' | 'delete', id: string, name: string) => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
if (action === 'view') onView(id);
|
||||||
|
else if (action === 'edit') onEdit(id);
|
||||||
|
else onDelete(id, name);
|
||||||
|
}, [onView, onEdit, onDelete]);
|
||||||
|
|
||||||
|
const toggleMenu = (ingredientId: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (openMenuId === ingredientId) {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPos(null);
|
||||||
|
} else {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||||
|
setOpenMenuId(ingredientId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMenuButton = (ingredient: Ingredient) => (
|
||||||
|
<button
|
||||||
|
onClick={(e) => toggleMenu(ingredient._id, e)}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const openIngredient = openMenuId ? ingredients.find(i => i._id === openMenuId) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop & Tablet Table View */}
|
{/* Desktop & Tablet Table View */}
|
||||||
<div className="hidden md:block bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="hidden md:block bg-white rounded-lg border border-gray-200">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
@ -127,11 +181,7 @@ export default function IngredientTable({ ingredients }: IngredientTableProps) {
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 lg:px-6 py-4">
|
<td className="px-4 lg:px-6 py-4">
|
||||||
<button className="text-gray-400 hover:text-gray-600">
|
{renderMenuButton(ingredient)}
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@ -157,11 +207,7 @@ export default function IngredientTable({ ingredients }: IngredientTableProps) {
|
|||||||
<h3 className="text-sm font-semibold text-gray-900">{ingredient.name}</h3>
|
<h3 className="text-sm font-semibold text-gray-900">{ingredient.name}</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">ID: {ingredient.code}</p>
|
<p className="text-xs text-gray-500 mt-0.5">ID: {ingredient.code}</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="text-gray-400 hover:text-gray-600">
|
{renderMenuButton(ingredient)}
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||||
@ -210,6 +256,44 @@ export default function IngredientTable({ ingredients }: IngredientTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fixed-position dropdown menu (renders outside overflow containers) */}
|
||||||
|
{openMenuId && menuPos && openIngredient && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed w-40 bg-white rounded-lg border border-gray-200 shadow-lg z-50 py-1"
|
||||||
|
style={{ top: menuPos.top, right: menuPos.right }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMenuAction('view', openIngredient._id, openIngredient.name)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMenuAction('edit', openIngredient._id, openIngredient.name)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMenuAction('delete', openIngredient._id, openIngredient.name)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
224
components/ViewIngredientModal.tsx
Normal file
224
components/ViewIngredientModal.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface IngredientDetail {
|
||||||
|
_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
subcategory: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
unitPrice: number;
|
||||||
|
vat: number;
|
||||||
|
supplier: string;
|
||||||
|
discountType?: 'value' | 'percent';
|
||||||
|
discountValue?: number;
|
||||||
|
applyDiscountToNet?: boolean;
|
||||||
|
minStockLevel?: number;
|
||||||
|
storageInstructions?: string;
|
||||||
|
shelfLifeDays?: number;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ViewIngredientModalProps {
|
||||||
|
open: boolean;
|
||||||
|
ingredientId: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewIngredientModal({ open, ingredientId, onClose }: ViewIngredientModalProps) {
|
||||||
|
const [ingredient, setIngredient] = useState<IngredientDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !ingredientId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setIngredient(null);
|
||||||
|
fetch(`/api/ingredients/${ingredientId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setIngredient(data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [open, ingredientId]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const fmt = (n: number) => n < 0 ? `-\u20AC${Math.abs(n).toFixed(2)}` : `\u20AC${n.toFixed(2)}`;
|
||||||
|
|
||||||
|
const netPrice = ingredient ? ingredient.unitPrice * (1 + ingredient.vat / 100) : 0;
|
||||||
|
|
||||||
|
const hasDiscount = ingredient && ingredient.discountValue && ingredient.discountValue > 0;
|
||||||
|
const hasAdvanced = ingredient && (ingredient.minStockLevel || ingredient.storageInstructions || ingredient.shelfLifeDays || ingredient.notes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto">
|
||||||
|
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
|
||||||
|
<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">Ingredient Details</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">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<svg className="animate-spin h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<span className="ml-2 text-sm text-gray-500">Loading...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && ingredient && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Name & Code */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{ingredient.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">Code: {ingredient.code}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category / Subcategory */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Category</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">{ingredient.category}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Subcategory</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">{ingredient.subcategory}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity / Unit */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Quantity</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">{ingredient.quantity} {ingredient.unit}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Supplier</p>
|
||||||
|
<p className="text-sm text-blue-600 font-medium mt-1">{ingredient.supplier}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Pricing</h4>
|
||||||
|
<div className="grid grid-cols-3 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">Gross Price</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 mt-1">{fmt(ingredient.unitPrice)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">VAT</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 mt-1">{ingredient.vat}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Net Price</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 mt-1">{fmt(netPrice)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discount */}
|
||||||
|
{hasDiscount && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Discount</h4>
|
||||||
|
<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">Type</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">
|
||||||
|
{ingredient.discountType === 'percent' ? 'Percentage' : 'Fixed value'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Value</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">
|
||||||
|
{ingredient.discountType === 'percent'
|
||||||
|
? `${ingredient.discountValue}%`
|
||||||
|
: fmt(ingredient.discountValue!)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{ingredient.applyDiscountToNet && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<p className="text-xs text-gray-500 italic">Applied to net price</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Advanced */}
|
||||||
|
{hasAdvanced && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3">Additional Details</h4>
|
||||||
|
<div className="space-y-3 pl-4 border-l-2 border-gray-200">
|
||||||
|
{ingredient.minStockLevel != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Min Stock Level</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-0.5">{ingredient.minStockLevel}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ingredient.shelfLifeDays != null && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Shelf Life</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-0.5">{ingredient.shelfLifeDays} days</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ingredient.storageInstructions && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Storage Instructions</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-0.5">{ingredient.storageInstructions}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ingredient.notes && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Notes</p>
|
||||||
|
<p className="text-sm text-gray-900 mt-0.5">{ingredient.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-200">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Created</p>
|
||||||
|
<p className="text-sm text-gray-700 mt-0.5">{new Date(ingredient.createdAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Updated</p>
|
||||||
|
<p className="text-sm text-gray-700 mt-0.5">{new Date(ingredient.updatedAt).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user