From 32291cddbeac9911cb2e4365cb3872d305543871 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 16 Jul 2026 17:31:29 +0530 Subject: [PATCH] Fix Global Catalogue browsing and import Removes InventoryView's broken duplicate Global Catalogue tab, which misused createproductlocation with catalogue ids that never match real product ids (imports silently failed to land) and showed fabricated Math.random() pricing/ratings. "Import Product" now routes to the already-correct CatalogueBrowser instead. Also fixes catalogueApi's product_count field mismatch (brand chip counts were always blank), a pagination bug that capped the catalogue at 100 of ~237 products with no way to see the rest, and replaces ImportProductModal's free-text category id input with a real dropdown scoped to the tenant's own categories, filtering subcategories to match. Co-Authored-By: Claude Sonnet 5 --- src/components/CatalogueBrowser.tsx | 2 +- src/components/ImportProductModal.tsx | 53 ++- src/components/InventoryView.tsx | 630 +------------------------- src/hooks/useCatalogueImport.ts | 4 +- src/services/catalogueApi.ts | 35 +- 5 files changed, 73 insertions(+), 651 deletions(-) diff --git a/src/components/CatalogueBrowser.tsx b/src/components/CatalogueBrowser.tsx index 1ce4ad0..3af58cb 100644 --- a/src/components/CatalogueBrowser.tsx +++ b/src/components/CatalogueBrowser.tsx @@ -107,7 +107,7 @@ export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrow > {b.brand.replace('brand_', '').toUpperCase()} - {b.count} + {b.product_count} )) diff --git a/src/components/ImportProductModal.tsx b/src/components/ImportProductModal.tsx index fb3b276..003011d 100644 --- a/src/components/ImportProductModal.tsx +++ b/src/components/ImportProductModal.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import { X, Save, AlertCircle } from 'lucide-react'; import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi'; import { useProductSubcategories } from '../hooks/useCatalogueImport'; +import { useFiestaProductCategories } from '../services/fiestaQueries'; +import { str as fstr } from '../services/fiestaApi'; interface ImportProductModalProps { product: CatalogueProduct; @@ -25,9 +27,18 @@ export default function ImportProductModal({ const [taxPercent, setTaxPercent] = useState('0'); const [quantity, setQuantity] = useState('1'); - // Load subcategories for this tenant (optional: filter by categoryId if category picker is also dynamic) - // The spec says "getproductsubcategories" with optional categoryid. We will fetch all for now and pick. - const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories(tenantid); + const { data: categoriesData = [], isLoading: isLoadingCategories } = useFiestaProductCategories(); + const categories = categoriesData.map((c: any) => ({ + categoryid: Number(c.categoryid), + categoryname: fstr(c.categoryname), + })); + + // Subcategories are scoped to the selected category, so a product can't be + // tagged with a subcategory that doesn't actually belong to its category. + const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories( + tenantid, + categoryId ? Number(categoryId) : undefined, + ); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -78,19 +89,33 @@ export default function ImportProductModal({
- - setCategoryId(e.target.value)} - className="w-full px-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-colors" - placeholder="e.g. 1" - required - /> + + {isLoadingCategories ? ( +
Loading...
+ ) : ( + + )}
- {isLoadingSubcats ? ( + {!categoryId ? ( +
Select a category first
+ ) : isLoadingSubcats ? (
Loading...
) : ( diff --git a/src/components/InventoryView.tsx b/src/components/InventoryView.tsx index 1410f53..50f560a 100644 --- a/src/components/InventoryView.tsx +++ b/src/components/InventoryView.tsx @@ -4,6 +4,7 @@ */ import React, { useState, useEffect, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Layers, Search, @@ -43,12 +44,9 @@ import { useFiestaProductCategories, useFiestaUpdateStockRequest, useFiestaGetStockRequests, - useFiestaGlobalBrands, - useFiestaGlobalCategories, - useFiestaGlobalProducts, } from '../services/fiestaQueries'; import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi'; -import { stockRowToProduct, stockRowToInventory, globalRowToProduct } from '../services/fiestaMappers'; +import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers'; import { useStoreCatalogue } from '../services/storeCatalogue'; import BulkCartDrawer from './BulkCartDrawer'; import AwaitingApi from './AwaitingApi'; @@ -75,8 +73,9 @@ export default function InventoryView({ tenantId = FIESTA_TENANT_ID, isSidebarOpen = false }: InventoryViewProps) { - const { selectedProducts, toggleProduct, setIsComparing, clearSelection, setHideCompareBar } = useCompare(); + const { setHideCompareBar } = useCompare(); const [searchTerm, setSearchTerm] = useState(''); + const navigate = useNavigate(); // ── Live stock across every outlet (Fiesta) ─────────────────────────────── // This page is the admin's command surface. The GLOBAL CATALOG is the deduped @@ -109,39 +108,12 @@ export default function InventoryView({ const allStoreRows = storesStock.flatMap((s) => s.rows); - const globalBrandsQ = useFiestaGlobalBrands(); - const [globalCatalogSearch, setGlobalCatalogSearch] = useState(''); - const [debouncedGlobalSearch, setDebouncedGlobalSearch] = useState(''); - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedGlobalSearch(globalCatalogSearch); - }, 500); - return () => clearTimeout(handler); - }, [globalCatalogSearch]); - - const [globalSelectedBrand, setGlobalSelectedBrand] = useState(''); - - const globalCategoriesQ = useFiestaGlobalCategories(globalSelectedBrand); - const [globalSelectedCategories, setGlobalSelectedCategories] = useState([]); - - const globalProductsQ = useFiestaGlobalProducts({ - brand: globalSelectedBrand, - keyword: debouncedGlobalSearch, - pagesize: 500 - }); - const liveMasterCatalog = useMemo(() => globalProductsQ.data ?? [], [globalProductsQ.data]); - const masterCatalogProducts = useMemo(() => liveMasterCatalog.map(globalRowToProduct), [liveMasterCatalog]); - - const [activeTab, setActiveTab] = useState<'catalog' | 'import_branding' | 'requests'>('catalog'); + const [activeTab, setActiveTab] = useState<'catalog' | 'requests'>('catalog'); const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true); - const [isGlobalSidebarOpen, setIsGlobalSidebarOpen] = useState(true); const [selectedCategories, setSelectedCategories] = useState([]); const [hoveredAdminProduct, setHoveredAdminProduct] = useState(null); const [localSearch, setLocalSearch] = useState(''); const storeCat = useStoreCatalogue(); - const [globalCatalogPicks, setGlobalCatalogPicks] = useState>(new Set()); - const [csvText, setCsvText] = useState(''); const [importPrice, setImportPrice] = useState(''); const [isSettingPrice, setIsSettingPrice] = useState(false); const [addingPriceProdId, setAddingPriceProdId] = useState(null); @@ -323,87 +295,10 @@ export default function InventoryView({ ); }; - // Note: globalSelectedCategories is declared at the top now! - - const globalCategories = useMemo(() => { - if (!globalCategoriesQ.data) return []; - // The new API returns an array of strings for categories - return (globalCategoriesQ.data as unknown as string[]).filter(Boolean); - }, [globalCategoriesQ.data]); - - const toggleGlobalCategory = (cat: string) => { - setGlobalSelectedCategories((prev) => - prev.includes(cat) ? [] : [cat], // Only allow one at a time for API compatibility, or filter client-side - ); - }; - - const filteredGlobalProducts = useMemo(() => { - let result = masterCatalogProducts; - // We already pass keyword to API, but can double filter client-side just in case - if (globalCatalogSearch) { - const q = globalCatalogSearch.toLowerCase(); - result = result.filter( - (p) => p.name.toLowerCase().includes(q) || p.sku.toLowerCase().includes(q) || p.category.toLowerCase().includes(q) - ); - } - if (globalSelectedCategories.length > 0) { - result = result.filter((p) => globalSelectedCategories.includes(p.category.split(' / ')[0])); - } - return result; - }, [masterCatalogProducts, globalCatalogSearch, globalSelectedCategories]); - - - - const handleToggleProductExposure = (id: string) => { // Left empty for now, as products are managed via storeCat }; - // Custom Raw CSV import - const handleCSVImport = () => { - const lines = csvText.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('Name')); - if (lines.length === 0) { - alert('CSV sequence contains no importable entries.'); - return; - } - - let parsedCount = 0; - const newProds: ProductMatrixItem[] = []; - - lines.forEach(line => { - const parts = line.split(',').map(p => p.trim()); - if (parts.length >= 2) { - const name = parts[0]; - const sku = parts[1]; - const category = parts[2] || 'Staples / Rice'; - - if (!products.some(p => p.sku === sku)) { - newProds.push({ - id: String(products.length + newProds.length + 1), - name, - sku, - unitsSold: 0, - revenue: 0, - stockStatus: 'Healthy', - trend: 'flat', - image: 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200', - category, - exposure: 'All Outlets', - verified: true, - isNew: true - }); - parsedCount++; - } - } - }); - - if (parsedCount > 0) { - newProds.forEach(p => storeCat.add({ productid: p.id, name: p.name, sku: p.sku, category: p.category, price: p.price || 0, image: p.image, unit: p.exposure, qty: 100, status: 'Active' })); - alert(`Synchronized ${parsedCount} regional products into Catalogue database successfully!`); - } else { - alert('All the specified SKU codes are already active in the catalogue ledger.'); - } - }; const flattenedRequests = useMemo(() => { return storeRequests .filter(r => requestStoreFilter === 'All Stores' || r.locationname === requestStoreFilter) @@ -412,21 +307,7 @@ export default function InventoryView({ .filter(([_, pick]) => requestStatusFilter === 'All Statuses' || String((pick as any).status).toLowerCase() === requestStatusFilter.toLowerCase()) .map(([requestId, pick]) => { const productId = (pick as any).productid; - let product = products.find(p => String(p.id) === String(productId)); - if (!product) { - const liveMatch = liveMasterCatalog.find((r: any) => String(r.productid) === String(productId)); - if (liveMatch) { - product = { - id: String(liveMatch.productid), - name: String(liveMatch.productname || 'Unknown'), - sku: String(liveMatch.sku || `SKU-${liveMatch.productid}`), - image: String(liveMatch.productimage || 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200'), - category: String(liveMatch.categoryname || 'Uncategorized'), - } as any; - } else { - product = masterCatalogProducts.find(p => String(p.id) === String(productId)) as any; - } - } + const product = products.find(p => String(p.id) === String(productId)); return { locationid: store.locationid, locationname: store.locationname, @@ -438,7 +319,7 @@ export default function InventoryView({ }); }) .sort((a, b) => new Date(b.pickData.requestedAt).getTime() - new Date(a.pickData.requestedAt).getTime()); - }, [storeRequests, products, requestStoreFilter, requestStatusFilter, liveMasterCatalog]); + }, [storeRequests, products, requestStoreFilter, requestStatusFilter]); const requestingStores = useMemo(() => { return Array.from(new Set(locations.map(l => l.locationname))).sort(); @@ -631,8 +512,8 @@ export default function InventoryView({ {storeRequests.length} )} -
- ) : activeTab === 'import_branding' ? ( -
- - {/* ── Sticky Sidebar (Filters) ── */} - {isGlobalSidebarOpen && ( -
- - - -
-
-

- Filter Global Product -

-
-
-
-

- Brands -

-
- {globalBrandsQ.isLoading ? ( - Loading brands... - ) : (globalBrandsQ.data || []).map((b) => { - const brandName = String(b.brand || b.name || b.brandname || b.id || 'Unknown'); - return ( -
- - {globalSelectedBrand && ( - <> -
-
-

- Categories -

-
- {globalCategoriesQ.isLoading ? ( - Loading categories... - ) : globalCategories.map((cat) => ( - - ))} -
-
- - )} - - {(globalSelectedCategories.length > 0 || globalCatalogSearch) && ( - - )} -
-
-
- )} - - {/* ── Main Content Area ── */} -
- - {/* Global Catalogue Header with Search and Filter */} -
-
-

- {!isGlobalSidebarOpen && ( - - )} - Global Catalogue -
- - {filteredGlobalProducts.length} items available - -
-

-

Select products from the global ledger to import them into your own catalogue.

-
- -
- - -
- setGlobalCatalogSearch(e.target.value)} - className="w-full h-full pl-9 pr-4 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" - /> - - {globalCatalogSearch && ( - - )} -
- - {globalCatalogPicks.size > 0 && ( - - )} - {filteredGlobalProducts.filter(p => p.isNew).length > 0 && ( -
- -

Recently Added

-
- )} -
-
- -
- {/* Left Side: Normal Catalogue */} -
- {!globalSelectedBrand ? ( -
-

Select a Brand

-
-
- - Please select a brand from the sidebar on the left to view and import products from the global catalogue. - -
-
- ) : globalProductsQ.isLoading ? ( -
-
-

Loading products...

-
- ) : filteredGlobalProducts.filter(p => !p.isNew).length === 0 ? ( -
- -

No Products Found

-

- We couldn't find any products matching your search or category filters for this brand. -

-
- ) : ( -
- {filteredGlobalProducts.filter(p => !p.isNew).map((prod) => { - const isSelected = globalCatalogPicks.has(prod.id); - const isAlreadyInCatalog = products.some(p => p.sku === prod.sku); - - const wholesalePrice = prod.unitsSold > 0 ? Math.max(1, Math.round(prod.revenue / prod.unitsSold)) : Math.floor(Math.random() * 50) + 20; - const mrp = Math.round(wholesalePrice * 1.35); - const profit = mrp - wholesalePrice; - const profitMargin = Math.round((profit / mrp) * 100); - const globalSales = Math.floor(Math.random() * 5000) + 1000; - const rating = Number((4.0 + Math.random()).toFixed(1)); - - const premiumTagSeed = (prod.name.length + prod.sku.length) % 3; - const premiumTag = premiumTagSeed === 0 ? 'Great Deal' : premiumTagSeed === 1 ? 'High Margin' : 'Trending'; - const tagColor = premiumTag === 'Great Deal' ? 'bg-emerald-100 text-emerald-800 border-emerald-200' : - premiumTag === 'High Margin' ? 'bg-amber-100 text-amber-800 border-amber-200' : - 'bg-indigo-100 text-indigo-800 border-indigo-200'; - const tagIcon = premiumTag === 'Great Deal' ? : - premiumTag === 'High Margin' ? : - ; - const sparklineColor = premiumTag === 'Great Deal' ? 'text-emerald-500' : - premiumTag === 'High Margin' ? 'text-amber-500' : - 'text-indigo-500'; - - return ( -
setSelectedAdminProduct(prod)} - className={`cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col min-h-[340px] shadow-sm transition-all duration-300 relative group overflow-hidden ${ - isSelected ? 'border-[#662582] shadow-[0_0_0_2px_#662582]' : 'hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1' - } ${isAlreadyInCatalog ? 'opacity-60 grayscale-[50%]' : ''}`} - > - {/* Image Section - Top */} -
- {prod.name} -
-
- - {prod.category.split(' / ')[0]} - -
- {isAlreadyInCatalog && ( -
- IN CATALOGUE -
- )} -
- - {/* Content Section */} -
-
-
-

{prod.name}

-

{prod.sku}

- - {tagIcon} {premiumTag} - -
-
- -
-
- Price Range - {prod.priceRange || 'N/A'} -
- {prod.providers && prod.providers.length > 0 && ( -
- {prod.providers.slice(0, 3).map(provider => ( - - {provider} - - ))} - {prod.providers.length > 3 && ( - +{prod.providers.length - 3} more - )} -
- )} -
- - -
-
- - - {isAlreadyInCatalog ? 'Synced to Local' : 'Available for Import'} - -
- - {isAlreadyInCatalog ? ( -
- Already in Catalogue -
- ) : ( - - )} -
-
-
- ); - })} -
- )} -
- - {/* Right Side: Recently Added Items */} - {filteredGlobalProducts.filter(p => p.isNew).length > 0 && ( -
-
- {filteredGlobalProducts.filter(p => p.isNew).map((prod) => { - const isAlreadyInCatalog = products.some(p => p.sku === prod.sku); - const rating = Number((4.0 + Math.random()).toFixed(1)); - - const tagIcon = ; - const tagColor = 'bg-indigo-100 text-indigo-800 border-indigo-200'; - const premiumTag = prod.brand || 'Global'; - - return ( -
setSelectedAdminProduct(prod)} - className={`bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col min-h-[340px] shadow-sm transition-all duration-300 relative group overflow-hidden hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 ${isAlreadyInCatalog ? 'opacity-60 grayscale-[50%]' : ''}`} - > - {/* Image Section - Top */} -
- {prod.name} -
-
- - {prod.category.split(' / ')[0]} - -
- {isAlreadyInCatalog && ( -
- IN CATALOGUE -
- )} -
- - {/* Content Section */} -
-
-
-

{prod.name}

-

{prod.sku}

- - {tagIcon} {premiumTag} - -
-
- -
-
- Price Range - {prod.priceRange || 'N/A'} -
- {prod.providers && prod.providers.length > 0 && ( -
- {prod.providers.slice(0, 3).map(provider => ( - - {provider} - - ))} - {prod.providers.length > 3 && ( - +{prod.providers.length - 3} more - )} -
- )} -
- - -
-
- - - {isAlreadyInCatalog ? 'Synced to Local' : 'Available for Import'} - -
- - {isAlreadyInCatalog ? ( -
- Already in Catalogue -
- ) : ( - - )} -
-
-
- ); - })} -
-
- )} -
-
-
) : activeTab === 'requests' ? (
@@ -1475,14 +919,7 @@ export default function InventoryView({ {/* Action Area */}
- {isAlreadyInCatalog ? (
- {activeTab === 'import_branding' && ( -
- - Synced to Local Catalogue -
- )} {storeCat.has(selectedAdminProduct.id) ? ( )}
- ) : ( -
- -
- )}
-

- This product is part of the Global Catalogue. Importing it will add it to your local inventory where you can manage pricing and availability. -

-

Retail Packaging Info

@@ -1644,37 +1063,6 @@ export default function InventoryView({ })()} - {/* ── Floating Import Button ── */} - {activeTab === 'import_branding' && selectedProducts.length > 0 && ( - - )} - - -
); } diff --git a/src/hooks/useCatalogueImport.ts b/src/hooks/useCatalogueImport.ts index 6b78483..5631f59 100644 --- a/src/hooks/useCatalogueImport.ts +++ b/src/hooks/useCatalogueImport.ts @@ -12,7 +12,9 @@ import { export function useCatalogueProducts(brand?: string, keyword?: string) { return useQuery({ queryKey: ['catalogue', 'products', brand ?? 'all', keyword ?? ''], - queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 100 }), + // Full catalogue is ~237 products across all brands today; fetch enough + // in one page that "show everything" (no brand filter) actually does. + queryFn: () => getCatalogueProducts({ brand, keyword, pagesize: 500 }), }); } diff --git a/src/services/catalogueApi.ts b/src/services/catalogueApi.ts index b228e7e..a86f782 100644 --- a/src/services/catalogueApi.ts +++ b/src/services/catalogueApi.ts @@ -36,17 +36,19 @@ export interface ImportCatalogueProductRequest { taxpercent: number; } -async function apiGet(url: URL): Promise { +async function apiGet(url: URL): Promise<{ items: T[]; total: number }> { const res = await fetch(url, { headers: { Accept: 'application/json' } }); if (!res.ok) { throw new Error(`Catalogue API failed: ${res.status} ${res.statusText}`); } const json = await res.json(); - if (Array.isArray(json)) return json; + if (Array.isArray(json)) return { items: json, total: json.length }; if (json && typeof json === 'object' && 'details' in json) { - return json.details || []; + const items = json.details || []; + const total = typeof json.total === 'number' ? json.total : items.length; + return { items, total }; } - return []; + return { items: [], total: 0 }; } // brand omitted → the entire catalogue, all brands merged. @@ -59,9 +61,9 @@ export async function getCatalogueProducts(opts: { if (keyword) url.searchParams.set("keyword", keyword); url.searchParams.set("pageno", String(pageno)); url.searchParams.set("pagesize", String(pagesize)); - - const products = await apiGet(url); - return { products, total: products.length }; + + const { items, total } = await apiGet(url); + return { products: items, total }; } // brand omitted → imported refs across every brand. @@ -69,9 +71,9 @@ export async function getImportedCatalogueRefs(tenantid: number, brand?: string) const url = new URL(`${API_BASE}/products/getimportedcatalogueproducts`); url.searchParams.set("tenantid", String(tenantid)); if (brand) url.searchParams.set("brand", brand); - - const refs = await apiGet(url); - return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`)); + + const { items } = await apiGet(url); + return new Set(items.map((r) => `${r.brand}:${r.catalogueid}`)); } export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) { @@ -102,16 +104,21 @@ export async function removeFromStoreCatalogue(tenantid: number, locationid: num return json; } +export interface CatalogueBrand { + brand: string; + product_count: number; +} + export async function getBrands() { const url = new URL(`${API_BASE}/catalogue/getbrands`); - const brands = await apiGet<{ brand: string; count: number }>(url); - return brands; + const { items } = await apiGet(url); + return items; } export async function getProductSubcategories(tenantid: number, categoryid?: number) { const url = new URL(`${API_BASE}/products/getproductsubcategories`); url.searchParams.set("tenantid", String(tenantid)); if (categoryid) url.searchParams.set("categoryid", String(categoryid)); - const subcategories = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url); - return subcategories; + const { items } = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url); + return items; }