dispatch testing
This commit is contained in:
11
src/App.tsx
11
src/App.tsx
@@ -595,8 +595,6 @@ 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),
|
||||
@@ -604,8 +602,6 @@ export default function App() {
|
||||
? LayoutDashboard
|
||||
: currentSection === 'inventory'
|
||||
? Layers
|
||||
: currentSection === 'catalogue'
|
||||
? Box
|
||||
: currentSection === 'stores'
|
||||
? Store
|
||||
: currentSection === 'reports'
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,124 +58,196 @@ 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 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="relative w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
{(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 products..."
|
||||
placeholder="Search catalogue..."
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
{keyword && (
|
||||
<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'
|
||||
}`}
|
||||
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"
|
||||
>
|
||||
All Brands
|
||||
<X size={10} />
|
||||
</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="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>
|
||||
Loading catalogue...
|
||||
<span className="text-xs font-bold uppercase tracking-widest">Loading catalogue...</span>
|
||||
</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 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="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
<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 = importedRefs.has(`${p.brand}:${p.id}`);
|
||||
const isImported = storeCat.has(String(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">
|
||||
<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 group-hover:scale-105 transition-transform duration-300" />
|
||||
<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>
|
||||
|
||||
<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}>
|
||||
<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}
|
||||
</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>
|
||||
</h4>
|
||||
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">ID: {p.id}</p>
|
||||
</div>
|
||||
|
||||
{!isImported && (
|
||||
<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={() => 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"
|
||||
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"
|
||||
>
|
||||
<DownloadCloud size={16} />
|
||||
Import to Store
|
||||
<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>
|
||||
@@ -177,8 +256,10 @@ export default function CatalogueBrowser({ tenantid, locationid }: CatalogueBrow
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importingProduct && (
|
||||
<ImportProductModal
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
{/* 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>
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
) : (
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={e => {
|
||||
setCategoryId(e.target.value);
|
||||
setSubcategoryId(''); // subcategory list is about to change
|
||||
<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 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
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 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} />
|
||||
<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>
|
||||
</SlideDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
</span>
|
||||
<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 && (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<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="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="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>
|
||||
<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
|
||||
{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>
|
||||
</div>
|
||||
) : 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,44 +905,68 @@ 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>
|
||||
{/* 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="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:border-[#662582]"
|
||||
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) || (selectedAdminProduct.unitsSold > 0 ? Math.round(selectedAdminProduct.revenue / selectedAdminProduct.unitsSold) : 0), unit: selectedAdminProduct.exposure, qty: 1, status: 'Active' });
|
||||
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.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"
|
||||
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"
|
||||
>
|
||||
<Trash2 size={18} strokeWidth={2} /> Delete from Local Catalogue
|
||||
<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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
<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="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="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>
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
73
src/components/SuperAdminSidebar.tsx
Normal file
73
src/components/SuperAdminSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -77,17 +77,52 @@ export async function getImportedCatalogueRefs(tenantid: number, brand?: string)
|
||||
}
|
||||
|
||||
export async function importCatalogueProducts(items: ImportCatalogueProductRequest[]) {
|
||||
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) throw new Error("Failed to import products");
|
||||
if (res.ok) {
|
||||
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 (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(payloads),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let errText = "";
|
||||
try { errText = await res.text(); } catch (_) {}
|
||||
throw new Error(errText || `Failed to add product to catalogue (${res.status})`);
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function removeFromStoreCatalogue(tenantid: number, locationid: number, productid: number) {
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user