IcostPro/app/dashboard/ingredients/IngredientTable.tsx
2026-02-12 21:13:15 +01:00

300 lines
13 KiB
TypeScript

'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
export interface Ingredient {
_id: string;
code: string;
name: string;
category: string;
subcategory: string;
quantity: number;
unit: string;
unitPrice: number;
vat: number;
supplier: string;
createdAt: string;
updatedAt: string;
}
interface IngredientTableProps {
ingredients: Ingredient[];
onView: (id: string) => void;
onEdit: (id: string) => void;
onDelete: (id: string, name: string) => void;
}
export default function IngredientTable({ ingredients, onView, onEdit, onDelete }: IngredientTableProps) {
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 newSelected = new Set(selectedIngredients);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIngredients(newSelected);
};
const toggleAll = () => {
if (selectedIngredients.size === ingredients.length) {
setSelectedIngredients(new Set());
} else {
setSelectedIngredients(new Set(ingredients.map(i => i._id)));
}
};
const calculateNetPrice = (unitPrice: number, vat: number) => {
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 (
<>
{/* Desktop & Tablet Table View */}
<div className="hidden md:block bg-white rounded-lg border border-gray-200">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-4 lg:px-6 py-3 text-left">
<input
type="checkbox"
checked={selectedIngredients.size === ingredients.length && ingredients.length > 0}
onChange={toggleAll}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</th>
<th className="px-4 lg:px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Ingredient
</th>
<th className="hidden lg:table-cell px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Category
</th>
<th className="hidden xl:table-cell px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Subcategory
</th>
<th className="px-4 lg:px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Quantity
</th>
<th className="hidden lg:table-cell px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Unit Price
</th>
<th className="hidden xl:table-cell px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
VAT
</th>
<th className="px-4 lg:px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Net Price
</th>
<th className="hidden lg:table-cell px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Supplier
</th>
<th className="px-4 lg:px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{ingredients.map((ingredient) => (
<tr key={ingredient._id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 lg:px-6 py-4">
<input
type="checkbox"
checked={selectedIngredients.has(ingredient._id)}
onChange={() => toggleIngredient(ingredient._id)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</td>
<td className="px-4 lg:px-6 py-4">
<div>
<p className="text-sm font-medium text-gray-900">{ingredient.name}</p>
<p className="text-xs text-gray-500">ID: {ingredient.code}</p>
<p className="text-xs text-gray-500 lg:hidden mt-1">
{ingredient.category} {ingredient.quantity} {ingredient.unit}
</p>
</div>
</td>
<td className="hidden lg:table-cell px-6 py-4 text-sm text-gray-700">{ingredient.category}</td>
<td className="hidden xl:table-cell px-6 py-4 text-sm text-gray-700">{ingredient.subcategory}</td>
<td className="px-4 lg:px-6 py-4 text-sm text-gray-700">
{ingredient.quantity} {ingredient.unit}
</td>
<td className="hidden lg:table-cell px-6 py-4 text-sm text-gray-700">{ingredient.unitPrice.toFixed(2)}</td>
<td className="hidden xl:table-cell px-6 py-4 text-sm text-gray-700">{ingredient.vat}%</td>
<td className="px-4 lg:px-6 py-4 text-sm font-bold text-gray-900">
{calculateNetPrice(ingredient.unitPrice, ingredient.vat).toFixed(2)}
</td>
<td className="hidden lg:table-cell px-6 py-4">
<a href="#" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
{ingredient.supplier}
</a>
</td>
<td className="px-4 lg:px-6 py-4">
{renderMenuButton(ingredient)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Card View */}
<div className="md:hidden space-y-4">
{ingredients.map((ingredient) => (
<div key={ingredient._id} className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={selectedIngredients.has(ingredient._id)}
onChange={() => toggleIngredient(ingredient._id)}
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 mt-1"
/>
<div className="flex-1">
<div className="flex items-start justify-between">
<div>
<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>
</div>
{renderMenuButton(ingredient)}
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-gray-500">Category</p>
<p className="text-sm text-gray-900 mt-0.5">{ingredient.category}</p>
</div>
<div>
<p className="text-xs text-gray-500">Subcategory</p>
<p className="text-sm text-gray-900 mt-0.5">{ingredient.subcategory}</p>
</div>
<div>
<p className="text-xs text-gray-500">Quantity</p>
<p className="text-sm text-gray-900 mt-0.5">{ingredient.quantity} {ingredient.unit}</p>
</div>
<div>
<p className="text-xs text-gray-500">Unit Price</p>
<p className="text-sm text-gray-900 mt-0.5">{ingredient.unitPrice.toFixed(2)}</p>
</div>
<div>
<p className="text-xs text-gray-500">VAT</p>
<p className="text-sm text-gray-900 mt-0.5">{ingredient.vat}%</p>
</div>
<div>
<p className="text-xs text-gray-500">Net Price</p>
<p className="text-sm font-bold text-gray-900 mt-0.5">
{calculateNetPrice(ingredient.unitPrice, ingredient.vat).toFixed(2)}
</p>
</div>
</div>
<div className="mt-3 pt-3 border-t border-gray-200 flex items-center justify-between">
<div>
<p className="text-xs text-gray-500">Supplier</p>
<a href="#" className="text-sm text-blue-600 hover:text-blue-800 font-medium">
{ingredient.supplier}
</a>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">Updated</p>
<p className="text-xs text-gray-700 mt-0.5">{new Date(ingredient.updatedAt).toLocaleDateString()}</p>
</div>
</div>
</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>
)}
</>
);
}