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()}
|
{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'}`}>
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React, { useState } from 'react';
|
|||||||
import { X, Save, AlertCircle } from 'lucide-react';
|
import { X, Save, AlertCircle } from 'lucide-react';
|
||||||
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
|
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
|
||||||
import { useProductSubcategories } from '../hooks/useCatalogueImport';
|
import { useProductSubcategories } from '../hooks/useCatalogueImport';
|
||||||
|
import { useFiestaProductCategories } from '../services/fiestaQueries';
|
||||||
|
import { str as fstr } from '../services/fiestaApi';
|
||||||
|
|
||||||
interface ImportProductModalProps {
|
interface ImportProductModalProps {
|
||||||
product: CatalogueProduct;
|
product: CatalogueProduct;
|
||||||
@@ -25,9 +27,18 @@ export default function ImportProductModal({
|
|||||||
const [taxPercent, setTaxPercent] = useState<string>('0');
|
const [taxPercent, setTaxPercent] = useState<string>('0');
|
||||||
const [quantity, setQuantity] = useState<string>('1');
|
const [quantity, setQuantity] = useState<string>('1');
|
||||||
|
|
||||||
// Load subcategories for this tenant (optional: filter by categoryId if category picker is also dynamic)
|
const { data: categoriesData = [], isLoading: isLoadingCategories } = useFiestaProductCategories();
|
||||||
// The spec says "getproductsubcategories" with optional categoryid. We will fetch all for now and pick.
|
const categories = categoriesData.map((c: any) => ({
|
||||||
const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories(tenantid);
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -78,19 +89,33 @@ export default function ImportProductModal({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Category ID *</label>
|
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Category *</label>
|
||||||
<input
|
{isLoadingCategories ? (
|
||||||
type="number"
|
<div className="w-full px-3 py-2 border border-slate-200 rounded-lg text-slate-400">Loading...</div>
|
||||||
value={categoryId}
|
) : (
|
||||||
onChange={e => setCategoryId(e.target.value)}
|
<select
|
||||||
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"
|
value={categoryId}
|
||||||
placeholder="e.g. 1"
|
onChange={e => {
|
||||||
required
|
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>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Subcategory *</label>
|
<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>
|
<div className="w-full px-3 py-2 border border-slate-200 rounded-lg text-slate-400">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
@@ -102,7 +127,7 @@ export default function ImportProductModal({
|
|||||||
<option value="">Select subcategory...</option>
|
<option value="">Select subcategory...</option>
|
||||||
{subcategories.map((s: any) => (
|
{subcategories.map((s: any) => (
|
||||||
<option key={s.subcategoryid || s.id} value={s.subcategoryid || s.id}>
|
<option key={s.subcategoryid || s.id} value={s.subcategoryid || s.id}>
|
||||||
{s.subcategoryname || s.name} (Cat {s.categoryid || '?'})
|
{s.subcategoryname || s.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Layers,
|
Layers,
|
||||||
Search,
|
Search,
|
||||||
@@ -43,12 +44,9 @@ import {
|
|||||||
useFiestaProductCategories,
|
useFiestaProductCategories,
|
||||||
useFiestaUpdateStockRequest,
|
useFiestaUpdateStockRequest,
|
||||||
useFiestaGetStockRequests,
|
useFiestaGetStockRequests,
|
||||||
useFiestaGlobalBrands,
|
|
||||||
useFiestaGlobalCategories,
|
|
||||||
useFiestaGlobalProducts,
|
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
|
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 { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import BulkCartDrawer from './BulkCartDrawer';
|
import BulkCartDrawer from './BulkCartDrawer';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
@@ -75,8 +73,9 @@ export default function InventoryView({
|
|||||||
tenantId = FIESTA_TENANT_ID,
|
tenantId = FIESTA_TENANT_ID,
|
||||||
isSidebarOpen = false
|
isSidebarOpen = false
|
||||||
}: InventoryViewProps) {
|
}: InventoryViewProps) {
|
||||||
const { selectedProducts, toggleProduct, setIsComparing, clearSelection, setHideCompareBar } = useCompare();
|
const { setHideCompareBar } = useCompare();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
|
||||||
// This page is the admin's command surface. The GLOBAL CATALOG is the deduped
|
// 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 allStoreRows = storesStock.flatMap((s) => s.rows);
|
||||||
|
|
||||||
const globalBrandsQ = useFiestaGlobalBrands();
|
const [activeTab, setActiveTab] = useState<'catalog' | 'requests'>('catalog');
|
||||||
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 [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
|
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
|
||||||
const [isGlobalSidebarOpen, setIsGlobalSidebarOpen] = useState(true);
|
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [hoveredAdminProduct, setHoveredAdminProduct] = useState<ProductMatrixItem | null>(null);
|
const [hoveredAdminProduct, setHoveredAdminProduct] = useState<ProductMatrixItem | null>(null);
|
||||||
const [localSearch, setLocalSearch] = useState('');
|
const [localSearch, setLocalSearch] = useState('');
|
||||||
const storeCat = useStoreCatalogue();
|
const storeCat = useStoreCatalogue();
|
||||||
const [globalCatalogPicks, setGlobalCatalogPicks] = useState<Set<string>>(new Set());
|
|
||||||
const [csvText, setCsvText] = useState('');
|
|
||||||
const [importPrice, setImportPrice] = useState<string>('');
|
const [importPrice, setImportPrice] = useState<string>('');
|
||||||
const [isSettingPrice, setIsSettingPrice] = useState(false);
|
const [isSettingPrice, setIsSettingPrice] = useState(false);
|
||||||
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null);
|
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) => {
|
const handleToggleProductExposure = (id: string) => {
|
||||||
// Left empty for now, as products are managed via storeCat
|
// 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(() => {
|
const flattenedRequests = useMemo(() => {
|
||||||
return storeRequests
|
return storeRequests
|
||||||
.filter(r => requestStoreFilter === 'All Stores' || r.locationname === requestStoreFilter)
|
.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())
|
.filter(([_, pick]) => requestStatusFilter === 'All Statuses' || String((pick as any).status).toLowerCase() === requestStatusFilter.toLowerCase())
|
||||||
.map(([requestId, pick]) => {
|
.map(([requestId, pick]) => {
|
||||||
const productId = (pick as any).productid;
|
const productId = (pick as any).productid;
|
||||||
let product = products.find(p => String(p.id) === String(productId));
|
const 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
locationid: store.locationid,
|
locationid: store.locationid,
|
||||||
locationname: store.locationname,
|
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());
|
.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(() => {
|
const requestingStores = useMemo(() => {
|
||||||
return Array.from(new Set(locations.map(l => l.locationname))).sort();
|
return Array.from(new Set(locations.map(l => l.locationname))).sort();
|
||||||
@@ -632,7 +513,7 @@ export default function InventoryView({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('import_branding')}
|
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"
|
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
|
<Plus size={12} /> Import Product
|
||||||
@@ -786,443 +667,6 @@ export default function InventoryView({
|
|||||||
</div>
|
</div>
|
||||||
</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' ? (
|
) : activeTab === 'requests' ? (
|
||||||
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar animate-in fade-in duration-300">
|
<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">
|
<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 */}
|
{/* Action Area */}
|
||||||
<div className="space-y-3 pt-4 border-t border-slate-100">
|
<div className="space-y-3 pt-4 border-t border-slate-100">
|
||||||
{isAlreadyInCatalog ? (
|
|
||||||
<div className="space-y-3">
|
<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) ? (
|
{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">
|
<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
|
<X size={18} strokeWidth={2} /> Remove from Store Catalogue
|
||||||
@@ -1521,26 +958,8 @@ export default function InventoryView({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</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">
|
<div className="mt-2">
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
|
<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} />
|
<FMCGHoverOverlay productId={selectedAdminProduct.id} category={selectedAdminProduct.category} productName={selectedAdminProduct.name} product={selectedAdminProduct} />
|
||||||
@@ -1644,37 +1063,6 @@ export default function InventoryView({
|
|||||||
})()}
|
})()}
|
||||||
</SlideDrawer>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
export function useCatalogueProducts(brand?: string, keyword?: string) {
|
export function useCatalogueProducts(brand?: string, keyword?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['catalogue', 'products', brand ?? 'all', keyword ?? ''],
|
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;
|
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' } });
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Catalogue API failed: ${res.status} ${res.statusText}`);
|
throw new Error(`Catalogue API failed: ${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
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) {
|
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.
|
// brand omitted → the entire catalogue, all brands merged.
|
||||||
@@ -60,8 +62,8 @@ export async function getCatalogueProducts(opts: {
|
|||||||
url.searchParams.set("pageno", String(pageno));
|
url.searchParams.set("pageno", String(pageno));
|
||||||
url.searchParams.set("pagesize", String(pagesize));
|
url.searchParams.set("pagesize", String(pagesize));
|
||||||
|
|
||||||
const products = await apiGet<CatalogueProduct>(url);
|
const { items, total } = await apiGet<CatalogueProduct>(url);
|
||||||
return { products, total: products.length };
|
return { products: items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
// brand omitted → imported refs across every brand.
|
// brand omitted → imported refs across every brand.
|
||||||
@@ -70,8 +72,8 @@ export async function getImportedCatalogueRefs(tenantid: number, brand?: string)
|
|||||||
url.searchParams.set("tenantid", String(tenantid));
|
url.searchParams.set("tenantid", String(tenantid));
|
||||||
if (brand) url.searchParams.set("brand", brand);
|
if (brand) url.searchParams.set("brand", brand);
|
||||||
|
|
||||||
const refs = await apiGet<ImportedRef>(url);
|
const { items } = await apiGet<ImportedRef>(url);
|
||||||
return new Set(refs.map((r) => `${r.brand}:${r.catalogueid}`));
|
return new Set(items.map((r) => `${r.brand}:${r.catalogueid}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
||||||
@@ -102,16 +104,21 @@ export async function removeFromStoreCatalogue(tenantid: number, locationid: num
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CatalogueBrand {
|
||||||
|
brand: string;
|
||||||
|
product_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getBrands() {
|
export async function getBrands() {
|
||||||
const url = new URL(`${API_BASE}/catalogue/getbrands`);
|
const url = new URL(`${API_BASE}/catalogue/getbrands`);
|
||||||
const brands = await apiGet<{ brand: string; count: number }>(url);
|
const { items } = await apiGet<CatalogueBrand>(url);
|
||||||
return brands;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductSubcategories(tenantid: number, categoryid?: number) {
|
export async function getProductSubcategories(tenantid: number, categoryid?: number) {
|
||||||
const url = new URL(`${API_BASE}/products/getproductsubcategories`);
|
const url = new URL(`${API_BASE}/products/getproductsubcategories`);
|
||||||
url.searchParams.set("tenantid", String(tenantid));
|
url.searchParams.set("tenantid", String(tenantid));
|
||||||
if (categoryid) url.searchParams.set("categoryid", String(categoryid));
|
if (categoryid) url.searchParams.set("categoryid", String(categoryid));
|
||||||
const subcategories = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url);
|
const { items } = await apiGet<{ subcategoryid: number; subcategoryname: string }>(url);
|
||||||
return subcategories;
|
return items;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user