dispatch page

This commit is contained in:
Gokul
2026-06-12 14:45:06 +05:30
parent d8c1517239
commit 5378f2df1f
34 changed files with 4451 additions and 1744 deletions

View File

@@ -4,57 +4,35 @@
*/
/**
* Inventory & Catalog — the store user's page.
* Inventory & Catalogue — the store user's page.
*
* Flow: the manager curates an assortment from the global catalog; the store user
* sees ONLY that manager-selected catalog (never the global one) and chooses which
* products to stock in their own store. Two tabs:
* • Browse Catalog — the manager-approved products, each addable to the store.
* • My Store Inventory — what's currently stocked at this outlet (live stock).
* Product-management flow (3 tiers):
* 1. Admin adds products to the GLOBAL catalogue and selects which ones (+ qty)
* to publish — that's the shared "store catalogue" (services/storeCatalogue).
* 2. The user sees ONLY that admin-curated catalogue here (never the global one)
* and chooses which products they need, each with their own quantity.
* 3. Those picks are the user's request for their store.
*
* The "manager-selected catalog" is sourced from the tenant master catalog
* (getMasterCatalog) for now — see CATALOG_SOURCE below; swap that one hook for
* the approved-products endpoint once it exists.
*
* Stocking a product at a location needs a write endpoint that isn't built yet,
* so selections are kept locally (persisted per store) and marked "pending sync".
* `commitSelectionToStore()` is the single integration point: replace its body
* with the real mutation when the backend is ready.
* The catalogue source is the shared store catalogue (localStorage bridge for now;
* backend: GET /products/getlocationproducts). The user's picks persist per store
* and `commitSelectionToStore()` is the single backend integration point
* (POST /products/createproductlocation / a stock-request endpoint).
*/
import React, { useEffect, useMemo, useState } from 'react';
import {
Search, Boxes, Layers, Plus, Check, CheckCircle2, X, Tag, Store, PackageSearch, AlertTriangle,
} from 'lucide-react';
import {
useFiestaMasterCatalog,
useFiestaStockStatement,
useFiestaProductCategories,
useFiestaProductSubcategories,
FIESTA_TENANT_ID,
} from '../services/fiestaQueries';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } 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';
const BRAND = '#581c87';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
interface StoreCatalogViewProps {
locationid?: number;
storeName?: string;
}
interface CatalogProduct {
id: string;
name: string;
image: string;
category: string;
categoryid: number;
subcategoryid: number;
subcategoryname: string;
price: number;
unit: string;
tenantId?: number;
}
function stockStatus(closing: number): { label: string; color: string } {
@@ -64,56 +42,68 @@ function stockStatus(closing: number): { label: string; color: string } {
return { label: 'Healthy', color: '#10b981' };
}
export default function StoreCatalogView({ locationid, storeName = 'your store' }: StoreCatalogViewProps) {
const tenantid = FIESTA_TENANT_ID;
const [view, setView] = useState<'catalog' | 'inventory'>('catalog');
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
function catBadgeClass(category: string): string {
const c = category.toLowerCase();
if (c.startsWith('staple')) return 'bg-amber-50 text-amber-600 border border-amber-100';
if (c.includes('grocer')) return 'bg-emerald-50 text-emerald-600 border border-emerald-100';
if (c.includes('beverage')) return 'bg-sky-50 text-sky-600 border border-sky-100';
return 'bg-rose-50 text-rose-600 border border-rose-100';
}
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
const tenantid = tenantId;
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
const [search, setSearch] = useState('');
const [categoryid, setCategoryid] = useState(0);
const [subcategoryid, setSubcategoryid] = useState(0);
const [category, setCategory] = useState('ALL');
const [notice, setNotice] = useState(false);
// Selections "to stock at this store" — persisted per outlet so choices survive
// a refresh until the backend write exists.
const storageKey = `nearledaily.catalog.selected.${locationid ?? 'na'}`;
const [selected, setSelected] = useState<Set<string>>(() => {
// The admin-curated catalogue (what the user is allowed to pick from).
const storeCat = useStoreCatalogue();
const products = useMemo(
() =>
storeCat.items.map((it) => ({
id: it.productid,
name: it.name,
sku: it.sku || `SKU-${it.productid}`,
image: it.image || PLACEHOLDER,
category: it.category || 'General',
price: it.price,
unit: it.unit,
adminQty: it.qty,
})),
[storeCat.items],
);
// The user's picks: productid → quantity they need. Persisted per store.
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
const [picks, setPicks] = useState<Record<string, number>>(() => {
try {
const raw = localStorage.getItem(storageKey);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
} catch {
return new Set();
return {};
}
});
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ }
}, [selected, storageKey]);
try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ }
}, [picks, storageKey]);
// ── Data ──────────────────────────────────────────────────────────────────────
// CATALOG_SOURCE: the manager-selected assortment. Swap this hook for the
// approved-products endpoint when it's available; the rest of the page is agnostic.
const catalogQ = useFiestaMasterCatalog({ tenantid, subcategoryid: subcategoryid || undefined, pagesize: 200 });
const togglePick = (id: string) => {
setNotice(false);
setPicks((prev) => {
const next = { ...prev };
if (next[id] != null) delete next[id];
else next[id] = 1;
return next;
});
};
const setPickQty = (id: string, qty: number) => setPicks((prev) => ({ ...prev, [id]: Math.max(1, Math.round(qty) || 1) }));
const pickCount = Object.keys(picks).length;
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
const categoriesQ = useFiestaProductCategories();
const subcategoriesQ = useFiestaProductSubcategories({ categoryid, tenantid });
const products = useMemo<CatalogProduct[]>(
() =>
(catalogQ.data ?? []).map((r: Row) => ({
id: fstr(r.productid) || fstr(r.productname),
name: fstr(r.productname) || 'Unnamed product',
image: fstr(r.productimage) || PLACEHOLDER,
category: categoryName(fnum(r.categoryid)),
categoryid: fnum(r.categoryid),
subcategoryid: fnum(r.subcategoryid),
subcategoryname: fstr(r.subcategoryname),
price: fnum(r.retailprice) || fnum(r.productcost),
unit: `${fstr(r.productunit) || 'unit'} · ${fstr(r.unitvalue) || '1'}`,
})),
[catalogQ.data],
);
// Products already stocked at this store (by productid) — drives the "In Store" state.
const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
const inventory = useMemo(
() =>
(stockQ.data ?? []).map((r: Row) => {
@@ -121,6 +111,8 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
return {
id: fstr(r.productid),
name: fstr(r.productname) || 'Unnamed product',
sku: fstr(r.sku) || `SKU-${fstr(r.productid)}`,
image: fstr(r.productimage) || PLACEHOLDER,
category: categoryName(fnum(r.categoryid)),
closing,
...stockStatus(closing),
@@ -128,78 +120,46 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
}),
[stockQ.data],
);
const filteredInventory = useMemo(() => {
const term = search.toLowerCase();
if (!term) return inventory;
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 filtered = useMemo(() => {
const term = search.toLowerCase();
return products.filter((p) => {
if (categoryid && p.categoryid !== categoryid) return false;
if (category !== 'ALL' && p.category !== category) return false;
if (!term) return true;
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
});
}, [products, search, categoryid]);
// Categories come from the Fiesta product-categories endpoint; if it returns
// nothing, fall back to the categories present in the loaded catalog so the
// filter is never empty.
const categories = useMemo(() => {
const fromApi = (categoriesQ.data ?? [])
.map((c) => ({ id: fnum(c.categoryid), name: fstr(c.categoryname) || categoryName(fnum(c.categoryid)) }))
.filter((c) => c.id);
if (fromApi.length) return fromApi;
const seen = new Map<number, string>();
for (const p of products) if (p.categoryid && !seen.has(p.categoryid)) seen.set(p.categoryid, p.category);
return [...seen.entries()].map(([id, name]) => ({ id, name }));
}, [categoriesQ.data, products]);
// Subcategories: Fiesta endpoint as source of truth; fall back to the
// subcategories present in the loaded catalog for the selected category.
const subcategories = useMemo(() => {
const fromApi = (subcategoriesQ.data ?? [])
.map((s) => ({ id: fnum(s.subcategoryid), name: fstr(s.subcategoryname) || `Subcategory ${fnum(s.subcategoryid)}` }))
.filter((s) => s.id);
if (fromApi.length) return fromApi;
const seen = new Map<number, string>();
for (const p of products) {
if (categoryid && p.categoryid !== categoryid) continue;
if (p.subcategoryid && !seen.has(p.subcategoryid)) seen.set(p.subcategoryid, p.subcategoryname || `Subcategory ${p.subcategoryid}`);
}
return [...seen.entries()].map(([id, name]) => ({ id, name }));
}, [subcategoriesQ.data, products, categoryid]);
const toggle = (id: string) => {
setNotice(false);
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
}, [products, search, category]);
// ── Integration point ──────────────────────────────────────────────────────────
// Replace this body with the real mutation: POST the selected product ids to the
// store/location assortment (stock-entry) endpoint, then invalidate stockQ.
const commitSelectionToStore = () => {
setNotice(true);
};
// Replace with the real request/stock POST (selected productids + quantities),
// then invalidate stockQ.
const commitSelectionToStore = () => setNotice(true);
return (
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-24">
<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]">Inventory &amp; Catalog</h1>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Product Catalogue</h1>
<p className="text-zinc-500 text-xs mt-1">
Browse the products approved for your store and choose what to stock at <span className="font-semibold text-[#581c87]">{storeName}</span>.
Products your admin published for <span className="font-semibold text-[#581c87]">{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">
<button
onClick={() => setView('catalog')}
onClick={() => setView('catalogue')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold transition-all ${
view === 'catalog' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
view === 'catalogue' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
>
<Boxes size={14} /> Browse Catalog ({products.length})
<Boxes size={14} /> Browse Catalogue ({products.length})
</button>
<button
onClick={() => setView('inventory')}
@@ -217,96 +177,123 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder={view === 'catalog' ? 'Search catalog products…' : 'Search your stock…'}
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-[#581c87] 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>
<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>
{view === 'catalog' && (
{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-[#581c87]" /> Filter
</span>
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"><Layers size={13} className="text-[#581c87]" /> Filter</span>
<select
value={categoryid}
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }}
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-[#581c87] cursor-pointer"
>
<option value={0}>All categories</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
<option value="ALL">All categories</option>
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
{categoryid > 0 && subcategories.length > 0 && (
<select
value={subcategoryid}
onChange={(e) => setSubcategoryid(Number(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-[#581c87] cursor-pointer"
>
<option value={0}>All subcategories</option>
{subcategories.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)}
</div>
)}
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
{view === 'catalog' ? `${filtered.length} products` : `${inventory.length} stocked`}
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
</div>
</div>
{/* ── Browse Catalog ── */}
{view === 'catalog' && (
catalogQ.isLoading ? (
<CenterState icon={<PackageSearch size={26} />} title="Loading catalog…" />
) : catalogQ.isError ? (
<CenterState icon={<AlertTriangle size={26} />} title="Couldn't load the catalog" sub="Check your connection and try again." tone="error" />
{/* ── 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={26} />} title="No products found" sub="Your manager hasn't approved products matching this filter yet." />
<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-[#581c87] hover:bg-purple-800 transition shadow-sm cursor-pointer"
>
<X size={13} /> Clear filters
</button>
}
/>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-gutter">
<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 isSelected = selected.has(p.id);
const picked = picks[p.id] != null;
return (
<div key={p.id} className="group bg-white border border-[#e2e8f0] rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition-all flex flex-col">
<div className="relative h-28 w-full overflow-hidden bg-zinc-50">
<img src={p.image} alt={p.name} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
{stocked && (
<span className="absolute top-2 right-2 inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500 text-white text-[9px] font-bold uppercase tracking-wide shadow">
<CheckCircle2 size={10} /> In Store
</span>
)}
</div>
<div className="p-3 flex-1 flex flex-col">
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-[#581c87] mb-1">
<Tag size={9} /> {p.category}
</span>
<p className="font-bold text-xs text-[#0f172a] leading-snug line-clamp-2 min-h-[2rem]">{p.name}</p>
<div className="flex items-center justify-between mt-1.5 mb-3">
<span className="font-mono font-extrabold text-sm text-zinc-800">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
<span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>
<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-16 h-16 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-[#581c87] 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>
{stocked ? (
<button disabled className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-100 cursor-default flex items-center justify-center gap-1.5">
<CheckCircle2 size={13} /> Stocked
</button>
) : isSelected ? (
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-[#581c87] text-white hover:bg-purple-800 transition flex items-center justify-center gap-1.5 cursor-pointer">
<Check size={13} /> Selected
</button>
) : (
<button onClick={() => toggle(p.id)} className="mt-auto w-full py-2 rounded-xl text-[11px] font-bold bg-white text-[#581c87] border border-purple-200 hover:bg-purple-50 transition flex items-center justify-center gap-1.5 cursor-pointer">
<Plus size={13} /> Add to Store
</button>
)}
<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 className="text-right">
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Admin Stock</span>
<span className="font-black text-emerald-600 font-mono text-xs">{p.adminQty}{p.unit ? ` ${p.unit}` : ''}</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-[#581c87]"><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-[#581c87] hover:text-purple-800 cursor-pointer"
>
<Plus size={13} /> Add to Store
</button>
)}
</div>
);
})}
@@ -314,73 +301,98 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
)
)}
{/* ── My Store Inventory ── */}
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
{view === 'inventory' && (
<div className="bg-white border border-[#e2e8f0] rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="bg-[#f8fafc] border-b border-[#e2e8f0] text-[10px] uppercase tracking-wider text-zinc-400 font-bold">
<th className="px-4 py-3 text-left">#</th>
<th className="px-4 py-3 text-left">Product</th>
<th className="px-4 py-3 text-left">Category</th>
<th className="px-4 py-3 text-right">In Stock</th>
<th className="px-4 py-3 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9]">
{stockQ.isLoading ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">Loading your stock</td></tr>
) : !locationid ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No store linked to your account yet.</td></tr>
) : inventory.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-zinc-400">No products stocked yet add some from the catalog.</td></tr>
) : (
inventory.map((it, i) => (
<tr key={it.id || i} className="hover:bg-zinc-50/70 transition-colors">
<td className="px-4 py-3 font-mono text-zinc-400">{i + 1}</td>
<td className="px-4 py-3 font-bold text-[#0f172a]">{it.name}</td>
<td className="px-4 py-3 text-zinc-500">{it.category}</td>
<td className="px-4 py-3 text-right font-mono font-bold text-zinc-700">{it.closing.toLocaleString('en-IN')}</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center px-2.5 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>
</td>
</tr>
))
)}
</tbody>
</table>
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-[#581c87] 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-16 h-16 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-[#581c87] 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>
</div>
)
)}
{/* ── Selection action bar (sticky) ── */}
{view === 'catalog' && selected.size > 0 && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(640px,calc(100vw-2rem))]">
{/* ── Selection action bar ── */}
{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">{selected.size} product{selected.size > 1 ? 's' : ''} marked for {storeName}</span>
<button onClick={() => { setSelected(new Set()); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
<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="Adding products to your store" api="stock-entry API" compact className="bg-white/5 border-white/15 text-purple-100" />
<AwaitingApi label="Submitting your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
</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">{selected.size} product{selected.size > 1 ? 's' : ''} selected</p>
<p className="text-[10px] text-purple-200">Ready to stock at {storeName}</p>
<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={() => setSelected(new Set())} 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={() => 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-[#581c87] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
<Plus size={13} /> Add to Store
<Check size={13} /> Request for Store
</button>
</div>
</div>
@@ -392,12 +404,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store'
);
}
function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) {
function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; title: string; sub?: string; action?: React.ReactNode }) {
return (
<div className="bg-white border border-dashed border-[#e2e8f0] rounded-2xl p-12 text-center">
<div className={`mx-auto mb-3 flex items-center justify-center w-14 h-14 rounded-2xl ${tone === 'error' ? 'bg-rose-50 text-rose-500' : 'bg-zinc-100 text-zinc-400'}`}>{icon}</div>
<p className="font-bold text-sm text-zinc-700">{title}</p>
{sub && <p className="text-xs text-zinc-400 mt-1">{sub}</p>}
<div className="relative overflow-hidden bg-gradient-to-b from-white to-[#faf9ff] border border-[#eceef2] rounded-3xl px-6 py-16 sm:py-20 text-center shadow-sm">
{/* Soft decorative glows */}
<div className="pointer-events-none absolute -top-20 -right-20 w-60 h-60 rounded-full bg-purple-200/30 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-20 w-60 h-60 rounded-full bg-indigo-200/30 blur-3xl" />
<div className="relative flex flex-col items-center">
{/* Icon with halo */}
<div className="relative mb-5">
<span className="absolute inset-0 -m-3 rounded-full bg-purple-300/25 blur-xl" />
<span className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-[#581c87] to-indigo-500 text-white shadow-lg shadow-purple-500/20 ring-8 ring-white">
{icon}
</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>}
{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>
);
}