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