2import { useEffect, useState } from "react";
3import { useRouter } from "next/navigation";
4import { Icon } from '@/contexts/IconContext';
5import { apiClient } from "@/lib/client/apiClient";
6import { SkeletonCard } from "@/components/LoadingSpinner";
9 Pid: number; // Product ID
10 Name: string; // Product Name
11 Barcode?: string; // Barcode
12 SellPrice: number; // Selling Price
13 StockLevel?: number; // Current Stock Level
14 StockMin?: number; // Minimum Stock Level
15 PLU?: string; // PLU Code
16 Key?: string; // Unique Key
17 SupplierId?: string | number;
18 SupplierName?: string;
21export default function ProductsPage() {
22 const router = useRouter();
23 const [products, setProducts] = useState<Product[]>([]);
24 const [searchTerm, setSearchTerm] = useState("");
25 const [sohFilter, setSohFilter] = useState<"all" | "in" | "out" | "belowMin">("all");
26 const [searchIn, setSearchIn] = useState<"all" | "name" | "barcode" | "plu" | "pid">("all");
27 const [minPrice, setMinPrice] = useState<string>("");
28 const [maxPrice, setMaxPrice] = useState<string>("");
29 const [lowStockFirst, setLowStockFirst] = useState<boolean>(false);
31 const [departmentId, setDepartmentId] = useState<string>("");
32 const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([]);
34 const [supplierSearch, setSupplierSearch] = useState("");
35 const [supplierId, setSupplierId] = useState<string>("");
36 const [supplierName, setSupplierName] = useState("");
37 const [showSupplierDropdown, setShowSupplierDropdown] = useState(false);
38 const [suppliers, setSuppliers] = useState<Array<{ id: string; name: string }>>([]);
39 const [loading, setLoading] = useState(false);
40 const [error, setError] = useState<string | null>(null);
41 const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
42 const [showStockModal, setShowStockModal] = useState(false);
43 const [stockAdjustment, setStockAdjustment] = useState<string>("");
44 const [stockReason, setStockReason] = useState<string>("");
45 const [saving, setSaving] = useState(false);
46 const [saveMessage, setSaveMessage] = useState<string>("");
49 const [currentPage, setCurrentPage] = useState(0);
50 const [pageSize] = useState(50);
51 const [totalCount, setTotalCount] = useState(0);
52 const [hasMore, setHasMore] = useState(true);
55 setCurrentPage(0); // Reset to first page on mount
57 loadDepartments(); // Load departments for filter
60 const loadDepartments = async () => {
62 const result = await apiClient.get('/v1/elink/departments');
63 if (result.success && result.data) {
64 const depts = result.data.DATS || [];
65 const mapped = depts.map((d: any) => ({
66 id: String(d.f100 || ''),
67 name: d.f101 || d.f500 || `Dept ${d.f100}`
68 })).filter((d: any) => d.id && d.name);
69 setDepartments(mapped);
72 console.error('Failed to load departments:', error);
77 // Debounce search - reset to page 0 when search changes
78 const timer = setTimeout(() => {
81 }, 400); // 400ms debounce for better UX
82 return () => clearTimeout(timer);
83 }, [searchTerm, supplierId, minPrice, maxPrice, sohFilter, departmentId]);
85 const clearAllFilters = () => {
91 setLowStockFirst(false);
93 setSupplierSearch("");
96 setShowSupplierDropdown(false);
99 setTimeout(() => loadProducts(0), 0);
102 const loadProducts = async (page: number = currentPage) => {
107 console.log('🔍 Loading products - Page:', page, 'PageSize:', pageSize, 'Skip:', page * pageSize);
109 // Build parameters for OpenAPI with server-side filtering
110 const params: any = {
112 skip: page * pageSize,
116 // Search filter - send raw search term, server will handle BUCK format
117 if (searchTerm.trim()) {
118 const term = searchTerm.trim();
121 params.search = term; // Server searches f101 (name/description)
124 params.barcode = term; // Server searches f105 (barcode)
127 params.plu = term; // Server searches f102 (PLU)
130 // For PID search, use search with numeric value
131 params.search = term;
134 // General search - server will search name/description
135 params.search = term;
139 // Stock filter - server-side (f130)
140 if (sohFilter === 'in') {
141 params.stockFilter = 'in'; // Server filters: f130 > 0
142 } else if (sohFilter === 'out') {
143 params.stockFilter = 'out'; // Server filters: f130 = 0
145 // belowMin requires client-side filtering (f130 <= f131 comparison)
147 // Price range filters - server-side (f103)
149 const min = Number(minPrice);
151 params.minPrice = min; // Server filters: f103 >= min
155 const max = Number(maxPrice);
157 params.maxPrice = max; // Server filters: f103 <= max
161 // Supplier filter - server-side (f104)
162 if (supplierId && supplierId !== 'all' && supplierId.trim()) {
163 params.supplierId = supplierId; // Server filters: f104 = supplierId
166 // Department filter - server-side (f106)
167 if (departmentId && departmentId !== 'all' && departmentId.trim()) {
168 params.departmentId = departmentId; // Server filters: f106 = departmentId
171 console.log('🌐 API Request params:', params);
173 // Use apiClient - server will handle ELINK/BUCK API with server-side filtering
174 const result = await apiClient.getProducts(params);
176 // Handle OpenAPI response
177 if (result.success) {
178 const apiData = result.data as any;
180 // ELINK API returns: { data: { value: [...] } }
181 let productData = apiData?.data?.Product || apiData?.value || [];
183 // Apply remaining client-side filters that can't be done server-side
184 if (Array.isArray(productData)) {
185 // belowMin filter (requires f130 <= f131 comparison)
186 if (sohFilter === 'belowMin') {
187 productData = productData.filter((p: any) =>
188 (p.StockLevel || p.f130 || 0) <= (p.StockMin || p.f131 || 0)
193 // Pagination handled on server now, but keep total count
194 const totalProducts = Array.isArray(productData) ? productData.length : 0;
196 console.log('📊 Server response:', {
197 total: totalProducts,
200 itemsOnPage: productData.length,
201 firstProductSample: productData[0]
204 if (Array.isArray(productData)) {
205 // Map OpenAPI format fields to component interface
206 const mappedProducts = productData.map((p: any) => ({
208 Name: p.Name || p.Description || '',
209 Barcode: p.Barcode || p.Plu || '',
210 SellPrice: p.SellPrice || p.Price || 0,
211 StockLevel: p.StockLevel,
212 StockMin: p.StockMin,
215 SupplierId: p.Supplier?.Id || p.SupplierId || undefined,
216 SupplierName: p.Supplier?.Name || p.SupplierName || undefined,
219 setProducts(mappedProducts);
220 setTotalCount(totalProducts);
221 setHasMore(mappedProducts.length >= pageSize); // More pages if we got full page
223 if (mappedProducts.length === 0 && page === 0) {
224 setError("No products found matching your filters");
227 setError("No products found");
232 setError("Failed to load products");
236 console.error('Failed to load products:', e);
237 setError("Network error while fetching products");
244 // Server-side filtering is now handled in loadProducts()
245 // Only need to compute low stock products for stats
246 const isLowStock = (p: Product) => {
247 if (p.StockLevel === 0) return true;
248 if (p.StockLevel !== undefined && p.StockMin !== undefined) return p.StockLevel <= p.StockMin;
252 const handleStockUpdate = async () => {
253 if (!selectedProduct) return;
255 const adjustment = parseFloat(stockAdjustment);
256 if (isNaN(adjustment) || adjustment === 0) {
257 setSaveMessage("Please enter a valid adjustment amount");
262 setSaveMessage("Please select a reason");
270 const response = await fetch('/api/v1/products/stock-adjustment', {
273 'Content-Type': 'application/json',
275 body: JSON.stringify({
276 productId: selectedProduct.Pid,
279 locationId: 1 // Default location
283 const result = await response.json();
285 if (result.success) {
286 setSaveMessage(`✓ ${result.message || 'Stock updated successfully'}`);
287 // Refresh products list
289 loadProducts(currentPage);
290 // Close modal after 1.5 seconds
292 setShowStockModal(false);
293 setSelectedProduct(null);
294 setStockAdjustment("");
300 setSaveMessage(`✗ ${result.error || 'Failed to update stock'}`);
303 console.error('Stock update error:', err);
304 setSaveMessage('✗ Network error. Please try again.');
310 const lowStockProducts = products.filter(p =>
311 p.StockLevel !== undefined && p.StockMin !== undefined && p.StockLevel <= p.StockMin
315 <div className="p-8 min-h-screen bg-bg">
316 <div className="flex justify-between items-center mb-6">
317 <h1 className="text-3xl font-bold text-text flex items-center gap-2">
318 Products <Icon name="inventory_2" size={32} />
320 <div className="flex gap-3">
323 setSelectedProduct(null);
324 setShowStockModal(true);
326 className="px-4 py-2 bg-brand text-white rounded-lg hover:opacity-90 font-medium"
332 window.location.href = '/pages/products/stocktake';
334 className="px-4 py-2 bg-brand text-white rounded-lg hover:opacity-90 font-medium flex items-center gap-2"
336 <Icon name="inventory_2" size={20} /> Manage Stock
342 <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
343 <div className="bg-brand-2 p-4 rounded-lg border border-border">
344 <div className="text-2xl font-bold text-brand">{totalCount || products.length}</div>
345 <div className="text-sm text-text">Total Products</div>
347 <div className="bg-surface-2 p-4 rounded-lg border border-warn">
348 <div className="text-2xl font-bold text-warn">{lowStockProducts.length}</div>
349 <div className="text-sm text-text">Low Stock (Current Page)</div>
351 <div className="bg-surface p-4 rounded-lg border border-border">
352 <div className="text-2xl font-bold text-text">{products.length}</div>
353 <div className="text-sm text-muted">Products on Page {currentPage + 1}</div>
357 <div className="bg-surface p-6 rounded-lg shadow border border-border">
358 <div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
360 <label className="block text-sm font-medium text-text mb-1">Search</label>
363 placeholder="Name, barcode or ID..."
364 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
366 onChange={(e) => setSearchTerm(e.target.value)}
370 <label className="block text-sm font-medium text-text mb-1">Search In</label>
372 className="w-full p-3 border rounded-lg"
374 onChange={(e) => setSearchIn(e.target.value as any)}
376 <option value="all">All fields</option>
377 <option value="name">Name only</option>
378 <option value="barcode">Barcode only</option>
379 <option value="plu">PLU only</option>
380 <option value="pid">Product ID only</option>
383 <div className="relative">
384 <label className="block text-sm font-medium text-text mb-1">Supplier</label>
385 <div className="flex gap-2">
388 placeholder="Search supplier name or ID..."
389 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
390 value={supplierSearch}
391 onChange={async (e) => {
392 const v = e.target.value;
393 setSupplierSearch(v);
394 setShowSupplierDropdown(true);
395 // Live search suppliers
396 const resp = await apiClient.getSuppliers({ search: v, limit: 20 });
397 const arr: any[] = (resp.data as any[]) || [];
398 const mapped = arr.map((s: any) => ({ id: String(s.f100 || s.Id || s.id || ''), name: s.f101 || s.Name || s.name || '' }));
399 setSuppliers(mapped.filter(s => s.id && s.name));
401 onFocus={async () => {
402 setShowSupplierDropdown(true);
403 if (suppliers.length === 0) {
404 const resp = await apiClient.getSuppliers({ limit: 20 });
405 const arr: any[] = (resp.data as any[]) || [];
406 const mapped = arr.map((s: any) => ({ id: String(s.f100 || s.Id || s.id || ''), name: s.f101 || s.Name || s.name || '' }));
407 setSuppliers(mapped.filter(s => s.id && s.name));
413 className="px-3 rounded bg-surface-2 border border-border text-sm text-text"
414 onClick={() => { setSupplierId(''); setSupplierName(''); setSupplierSearch(''); }}
415 title="Clear supplier"
421 {showSupplierDropdown && suppliers.length > 0 && (
422 <div className="absolute z-20 bg-surface border border-border rounded shadow w-full max-h-56 overflow-auto mt-1">
423 {suppliers.map(s => (
426 className="px-3 py-2 hover:bg-surface-2 cursor-pointer"
429 setSupplierName(s.name);
430 setSupplierSearch(s.name);
431 setShowSupplierDropdown(false);
434 <div className="text-sm text-text">{s.name}</div>
435 <div className="text-xs text-muted">ID: {s.id}</div>
441 <div className="mt-1 text-xs text-brand">Selected: {supplierName}</div>
445 <label className="block text-sm font-medium text-text mb-1">Department</label>
447 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
449 onChange={(e) => setDepartmentId(e.target.value)}
451 <option value="">All Departments</option>
452 {departments.map(dept => (
453 <option key={dept.id} value={dept.id}>
460 <label className="block text-sm font-medium text-text mb-1">Stock On Hand</label>
462 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
464 onChange={(e) => setSohFilter(e.target.value as any)}
466 <option value="all">All</option>
467 <option value="in">In Stock (> 0)</option>
468 <option value="out">Out of Stock (0)</option>
469 <option value="belowMin">Below Min</option>
474 <div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
475 <div className="md:col-span-2">
476 <label className="block text-sm font-medium text-text mb-1">Price Range</label>
477 <div className="flex gap-2">
483 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
485 onChange={(e) => setMinPrice(e.target.value)}
492 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
494 onChange={(e) => setMaxPrice(e.target.value)}
498 <div className="flex items-end">
499 <label className="flex items-center gap-2 text-sm text-text">
503 checked={lowStockFirst}
504 onChange={(e) => setLowStockFirst(e.target.checked)}
511 <div className="flex justify-end mb-2">
513 className="text-sm px-3 py-2 border border-border rounded hover:bg-surface-2 bg-surface text-text"
514 onClick={clearAllFilters}
515 title="Reset all filters"
523 <div className="bg-warn/10 border border-warn rounded-lg p-4 mb-4">
524 <div className="flex items-center">
525 <span className="text-warn">⚠️</span>
526 <span className="ml-2 text-warn">{error}</span>
528 onClick={() => loadProducts(currentPage)}
529 className="ml-auto text-warn hover:opacity-80 underline"
537 <div className="max-h-[540px] overflow-y-auto border border-border rounded-lg relative">
539 <div className="p-4 space-y-3 bg-surface">
540 {[...Array(8)].map((_, i) => (
541 <div key={i} className="p-3 border-b border-border animate-pulse">
542 <div className="h-5 bg-surface-2 rounded w-3/4 mb-2"></div>
543 <div className="h-3 bg-surface-2 rounded w-1/2 mb-2"></div>
544 <div className="h-3 bg-surface-2 rounded w-1/4"></div>
548 ) : products.length === 0 ? (
549 <div className="p-6 text-center text-muted bg-surface">
550 {searchTerm ? `No products match "${searchTerm}"` : "No products found"}
556 onClick={() => router.push(`/pages/products/${p.Pid}`)}
557 className="p-3 border-b border-border flex justify-between items-center hover:bg-surface-2 cursor-pointer bg-surface"
559 <div className="flex-1">
560 <div className="font-medium text-text">{p.Name || "Unnamed Product"}</div>
561 <div className="text-xs text-muted">
563 {p.Barcode && ` • ${p.Barcode}`}
564 {p.PLU && ` • PLU: ${p.PLU}`}
565 {p.SupplierName && ` • Supplier: ${p.SupplierName}`}
567 {p.StockLevel !== undefined && (
568 <div className="text-xs">
569 <span className={`font-medium ${
570 p.StockMin !== undefined && p.StockLevel <= p.StockMin
574 Stock: {p.StockLevel}
575 {p.StockMin !== undefined && ` (Min: ${p.StockMin})`}
580 <div className="flex items-center gap-3">
581 <div className="text-right">
582 <div className="text-brand font-bold">${p.SellPrice ? p.SellPrice.toFixed(2) : '0.00'}</div>
583 {p.StockLevel !== undefined && p.StockMin !== undefined && p.StockLevel <= p.StockMin && (
584 <div className="text-xs text-danger font-medium">LOW STOCK</div>
590 setSelectedProduct(p);
591 setShowStockModal(true);
593 className="px-3 py-1 text-sm bg-brand text-white rounded hover:opacity-90"
603 {/* Pagination Controls */}
604 {!loading && products.length > 0 && (
605 <div className="mt-4 flex justify-between items-center">
606 <div className="text-sm text-muted">
609 Showing {currentPage * pageSize + 1}-{Math.min((currentPage + 1) * pageSize, totalCount)} of {totalCount} products
613 Page {currentPage + 1}
617 <div className="flex gap-2">
620 const newPage = currentPage - 1;
621 setCurrentPage(newPage);
622 loadProducts(newPage);
624 disabled={currentPage === 0}
625 className="px-4 py-2 border border-border rounded-lg bg-surface text-text disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-2"
631 const newPage = currentPage + 1;
632 setCurrentPage(newPage);
633 loadProducts(newPage);
635 disabled={!hasMore || (totalCount > 0 && (currentPage + 1) * pageSize >= totalCount)}
636 className="px-4 py-2 border border-border rounded-lg bg-surface text-text disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-2"
645 {/* Stock Edit Modal */}
647 <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
648 <div className="bg-surface rounded-lg shadow-xl border border-border max-w-md w-full mx-4 p-6">
649 <div className="flex justify-between items-center mb-4">
650 <h2 className="text-xl font-bold text-text">
651 {selectedProduct ? `Edit Stock: ${selectedProduct.Name}` : 'Create New Product'}
655 setShowStockModal(false);
656 setSelectedProduct(null);
658 className="text-muted hover:text-text"
665 <div className="space-y-4">
667 <label className="block text-sm font-medium text-text mb-1">Product</label>
668 <div className="p-3 bg-surface-2 rounded border border-border">
669 <div className="font-medium text-text">{selectedProduct.Name}</div>
670 <div className="text-xs text-muted">ID: {selectedProduct.Pid}</div>
675 <label className="block text-sm font-medium text-text mb-1">Current Stock</label>
676 <div className="text-2xl font-bold text-text">
677 {selectedProduct.StockLevel ?? 'N/A'}
682 <label className="block text-sm font-medium text-text mb-1">Adjust Stock</label>
685 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
686 placeholder="Enter adjustment (+/-)"
687 value={stockAdjustment}
688 onChange={(e) => setStockAdjustment(e.target.value)}
691 <div className="text-xs text-muted mt-1">
692 Enter positive number to add stock, negative to reduce
697 <label className="block text-sm font-medium text-text mb-1">Reason</label>
699 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
701 onChange={(e) => setStockReason(e.target.value)}
704 <option value="">Select reason...</option>
705 <option value="received">Stock Received</option>
706 <option value="sale">Sale</option>
707 <option value="damage">Damage/Loss</option>
708 <option value="return">Return</option>
709 <option value="adjustment">Manual Adjustment</option>
714 <div className={`p-3 rounded-lg text-sm ${
715 saveMessage.startsWith('✓')
716 ? 'bg-brand/10 text-brand border border-brand'
717 : 'bg-danger/10 text-danger border border-danger'
723 <div className="flex gap-3 pt-4">
725 onClick={handleStockUpdate}
727 className="flex-1 px-4 py-2 bg-brand text-white rounded-lg hover:opacity-90 font-medium disabled:opacity-50 disabled:cursor-not-allowed"
729 {saving ? 'Updating...' : 'Update Stock'}
733 setShowStockModal(false);
734 setSelectedProduct(null);
735 setStockAdjustment("");
740 className="px-4 py-2 border border-border rounded-lg bg-surface text-text hover:bg-surface-2 disabled:opacity-50"
747 <div className="space-y-4">
749 <label className="block text-sm font-medium text-text mb-1">Product Name</label>
752 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
753 placeholder="Enter product name"
758 <label className="block text-sm font-medium text-text mb-1">Barcode/PLU</label>
761 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
762 placeholder="Enter barcode"
767 <label className="block text-sm font-medium text-text mb-1">Sell Price</label>
771 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
777 <label className="block text-sm font-medium text-text mb-1">Initial Stock</label>
780 className="w-full p-3 border border-border rounded-lg bg-surface text-text focus:ring-2 focus:ring-brand"
785 <div className="flex gap-3 pt-4">
788 alert('Product creation functionality will be implemented with API integration');
790 className="flex-1 px-4 py-2 bg-brand text-white rounded-lg hover:opacity-90 font-medium"
796 setShowStockModal(false);
797 setSelectedProduct(null);
799 className="px-4 py-2 border border-border rounded-lg bg-surface text-text hover:bg-surface-2"