/** * @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, Info, Inbox } from 'lucide-react'; import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries'; import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi'; import { useStoreCatalogue } from '../services/storeCatalogue'; import AwaitingApi from './AwaitingApi'; import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi'; import FMCGHoverOverlay from './FMCGHoverOverlay'; 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; isSidebarOpen?: boolean; } 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 = String(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, isSidebarOpen = false }: StoreCatalogViewProps) { const tenantid = tenantId; const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue'); const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true); const [search, setSearch] = useState(''); const [selectedCategories, setSelectedCategories] = useState([]); const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]); const [stockHealthFilter, setStockHealthFilter] = useState([]); const [selectedProduct, setSelectedProduct] = useState(null); const [hoveredProduct, setHoveredProduct] = useState(null); const [activeQtyProduct, setActiveQtyProduct] = useState(null); const [tempQty, setTempQty] = useState(1); 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 .filter((it) => it.status === 'Active') .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], ); const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500, date: requestDate }); const [picks, setPicks] = useState>({}); useEffect(() => { if (stockRequestsQ.data) { const map: Record = {}; stockRequestsQ.data.forEach((req: any) => { if (!map[req.productid]) { map[req.productid] = { qty: req.qty, status: req.status, requestedAt: req.created, resolvedAt: req.updated, requestid: req.requestid }; } }); setPicks(map); } }, [stockRequestsQ.data]); const createRequestMutation = useFiestaCreateStockRequest(); const updateRequestMutation = useFiestaUpdateStockRequest(); const togglePick = (id: string) => { setNotice(false); const existing = picks[id]; if (existing != null && existing.status !== 'Cancelled') { setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } })); } else { setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } })); createRequestMutation.mutate({ tenantid, locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, productid: Number(id), qty: 1, status: 'Pending', locationname: storeName }); } }; const setPickQty = (id: string, qty: number) => { const safeQty = Math.max(1, Math.round(qty) || 1); setPicks((prev) => { const existing = prev[id] || {}; return { ...prev, [id]: { ...existing, qty: safeQty, status: 'Pending', requestedAt: new Date().toISOString() } }; }); createRequestMutation.mutate({ tenantid, locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID, productid: Number(id), qty: safeQty, status: 'Pending', locationname: storeName }); }; 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(() => { const set = new Set((stockQ.data ?? []).map((r) => fstr(r.productid))); Object.entries(picks).forEach(([pid, data]: [string, any]) => { if (data.status === 'Approved') set.add(pid); }); return set; }, [stockQ.data, picks]); const inventory = useMemo( () => { const baseInventory = (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: fstr(r.categoryname) || 'General', closing, ...stockStatus(closing), price: Math.floor(Math.random() * 50) + 10, // mock price qty: closing, // fallback if actual qty not mapped }; }); // Merge approved picks into the inventory const inventoryMap = new Map(baseInventory.map(item => [item.id, item])); Object.entries(picks).forEach(([pid, data]: [string, any]) => { if (data.status === 'Approved') { if (inventoryMap.has(pid)) { const item = inventoryMap.get(pid)!; item.qty += data.qty; item.closing += data.qty; Object.assign(item, stockStatus(item.closing)); } else { // Find product info from catalogue const prod = products.find(p => p.id === pid); if (prod) { inventoryMap.set(pid, { id: prod.id, name: prod.name, sku: prod.sku, image: prod.image, category: prod.category, ...stockStatus(data.qty), price: prod.price, qty: data.qty, closing: data.qty }); } } } }); return Array.from(inventoryMap.values()); }, [stockQ.data, picks, products], ); 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 ────────────────────────────────────────────────────────── // The request is saved to localStorage automatically via the useEffect on `picks`. return (
{/* Tabs and Controls */}
{view === 'requests' && (
Date: setRequestDate(e.target.value)} className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white" />
)}
{/* Sticky Sidebar Filter */} {view !== 'requests' && isLocalSidebarOpen && (

Filter Product

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 */}
{/* Results Summary & Toolbar */}

{view === 'catalogue' ? 'Store Catalogue' : view === 'inventory' ? 'My Inventory' : 'My Requests'}

{view !== 'requests' && (

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

)}
{/* Action Toolbar */}
setSearch(e.target.value)} className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium" /> {search && ( )}
{/* ── 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 pick = picks[p.id]; const picked = pick != null && pick.status !== 'Cancelled' && pick.status !== 'Approved'; return (
setSelectedProduct(p)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden"> {/* Image Bento Block */}
{p.name}
{stocked && (
In Store
)}
{p.category.split(' / ')[0]}
{/* Details Bento Block */}

{p.name}

{p.sku}

Price {p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}
Unit {p.unit || 'Pc'}
{/* Action Bento Block */}
{picked ? (
{picks[p.id].status === 'Approved' ? : picks[p.id].status === 'Rejected' ? :
} {picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
{picks[p.id].status === 'Pending' && ( )}
) : ( )}
); })}
) )} {/* ── My Requests ── */} {view === 'requests' && (
{(!stockRequestsQ.data || stockRequestsQ.data.length === 0) ? ( } title="No stock requested yet" sub="Browse the catalogue and add items to your store to request stock." /> ) : (
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => ( ))} {(stockRequestsQ.data || []).map((data: any) => { const pid = String(data.productid); const prod = products.find(p => String(p.id) === pid); const isApproved = data.status === 'Approved'; const isRejected = data.status === 'Rejected'; const isCancelled = data.status === 'Cancelled'; return ( ); })}
{h}
{data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'} {data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
{storeName}
<>
{data.productname

{data.productname || prod?.name}

{prod?.sku || `SKU-${data.productid}`}

{data.qty || '—'} {data.status || '—'} {data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'} {isApproved && ( )}
)}
)} {/* ── 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="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden"> {/* Image Bento Block */}
{it.name}
{it.label}
{it.category.split(' / ')[0]}
{/* Details Bento Block */}

{it.name}

{it.sku}

Live Stock {(it.closing ?? 0).toLocaleString('en-IN')}
))}
) )}
{/* ── Auto-Submit Toast ── */} {notice && (
{pickCount} product{pickCount > 1 ? 's' : ''} requested
)} {/* QUANTITY SELECTION CENTERED MODAL */} {activeQtyProduct && (
setActiveQtyProduct(null)} />
{/* Header */}

Request Stock

Bulk Order

{/* Product Card summary */}
{activeQtyProduct.name}

{activeQtyProduct.name}

{activeQtyProduct.sku}

₹{activeQtyProduct.price.toLocaleString('en-IN')} / {activeQtyProduct.unit || 'Pc'}
{/* Quantity Selector */}
{[10, 25, 50, 100].map(qty => ( ))}
setTempQty(Math.max(1, parseInt(e.target.value) || 1))} className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner" />
Units
{/* Total & Submit */}
Estimated Total ₹{(tempQty * activeQtyProduct.price).toLocaleString('en-IN')}
)} {/* Floating FMCG Details Panel */} {hoveredProduct && (
)} {/* ── Slide Drawer for Product Details ── */} setSelectedProduct(null)} title="Product Details" > {selectedProduct && (() => { const stocked = inStore.has(selectedProduct.id); const pick = picks[selectedProduct.id]; const isPending = pick != null && pick.status === 'Pending'; const isApproved = pick != null && pick.status === 'Approved'; const isRejected = pick != null && pick.status === 'Rejected'; const isRequested = pick != null && pick.status !== 'Cancelled'; return (
{/* Clean Image Container */}
{selectedProduct.name}
{/* Title & Basics */}

SKU: {selectedProduct.sku}

{selectedProduct.name}

{String(selectedProduct.category || '').split(' / ')[0]} {stocked && ( In Store )}
{/* Clean Pricing Card */}
Store Price
₹{selectedProduct.price?.toLocaleString('en-IN') || 0} / {selectedProduct.unit || 'Pc'}
{/* Action Area */}

Stock Management

{isApproved ? (
Approved and stocked in your inventory
) : isRejected ? (
Stock request was rejected
) : isPending ? (
Requested ({pick.qty} units)
) : ( )}
{/* Retail Packaging Info */}

Retail Packaging Info

); })()}
); } 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}
}
); }