318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
'use client';
|
|
|
|
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;
|
|
name: string;
|
|
}
|
|
|
|
interface Subcategory {
|
|
_id: string;
|
|
name: string;
|
|
categoryId: string;
|
|
}
|
|
|
|
interface IngredientsResponse {
|
|
data: Ingredient[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
}
|
|
|
|
export default function IngredientsPage() {
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [search, setSearch] = useState('');
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [categoryId, setCategoryId] = useState('');
|
|
const [subcategoryId, setSubcategoryId] = useState('');
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [allSubcategories, setAllSubcategories] = useState<Subcategory[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
|
|
const limit = 10;
|
|
const totalPages = Math.ceil(total / limit);
|
|
|
|
// Fetch categories and subcategories on mount
|
|
useEffect(() => {
|
|
Promise.all([
|
|
fetch('/api/categories').then(r => r.json()),
|
|
fetch('/api/subcategories').then(r => r.json()),
|
|
]).then(([cats, subs]) => {
|
|
setCategories(cats);
|
|
setAllSubcategories(subs);
|
|
});
|
|
}, []);
|
|
|
|
const filteredSubcategories = categoryId
|
|
? allSubcategories.filter(s => s.categoryId === categoryId)
|
|
: allSubcategories;
|
|
|
|
// Fetch ingredients
|
|
const fetchIngredients = useCallback(async () => {
|
|
setLoading(true);
|
|
const params = new URLSearchParams();
|
|
params.set('page', String(page));
|
|
params.set('limit', String(limit));
|
|
if (search) params.set('search', search);
|
|
if (categoryId) params.set('categoryId', categoryId);
|
|
if (subcategoryId) params.set('subcategoryId', subcategoryId);
|
|
|
|
const res = await fetch(`/api/ingredients?${params}`);
|
|
const data: IngredientsResponse = await res.json();
|
|
setIngredients(data.data);
|
|
setTotal(data.total);
|
|
setLoading(false);
|
|
}, [page, search, categoryId, subcategoryId]);
|
|
|
|
useEffect(() => {
|
|
fetchIngredients();
|
|
}, [fetchIngredients]);
|
|
|
|
// Reset page when filters change
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [search, categoryId, subcategoryId]);
|
|
|
|
// Clear subcategory when category changes
|
|
useEffect(() => {
|
|
setSubcategoryId('');
|
|
}, [categoryId]);
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setSearch(searchInput);
|
|
};
|
|
|
|
const activeFilters: { label: string; onClear: () => void }[] = [];
|
|
if (categoryId) {
|
|
const cat = categories.find(c => c._id === categoryId);
|
|
if (cat) activeFilters.push({ label: `Category: ${cat.name}`, onClear: () => setCategoryId('') });
|
|
}
|
|
if (subcategoryId) {
|
|
const sub = allSubcategories.find(s => s._id === subcategoryId);
|
|
if (sub) activeFilters.push({ label: `Subcategory: ${sub.name}`, onClear: () => setSubcategoryId('') });
|
|
}
|
|
if (search) {
|
|
activeFilters.push({ label: `Search: "${search}"`, onClear: () => { setSearch(''); setSearchInput(''); } });
|
|
}
|
|
|
|
const clearAllFilters = () => {
|
|
setCategoryId('');
|
|
setSubcategoryId('');
|
|
setSearch('');
|
|
setSearchInput('');
|
|
};
|
|
|
|
const startItem = total === 0 ? 0 : (page - 1) * limit + 1;
|
|
const endItem = Math.min(page * limit, total);
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-gray-50">
|
|
<Sidebar isOpen={isSidebarOpen} onClose={() => setIsSidebarOpen(false)} />
|
|
|
|
<main className="flex-1 lg:ml-64">
|
|
{/* Mobile Header */}
|
|
<div className="lg:hidden sticky top-0 z-30 bg-white border-b border-gray-200 px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => setIsSidebarOpen(true)}
|
|
className="text-gray-500 hover:text-gray-700"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
|
</svg>
|
|
</button>
|
|
<h1 className="text-lg font-bold text-gray-900">KitchenOS</h1>
|
|
<div className="w-6" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 sm:p-6 lg:p-8">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 lg:mb-8">
|
|
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Ingredient Management</h1>
|
|
<div className="flex gap-2 sm:gap-3">
|
|
<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
|
|
</button>
|
|
<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
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters and Search */}
|
|
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-6 mb-4 sm:mb-6">
|
|
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-3 sm:gap-4 mb-4">
|
|
{/* Search */}
|
|
<form onSubmit={handleSearch} className="w-full sm:flex-1 sm:min-w-62.5">
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name or code…"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
onBlur={() => setSearch(searchInput)}
|
|
className="w-full px-4 py-2.5 pl-10 rounded-lg border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base"
|
|
/>
|
|
<svg
|
|
className="absolute left-3 top-3 w-5 h-5 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Filters Row */}
|
|
<div className="grid grid-cols-2 sm:flex gap-2 sm:gap-3">
|
|
{/* Category Filter */}
|
|
<select
|
|
value={categoryId}
|
|
onChange={(e) => setCategoryId(e.target.value)}
|
|
className="px-3 sm:px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base"
|
|
>
|
|
<option value="">Categories</option>
|
|
{categories.map((cat) => (
|
|
<option key={cat._id} value={cat._id}>{cat.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Subcategory Filter */}
|
|
<select
|
|
value={subcategoryId}
|
|
onChange={(e) => setSubcategoryId(e.target.value)}
|
|
className="px-3 sm:px-4 py-2.5 rounded-lg border border-gray-300 bg-white text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-sm sm:text-base"
|
|
>
|
|
<option value="">Subcategories</option>
|
|
{filteredSubcategories.map((sub) => (
|
|
<option key={sub._id} value={sub._id}>{sub.name}</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Bulk Actions */}
|
|
<button className="col-span-2 sm:col-span-1 px-3 sm:px-4 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">
|
|
Bulk Actions
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active Filters */}
|
|
{activeFilters.length > 0 && (
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
|
<span className="text-sm text-gray-600">Active filters:</span>
|
|
<div className="flex flex-wrap gap-2">
|
|
{activeFilters.map((filter) => (
|
|
<span key={filter.label} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-xs sm:text-sm font-medium">
|
|
{filter.label}
|
|
<button onClick={filter.onClear} className="hover:text-blue-900">
|
|
<svg className="w-3 h-3 sm:w-4 sm:h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
<button onClick={clearAllFilters} className="text-xs sm:text-sm text-blue-600 hover:text-blue-800 font-medium sm:ml-2 self-start">
|
|
Clear all
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Ingredient Table */}
|
|
{loading ? (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
|
|
<p className="text-gray-500">Loading ingredients...</p>
|
|
</div>
|
|
) : ingredients.length === 0 ? (
|
|
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
|
|
<p className="text-gray-500">No ingredients found.</p>
|
|
</div>
|
|
) : (
|
|
<IngredientTable ingredients={ingredients} />
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{total > 0 && (
|
|
<div className="mt-4 sm:mt-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
<p className="text-xs sm:text-sm text-gray-600 text-center sm:text-left">
|
|
Showing <span className="font-medium">{startItem}</span> to{' '}
|
|
<span className="font-medium">{endItem}</span> of{' '}
|
|
<span className="font-medium">{total}</span> results
|
|
</p>
|
|
<div className="flex items-center gap-1 sm:gap-2">
|
|
<button
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm"
|
|
>
|
|
Previous
|
|
</button>
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
.filter(p => {
|
|
if (p === 1 || p === totalPages) return true;
|
|
if (Math.abs(p - page) <= 1) return true;
|
|
return false;
|
|
})
|
|
.map((p, idx, arr) => (
|
|
<React.Fragment key={p}>
|
|
{idx > 0 && arr[idx - 1] !== p - 1 && (
|
|
<span className="px-1 text-gray-400 text-xs">…</span>
|
|
)}
|
|
<button
|
|
onClick={() => setPage(p)}
|
|
className={`px-2 sm:px-3 py-2 rounded-lg text-xs sm:text-sm min-w-8 sm:min-w-9 ${
|
|
p === page
|
|
? 'bg-blue-600 text-white font-medium'
|
|
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors'
|
|
}`}
|
|
>
|
|
{p}
|
|
</button>
|
|
</React.Fragment>
|
|
))}
|
|
<button
|
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages}
|
|
className="px-2 sm:px-3 py-2 rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-xs sm:text-sm"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
<AddIngredientModal
|
|
open={isAddModalOpen}
|
|
onClose={() => setIsAddModalOpen(false)}
|
|
onSaved={fetchIngredients}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|