/** * @license * SPDX-License-Identifier: Apache-2.0 */ /** * Inventory & Catalogue — the store user's page. * * Product-management flow (3 tiers): * 1. Admin adds products to the GLOBAL catalogue and selects which ones (+ qty) * to publish — that's the shared "store catalogue" (services/storeCatalogue). * 2. The user sees ONLY that admin-curated catalogue here (never the global one) * and chooses which products they need, each with their own quantity. * 3. Those picks are the user's request for their store. * * The catalogue source is the shared store catalogue (localStorage bridge for now; * backend: GET /products/getlocationproducts). The user's picks persist per store * and `commitSelectionToStore()` is the single backend integration point * (POST /products/createproductlocation / a stock-request endpoint). */ import React, { useEffect, useMemo, useState } from 'react'; import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity } from 'lucide-react'; import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row } from '../services/fiestaApi'; import { categoryName } from '../services/fiestaMappers'; import { useStoreCatalogue } from '../services/storeCatalogue'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer } from './consoleUi'; import { useCompare } from '../contexts/CompareContext'; const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'; interface StoreCatalogViewProps { locationid?: number; storeName?: string; tenantId?: number; } function stockStatus(closing: number): { label: string; color: string } { if (closing <= 0) return { label: 'Out of stock', color: '#ef4444' }; if (closing < 25) return { label: 'Critical', color: '#ef4444' }; if (closing < 120) return { label: 'Low', color: '#f59e0b' }; return { label: 'Healthy', color: '#10b981' }; } /** Category → pill badge classes (mirrors the admin Global Catalogue card). */ function catBadgeClass(category: string): string { const c = category.toLowerCase(); if (c.startsWith('staple')) return 'bg-amber-50 text-amber-600 border border-amber-100'; if (c.includes('grocer')) return 'bg-emerald-50 text-emerald-600 border border-emerald-100'; if (c.includes('beverage')) return 'bg-sky-50 text-sky-600 border border-sky-100'; return 'bg-rose-50 text-rose-600 border border-rose-100'; } export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) { const { selectedProducts, toggleProduct, setIsComparing } = useCompare(); const tenantid = tenantId; const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue'); const [search, setSearch] = useState(''); const [selectedCategories, setSelectedCategories] = useState([]); const [stockHealthFilter, setStockHealthFilter] = useState([]); const [selectedProduct, setSelectedProduct] = useState(null); const [notice, setNotice] = useState(false); // The admin-curated catalogue (what the user is allowed to pick from). const storeCat = useStoreCatalogue(); const products = useMemo( () => storeCat.items.map((it) => ({ id: it.productid, name: it.name, sku: it.sku || `SKU-${it.productid}`, image: it.image || PLACEHOLDER, category: it.category || 'General', price: it.price, unit: it.unit, adminQty: it.qty, })), [storeCat.items], ); // The user's picks: productid → quantity they need. Persisted per store. const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`; const [picks, setPicks] = useState>(() => { try { const raw = localStorage.getItem(storageKey); return raw ? (JSON.parse(raw) as Record) : {}; } catch { return {}; } }); useEffect(() => { try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ } }, [picks, storageKey]); const togglePick = (id: string) => { setNotice(false); setPicks((prev) => { const next = { ...prev }; if (next[id] != null) delete next[id]; else next[id] = 1; return next; }); }; const setPickQty = (id: string, qty: number) => setPicks((prev) => ({ ...prev, [id]: Math.max(1, Math.round(qty) || 1) })); const pickCount = Object.keys(picks).length; // Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags. const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 }); const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]); const inventory = useMemo( () => (stockQ.data ?? []).map((r: Row) => { const closing = fnum(r.closing) ?? 0; return { id: fstr(r.productid), name: fstr(r.productname) || 'Unnamed product', sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`, image: fstr(r.productimage) || PLACEHOLDER, category: categoryName(fnum(r.categoryid)), closing, ...stockStatus(closing), }; }), [stockQ.data], ); const filteredInventory = useMemo(() => { const term = search.toLowerCase(); if (!term) return inventory; return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term)); }, [inventory, search]); const categories = useMemo(() => [...new Set(products.map((p) => p.category.split(' / ')[0]))].sort(), [products]); const filtered = useMemo(() => { const term = search.toLowerCase(); return products.filter((p) => { const pCat = p.category.split(' / ')[0]; if (selectedCategories.length > 0 && !selectedCategories.includes(pCat)) return false; if (!term) return true; return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term); }); }, [products, search, selectedCategories]); const toggleCategory = (cat: string) => { setSelectedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]); }; const toggleStockHealth = (health: string) => { setStockHealthFilter(prev => prev.includes(health) ? prev.filter(h => h !== health) : [...prev, health]); }; const finalFilteredInventory = useMemo(() => { return filteredInventory.filter(it => { if (selectedCategories.length > 0 && !selectedCategories.includes(it.category.split(' / ')[0])) return false; if (stockHealthFilter.length > 0 && !stockHealthFilter.includes(it.label)) return false; return true; }); }, [filteredInventory, selectedCategories, stockHealthFilter]); // ── Integration point ────────────────────────────────────────────────────────── // Replace with the real request/stock POST (selected productids + quantities), // then invalidate stockQ. const commitSelectionToStore = () => setNotice(true); return (
{/* Tabs */}
{/* Sticky Sidebar Filter */}

Search

setSearch(e.target.value)} className="w-full pl-3 pr-8 py-2.5 border border-[#e2e8f0] rounded-xl text-xs font-semibold outline-none bg-slate-50 focus:bg-white focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all shadow-sm" /> {search && ( )}

Categories

{categories.map((c) => ( ))}
{view === 'inventory' && ( <>

Stock Health

{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => ( ))}
)} {(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && ( )}
{/* Product Grid Area */}
{/* Mobile Search Input (Visible only on mobile) */}
setSearch(e.target.value)} className="w-full pl-9 pr-9 py-3 border border-[#e2e8f0] rounded-xl text-sm font-semibold outline-none bg-white focus:ring-2 focus:ring-purple-500/20 shadow-sm" /> {search && ( )}
{/* Results Summary & Toolbar */}

{view === 'catalogue' ? 'Global Catalogue' : 'My Inventory'}

Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results

{/* Can add sorting dropdown here if needed */}
{/* ── Browse Catalogue ── */} {view === 'catalogue' && ( products.length === 0 ? ( } title="No products published yet" sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select." /> ) : filtered.length === 0 ? ( } title="No products match your search" sub="Try a different keyword or clear the filters to see the full catalogue." /> ) : (
{filtered.map((p) => { const stocked = inStore.has(p.id); const picked = picks[p.id] != null; return (
setSelectedProduct(p)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer"> {/* Image Bento Block */}
{p.name}
{stocked && (
In Store
)}
{p.category.split(' / ')[0]}
{/* Details Bento Block */}

{p.name}

{p.sku}

{ e.stopPropagation(); toggleProduct({ id: p.id, name: p.name, sku: p.sku, category: p.category.split(' / ')[0], price: p.price, image: p.image, unit: p.unit }); }} >
Price {p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}
Unit {p.unit || 'Pc'}
{/* Action Bento Block */}
{picked ? (
{picks[p.id]}
) : ( )}
); })}
) )} {/* ── My Store Inventory ── */} {view === 'inventory' && ( stockQ.isLoading ? ( } title="Loading your stock…" sub="Fetching the latest stock levels for your store." /> ) : !locationid ? ( } title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." /> ) : inventory.length === 0 ? ( } title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." /> ) : finalFilteredInventory.length === 0 ? ( } title="No stock matches your filters" sub="Try a different keyword or clear the sidebar filters to find an item in your store." action={ } /> ) : (
{finalFilteredInventory.map((it, i) => (
setSelectedProduct(it)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer"> {/* Image Bento Block */}
{it.name}
{it.label}
{it.category.split(' / ')[0]}
{/* Details Bento Block */}

{it.name}

{it.sku}

{ e.stopPropagation(); toggleProduct({ id: it.id, name: it.name, sku: it.sku, category: it.category.split(' / ')[0], price: it.price, image: it.image, closing: it.closing, label: it.label, color: it.color, unit: it.unit }); }} >
Live Stock {(it.closing ?? 0).toLocaleString('en-IN')}
))}
) )}
{/* ── Selection FAB ── */} {view === 'catalogue' && pickCount > 0 && (
{notice ? (
{pickCount} product{pickCount > 1 ? 's' : ''} requested
) : ( )}
)} {/* ADD/EDIT PRODUCT MODAL (simulated) */} setSelectedProduct(null)} title={view === 'catalogue' ? 'Catalogue Product Details' : 'Store Inventory Status'} > {selectedProduct && (
{selectedProduct.name}
{selectedProduct.category.split(' / ')[0]}

{selectedProduct.name}

{selectedProduct.sku}

Pricing {selectedProduct.price > 0 ? `₹${selectedProduct.price.toLocaleString('en-IN')}` : '—'}
Unit {selectedProduct.unit || 'Piece'}
{selectedProduct.closing !== undefined && (
Live Stock Level {selectedProduct.label}
{selectedProduct.closing.toLocaleString('en-IN')}
)}

This is a shared product available in the Global Catalogue. Make sure to keep adequate stock to prevent customer order cancellations due to unavailability.

)}
); } function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; title: string; sub?: string; action?: React.ReactNode }) { return (
{/* Soft decorative glows */}
{/* Icon with halo */}
{icon}

{title}

{sub &&

{sub}

} {action &&
{action}
}
); }