IcostPro/app/dashboard/products/page.tsx
2026-02-11 21:11:19 +01:00

308 lines
13 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback } from 'react';
import Sidebar from '@/components/Sidebar';
import ProductTable, { Product } from '@/components/ProductTable';
interface Category {
_id: string;
name: string;
}
interface Subcategory {
_id: string;
name: string;
categoryId: string;
}
interface ProductsResponse {
data: Product[];
total: number;
page: number;
limit: number;
}
export default function ProductsPage() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [products, setProducts] = useState<Product[]>([]);
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 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 products
const fetchProducts = 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/products?${params}`);
const data: ProductsResponse = await res.json();
setProducts(data.data);
setTotal(data.total);
setLoading(false);
}, [page, search, categoryId, subcategoryId]);
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
// 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">Product 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 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">
+ Create Product
</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-[250px]">
<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>
{/* Product Table */}
{loading ? (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
<p className="text-gray-500">Loading products...</p>
</div>
) : products.length === 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center">
<p className="text-gray-500">No products found.</p>
</div>
) : (
<ProductTable products={products} />
)}
{/* 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 => {
// Show first, last, current, and neighbors
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-[32px] sm:min-w-[36px] ${
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>
</div>
);
}