update on the user page regardinga the dispatch and order page and the deliveries page
This commit is contained in:
403
src/components/StoreCatalogView.tsx
Normal file
403
src/components/StoreCatalogView.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inventory & Catalog — 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).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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 { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||
import { categoryName } from '../services/fiestaMappers';
|
||||
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;
|
||||
}
|
||||
|
||||
function stockStatus(closing: number): { label: string; color: string } {
|
||||
if (closing <= 0) return { label: 'Out of stock', color: '#ef4444' };
|
||||
if (closing < 25) return { label: 'Critical', color: '#ef4444' };
|
||||
if (closing < 120) return { label: 'Low', color: '#f59e0b' };
|
||||
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');
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryid, setCategoryid] = useState(0);
|
||||
const [subcategoryid, setSubcategoryid] = useState(0);
|
||||
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>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem(storageKey, JSON.stringify([...selected])); } catch { /* ignore */ }
|
||||
}, [selected, 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 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) => {
|
||||
const closing = fnum(r.closing);
|
||||
return {
|
||||
id: fstr(r.productid),
|
||||
name: fstr(r.productname) || 'Unnamed product',
|
||||
category: categoryName(fnum(r.categoryid)),
|
||||
closing,
|
||||
...stockStatus(closing),
|
||||
};
|
||||
}),
|
||||
[stockQ.data],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.toLowerCase();
|
||||
return products.filter((p) => {
|
||||
if (categoryid && p.categoryid !== categoryid) 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;
|
||||
});
|
||||
};
|
||||
|
||||
// ── 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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-24">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Inventory & Catalog</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>.
|
||||
</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')}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Boxes size={14} /> Browse Catalog ({products.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('inventory')}
|
||||
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 === 'inventory' ? 'bg-white text-[#581c87] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||
</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 === 'catalog' ? 'Search catalog 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === 'catalog' && (
|
||||
<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>
|
||||
<select
|
||||
value={categoryid}
|
||||
onChange={(e) => { setCategoryid(Number(e.target.value)); setSubcategoryid(0); }}
|
||||
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>)}
|
||||
</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`}
|
||||
</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" />
|
||||
) : filtered.length === 0 ? (
|
||||
<CenterState icon={<Boxes size={26} />} title="No products found" sub="Your manager hasn't approved products matching this filter yet." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-gutter">
|
||||
{filtered.map((p) => {
|
||||
const stocked = inStore.has(p.id);
|
||||
const isSelected = selected.has(p.id);
|
||||
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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── My Store Inventory ── */}
|
||||
{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>
|
||||
</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))]">
|
||||
<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>
|
||||
</div>
|
||||
<AwaitingApi label="Adding products to your store" api="stock-entry 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>
|
||||
</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={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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CenterState({ icon, title, sub, tone }: { icon: React.ReactNode; title: string; sub?: string; tone?: 'error' }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user