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 <noreply@anthropic.com>
This commit is contained in:
@@ -107,7 +107,7 @@ export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrow
|
||||
>
|
||||
{b.brand.replace('brand_', '').toUpperCase()}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${brand === b.brand ? 'bg-white/20' : 'bg-slate-200 text-slate-500'}`}>
|
||||
{b.count}
|
||||
{b.product_count}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -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<string>('0');
|
||||
const [quantity, setQuantity] = useState<string>('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({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Category ID *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={categoryId}
|
||||
onChange={e => 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
|
||||
/>
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Category *</label>
|
||||
{isLoadingCategories ? (
|
||||
<div className="w-full px-3 py-2 border border-slate-200 rounded-lg text-slate-400">Loading...</div>
|
||||
) : (
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={e => {
|
||||
setCategoryId(e.target.value);
|
||||
setSubcategoryId(''); // subcategory list is about to change
|
||||
}}
|
||||
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 bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Select category...</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.categoryid} value={c.categoryid}>
|
||||
{c.categoryname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Subcategory *</label>
|
||||
{isLoadingSubcats ? (
|
||||
{!categoryId ? (
|
||||
<div className="w-full px-3 py-2 border border-slate-200 rounded-lg text-slate-400">Select a category first</div>
|
||||
) : isLoadingSubcats ? (
|
||||
<div className="w-full px-3 py-2 border border-slate-200 rounded-lg text-slate-400">Loading...</div>
|
||||
) : (
|
||||
<select
|
||||
@@ -102,7 +127,7 @@ export default function ImportProductModal({
|
||||
<option value="">Select subcategory...</option>
|
||||
{subcategories.map((s: any) => (
|
||||
<option key={s.subcategoryid || s.id} value={s.subcategoryid || s.id}>
|
||||
{s.subcategoryname || s.name} (Cat {s.categoryid || '?'})
|
||||
{s.subcategoryname || s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -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<string>('');
|
||||
|
||||
const globalCategoriesQ = useFiestaGlobalCategories(globalSelectedBrand);
|
||||
const [globalSelectedCategories, setGlobalSelectedCategories] = useState<string[]>([]);
|
||||
|
||||
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<string[]>([]);
|
||||
const [hoveredAdminProduct, setHoveredAdminProduct] = useState<ProductMatrixItem | null>(null);
|
||||
const [localSearch, setLocalSearch] = useState('');
|
||||
const storeCat = useStoreCatalogue();
|
||||
const [globalCatalogPicks, setGlobalCatalogPicks] = useState<Set<string>>(new Set());
|
||||
const [csvText, setCsvText] = useState('');
|
||||
const [importPrice, setImportPrice] = useState<string>('');
|
||||
const [isSettingPrice, setIsSettingPrice] = useState(false);
|
||||
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(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({
|
||||
<span className="ml-1 bg-rose-500 text-white text-[8px] px-1 py-0.5 rounded-sm leading-none">{storeRequests.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('import_branding')}
|
||||
<button
|
||||
onClick={() => navigate('/admin/catalogue')}
|
||||
className="flex items-center gap-1.5 bg-[#662582] hover:bg-purple-800 text-white px-2.5 py-1.5 rounded-lg text-[10px] font-bold transition-colors shadow-sm cursor-pointer h-[32px] whitespace-nowrap"
|
||||
>
|
||||
<Plus size={12} /> Import Product
|
||||
@@ -786,443 +667,6 @@ export default function InventoryView({
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : activeTab === 'import_branding' ? (
|
||||
<div className="flex flex-col xl:flex-row gap-6 mt-2 animate-in fade-in duration-300 flex-1 min-h-0 overflow-hidden pb-4">
|
||||
|
||||
{/* ── Sticky Sidebar (Filters) ── */}
|
||||
{isGlobalSidebarOpen && (
|
||||
<div className="w-full xl:w-64 shrink-0 flex flex-col gap-5 pr-2 pb-2">
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('catalog')}
|
||||
className="flex items-center gap-1.5 bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900 px-4 py-2 rounded-xl text-sm font-bold transition-colors shadow-sm cursor-pointer shrink-0"
|
||||
>
|
||||
← Back to Product Catalogue
|
||||
</button>
|
||||
|
||||
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0">
|
||||
<h3 className="font-bold text-sm text-slate-800 flex items-center gap-2">
|
||||
<Search size={16} className="text-[#662582]" /> Filter Global Product
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Award size={14} className="text-[#662582]" /> Brands
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{globalBrandsQ.isLoading ? (
|
||||
<span className="text-xs text-slate-400 ml-2">Loading brands...</span>
|
||||
) : (globalBrandsQ.data || []).map((b) => {
|
||||
const brandName = String(b.brand || b.name || b.brandname || b.id || 'Unknown');
|
||||
return (
|
||||
<label key={brandName} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="globalBrand"
|
||||
checked={globalSelectedBrand === brandName}
|
||||
onChange={() => {
|
||||
setGlobalSelectedBrand(brandName);
|
||||
setGlobalSelectedCategories([]);
|
||||
}}
|
||||
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-full checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
|
||||
/>
|
||||
<div className="absolute w-2 h-2 rounded-full bg-white opacity-0 peer-checked:opacity-100 pointer-events-none" />
|
||||
</div>
|
||||
<span className={`text-xs font-semibold select-none transition-colors ${globalSelectedBrand === brandName ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
||||
{brandName.charAt(0).toUpperCase() + brandName.slice(1)}
|
||||
{b.productCount ? ` (${b.productCount})` : ''}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{globalSelectedBrand && (
|
||||
<>
|
||||
<div className="border-t border-slate-100"></div>
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-[#662582]" /> Categories
|
||||
</h3>
|
||||
<div className="space-y-1.5">
|
||||
{globalCategoriesQ.isLoading ? (
|
||||
<span className="text-xs text-slate-400 ml-2">Loading categories...</span>
|
||||
) : globalCategories.map((cat) => (
|
||||
<label key={cat} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={globalSelectedCategories.includes(cat)}
|
||||
onChange={() => toggleGlobalCategory(cat)}
|
||||
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
|
||||
/>
|
||||
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||
</div>
|
||||
<span className={`text-xs font-semibold select-none transition-colors ${globalSelectedCategories.includes(cat) ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
|
||||
{cat}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(globalSelectedCategories.length > 0 || globalCatalogSearch) && (
|
||||
<button
|
||||
onClick={() => { setGlobalSelectedCategories([]); setGlobalCatalogSearch(''); }}
|
||||
className="w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer border-none mt-4"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Main Content Area ── */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto custom-scrollbar bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-5 shadow-sm flex flex-col">
|
||||
|
||||
{/* Global Catalogue Header with Search and Filter */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-start justify-between mb-5 shrink-0 gap-4">
|
||||
<div>
|
||||
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap">
|
||||
{!isGlobalSidebarOpen && (
|
||||
<button
|
||||
onClick={() => setActiveTab('catalog')}
|
||||
className="p-1 -ml-1 mr-0.5 hover:bg-slate-100 rounded-lg text-slate-500 hover:text-slate-900 transition-colors cursor-pointer"
|
||||
title="Back to Product Catalogue"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
<Sparkles size={15} className="text-amber-500 animate-pulse" /> Global Catalogue
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<span className="text-[10px] text-[#662582] font-bold bg-purple-50 px-2 py-0.5 rounded-lg border border-purple-100/50">
|
||||
{filteredGlobalProducts.length} items available
|
||||
</span>
|
||||
</div>
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-400 font-medium mt-1">Select products from the global ledger to import them into your own catalogue.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 w-full xl:w-auto">
|
||||
<button
|
||||
onClick={() => setIsGlobalSidebarOpen(!isGlobalSidebarOpen)}
|
||||
className="flex items-center gap-2 bg-white border border-slate-200 px-3 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[36px]"
|
||||
>
|
||||
<Filter size={14} /> Filter
|
||||
</button>
|
||||
|
||||
<div className="relative flex-1 xl:w-64 h-[36px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search global catalogue..."
|
||||
value={globalCatalogSearch}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 w-4 h-4" />
|
||||
{globalCatalogSearch && (
|
||||
<button
|
||||
onClick={() => setGlobalCatalogSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 bg-transparent border-none cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{globalCatalogPicks.size > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newProds = masterCatalogProducts.filter(p => globalCatalogPicks.has(p.id) && !products.some(ext => ext.sku === p.sku));
|
||||
if (newProds.length > 0) {
|
||||
newProds.forEach(p => storeCat.add({ productid: String(p.id), name: p.name, sku: p.sku, category: p.category, price: 0, image: p.image, unit: 'unit', qty: 100, status: 'Active' }));
|
||||
setGlobalCatalogPicks(new Set());
|
||||
setActiveTab('catalog');
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 bg-[#662582] hover:bg-purple-800 text-white px-4 py-2 rounded-lg text-xs font-bold transition-colors shadow-sm cursor-pointer animate-in zoom-in-95"
|
||||
>
|
||||
<UploadCloud size={14} /> Import Selected ({globalCatalogPicks.size})
|
||||
</button>
|
||||
)}
|
||||
{filteredGlobalProducts.filter(p => p.isNew).length > 0 && (
|
||||
<div className="hidden xl:flex items-center gap-2 bg-[#f8fafc] px-3 py-1.5 rounded-lg border border-purple-100/50 mt-1">
|
||||
<Sparkles size={14} className="text-purple-600" />
|
||||
<h4 className="text-[10px] font-bold text-purple-900 uppercase tracking-widest">Recently Added</h4>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col xl:flex-row gap-6">
|
||||
{/* Left Side: Normal Catalogue */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{!globalSelectedBrand ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-center px-4 gap-4">
|
||||
<h3 className="text-xl font-bold text-slate-800">Select a Brand</h3>
|
||||
<div className="w-16 h-1 bg-slate-200 rounded-full"></div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-slate-500 text-sm whitespace-nowrap">
|
||||
Please select a brand from the sidebar on the left to view and import products from the global catalogue.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : globalProductsQ.isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-center px-4">
|
||||
<div className="w-8 h-8 border-4 border-[#662582] border-t-transparent rounded-full animate-spin mb-4" />
|
||||
<h3 className="text-sm font-bold text-slate-800">Loading products...</h3>
|
||||
</div>
|
||||
) : filteredGlobalProducts.filter(p => !p.isNew).length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-center px-4">
|
||||
<Search size={48} className="text-slate-200 mb-4" />
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-2">No Products Found</h3>
|
||||
<p className="text-slate-500 text-sm max-w-sm">
|
||||
We couldn't find any products matching your search or category filters for this brand.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isGlobalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-3 pb-8`}>
|
||||
{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' ? <CheckCircle size={10} /> :
|
||||
premiumTag === 'High Margin' ? <Sparkles size={10} /> :
|
||||
<TrendingUp size={10} />;
|
||||
const sparklineColor = premiumTag === 'Great Deal' ? 'text-emerald-500' :
|
||||
premiumTag === 'High Margin' ? 'text-amber-500' :
|
||||
'text-indigo-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prod.id}
|
||||
onClick={() => 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 */}
|
||||
<div className="w-full h-48 bg-white p-4 relative overflow-hidden shrink-0 border-b border-slate-100 flex items-center justify-center">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute top-2 left-2 flex items-center gap-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
|
||||
prod.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
|
||||
prod.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
|
||||
prod.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
|
||||
'bg-rose-100 text-rose-800'
|
||||
}`}>
|
||||
{prod.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
{isAlreadyInCatalog && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded-md text-[9px] font-black tracking-widest shadow-sm flex items-center gap-1 border border-emerald-200 backdrop-blur-md">
|
||||
<CheckCircle size={10} /> IN CATALOGUE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="p-3 flex flex-col flex-1 gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="grow shrink-0 min-w-0 pb-2">
|
||||
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{prod.name}</h4>
|
||||
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1 mb-2">{prod.sku}</p>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest shadow-sm border ${tagColor}`}>
|
||||
{tagIcon} {premiumTag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-2 bg-indigo-50/50 rounded-xl border border-indigo-100/50 mt-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-wider font-extrabold">Price Range</span>
|
||||
<span className="font-black text-indigo-900 font-mono text-xs">{prod.priceRange || 'N/A'}</span>
|
||||
</div>
|
||||
{prod.providers && prod.providers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{prod.providers.slice(0, 3).map(provider => (
|
||||
<span key={provider} className="text-[8px] bg-white border border-indigo-100 text-indigo-600 px-1.5 py-0.5 rounded-md font-bold uppercase tracking-wider">
|
||||
{provider}
|
||||
</span>
|
||||
))}
|
||||
{prod.providers.length > 3 && (
|
||||
<span className="text-[8px] text-indigo-400 font-bold self-center">+{prod.providers.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-slate-100">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${isAlreadyInCatalog ? 'text-emerald-600' : 'text-zinc-500'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isAlreadyInCatalog ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
|
||||
{isAlreadyInCatalog ? 'Synced to Local' : 'Available for Import'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isAlreadyInCatalog ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 p-2 bg-emerald-50 rounded-xl border border-emerald-100/50">
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-emerald-700 whitespace-nowrap"><CheckCircle size={13} /> Already in Catalogue</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
storeCat.add({ productid: String(prod.id), name: prod.name, sku: prod.sku, category: prod.category, price: 0, image: prod.image, unit: 'unit', qty: 100, status: 'Active' });
|
||||
setActiveTab('catalog');
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-bold transition-all bg-[#662582]/5 text-[#662582] hover:bg-[#662582] hover:text-white cursor-pointer shadow-sm border border-[#662582]/20"
|
||||
>
|
||||
<Plus size={14} /> Import to Catalogue
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Side: Recently Added Items */}
|
||||
{filteredGlobalProducts.filter(p => p.isNew).length > 0 && (
|
||||
<div className="w-full xl:w-72 shrink-0 xl:border-l xl:border-purple-100/50 xl:pl-6 relative">
|
||||
<div className="flex flex-col gap-md">
|
||||
{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 = <Sparkles size={10} />;
|
||||
const tagColor = 'bg-indigo-100 text-indigo-800 border-indigo-200';
|
||||
const premiumTag = prod.brand || 'Global';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={prod.id}
|
||||
onClick={() => 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 */}
|
||||
<div className="w-full h-48 bg-white p-4 relative overflow-hidden shrink-0 border-b border-slate-100 flex items-center justify-center">
|
||||
<img
|
||||
src={prod.image}
|
||||
alt={prod.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute top-2 left-2 flex items-center gap-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
|
||||
prod.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
|
||||
prod.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
|
||||
prod.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
|
||||
'bg-rose-100 text-rose-800'
|
||||
}`}>
|
||||
{prod.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
{isAlreadyInCatalog && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded-md text-[9px] font-black tracking-widest shadow-sm flex items-center gap-1 border border-emerald-200 backdrop-blur-md">
|
||||
<CheckCircle size={10} /> IN CATALOGUE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Section */}
|
||||
<div className="p-3 flex flex-col flex-1 gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="grow shrink-0 min-w-0 pb-2">
|
||||
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{prod.name}</h4>
|
||||
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1 mb-2">{prod.sku}</p>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-black uppercase tracking-widest shadow-sm border ${tagColor}`}>
|
||||
{tagIcon} {premiumTag}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-2 bg-indigo-50/50 rounded-xl border border-indigo-100/50 mt-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[9px] text-slate-500 uppercase tracking-wider font-extrabold">Price Range</span>
|
||||
<span className="font-black text-indigo-900 font-mono text-xs">{prod.priceRange || 'N/A'}</span>
|
||||
</div>
|
||||
{prod.providers && prod.providers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{prod.providers.slice(0, 3).map(provider => (
|
||||
<span key={provider} className="text-[8px] bg-white border border-indigo-100 text-indigo-600 px-1.5 py-0.5 rounded-md font-bold uppercase tracking-wider">
|
||||
{provider}
|
||||
</span>
|
||||
))}
|
||||
{prod.providers.length > 3 && (
|
||||
<span className="text-[8px] text-indigo-400 font-bold self-center">+{prod.providers.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-slate-100">
|
||||
<div className="flex justify-between items-center select-none">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${isAlreadyInCatalog ? 'text-emerald-600' : 'text-zinc-500'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${isAlreadyInCatalog ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
|
||||
{isAlreadyInCatalog ? 'Synced to Local' : 'Available for Import'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isAlreadyInCatalog ? (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 p-2 bg-emerald-50 rounded-xl border border-emerald-100/50">
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-emerald-700 whitespace-nowrap"><CheckCircle size={13} /> Already in Catalogue</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
storeCat.add({ productid: String(prod.id), name: prod.name, sku: prod.sku, category: prod.category, price: 0, image: prod.image, unit: 'unit', qty: 100, status: 'Active' });
|
||||
setActiveTab('catalog');
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-[11px] font-bold transition-all bg-[#662582]/5 text-[#662582] hover:bg-[#662582] hover:text-white cursor-pointer shadow-sm border border-[#662582]/20"
|
||||
>
|
||||
<Plus size={14} /> Import to Catalogue
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'requests' ? (
|
||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar animate-in fade-in duration-300">
|
||||
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-end gap-4 mb-2">
|
||||
@@ -1475,14 +919,7 @@ export default function InventoryView({
|
||||
|
||||
{/* Action Area */}
|
||||
<div className="space-y-3 pt-4 border-t border-slate-100">
|
||||
{isAlreadyInCatalog ? (
|
||||
<div className="space-y-3">
|
||||
{activeTab === 'import_branding' && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 bg-emerald-50 rounded-xl border border-emerald-200">
|
||||
<CheckCircle size={18} className="text-emerald-600" />
|
||||
<span className="text-sm font-semibold text-emerald-800">Synced to Local Catalogue</span>
|
||||
</div>
|
||||
)}
|
||||
{storeCat.has(selectedAdminProduct.id) ? (
|
||||
<button onClick={() => storeCat.remove(selectedAdminProduct.id)} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-rose-600 border border-slate-200 font-semibold text-sm transition-colors hover:bg-slate-50">
|
||||
<X size={18} strokeWidth={2} /> Remove from Store Catalogue
|
||||
@@ -1521,26 +958,8 @@ export default function InventoryView({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
storeCat.add({ productid: selectedAdminProduct.id, name: selectedAdminProduct.name, sku: selectedAdminProduct.sku, category: selectedAdminProduct.category, price: Number(importPrice) || 0, image: selectedAdminProduct.image, unit: selectedAdminProduct.exposure, qty: 100, status: 'Active' });
|
||||
setSelectedAdminProduct(null);
|
||||
setActiveTab('catalog');
|
||||
}}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-colors bg-[#662582] text-white hover:bg-[#531e6a]"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2} /> Import Local
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500 leading-relaxed mt-2 border-t border-slate-100 pt-5">
|
||||
This product is part of the Global Catalogue. Importing it will add it to your local inventory where you can manage pricing and availability.
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
||||
<FMCGHoverOverlay productId={selectedAdminProduct.id} category={selectedAdminProduct.category} productName={selectedAdminProduct.name} product={selectedAdminProduct} />
|
||||
@@ -1644,37 +1063,6 @@ export default function InventoryView({
|
||||
})()}
|
||||
</SlideDrawer>
|
||||
|
||||
{/* ── Floating Import Button ── */}
|
||||
{activeTab === 'import_branding' && selectedProducts.length > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
selectedProducts.forEach(sp => {
|
||||
if (!storeCat.has(sp.id)) {
|
||||
storeCat.add({
|
||||
productid: sp.id,
|
||||
name: sp.name,
|
||||
sku: sp.sku,
|
||||
category: sp.category,
|
||||
price: sp.price || 0,
|
||||
image: sp.image,
|
||||
unit: 'unit',
|
||||
qty: 100,
|
||||
status: 'Draft'
|
||||
});
|
||||
}
|
||||
});
|
||||
clearSelection();
|
||||
setActiveTab('catalog');
|
||||
}}
|
||||
className="fixed bottom-8 right-8 z-[100] px-6 h-14 bg-[#662582] text-white rounded-full shadow-[0_10px_25px_rgba(102,37,130,0.5)] flex items-center justify-center gap-2.5 hover:scale-105 hover:bg-purple-800 transition-all duration-300 animate-in slide-in-from-bottom-10 group"
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span className="font-bold text-sm">Import {selectedProducts.length} Product{selectedProducts.length > 1 ? 's' : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -36,17 +36,19 @@ export interface ImportCatalogueProductRequest {
|
||||
taxpercent: number;
|
||||
}
|
||||
|
||||
async function apiGet<T>(url: URL): Promise<T[]> {
|
||||
async function apiGet<T>(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<CatalogueProduct>(url);
|
||||
return { products, total: products.length };
|
||||
|
||||
const { items, total } = await apiGet<CatalogueProduct>(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<ImportedRef>(url);
|
||||
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
|
||||
|
||||
const { items } = await apiGet<ImportedRef>(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<CatalogueBrand>(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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user