changes on global catalogue
This commit is contained in:
@@ -20,12 +20,14 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-react';
|
||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity } from 'lucide-react';
|
||||
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { categoryName } from '../services/fiestaMappers';
|
||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||
import AwaitingApi from './AwaitingApi';
|
||||
import { SlideDrawer } from './consoleUi';
|
||||
import { useCompare } from '../contexts/CompareContext';
|
||||
|
||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||
|
||||
@@ -52,10 +54,13 @@ function catBadgeClass(category: string): string {
|
||||
}
|
||||
|
||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
||||
const { selectedProducts, toggleProduct, setIsComparing } = useCompare();
|
||||
const tenantid = tenantId;
|
||||
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('ALL');
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
|
||||
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
||||
const [notice, setNotice] = useState(false);
|
||||
|
||||
// The admin-curated catalogue (what the user is allowed to pick from).
|
||||
@@ -107,7 +112,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
const inventory = useMemo(
|
||||
() =>
|
||||
(stockQ.data ?? []).map((r: Row) => {
|
||||
const closing = fnum(r.closing);
|
||||
const closing = fnum(r.closing) ?? 0;
|
||||
return {
|
||||
id: fstr(r.productid),
|
||||
name: fstr(r.productname) || 'Unnamed product',
|
||||
@@ -126,15 +131,33 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term));
|
||||
}, [inventory, search]);
|
||||
|
||||
const categories = useMemo(() => [...new Set(products.map((p) => p.category))].sort(), [products]);
|
||||
const categories = useMemo(() => [...new Set(products.map((p) => p.category.split(' / ')[0]))].sort(), [products]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
return products.filter((p) => {
|
||||
if (category !== 'ALL' && p.category !== category) return false;
|
||||
const pCat = p.category.split(' / ')[0];
|
||||
if (selectedCategories.length > 0 && !selectedCategories.includes(pCat)) return false;
|
||||
if (!term) return true;
|
||||
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
||||
});
|
||||
}, [products, search, category]);
|
||||
}, [products, search, selectedCategories]);
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
setSelectedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]);
|
||||
};
|
||||
|
||||
const toggleStockHealth = (health: string) => {
|
||||
setStockHealthFilter(prev => prev.includes(health) ? prev.filter(h => h !== health) : [...prev, health]);
|
||||
};
|
||||
|
||||
const finalFilteredInventory = useMemo(() => {
|
||||
return filteredInventory.filter(it => {
|
||||
if (selectedCategories.length > 0 && !selectedCategories.includes(it.category.split(' / ')[0])) return false;
|
||||
if (stockHealthFilter.length > 0 && !stockHealthFilter.includes(it.label)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [filteredInventory, selectedCategories, stockHealthFilter]);
|
||||
|
||||
// ── Integration point ──────────────────────────────────────────────────────────
|
||||
// Replace with the real request/stock POST (selected productids + quantities),
|
||||
@@ -143,13 +166,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Product Catalogue</h1>
|
||||
<p className="text-zinc-500 text-xs mt-1">
|
||||
Products your admin published for <span className="font-semibold text-[#662582]">{storeName}</span> — choose what you need and set quantities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
|
||||
@@ -171,231 +188,417 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
||||
<div className="relative w-full md:w-80 md:shrink-0">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#662582] transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"><X size={13} /></button>
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start mt-6">
|
||||
{/* Sticky Sidebar Filter */}
|
||||
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Search size={14} className="text-[#662582]" /> Search
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Product name or SKU..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-3 pr-8 py-2.5 border border-[#e2e8f0] rounded-xl text-xs font-semibold outline-none bg-slate-50 focus:bg-white focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all shadow-sm"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full h-px bg-slate-100" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-[#662582]" /> Categories
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{categories.map((c) => (
|
||||
<label key={c} className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.includes(c)}
|
||||
onChange={() => toggleCategory(c)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedCategories.includes(c) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
|
||||
{selectedCategories.includes(c) && <Check size={10} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700 group-hover:text-slate-900">{c}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'inventory' && (
|
||||
<>
|
||||
<div className="w-full h-px bg-slate-100" />
|
||||
<div>
|
||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||
<Activity size={14} className="text-[#662582]" /> Stock Health
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
|
||||
<label key={h} className="flex items-center gap-2.5 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stockHealthFilter.includes(h)}
|
||||
onChange={() => toggleStockHealth(h)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${stockHealthFilter.includes(h) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
|
||||
{stockHealthFilter.includes(h) && <Check size={10} className="text-white" strokeWidth={3} />}
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700 group-hover:text-slate-900">{h}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && (
|
||||
<button
|
||||
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
|
||||
className="mt-2 w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer"
|
||||
>
|
||||
Clear All Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{view === 'catalogue' && categories.length > 0 && (
|
||||
<div className="flex items-center gap-sm flex-wrap">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"><Layers size={13} className="text-[#662582]" /> Filter</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#662582] cursor-pointer"
|
||||
>
|
||||
<option value="ALL">All categories</option>
|
||||
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Product Grid Area */}
|
||||
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
||||
{/* Mobile Search Input (Visible only on mobile) */}
|
||||
<div className="md:hidden mb-4">
|
||||
<div className="relative">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-9 py-3 border border-[#e2e8f0] rounded-xl text-sm font-semibold outline-none bg-white focus:ring-2 focus:ring-purple-500/20 shadow-sm"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
||||
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
||||
|
||||
{/* Results Summary & Toolbar */}
|
||||
<div className="flex justify-between items-end mb-4 px-1">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-900">
|
||||
{view === 'catalogue' ? 'Global Catalogue' : 'My Inventory'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
|
||||
</p>
|
||||
</div>
|
||||
{/* Can add sorting dropdown here if needed */}
|
||||
</div>
|
||||
|
||||
{/* ── Browse Catalogue ── */}
|
||||
{view === 'catalogue' && (
|
||||
products.length === 0 ? (
|
||||
<CenterState
|
||||
icon={<PackageSearch size={34} />}
|
||||
title="No products published yet"
|
||||
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
|
||||
/>
|
||||
) : filtered.length === 0 ? (
|
||||
<CenterState
|
||||
icon={<Boxes size={34} />}
|
||||
title="No products match your search"
|
||||
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
||||
{filtered.map((p) => {
|
||||
const stocked = inStore.has(p.id);
|
||||
const picked = picks[p.id] != null;
|
||||
return (
|
||||
<div key={p.id} onClick={() => setSelectedProduct(p)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer">
|
||||
{/* Image Bento Block */}
|
||||
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
||||
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
||||
|
||||
{stocked && (
|
||||
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10">
|
||||
<CheckCircle2 size={12} className="text-emerald-500" />
|
||||
<span className="text-[9px] font-black uppercase text-emerald-700 tracking-wider">In Store</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(p.category)}`}>
|
||||
{p.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Details Bento Block */}
|
||||
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{p.name}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
|
||||
</div>
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleProduct({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
sku: p.sku,
|
||||
category: p.category.split(' / ')[0],
|
||||
price: p.price,
|
||||
image: p.image,
|
||||
unit: p.unit
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="relative flex items-center justify-center cursor-pointer group/cb">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer appearance-none w-5 h-5 bg-slate-50 border border-slate-300 rounded-md checked:bg-[#662582] checked:border-[#662582] transition-all"
|
||||
checked={selectedProducts.some(sp => String(sp.id) === String(p.id))}
|
||||
readOnly
|
||||
/>
|
||||
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto bg-slate-50 rounded-xl p-2.5 border border-slate-100 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
|
||||
<span className="font-extrabold text-slate-800 font-mono text-sm">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
|
||||
<span className="font-bold text-slate-600 text-[11px] 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 bg-emerald-50 rounded-xl border border-emerald-200 shadow-inner">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-emerald-100 text-emerald-600"><Check size={14} /></span>
|
||||
<div className="flex items-center gap-1 bg-white rounded-lg p-0.5 shadow-sm border border-emerald-100">
|
||||
<button onClick={(e) => { e.stopPropagation(); setPickQty(p.id, picks[p.id] - 1); }} className="w-6 h-6 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-600 font-bold flex items-center justify-center transition-colors"><Minus size={12} /></button>
|
||||
<span className="w-6 text-center font-mono font-bold text-xs text-slate-900">{picks[p.id]}</span>
|
||||
<button onClick={(e) => { e.stopPropagation(); setPickQty(p.id, picks[p.id] + 1); }} className="w-6 h-6 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-600 font-bold flex items-center justify-center transition-colors"><Plus size={12} /></button>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className="w-7 h-7 rounded-lg text-rose-500 hover:bg-rose-50 hover:text-rose-600 bg-white border border-rose-100 flex items-center justify-center transition-colors shadow-sm ml-1"><X size={14} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); togglePick(p.id); }}
|
||||
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"
|
||||
>
|
||||
<Plus size={14} /> Add to Store
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── My Store Inventory ── */}
|
||||
{view === 'inventory' && (
|
||||
stockQ.isLoading ? (
|
||||
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
|
||||
) : !locationid ? (
|
||||
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
|
||||
) : inventory.length === 0 ? (
|
||||
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
|
||||
) : finalFilteredInventory.length === 0 ? (
|
||||
<CenterState
|
||||
icon={<Boxes size={34} />}
|
||||
title="No stock matches your filters"
|
||||
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
|
||||
action={
|
||||
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
|
||||
<X size={13} /> Clear filters
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{finalFilteredInventory.map((it, i) => (
|
||||
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer">
|
||||
{/* Image Bento Block */}
|
||||
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
||||
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
||||
|
||||
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow 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 }} />
|
||||
<span className="text-[9px] font-black uppercase tracking-wider">{it.label}</span>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(it.category)}`}>
|
||||
{it.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Details Bento Block */}
|
||||
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{it.name}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
|
||||
</div>
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleProduct({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
sku: it.sku,
|
||||
category: it.category.split(' / ')[0],
|
||||
price: it.price,
|
||||
image: it.image,
|
||||
closing: it.closing,
|
||||
label: it.label,
|
||||
color: it.color,
|
||||
unit: it.unit
|
||||
});
|
||||
}}
|
||||
>
|
||||
<label className="relative flex items-center justify-center cursor-pointer group/cb">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer appearance-none w-5 h-5 bg-slate-50 border border-slate-300 rounded-md checked:bg-[#662582] checked:border-[#662582] transition-all"
|
||||
checked={selectedProducts.some(sp => String(sp.id) === String(it.id))}
|
||||
readOnly
|
||||
/>
|
||||
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto bg-slate-50 rounded-xl p-3 border border-slate-100 text-center">
|
||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-1">Live Stock</span>
|
||||
<span className="font-black font-mono text-xl" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Browse Catalogue ── */}
|
||||
{view === 'catalogue' && (
|
||||
products.length === 0 ? (
|
||||
<CenterState
|
||||
icon={<PackageSearch size={34} />}
|
||||
title="No products published yet"
|
||||
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
|
||||
/>
|
||||
) : filtered.length === 0 ? (
|
||||
<CenterState
|
||||
icon={<Boxes size={34} />}
|
||||
title="No products match your search"
|
||||
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||
action={
|
||||
<button
|
||||
onClick={() => { setSearch(''); setCategory('ALL'); }}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer"
|
||||
>
|
||||
<X size={13} /> Clear filters
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
||||
{filtered.map((p) => {
|
||||
const stocked = inStore.has(p.id);
|
||||
const picked = picks[p.id] != null;
|
||||
return (
|
||||
<div key={p.id} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
|
||||
<div className="flex gap-md">
|
||||
{/* Thumbnail with hover zoom */}
|
||||
<div className="w-32 h-32 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
|
||||
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
{stocked && (
|
||||
<span className="absolute top-1 right-1 inline-flex items-center justify-center w-4 h-4 rounded-full bg-emerald-500 text-white shadow" title="In your store"><CheckCircle2 size={10} /></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#662582] transition-colors">{p.name}</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{p.sku}</span>
|
||||
</div>
|
||||
{/* Category pill badge */}
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(p.category)}`}>
|
||||
{p.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
|
||||
<span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stocked-status row (mirrors the admin card's status line) */}
|
||||
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
|
||||
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${stocked ? 'text-emerald-600' : 'text-zinc-400'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${stocked ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
|
||||
{stocked ? 'In Your Store' : 'Not stocked yet'}
|
||||
</span>
|
||||
{p.unit && <span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* Pick action: quantity stepper when selected, else add button */}
|
||||
{picked ? (
|
||||
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#662582]"><Check size={12} /> Selected</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPickQty(p.id, picks[p.id] - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Minus size={12} /></button>
|
||||
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{picks[p.id]}</span>
|
||||
<button onClick={() => setPickQty(p.id, picks[p.id] + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Plus size={12} /></button>
|
||||
<button onClick={() => togglePick(p.id)} title="Remove" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => togglePick(p.id)}
|
||||
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#662582] hover:text-purple-800 cursor-pointer"
|
||||
>
|
||||
<Plus size={13} /> Add to Store
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
|
||||
{view === 'inventory' && (
|
||||
stockQ.isLoading ? (
|
||||
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
|
||||
) : !locationid ? (
|
||||
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
|
||||
) : inventory.length === 0 ? (
|
||||
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
|
||||
) : filteredInventory.length === 0 ? (
|
||||
<CenterState
|
||||
icon={<Boxes size={34} />}
|
||||
title="No stock matches your search"
|
||||
sub="Try a different keyword to find an item in your store."
|
||||
action={
|
||||
<button onClick={() => setSearch('')} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
|
||||
<X size={13} /> Clear search
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
||||
{filteredInventory.map((it, i) => (
|
||||
<div key={it.id || i} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
|
||||
<div className="flex gap-md">
|
||||
{/* Thumbnail with status corner dot */}
|
||||
<div className="w-32 h-32 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
|
||||
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
||||
<span className="absolute top-1 right-1 w-3 h-3 rounded-full border-2 border-white shadow" style={{ background: it.color }} title={it.label} />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#662582] transition-colors">{it.name}</h4>
|
||||
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{it.sku}</span>
|
||||
</div>
|
||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(it.category)}`}>
|
||||
{it.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div>
|
||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">In Stock</span>
|
||||
<span className="font-black font-mono text-xs" style={{ color: it.color }}>{it.closing.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Status</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>{it.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status line (mirrors the catalogue card's footer) */}
|
||||
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight" style={{ color: it.color }}>
|
||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
|
||||
{it.label}
|
||||
</span>
|
||||
<span className="text-[9px] text-zinc-400 font-semibold">{it.category.split(' / ')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Selection action bar ── */}
|
||||
{/* ── Selection FAB ── */}
|
||||
{view === 'catalogue' && pickCount > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(680px,calc(100vw-2rem))]">
|
||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
||||
{notice ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</span>
|
||||
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||
</div>
|
||||
<AwaitingApi label="Submitting your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||
<div className="fixed bottom-6 right-6 z-[120]">
|
||||
{notice ? (
|
||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-5 py-4 w-80 animate-in slide-in-from-bottom-4 duration-300">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
|
||||
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold truncate">{pickCount} product{pickCount > 1 ? 's' : ''} · {Object.values(picks).reduce((a: number, b: number) => a + b, 0)} units</p>
|
||||
<p className="text-[10px] text-purple-200">Selected for {storeName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => setPicks({})} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
|
||||
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#662582] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
|
||||
<Check size={13} /> Request for Store
|
||||
</button>
|
||||
</div>
|
||||
<AwaitingApi label="Submitting to store" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={commitSelectionToStore}
|
||||
className="flex items-center gap-3 bg-gradient-to-r from-purple-700 to-[#662582] text-white px-6 py-4 rounded-full shadow-[0_8px_30px_rgba(102,37,130,0.4)] hover:shadow-[0_12px_40px_rgba(102,37,130,0.6)] hover:-translate-y-1 transition-all duration-300 cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-white/20 text-white font-bold text-sm shadow-inner group-hover:scale-110 transition-transform">
|
||||
{pickCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-sm font-bold uppercase tracking-wider">Save Picks</span>
|
||||
<span className="block text-[10px] text-purple-200">{Object.values(picks).reduce((a: number, b: number) => a + b, 0)} items total</span>
|
||||
</div>
|
||||
<div className="ml-2 w-8 h-8 rounded-full bg-white text-purple-700 flex items-center justify-center shadow-md">
|
||||
<Check size={16} strokeWidth={3} />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ADD/EDIT PRODUCT MODAL (simulated) */}
|
||||
<SlideDrawer
|
||||
isOpen={!!selectedProduct}
|
||||
onClose={() => setSelectedProduct(null)}
|
||||
title={view === 'catalogue' ? 'Catalogue Product Details' : 'Store Inventory Status'}
|
||||
>
|
||||
{selectedProduct && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="w-full h-64 bg-slate-50 rounded-2xl overflow-hidden border border-slate-100 relative">
|
||||
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-full h-full object-cover" />
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(selectedProduct.category)}`}>
|
||||
{selectedProduct.category.split(' / ')[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{selectedProduct.name}</h3>
|
||||
<p className="text-xs font-bold text-slate-400 font-mono tracking-wider">{selectedProduct.sku}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Pricing</span>
|
||||
<span className="text-2xl font-black font-mono text-slate-900">
|
||||
{selectedProduct.price > 0 ? `₹${selectedProduct.price.toLocaleString('en-IN')}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Unit</span>
|
||||
<span className="text-sm font-bold text-slate-600 bg-white px-3 py-1 rounded-lg border border-slate-200 shadow-sm">{selectedProduct.unit || 'Piece'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedProduct.closing !== undefined && (
|
||||
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center shadow-inner" style={{ backgroundColor: selectedProduct.color, color: 'white' }}>
|
||||
<Boxes size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-purple-400 block mb-0.5">Live Stock Level</span>
|
||||
<span className="text-sm font-black text-purple-900 flex items-center gap-1.5">
|
||||
{selectedProduct.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-2xl font-black font-mono text-purple-700">{selectedProduct.closing.toLocaleString('en-IN')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-slate-500 leading-relaxed mt-2 border-t border-slate-100 pt-5">
|
||||
This is a shared product available in the Global Catalogue. Make sure to keep adequate stock to prevent customer order cancellations due to unavailability.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SlideDrawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -416,28 +619,11 @@ function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; titl
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight">{title}</h3>
|
||||
{sub && <p className="text-sm text-zinc-500 mt-2 max-w-md leading-relaxed">{sub}</p>}
|
||||
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight text-center">{title}</h3>
|
||||
{sub && <p className="text-sm text-zinc-500 mt-2 text-center whitespace-nowrap overflow-hidden text-ellipsis">{sub}</p>}
|
||||
|
||||
{action && <div className="mt-6">{action}</div>}
|
||||
|
||||
{/* Ghost preview cards — hint at what will appear here */}
|
||||
<div className="mt-9 flex items-end justify-center gap-3 sm:gap-4 opacity-70 select-none" aria-hidden>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-24 sm:w-28 rounded-2xl border border-[#eceef2] bg-white/70 p-3 shadow-sm ${i === 1 ? 'scale-110' : 'opacity-80'}`}
|
||||
>
|
||||
<div className="w-full h-10 rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-200/70 mb-2" />
|
||||
<div className="h-2 w-3/4 rounded-full bg-zinc-200 mb-1.5" />
|
||||
<div className="h-2 w-1/2 rounded-full bg-zinc-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> Syncs automatically
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user