dispatch testing

This commit is contained in:
2026-07-20 18:54:09 +05:30
parent 6b154997fb
commit 407574d62a
11 changed files with 663 additions and 518 deletions

View File

@@ -595,26 +595,22 @@ export default function App() {
? (summaryQ.data?.tenantname ? `${summaryQ.data.tenantname} Admin` : 'Admin Console')
: currentSection === 'inventory'
? 'Products'
: currentSection === 'catalogue'
? 'Global Catalogue'
: currentSection === 'dispatch'
? 'Console'
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
: currentSection === 'dispatch'
? 'Console'
: currentSection.charAt(0).toUpperCase() + currentSection.slice(1),
icon: currentSection === 'dashboard'
? LayoutDashboard
: currentSection === 'inventory'
? Layers
: currentSection === 'catalogue'
? Box
: currentSection === 'stores'
? Store
: currentSection === 'reports'
? TrendingUp
: currentSection === 'settings'
? Settings
: currentSection === 'dispatch'
? Truck
: undefined
: currentSection === 'stores'
? Store
: currentSection === 'reports'
? TrendingUp
: currentSection === 'settings'
? Settings
: currentSection === 'dispatch'
? Truck
: undefined
}}
/>
@@ -657,12 +653,7 @@ export default function App() {
/>
} />
<Route path="catalogue" element={
<CatalogueBrowser
tenantid={tenantId}
locationid={FIESTA_PRIMARY_LOCATION_ID}
/>
} />
<Route path="catalogue" element={<Navigate to="/admin/inventory" replace />} />
<Route path="reports" element={
<ReportsView

View File

@@ -947,16 +947,6 @@ VALUES (${newUserId}, 1, 'Active', NOW());
return (
<div className="space-y-lg animate-in fade-in duration-300 font-sans text-slate-700">
{/* Header section */}
<div className="pb-4 border-b border-slate-100 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<span className="text-xs font-bold text-purple-650 uppercase tracking-widest block">System Operations</span>
<h2 className="text-2xl font-bold text-slate-900 mt-1">Admin Onboarding Console</h2>
<p className="text-slate-500 text-xs mt-1.5 leading-relaxed">
Provision new tenants, register store outlet branches, and onboard delivery fleet riders into the system.
</p>
</div>
</div>
{/* Tabs Layout */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">

View File

@@ -1,26 +1,31 @@
import React, { useState } from 'react';
import { Search, CheckCircle2, DownloadCloud, Box, PackageOpen } from 'lucide-react';
import { Search, CheckCircle2, DownloadCloud, Box, PackageOpen, Filter, Layers, Check, Package, X, ArrowLeft } from 'lucide-react';
import {
useCatalogueProducts,
useCatalogueBrands,
useImportedCatalogueRefs,
useImportCatalogueProduct
useImportCatalogueProduct,
useTenantCategories
} from '../hooks/useCatalogueImport';
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
import { useStoreCatalogue } from '../services/storeCatalogue';
import ImportProductModal from './ImportProductModal';
interface CatalogueBrowserProps {
tenantid: number;
locationid: number;
onClose?: () => void;
}
export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrowserProps) {
export default function CatalogueBrowser({ tenantid, locationid, onClose }: CatalogueBrowserProps) {
const [brand, setBrand] = useState<string | undefined>(undefined);
const [keyword, setKeyword] = useState<string>('');
const [debouncedKeyword, setDebouncedKeyword] = useState<string>('');
const [importingProduct, setImportingProduct] = useState<CatalogueProduct | null>(null);
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
// Debounce search
React.useEffect(() => {
const handler = setTimeout(() => {
@@ -36,6 +41,8 @@ export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrow
const { data: brandsData = [], isLoading: isLoadingBrands } = useCatalogueBrands();
const { data: importedRefs = new Set<string>() } = useImportedCatalogueRefs(tenantid, brand);
const { data: tenantCategories = [] } = useTenantCategories(tenantid);
const storeCat = useStoreCatalogue();
const importProductMutation = useImportCatalogueProduct(tenantid, locationid);
@@ -51,133 +58,207 @@ export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrow
};
return (
<div className="flex flex-col h-full bg-white relative">
<div className="flex flex-col h-full bg-transparent relative animate-in fade-in duration-300 font-sans text-slate-700">
{/* Top Filters */}
<div className="flex flex-col p-6 border-b border-slate-100 gap-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
<Box className="text-purple-650" size={24} />
Global Catalogue
</h2>
<p className="text-sm text-slate-500 mt-1">
Browse {total > 0 ? total : ''} global FMCG products and import them to your store.
</p>
</div>
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="Search catalogue products..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 focus:bg-white transition-all"
/>
</div>
</div>
{/* Brands Chip Row */}
<div>
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Filter by Brand</h3>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setBrand(undefined)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
brand === undefined
? 'bg-purple-650 text-white shadow-sm shadow-purple-650/20'
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
}`}
>
All Brands
</button>
{isLoadingBrands ? (
<span className="text-sm text-slate-400 py-1.5 px-2">Loading brands...</span>
) : (
brandsData.map(b => (
<button
key={b.brand}
onClick={() => setBrand(b.brand)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all flex items-center gap-2 ${
brand === b.brand
? 'bg-purple-650 text-white shadow-sm shadow-purple-650/20'
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
}`}
>
{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.product_count}
</span>
</button>
))
)}
</div>
</div>
</div>
{/* Product Grid */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 bg-slate-50/50">
{isLoadingProducts ? (
<div className="flex flex-col items-center justify-center h-40 text-slate-400 gap-3">
<div className="w-6 h-6 border-2 border-purple-650 border-t-transparent rounded-full animate-spin"></div>
Loading catalogue...
</div>
) : products.length === 0 ? (
<div className="flex flex-col items-center justify-center h-60 text-slate-400 gap-4">
<PackageOpen size={48} className="text-slate-300" />
<p>No products found in the catalogue.</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products.map(p => {
const isImported = importedRefs.has(`${p.brand}:${p.id}`);
return (
<div key={`${p.brand}:${p.id}`} className="group relative bg-white border border-slate-200 rounded-xl overflow-hidden hover:shadow-lg hover:shadow-slate-200/50 hover:border-purple-200 transition-all flex flex-col">
{isImported && (
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 bg-emerald-500 text-white text-xs font-semibold px-2.5 py-1 rounded-full shadow-sm">
<CheckCircle2 size={14} />
Imported
</div>
)}
<div className="aspect-square bg-slate-50 border-b border-slate-100 p-4 flex items-center justify-center relative overflow-hidden">
{p.images && p.images.length > 0 ? (
<img src={p.images[0]} alt={p.product_name} className="w-full h-full object-contain group-hover:scale-105 transition-transform duration-300" />
) : (
<Box className="w-16 h-16 text-slate-200" />
)}
<div className="flex flex-col xl:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
{/* Left Side: Filters */}
{isLocalSidebarOpen && (
<div className="w-full xl:w-52 shrink-0 flex flex-col gap-5 pr-2 pb-2">
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col rounded-none">
<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">
<Filter size={16} className="text-[#662582]" /> Filter Brands
</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">
<Layers size={14} className="text-[#662582]" /> Brands
</h3>
{isLoadingBrands ? (
<span className="text-xs text-slate-400 py-1.5">Loading brands...</span>
) : (
<div className="space-y-1.5">
{brandsData.map((b) => (
<label key={b.brand} className="flex items-center gap-3 p-2 rounded-none hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center shrink-0">
<input
type="checkbox"
checked={brand === b.brand}
onChange={() => setBrand(brand === b.brand ? undefined : b.brand)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-none 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 truncate ${brand === b.brand ? 'text-[#662582]' : 'text-slate-600 group-hover:text-slate-900'}`}>
{b.brand.replace('brand_', '').toUpperCase()}
</span>
<span className="ml-auto text-[10px] text-slate-400 font-bold">{b.product_count}</span>
</label>
))}
</div>
)}
</div>
{(brand || keyword) && (
<button
onClick={() => { setBrand(undefined); setKeyword(''); }}
className="w-full py-2 bg-slate-100 text-slate-600 rounded-none 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>
)}
{/* Right Side: 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 rounded-none">
<div className={`flex justify-between gap-4 mb-5 shrink-0 ${isLocalSidebarOpen ? 'flex-col lg:flex-row lg:items-center items-start' : 'flex-col lg:flex-row lg:items-center items-start'}`}>
<div className="flex-1 min-w-0 pr-4 flex items-start gap-2">
{onClose && (
<button
onClick={onClose}
className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0"
title="Back to Products"
>
<ArrowLeft size={16} />
</button>
)}
<button
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
className="p-1.5 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-100 hover:text-[#662582] transition-colors mt-0.5 cursor-pointer shrink-0"
title="Toggle Filters"
>
<Filter size={16} />
</button>
<div>
<h3 className="font-sans font-bold text-sm text-[#0f172a] flex items-center gap-1.5 flex-wrap">
<Box size={15} className="text-[#662582] shrink-0" /> <span className="truncate">Global Product Catalogue</span>
<div className="flex items-center gap-1.5 ml-1">
<span className="text-[9px] text-[#662582] font-bold bg-purple-50 px-1.5 py-0.5 rounded-none border border-purple-100/50 whitespace-nowrap">
{total} product{total === 1 ? '' : 's'} available
</span>
</div>
</h3>
<p className="text-[9px] text-zinc-400 font-medium mt-1 truncate">Browse global FMCG products and import them into your local merchant catalogue.</p>
</div>
</div>
<div className="flex flex-nowrap items-center gap-2 shrink-0">
<div className="relative bg-slate-50 border border-slate-200 rounded-none flex items-center h-[32px] px-2.5 shadow-sm min-w-[200px] hover:border-purple-300 transition-colors group focus-within:border-purple-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-purple-100">
<Search size={14} className="text-slate-400 group-focus-within:text-purple-600 transition-colors shrink-0" />
<input
type="text"
placeholder="Search catalogue..."
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="bg-transparent border-none outline-none text-xs font-semibold text-slate-700 ml-2 w-full placeholder:text-slate-400 placeholder:font-medium"
/>
{keyword && (
<button
onClick={() => setKeyword('')}
className="ml-1 text-slate-400 hover:text-slate-700 bg-slate-200 hover:bg-slate-300 rounded-full p-0.5 transition-colors"
>
<X size={10} />
</button>
)}
</div>
</div>
</div>
{isLoadingProducts ? (
<div className="flex flex-col items-center justify-center flex-1 text-slate-400 gap-3">
<div className="w-6 h-6 border-2 border-purple-650 border-t-transparent rounded-full animate-spin"></div>
<span className="text-xs font-bold uppercase tracking-widest">Loading catalogue...</span>
</div>
) : products.length === 0 ? (
<div className="text-center py-xl text-zinc-400 text-xs font-bold flex-1 flex items-center justify-center">
No products match your selection.
</div>
) : (
<div className="flex-1 min-w-0">
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 ${!isLocalSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5'} gap-4`}>
{products.map(p => {
const isImported = storeCat.has(String(p.id));
<div className="p-4 flex flex-col flex-1">
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">{p.brand.replace('brand_', '')}</div>
<h3 className="font-semibold text-slate-800 text-sm leading-tight mb-2 line-clamp-2" title={p.product_name}>
{p.product_name}
</h3>
<div className="mt-auto space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500 font-medium bg-slate-100 px-2 py-0.5 rounded">{p.size || 'N/A'}</span>
<span className="text-xs font-bold text-slate-700">{p.price_range || 'Price N/A'}</span>
return (
<div
key={`${p.brand}:${p.id}`}
onClick={() => setImportingProduct(p)}
className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer"
>
<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">
{p.images && p.images.length > 0 ? (
<img
src={p.images[0]}
alt={p.product_name}
className="w-full h-full object-contain mix-blend-multiply group-hover:scale-110 transition-transform duration-700 ease-in-out"
/>
) : (
<Box className="w-16 h-16 text-slate-200" />
)}
<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 pointer-events-none" />
</div>
{!isImported && (
<button
onClick={() => setImportingProduct(p)}
className="w-full flex items-center justify-center gap-2 py-2 bg-slate-50 hover:bg-purple-50 text-slate-600 hover:text-purple-700 border border-slate-200 hover:border-purple-200 rounded-lg text-sm font-medium transition-all group-hover:bg-purple-650 group-hover:text-white group-hover:border-purple-650 shadow-sm"
>
<DownloadCloud size={16} />
Import to Store
</button>
)}
<div className="p-3 flex flex-col flex-1 gap-2">
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2" title={p.product_name}>
{p.product_name}
</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">ID: {p.id}</p>
</div>
<div className="mt-auto pt-2 border-t border-slate-100/50 flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-500 font-medium">{p.size || 'N/A'}</span>
<span className="text-[11px] font-extrabold font-mono text-slate-800">{p.price_range || 'N/A'}</span>
</div>
{isImported ? (
<button
onClick={(e) => { e.stopPropagation(); setImportingProduct(p); }}
className="w-full bg-emerald-50 text-emerald-700 border border-emerald-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<CheckCircle2 size={12} />
Imported
</button>
) : (
<button
onClick={(e) => {
e.stopPropagation();
const validCategory = tenantCategories.find(c => c.categoryid > 0)?.categoryid || (tenantCategories[0]?.categoryid ?? 2);
handleImportSubmit({
tenantid,
locationid,
brand: p.brand,
catalogueid: p.id,
categoryid: validCategory,
subcategoryid: 0,
quantity: 1,
stocktype: "in",
status: "Active",
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
}}
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<DownloadCloud size={12} />
Import Product
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
)}
);
})}
</div>
</div>
)}
</div>
</div>
{importingProduct && (
@@ -187,13 +268,14 @@ export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrow
locationid={locationid}
onClose={() => setImportingProduct(null)}
onImport={handleImportSubmit}
isImported={storeCat.has(String(importingProduct.id))}
/>
)}
{importProductMutation.isPending && (
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex flex-col items-center justify-center gap-3">
<div className="w-8 h-8 border-4 border-purple-650 border-t-transparent rounded-full animate-spin"></div>
<div className="text-sm font-medium text-slate-700 shadow-sm bg-white px-4 py-2 rounded-full border border-slate-200">Importing product...</div>
<div className="text-sm font-bold uppercase tracking-widest text-slate-700 shadow-sm bg-white px-4 py-2 rounded-none border border-slate-200">Importing product...</div>
</div>
)}
</div>

View File

@@ -1,7 +1,9 @@
import React, { useState } from 'react';
import { X, Save, AlertCircle } from 'lucide-react';
import { X, Save, AlertCircle, CheckCircle, DownloadCloud, Plus, Box } from 'lucide-react';
import { CatalogueProduct, ImportCatalogueProductRequest } from '../services/catalogueApi';
import { useProductSubcategories, useTenantCategories } from '../hooks/useCatalogueImport';
import { SlideDrawer } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
interface ImportProductModalProps {
product: CatalogueProduct;
@@ -9,6 +11,7 @@ interface ImportProductModalProps {
locationid: number;
onClose: () => void;
onImport: (item: ImportCatalogueProductRequest) => void;
isImported?: boolean;
}
export default function ImportProductModal({
@@ -16,7 +19,8 @@ export default function ImportProductModal({
tenantid,
locationid,
onClose,
onImport
onImport,
isImported = false
}: ImportProductModalProps) {
const [categoryId, setCategoryId] = useState<string>('');
const [subcategoryId, setSubcategoryId] = useState<string>('');
@@ -25,14 +29,10 @@ export default function ImportProductModal({
const [taxPercent, setTaxPercent] = useState<string>('0');
const [quantity, setQuantity] = useState<string>('1');
// Categories this tenant's own products actually use — the global
// categories list is missing categoryids that are nonetheless in real use.
// Categories this tenant's own products actually use
const { data: categories = [], isLoading: isLoadingCategories } = useTenantCategories(tenantid);
// 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.
// Optional: many subcategories are unowned/shared and a product can be
// imported without one (falls back to "Uncategorized" in subcategory views).
// Subcategories scoped to selected category
const { data: subcategories = [], isLoading: isLoadingSubcats } = useProductSubcategories(
tenantid,
categoryId ? Number(categoryId) : undefined,
@@ -62,151 +62,114 @@ export default function ImportProductModal({
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]">
<SlideDrawer
isOpen={!!product}
onClose={onClose}
title="Global Product Details"
>
<div className="flex flex-col gap-6 pb-8 text-xs font-sans">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<div>
<h2 className="text-lg font-bold text-slate-800">Import Product</h2>
<p className="text-sm text-slate-500">{product.product_name} ({product.brand})</p>
{/* Clean Image Container */}
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4 shadow-xs">
{product.images && product.images.length > 0 ? (
<img src={product.images[0]} alt={product.product_name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
) : (
<Box size={48} className="text-slate-300" />
)}
{isImported && (
<div className="absolute top-3 right-3 flex items-center gap-1 bg-emerald-500 text-white text-[10px] font-extrabold uppercase px-2 py-1 shadow-sm rounded-md">
<CheckCircle size={12} />
In Store Catalogue
</div>
)}
</div>
{/* Title & Basics */}
<div>
<p className="text-xs font-bold text-slate-400 font-mono tracking-tight mb-1">ID: {product.id}</p>
<h3 className="text-xl font-extrabold text-slate-900 leading-tight mb-3">{product.product_name}</h3>
<div className="flex flex-wrap items-center gap-2">
<span className="px-2.5 py-1 rounded bg-purple-50 text-[#662582] text-[10px] font-extrabold uppercase tracking-wider border border-purple-100">
{product.brand.replace('brand_', '')}
</span>
</div>
<button onClick={onClose} className="p-2 text-slate-400 hover:bg-slate-50 hover:text-slate-600 rounded-full transition-colors">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="p-6 overflow-y-auto custom-scrollbar flex-1">
<form id="import-form" onSubmit={handleSubmit} className="space-y-5">
<div className="bg-blue-50/50 rounded-lg p-3 border border-blue-100 flex gap-3 text-sm text-blue-700">
<AlertCircle size={18} className="shrink-0 mt-0.5" />
<p>You need to map this catalogue product to your store's categories and set your own pricing.</p>
{/* Price Range Container */}
<div className="bg-slate-50/90 p-4 rounded-xl border border-slate-200 flex items-center justify-between shadow-xs">
<div>
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-widest block mb-0.5">Price Range</span>
<div className="text-2xl font-black text-slate-900 font-mono tracking-tight">
{product.price_range || 'N/A'}
</div>
<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 *</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 (optional)</label>
{!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
value={subcategoryId}
onChange={e => setSubcategoryId(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 bg-white"
>
<option value="">None</option>
{subcategories.map((s: any) => (
<option key={s.subcategoryid || s.id} value={s.subcategoryid || s.id}>
{s.subcategoryname || s.name}
</option>
))}
</select>
)}
</div>
</div>
<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">Retail Price *</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></span>
<input
type="number" step="0.01"
value={retailPrice} onChange={e => setRetailPrice(e.target.value)}
className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-colors"
placeholder="0.00"
required
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Cost Price *</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"></span>
<input
type="number" step="0.01"
value={productCost} onChange={e => setProductCost(e.target.value)}
className="w-full pl-7 pr-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-colors"
placeholder="0.00"
required
/>
</div>
</div>
</div>
<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">Tax Percent</label>
<div className="relative">
<input
type="number" step="0.1"
value={taxPercent} onChange={e => setTaxPercent(e.target.value)}
className="w-full pr-8 pl-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="0"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400">%</span>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-slate-600 uppercase tracking-wider">Initial Quantity</label>
<input
type="number" min="1"
value={quantity} onChange={e => setQuantity(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"
required
/>
</div>
</div>
</form>
</div>
{product.size && (
<span className="px-3 py-1.5 rounded-lg bg-white text-slate-700 text-xs font-extrabold uppercase tracking-wider border border-slate-200 shadow-2xs">
Size: {product.size}
</span>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-100 bg-slate-50 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-800 transition-colors"
>
Cancel
</button>
<button
type="submit"
form="import-form"
className="flex items-center gap-2 px-5 py-2 bg-purple-650 hover:bg-purple-750 text-white text-sm font-medium rounded-lg shadow-sm shadow-purple-650/20 transition-all hover:-translate-y-0.5 active:translate-y-0"
>
<Save size={16} />
Import Product
</button>
{/* Import Action Container */}
<div className="bg-slate-50/80 rounded-2xl border border-slate-200 p-5 space-y-4 shadow-xs">
{isImported ? (
<div className="p-4 bg-emerald-50/80 border border-emerald-200 rounded-xl flex items-center gap-3 text-xs text-emerald-800 font-semibold shadow-xs">
<CheckCircle size={22} className="text-emerald-600 shrink-0" />
<div>
<div className="font-extrabold text-sm text-emerald-900">Imported</div>
<p className="text-[11px] text-emerald-700 font-medium mt-0.5">This product is available in your Admin Catalogue. Set pricing and store parameters in the Admin Catalogue.</p>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
<h4 className="font-extrabold text-slate-800 text-sm flex items-center gap-2">
<DownloadCloud size={16} className="text-[#662582]" /> Import Product
</h4>
<span className="text-[10px] font-semibold text-slate-500">Global FMCG</span>
</div>
<p className="text-[11px] text-slate-600 font-medium leading-relaxed">
Import this product to your Admin Catalogue. Selling price and outlet parameters will be managed directly in your Admin Catalogue.
</p>
<button
onClick={() => {
const validCategory = categories.find(c => c.categoryid > 0)?.categoryid || (categories[0]?.categoryid ?? 2);
onImport({
tenantid,
locationid,
brand: product.brand,
catalogueid: product.id,
categoryid: validCategory,
subcategoryid: 0,
quantity: 1,
stocktype: "in",
status: "Active",
retailprice: 0,
productcost: 0,
taxpercent: 0,
});
}}
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md cursor-pointer"
>
<Plus size={18} strokeWidth={2.5} />
Import Product
</button>
</div>
)}
</div>
{/* Retail Packaging Details */}
<div>
<h4 className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-2">Retail Packaging Info</h4>
<FMCGHoverOverlay
productId={String(product.id)}
category={product.brand}
productName={product.product_name}
product={product}
/>
</div>
</div>
</div>
</SlideDrawer>
);
}

View File

@@ -35,7 +35,9 @@ import {
Store,
Activity,
Award,
Filter
Filter,
Box,
ArrowLeft
} from 'lucide-react';
import { ProductMatrixItem } from '../types';
import {
@@ -45,13 +47,14 @@ import {
useFiestaUpdateStockRequest,
useFiestaGetStockRequests,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, str as fstr } from '../services/fiestaApi';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, str as fstr } from '../services/fiestaApi';
import { stockRowToProduct, stockRowToInventory } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import BulkCartDrawer from './BulkCartDrawer';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer, Skeleton, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND, tint, edge, StatusChip } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
import CatalogueBrowser from './CatalogueBrowser';
import { useCompare } from '../contexts/CompareContext';
@@ -75,6 +78,7 @@ export default function InventoryView({
}: InventoryViewProps) {
const { setHideCompareBar } = useCompare();
const [searchTerm, setSearchTerm] = useState('');
const [showCatalogueModal, setShowCatalogueModal] = useState(false);
const navigate = useNavigate();
// ── Live stock across every outlet (Fiesta) ───────────────────────────────
@@ -108,7 +112,7 @@ export default function InventoryView({
const allStoreRows = storesStock.flatMap((s) => s.rows);
const [activeTab, setActiveTab] = useState<'catalog' | 'requests'>('catalog');
const [activeTab, setActiveTab] = useState<'catalog' | 'requests' | 'global_catalogue'>('catalog');
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [hoveredAdminProduct, setHoveredAdminProduct] = useState<ProductMatrixItem | null>(null);
@@ -119,19 +123,22 @@ export default function InventoryView({
const [addingPriceProdId, setAddingPriceProdId] = useState<string | null>(null);
const [cardImportPrice, setCardImportPrice] = useState<string>('');
const selectedProductId = selectedAdminProduct?.id;
useEffect(() => {
if (selectedAdminProduct) {
const storeItem = storeCat.items.find(i => i.productid === selectedAdminProduct.id);
const existingPrice = storeItem?.price || selectedAdminProduct.price;
setImportPrice(
selectedAdminProduct.price
? String(selectedAdminProduct.price)
: String(selectedAdminProduct.unitsSold > 0 ? Math.round(selectedAdminProduct.revenue / selectedAdminProduct.unitsSold) : 0)
existingPrice && existingPrice > 0
? String(existingPrice)
: (selectedAdminProduct.unitsSold > 0 ? String(Math.round(selectedAdminProduct.revenue / selectedAdminProduct.unitsSold)) : '')
);
setIsSettingPrice(false);
} else {
setImportPrice('');
setIsSettingPrice(false);
}
}, [selectedAdminProduct]);
}, [selectedProductId]);
const products = useMemo(() => {
const byId = new Map<string, ProductMatrixItem>();
@@ -513,7 +520,7 @@ export default function InventoryView({
)}
</button>
<button
onClick={() => navigate('/admin/catalogue')}
onClick={() => setActiveTab('global_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
@@ -544,8 +551,11 @@ export default function InventoryView({
{/* Left Side: Normal Catalogue */}
<div className="flex-1 min-w-0">
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!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-4`}>
{filteredProducts.map((prod) => (
<div key={prod.id} onClick={() => setSelectedAdminProduct(prod)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
{filteredProducts.map((prod) => {
const isPublished = storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active';
return (
<div key={prod.id} onClick={() => setSelectedAdminProduct(prod)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer">
{/* 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
@@ -554,82 +564,51 @@ export default function InventoryView({
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 flex-col items-start gap-1">
<div className="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]}
<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 pointer-events-none" />
{prod.isSample && (
<div className="absolute top-2 left-2 flex flex-col items-start gap-1 z-10">
<span className="px-1.5 py-0.5 rounded-none bg-indigo-100 text-indigo-800 text-[8px] font-extrabold uppercase shadow-sm flex items-center gap-1 border border-indigo-200">
<Package size={10} /> SAMPLE
</span>
{prod.isSample && (
<span className="px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-800 text-[8px] font-extrabold uppercase shadow-sm flex items-center gap-1 border border-indigo-200">
<Package size={10} /> SAMPLE
</span>
)}
</div>
</div>
)}
</div>
{/* Cannot be removed from local catalogue if it is active in the store catalogue */}
{!(storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active') && (
<button
onClick={(e) => {
e.stopPropagation();
if (storeCat.has(prod.id)) {
storeCat.remove(prod.id);
}
}}
className="absolute top-2 right-2 w-7 h-7 bg-white/90 border border-slate-200 rounded-lg flex items-center justify-center text-slate-400 hover:text-rose-500 hover:border-rose-200 hover:bg-rose-50 shadow-sm z-20 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Remove from Product Catalogue"
>
<Trash2 size={14} />
</button>
)}
{/* Content Section */}
<div className="p-3 flex flex-col flex-1 gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 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">{prod.sku}</p>
</div>
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2" title={prod.name}>{prod.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{prod.sku}</p>
</div>
<div className="grid grid-cols-2 gap-1.5 p-2 bg-slate-50/80 rounded-xl border border-slate-100 mt-auto">
<div>
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Units Sold</span>
<span className="font-extrabold text-slate-800 font-mono text-xs">{prod.unitsSold.toLocaleString()}</span>
<div className="mt-auto pt-2 border-t border-slate-100/50 flex flex-col gap-2">
<div className="flex items-center justify-between text-[10px] text-slate-500 font-medium">
<span>Sold: <strong className="text-slate-800 font-mono">{prod.unitsSold.toLocaleString()}</strong></span>
<span className="font-extrabold font-mono text-emerald-600">{prod.revenue.toLocaleString()}</span>
</div>
<div className="text-right">
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Revenue</span>
<span className="font-black text-emerald-600 font-mono text-xs">₹{prod.revenue.toLocaleString()}</span>
</div>
</div>
<div className="space-y-2 pt-1 border-t border-slate-100">
{storeCat.has(prod.id) && storeCat.items.find(i => i.productid === prod.id)?.status === 'Active' ? (
<div className="flex flex-wrap items-center justify-between 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} /> In Store Catalogue</span>
<button onClick={(e) => { e.stopPropagation(); storeCat.remove(prod.id); }} title="Remove from store catalogue" className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-white text-rose-600 hover:bg-rose-50 shadow-sm border border-rose-100 font-bold text-[10px] cursor-pointer transition-colors whitespace-nowrap">
<X size={12} /> Remove
</button>
</div>
{isPublished ? (
<button
onClick={(e) => { e.stopPropagation(); storeCat.remove(prod.id); }}
title="Remove from store catalogue"
className="w-full bg-emerald-50 hover:bg-rose-50 text-emerald-700 hover:text-rose-600 border border-emerald-200 hover:border-rose-200 px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<CheckCircle size={12} />
Remove from Store
</button>
) : addingPriceProdId === prod.id ? (
<div className="flex flex-col gap-2 p-2 bg-slate-50 border border-slate-200 rounded-xl" onClick={(e) => e.stopPropagation()}>
<label className="text-[10px] font-extrabold text-slate-600 uppercase tracking-widest text-center">Set Selling Price (₹)</label>
<div className="flex flex-col gap-1.5 p-2 bg-slate-50 border border-slate-200 rounded-none" onClick={(e) => e.stopPropagation()}>
<label className="text-[9px] font-extrabold text-slate-600 uppercase tracking-widest text-center">Set Selling Price (₹)</label>
<input
type="number"
value={cardImportPrice}
onChange={(e) => setCardImportPrice(e.target.value)}
placeholder="e.g. 50"
className="px-2 py-1.5 border border-slate-200 rounded-md text-[11px] font-bold focus:outline-none focus:border-[#662582]"
className="px-2 py-1 border border-slate-200 rounded-none text-[10px] font-bold focus:outline-none focus:border-[#662582]"
autoFocus
/>
<div className="flex gap-1.5">
<button onClick={(e) => { e.stopPropagation(); setAddingPriceProdId(null); }} className="flex-1 py-1 rounded bg-white text-slate-500 border border-slate-200 hover:bg-slate-100 text-[10px] font-bold transition-colors">Cancel</button>
<div className="flex gap-1">
<button onClick={(e) => { e.stopPropagation(); setAddingPriceProdId(null); }} className="flex-1 py-1 bg-white text-slate-500 border border-slate-200 text-[9px] font-bold">Cancel</button>
<button
disabled={!cardImportPrice || Number(cardImportPrice) <= 0}
onClick={(e) => {
@@ -637,7 +616,7 @@ export default function InventoryView({
storeCat.add({ productid: prod.id, name: prod.name, image: prod.image, category: prod.category, sku: prod.sku, price: Number(cardImportPrice), unit: prod.exposure, qty: 1, status: 'Active' });
setAddingPriceProdId(null);
}}
className="flex-1 py-1 rounded bg-[#662582] text-white hover:bg-[#531e6a] text-[10px] font-bold disabled:opacity-50 transition-colors"
className="flex-1 py-1 bg-[#662582] text-white text-[9px] font-bold disabled:opacity-50"
>
Confirm
</button>
@@ -650,16 +629,17 @@ export default function InventoryView({
setCardImportPrice(String(prod.price || (prod.unitsSold > 0 ? Math.round(prod.revenue / prod.unitsSold) : 0)));
setAddingPriceProdId(prod.id);
}}
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"
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
title="Add to Store Catalogue"
>
<Plus size={14} /> Add to Store Catalogue
<Plus size={12} /> Add to Store
</button>
)}
</div>
</div>
</div>
))}
);
})}
</div>
</div>
</div>
@@ -859,6 +839,14 @@ export default function InventoryView({
</div>
)}
</div>
) : activeTab === 'global_catalogue' ? (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden animate-in fade-in duration-300">
<CatalogueBrowser
tenantid={tenantId}
locationid={FIESTA_PRIMARY_LOCATION_ID}
onClose={() => setActiveTab('catalog')}
/>
</div>
) : null}
{/* ── Slide Drawer ── */}
@@ -917,47 +905,71 @@ export default function InventoryView({
)}
</div>
{/* Action Area */}
<div className="space-y-3 pt-4 border-t border-slate-100">
<div className="space-y-3">
{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
</button>
) : (
<div className="flex flex-col gap-3 p-4 bg-slate-50 border border-slate-200 rounded-xl">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-slate-600">Set Selling Price ()</label>
<input
type="number"
value={importPrice}
onChange={(e) => setImportPrice(e.target.value)}
placeholder="e.g. 50"
className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-[#662582]"
/>
</div>
<button
disabled={!importPrice || Number(importPrice) <= 0}
onClick={() => {
storeCat.add({ productid: selectedAdminProduct.id, name: selectedAdminProduct.name, image: selectedAdminProduct.image, category: selectedAdminProduct.category, sku: selectedAdminProduct.sku, price: Number(importPrice) || (selectedAdminProduct.unitsSold > 0 ? Math.round(selectedAdminProduct.revenue / selectedAdminProduct.unitsSold) : 0), unit: selectedAdminProduct.exposure, qty: 1, status: 'Active' });
}}
className="w-full 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] disabled:opacity-50 disabled:cursor-not-allowed">
<Plus size={18} strokeWidth={2} /> Add to Store Catalogue
</button>
</div>
)}
{!storeCat.has(selectedAdminProduct.id) && (
<button
onClick={() => {
storeCat.remove(selectedAdminProduct.id);
setSelectedAdminProduct(null);
}}
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 mt-2"
>
<Trash2 size={18} strokeWidth={2} /> Delete from Local Catalogue
</button>
)}
{/* Selling Price & Store Catalogue Action Area */}
<div className="bg-slate-50/90 border border-slate-200 rounded-2xl p-5 space-y-4 shadow-xs">
<div className="flex items-center justify-between border-b border-slate-200 pb-3">
<div>
<h4 className="font-extrabold text-slate-800 text-sm flex items-center gap-2">
<Tag size={16} className="text-[#662582]" /> Store Selling Price ()
</h4>
<p className="text-[10px] font-semibold text-slate-500 mt-0.5">
{storeCat.has(selectedAdminProduct.id) ? 'Currently active in store catalogue' : 'Set selling price to publish in store catalogue'}
</p>
</div>
{storeCat.has(selectedAdminProduct.id) && (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-800 text-[10px] font-extrabold uppercase border border-emerald-200 flex items-center gap-1">
<CheckCircle size={10} /> Active
</span>
)}
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5" onClick={(e) => e.stopPropagation()}>
<label className="text-[10px] font-extrabold text-slate-500 uppercase tracking-wider">Selling Price ()</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 font-bold pointer-events-none"></span>
<input
type="number"
step="0.01"
value={importPrice}
onChange={(e) => setImportPrice(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="e.g. 50"
className="w-full pl-7 pr-3 py-2.5 border border-slate-200 rounded-xl text-sm font-bold text-slate-900 focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] bg-white transition-all shadow-xs"
/>
</div>
</div>
<button
disabled={!importPrice || Number(importPrice) <= 0}
onClick={() => {
storeCat.add({
productid: selectedAdminProduct.id,
name: selectedAdminProduct.name,
image: selectedAdminProduct.image,
category: selectedAdminProduct.category,
sku: selectedAdminProduct.sku,
price: Number(importPrice),
unit: selectedAdminProduct.exposure || 'Pc',
qty: 1,
status: 'Active'
});
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-extrabold transition-all bg-[#662582] hover:bg-[#531e6a] text-white shadow-md disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<CheckCircle size={16} strokeWidth={2.5} />
{storeCat.has(selectedAdminProduct.id) ? 'Save Price to Database' : 'Save Price & Publish to Store'}
</button>
{storeCat.has(selectedAdminProduct.id) && (
<button
onClick={() => storeCat.remove(selectedAdminProduct.id)}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white text-rose-600 border border-slate-200 font-bold text-xs transition-colors hover:bg-slate-50 cursor-pointer mt-1"
>
<X size={14} strokeWidth={2} /> Remove from Store Catalogue
</button>
)}
</div>
</div>
<div className="mt-2">

View File

@@ -38,7 +38,6 @@ export default function Sidebar({
const navItems = [
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
{ id: 'catalogue' as MainSection, label: 'Global Catalogue', icon: Box },
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
{ id: 'dispatch' as MainSection, label: 'Console', icon: Truck },
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }

View File

@@ -441,85 +441,59 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const pick = picks[p.id];
const picked = pick != null && pick.status !== 'Cancelled' && pick.status !== 'Approved';
return (
<div key={p.id} onClick={() => setSelectedProduct(p)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
{/* Image Bento Block */}
<div className="w-full h-28 bg-zinc-50 relative overflow-hidden shrink-0">
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover 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" />
{stocked && (
<div className="absolute top-2 right-2 bg-emerald-100/90 backdrop-blur-sm border border-emerald-200 px-1.5 py-0.5 rounded shadow-sm flex items-center gap-1 z-10">
<CheckCircle2 size={10} className="text-emerald-700" />
<span className="text-[8px] font-extrabold uppercase text-emerald-800 tracking-wider">In Store</span>
</div>
)}
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1">
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
p.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
p.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
p.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
'bg-rose-100 text-rose-800'
}`}>
{p.category.split(' / ')[0]}
</span>
</div>
<div key={p.id} onClick={() => setSelectedProduct(p)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer">
{/* 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={p.image} alt={p.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 pointer-events-none" />
</div>
{/* Details Bento Block */}
{/* Content Section */}
<div className="p-3 flex flex-col flex-1 gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{p.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
</div>
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2" title={p.name}>{p.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
</div>
<div className="grid grid-cols-2 gap-1.5 p-2 bg-slate-50/80 rounded-xl border border-slate-100 mt-auto">
<div>
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
<span className="font-extrabold text-slate-800 font-mono text-xs">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
<div className="mt-auto pt-2 border-t border-slate-100/50 flex flex-col gap-2">
<div className="flex items-center justify-between text-[10px] text-slate-500 font-medium">
<span>Unit: <strong className="text-slate-800 font-semibold">{p.unit || 'Pc'}</strong></span>
<span className="font-extrabold font-mono text-slate-800">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
</div>
<div className="text-right">
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
<span className="font-bold text-slate-600 text-xs bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
</div>
</div>
{/* Action Bento Block */}
<div className="pt-1">
{picked ? (
<div className={`flex items-center justify-between gap-1 p-1.5 rounded-xl border shadow-inner ${
<div className={`flex items-center justify-between gap-1 p-1.5 rounded-none border shadow-inner ${
picks[p.id].status === 'Approved' ? 'bg-emerald-50 border-emerald-200' :
picks[p.id].status === 'Rejected' ? 'bg-rose-50 border-rose-200' :
'bg-amber-50 border-amber-200'
}`}>
<span className={`inline-flex items-center gap-1.5 px-2 text-[11px] font-bold ${
<span className={`inline-flex items-center gap-1.5 px-2 text-[10px] font-bold ${
picks[p.id].status === 'Approved' ? 'text-emerald-700' :
picks[p.id].status === 'Rejected' ? 'text-rose-700' :
'text-amber-700'
}`}>
{picks[p.id].status === 'Approved' ? <CheckCircle2 size={13} /> :
picks[p.id].status === 'Rejected' ? <X size={13} /> :
{picks[p.id].status === 'Approved' ? <CheckCircle2 size={12} /> :
picks[p.id].status === 'Rejected' ? <X size={12} /> :
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
{picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
</span>
<div className="flex gap-1">
{picks[p.id].status === 'Pending' && (
<button onClick={(e) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }} title="Edit Quantity" className="px-2 h-7 rounded-lg text-amber-600 hover:bg-amber-100 bg-white border border-amber-100 flex items-center justify-center transition-colors shadow-sm text-[10px] font-bold">Edit</button>
<button onClick={(e) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }} title="Edit Quantity" className="px-2 py-0.5 text-amber-600 hover:bg-amber-100 bg-white border border-amber-200 transition-colors shadow-sm text-[9px] font-bold">Edit</button>
)}
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className={`w-7 h-7 rounded-lg bg-white border flex items-center justify-center transition-colors shadow-sm ${
picks[p.id].status === 'Approved' ? 'text-emerald-500 hover:bg-emerald-50 hover:text-emerald-600 border-emerald-100' :
picks[p.id].status === 'Rejected' ? 'text-rose-500 hover:bg-rose-50 hover:text-rose-600 border-rose-100' :
'text-amber-500 hover:bg-amber-50 hover:text-amber-600 border-amber-100'
}`}><X size={14} /></button>
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className={`px-1.5 py-0.5 bg-white border transition-colors shadow-sm ${
picks[p.id].status === 'Approved' ? 'text-emerald-500 border-emerald-200' :
picks[p.id].status === 'Rejected' ? 'text-rose-500 border-rose-200' :
'text-amber-500 border-amber-200'
}`}><X size={12} /></button>
</div>
</div>
) : (
<button
onClick={(e) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[11px] font-bold transition-all bg-white text-purple-700 hover:bg-purple-600 hover:text-white hover:shadow-md border border-purple-200 hover:border-purple-600"
className="w-full bg-[#f8fafc] hover:bg-[#662582] text-slate-600 hover:text-white border border-slate-200 hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
<Plus size={14} /> Add to Store
<Plus size={12} /> Add to Store
</button>
)}
</div>
@@ -668,40 +642,41 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
) : (
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!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-4 pb-8`}>
{finalFilteredInventory.map((it, i) => (
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
{/* Image Bento Block */}
<div className="w-full h-28 bg-zinc-50 relative overflow-hidden shrink-0">
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover 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 key={it.id || i} onClick={() => setSelectedProduct(it)} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-none flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden cursor-pointer">
{/* 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={it.image} alt={it.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 pointer-events-none" />
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-md border border-slate-200 px-1.5 py-0.5 rounded shadow-sm flex items-center gap-1 z-10" style={{ color: it.color }}>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
<div className="absolute top-2 right-2 bg-slate-900 text-white px-1.5 py-0.5 shadow-sm flex items-center gap-1 z-10 text-[9px] font-extrabold uppercase rounded-none" style={{ backgroundColor: it.color }}>
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
<span className="text-[8px] font-extrabold uppercase tracking-wider">{it.label}</span>
</div>
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1">
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
it.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
it.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
it.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
'bg-rose-100 text-rose-800'
}`}>
<span className="px-1.5 py-0.5 text-[8px] font-extrabold uppercase shadow-sm bg-purple-100 text-purple-800 rounded-none">
{it.category.split(' / ')[0]}
</span>
</div>
</div>
{/* Details Bento Block */}
{/* Content Section */}
<div className="p-3 flex flex-col flex-1 gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{it.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
</div>
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2" title={it.name}>{it.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
</div>
<div className="mt-auto bg-slate-50/80 rounded-xl p-2.5 border border-slate-100 text-center">
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Live Stock</span>
<span className="font-extrabold font-mono text-sm" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
<div className="mt-auto pt-2 border-t border-slate-100/50 flex flex-col gap-2">
<div className="flex items-center justify-between text-[10px] text-slate-500 font-medium">
<span>Unit: <strong className="text-slate-800 font-semibold">{it.unit || 'Pc'}</strong></span>
<span>Stock: <strong className="font-extrabold font-mono text-slate-900">{(it.closing ?? 0).toLocaleString('en-IN')}</strong></span>
</div>
<button
className="w-full bg-[#f8fafc] group-hover:bg-[#662582] text-slate-600 group-hover:text-white border border-slate-200 group-hover:border-[#662582] px-3 py-2 rounded-none text-[10px] font-bold uppercase tracking-wider transition-all shadow-sm flex items-center justify-center gap-1.5 cursor-pointer"
>
Stock Level: {(it.closing ?? 0).toLocaleString('en-IN')}
</button>
</div>
</div>
</div>

View File

@@ -11,36 +11,66 @@
* scoped to a single tenant's own Settings page.
*/
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import React, { useState } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import Header from './Header';
import AdminConsole from './AdminConsole';
import CatalogueBrowser from './CatalogueBrowser';
import SuperAdminSidebar, { type SuperAdminNavItem } from './SuperAdminSidebar';
import { UserPlus, Box } from 'lucide-react';
import type { AuthUser } from '../services/auth';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
interface SuperAdminPageProps {
user: AuthUser;
onLogout: () => void;
}
const NAV_ITEMS: SuperAdminNavItem[] = [
{ id: 'onboarding', label: 'Onboard Tenant', icon: UserPlus },
{ id: 'catalogue', label: 'Global Catalogue', icon: Box },
];
export default function SuperAdminPage({ user, onLogout }: SuperAdminPageProps) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const location = useLocation();
const profile = { name: user.name, role: 'Super Admin', email: user.email };
const tenantId = user.tenantid || FIESTA_TENANT_ID;
const currentPath = location.pathname.split('/').pop();
const currentItem = NAV_ITEMS.find(item => item.id === currentPath) || NAV_ITEMS[0];
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-[#f8fafc] text-[#0f172a] font-sans antialiased overflow-x-hidden">
<Header
isSidebarOpen={false}
onToggleSidebar={() => {}}
isSidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onHelpClick={() => {}}
onLogoutClick={onLogout}
profile={profile}
storeContext={{
storeName: currentItem.label,
icon: currentItem.icon
}}
/>
{/* Header is fixed (h-16); pt-16 keeps content from rendering under it. */}
<main className="pt-16 p-4 md:p-6 max-w-6xl mx-auto">
<Routes>
<Route path="onboarding" element={<AdminConsole showHeader />} />
<Route path="*" element={<Navigate to="onboarding" replace />} />
</Routes>
</main>
<div className="flex pt-16 w-full max-w-[100vw]">
<SuperAdminSidebar
items={NAV_ITEMS}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-16'} min-h-[calc(100vh-64px)]`}>
<div className="w-full p-4 md:p-6 transition-all duration-300">
<Routes>
<Route path="onboarding" element={<AdminConsole showHeader />} />
<Route path="catalogue" element={<CatalogueBrowser tenantid={tenantId} locationid={FIESTA_PRIMARY_LOCATION_ID} />} />
<Route path="*" element={<Navigate to="onboarding" replace />} />
</Routes>
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { type LucideIcon } from 'lucide-react';
import { NavLink } from 'react-router-dom';
/** One entry in the super admin sidebar. */
export interface SuperAdminNavItem {
id: string;
label: string;
icon: LucideIcon;
}
interface SuperAdminSidebarProps {
items: SuperAdminNavItem[];
/** Collapsed → icon-only rail; expanded → labels visible. */
isOpen: boolean;
onClose: () => void;
}
/**
* The Super Admin page's left rail. Visually identical to the admin `Sidebar`.
*/
export default function SuperAdminSidebar({ items, isOpen, onClose }: SuperAdminSidebarProps) {
return (
<>
{/* Mobile Overlay */}
{isOpen && (
<div
className="md:hidden fixed inset-0 bg-black/50 z-40 backdrop-blur-sm"
onClick={onClose}
/>
)}
<aside
className={`fixed left-0 top-0 h-screen bg-[#662582] border-r border-[#662582] text-white flex flex-col py-xl pt-16 z-40 transition-all duration-300 ${
isOpen ? 'translate-x-0 w-64' : '-translate-x-full md:translate-x-0 md:w-16'
}`}
>
{/* Main Navigation Sidebar Links */}
<nav className="flex-1 space-y-1 overflow-y-auto px-xs">
{items.map((item) => {
const IconComponent = item.icon;
return (
<NavLink
key={item.id}
to={`/superadmin/${item.id}`}
title={item.label}
className={({ isActive }) => `w-full flex items-center py-3 rounded-none text-left transition-all duration-200 cursor-pointer ${
isOpen ? 'gap-md px-md border-l-4' : 'justify-center px-0'
} ${
isActive
? 'bg-black/20 text-white font-semibold' + (isOpen ? ' border-white' : '')
: 'text-purple-200 hover:bg-white/10 hover:text-white' + (isOpen ? ' border-transparent' : '')
}`}
>
{({ isActive }) => (
<>
<IconComponent size={18} className={isActive ? 'text-white' : 'text-purple-300'} />
{isOpen && <span className="font-sans text-sm font-medium">{item.label}</span>}
</>
)}
</NavLink>
);
})}
</nav>
</aside>
</>
);
}

View File

@@ -77,17 +77,52 @@ export async function getImportedCatalogueRefs(tenantid: number, brand?: string)
}
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
const res = await fetch(`${API_BASE}/products/importcatalogueproduct`, {
try {
const res = await fetch(`${API_BASE}/products/importcatalogueproduct`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(items),
});
if (res.ok) {
const json = await res.json();
if (json && typeof json === 'object' && 'status' in json && json.status === false) {
console.warn("importcatalogueproduct backend notice, falling back to createproductlocation:", json.message);
return await fallbackCreateProductLocation(items);
}
return json;
}
} catch (err) {
console.warn("importcatalogueproduct error, falling back to createproductlocation:", err);
}
return await fallbackCreateProductLocation(items);
}
async function fallbackCreateProductLocation(items: ImportCatalogueProductRequest[]) {
const payloads = items.map(item => ({
tenantid: item.tenantid,
locationid: item.locationid,
productid: item.catalogueid,
quantity: item.quantity || 1,
stocktype: item.stocktype || "in",
status: item.status || "Active",
retailprice: item.retailprice || 0,
productcost: item.productcost || 0,
taxpercent: item.taxpercent || 0
}));
const res = await fetch(`${API_BASE}/products/createproductlocation`, {
method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify(items),
body: JSON.stringify(payloads),
});
if (!res.ok) throw new Error("Failed to import products");
const json = await res.json();
if (json && typeof json === 'object' && 'status' in json && !json.status) {
throw new Error(json.message || "Failed to import products");
if (!res.ok) {
let errText = "";
try { errText = await res.text(); } catch (_) {}
throw new Error(errText || `Failed to add product to catalogue (${res.status})`);
}
return json;
return await res.json();
}
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {

View File

@@ -941,14 +941,9 @@ export function useFiestaCreateProductLocation() {
return useMutation({
mutationFn: (input: CreateProductLocationInput) => createProductLocation(input),
onSuccess: (_, variables) => {
// Invalidate the store catalogue so it fetches the new item (prefix match)
qc.invalidateQueries({
queryKey: ['fiesta', 'productLocations']
});
// Also invalidate productStocks so the Admin Catalogue UI updates
qc.invalidateQueries({
queryKey: ['fiesta', 'productStocks']
});
qc.invalidateQueries({ queryKey: ['catalogue', 'imported'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'productStocks'] });
},
onError: (error: any) => {
alert(`Error adding product: ${error.message}`);
@@ -961,9 +956,9 @@ export function useFiestaDeleteProductLocation() {
return useMutation({
mutationFn: (input: DeleteProductLocationInput) => deleteProductLocation(input),
onSuccess: (_, variables) => {
qc.invalidateQueries({
queryKey: ['fiesta', 'productLocations']
});
qc.invalidateQueries({ queryKey: ['catalogue', 'imported'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
qc.invalidateQueries({ queryKey: ['fiesta', 'productStocks'] });
},
});
}