Files
daily_merchant_web/src/components/StoreCatalogView.tsx
2026-07-16 17:05:06 +05:30

973 lines
54 KiB
TypeScript

/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Inventory & Catalogue — the store user's page.
*
* 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 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, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
import { useFiestaStockStatement, useFiestaCreateStockRequest, useFiestaGetStockRequests, useFiestaUpdateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
interface StoreCatalogViewProps {
locationid?: number;
storeName?: string;
tenantId?: number;
isSidebarOpen?: boolean;
}
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' };
}
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
function catBadgeClass(category: string): string {
const c = String(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, isSidebarOpen = false }: StoreCatalogViewProps) {
const tenantid = tenantId;
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
const [isLocalSidebarOpen, setIsLocalSidebarOpen] = useState(true);
const [search, setSearch] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [requestDate, setRequestDate] = useState(() => new Date().toISOString().split('T')[0]);
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
const [selectedProduct, setSelectedProduct] = useState<any>(null);
const [hoveredProduct, setHoveredProduct] = useState<any>(null);
const [activeQtyProduct, setActiveQtyProduct] = useState<any>(null);
const [tempQty, setTempQty] = useState(1);
const [notice, setNotice] = useState(false);
// The admin-curated catalogue (what the user is allowed to pick from).
const storeCat = useStoreCatalogue();
const products = useMemo(
() =>
storeCat.items
.filter((it) => it.status === 'Active')
.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],
);
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500, date: requestDate });
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled' | 'Received'; requestedAt: string; resolvedAt?: string }>>({});
useEffect(() => {
if (stockRequestsQ.data) {
const map: Record<string, any> = {};
stockRequestsQ.data.forEach((req: any) => {
if (!map[req.productid]) {
map[req.productid] = {
qty: req.qty,
status: req.status,
requestedAt: req.created,
resolvedAt: req.updated,
requestid: req.requestid
};
}
});
setPicks(map);
}
}, [stockRequestsQ.data]);
const createRequestMutation = useFiestaCreateStockRequest();
const updateRequestMutation = useFiestaUpdateStockRequest();
const togglePick = (id: string) => {
setNotice(false);
const existing = picks[id];
if (existing != null && existing.status !== 'Cancelled') {
setPicks(prev => ({ ...prev, [id]: { ...prev[id], status: 'Cancelled', resolvedAt: new Date().toISOString() } }));
} else {
setPicks(prev => ({ ...prev, [id]: { qty: 1, status: 'Pending', requestedAt: new Date().toISOString() } }));
createRequestMutation.mutate({
tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
productid: Number(id),
qty: 1,
status: 'Pending',
locationname: storeName
});
}
};
const setPickQty = (id: string, qty: number) => {
const safeQty = Math.max(1, Math.round(qty) || 1);
setPicks((prev) => {
const existing = prev[id] || {};
return {
...prev,
[id]: {
...existing,
qty: safeQty,
status: 'Pending',
requestedAt: new Date().toISOString()
}
};
});
createRequestMutation.mutate({
tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
productid: Number(id),
qty: safeQty,
status: 'Pending',
locationname: storeName
});
};
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 inStore = useMemo(() => {
const set = new Set((stockQ.data ?? []).map((r) => fstr(r.productid)));
Object.entries(picks).forEach(([pid, data]: [string, any]) => {
if (data.status === 'Approved') set.add(pid);
});
return set;
}, [stockQ.data, picks]);
const inventory = useMemo(
() => {
const baseInventory = (stockQ.data ?? []).map((r: Row) => {
const closing = fnum(r.closing) ?? 0;
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: fstr(r.categoryname) || 'General',
closing,
...stockStatus(closing),
price: Math.floor(Math.random() * 50) + 10, // mock price
qty: closing, // fallback if actual qty not mapped
};
});
// Merge approved picks into the inventory
const inventoryMap = new Map(baseInventory.map(item => [item.id, item]));
Object.entries(picks).forEach(([pid, data]: [string, any]) => {
if (data.status === 'Approved') {
if (inventoryMap.has(pid)) {
const item = inventoryMap.get(pid)!;
item.qty += data.qty;
item.closing += data.qty;
Object.assign(item, stockStatus(item.closing));
} else {
// Find product info from catalogue
const prod = products.find(p => p.id === pid);
if (prod) {
inventoryMap.set(pid, {
id: prod.id,
name: prod.name,
sku: prod.sku,
image: prod.image,
category: prod.category,
...stockStatus(data.qty),
price: prod.price,
qty: data.qty,
closing: data.qty
});
}
}
}
});
return Array.from(inventoryMap.values());
},
[stockQ.data, picks, products],
);
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.split(' / ')[0]))].sort(), [products]);
const filtered = useMemo(() => {
const term = search.toLowerCase();
return products.filter((p) => {
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, 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 ──────────────────────────────────────────────────────────
// The request is saved to localStorage automatically via the useEffect on `picks`.
return (
<div className="animate-in fade-in duration-300 font-sans pb-28">
{/* Tabs and Controls */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 w-full">
<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('catalogue')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
view === 'catalogue' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
>
<Boxes size={14} /> Browse Catalogue ({products.length})
</button>
<button
onClick={() => setView('inventory')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
view === 'inventory' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
>
<Store size={14} /> My Store Inventory ({inventory.length})
</button>
<button
onClick={() => setView('requests')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
view === 'requests' ? 'bg-white text-[#662582] shadow-sm' : 'text-zinc-500 hover:text-zinc-800'
}`}
>
<Inbox size={14} /> My Requests ({Object.keys(picks).length})
</button>
</div>
{view === 'requests' && (
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">Date:</span>
<input
type="date"
value={requestDate}
onChange={(e) => setRequestDate(e.target.value)}
className="px-3 py-1.5 border border-slate-200 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-[#662582] focus:border-[#662582] text-xs font-semibold text-slate-700 bg-white"
/>
</div>
)}
</div>
<div className="flex flex-col md:flex-row gap-6 mt-2 flex-1 min-h-0 overflow-hidden pb-4">
{/* Sticky Sidebar Filter */}
{view !== 'requests' && isLocalSidebarOpen && (
<div className="w-full xl:w-64 shrink-0 flex flex-col gap-5 pr-2 pb-2">
<div className="bg-white border border-slate-200 shadow-sm overflow-hidden flex-1 min-h-0 flex flex-col">
<div className="p-4 bg-slate-50 border-b border-slate-100 shrink-0">
<h3 className="font-bold text-slate-800 flex items-center gap-2">
<Layers size={16} className="text-[#662582]" /> Filter Product
</h3>
</div>
<div className="p-4 space-y-5 overflow-y-auto custom-scrollbar">
<div>
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
<Layers size={14} className="text-[#662582]" /> Categories
</h3>
<div className="space-y-1.5">
{categories.map((c) => (
<label key={c} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center">
<input
type="checkbox"
checked={selectedCategories.includes(c)}
onChange={() => toggleCategory(c)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
/>
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
</div>
<span className={`text-xs font-semibold select-none transition-colors ${selectedCategories.includes(c) ? 'text-[#662582]' : 'text-slate-600 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-1.5">
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
<label key={h} className="flex items-center gap-3 p-2 rounded-xl hover:bg-slate-50 cursor-pointer group transition-colors">
<div className="relative flex items-center justify-center">
<input
type="checkbox"
checked={stockHealthFilter.includes(h)}
onChange={() => toggleStockHealth(h)}
className="peer appearance-none w-5 h-5 border-2 border-slate-300 rounded-lg checked:border-[#662582] checked:bg-[#662582] transition-colors cursor-pointer"
/>
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
</div>
<span className={`text-xs font-semibold select-none transition-colors ${stockHealthFilter.includes(h) ? 'text-[#662582]' : 'text-slate-600 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>
</div>
</div>
)}
{/* Product Grid Area */}
<div className="flex-1 min-w-0 overflow-y-auto custom-scrollbar bg-white/40 backdrop-blur-md border border-[#e2e8f0] p-5 shadow-sm flex flex-col">
{/* Results Summary & Toolbar */}
<div className="flex justify-between items-end mb-4 px-1 gap-4">
<div>
<h2 className="text-lg font-bold text-slate-900">
{view === 'catalogue' ? 'Store Catalogue' : view === 'inventory' ? 'My Inventory' : 'My Requests'}
</h2>
{view !== 'requests' && (
<p className="text-xs text-slate-500 mt-0.5">
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
</p>
)}
</div>
{/* Action Toolbar */}
<div className="flex items-center gap-3">
<button
onClick={() => setIsLocalSidebarOpen(!isLocalSidebarOpen)}
className="flex items-center gap-2 bg-white border border-slate-200 px-3 py-2 rounded-xl text-xs font-bold hover:bg-slate-50 transition-colors text-slate-700 shadow-sm cursor-pointer h-[36px]"
>
<Layers size={14} /> Filter
</button>
<div className="relative hidden md:block md:w-64 h-[36px]">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full h-full pl-9 pr-8 py-2 bg-white border border-slate-200 rounded-xl text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-[#662582] transition-all shadow-sm font-medium"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer border-none bg-transparent">
<X size={12} />
</button>
)}
</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."
/>
) : (
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4 pb-8`}>
{filtered.map((p) => {
const stocked = inStore.has(p.id);
const pick = picks[p.id];
const picked = pick != null && pick.status !== 'Cancelled' && pick.status !== 'Approved';
return (
<div key={p.id} onClick={() => setSelectedProduct(p)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
{/* Image Bento Block */}
<div className="w-full h-28 bg-zinc-50 relative overflow-hidden shrink-0">
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 ease-in-out" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
{stocked && (
<div className="absolute top-2 right-2 bg-emerald-100/90 backdrop-blur-sm border border-emerald-200 px-1.5 py-0.5 rounded shadow-sm flex items-center gap-1 z-10">
<CheckCircle2 size={10} className="text-emerald-700" />
<span className="text-[8px] font-extrabold uppercase text-emerald-800 tracking-wider">In Store</span>
</div>
)}
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1">
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
p.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
p.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
p.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
'bg-rose-100 text-rose-800'
}`}>
{p.category.split(' / ')[0]}
</span>
</div>
</div>
{/* Details Bento Block */}
<div className="p-3 flex flex-col flex-1 gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{p.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5 p-2 bg-slate-50/80 rounded-xl border border-slate-100 mt-auto">
<div>
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
<span className="font-extrabold text-slate-800 font-mono text-xs">{p.price > 0 ? `${p.price.toLocaleString('en-IN')}` : '—'}</span>
</div>
<div className="text-right">
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
<span className="font-bold text-slate-600 text-xs bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
</div>
</div>
{/* Action Bento Block */}
<div className="pt-1">
{picked ? (
<div className={`flex items-center justify-between gap-1 p-1.5 rounded-xl border shadow-inner ${
picks[p.id].status === 'Approved' ? 'bg-emerald-50 border-emerald-200' :
picks[p.id].status === 'Rejected' ? 'bg-rose-50 border-rose-200' :
'bg-amber-50 border-amber-200'
}`}>
<span className={`inline-flex items-center gap-1.5 px-2 text-[11px] font-bold ${
picks[p.id].status === 'Approved' ? 'text-emerald-700' :
picks[p.id].status === 'Rejected' ? 'text-rose-700' :
'text-amber-700'
}`}>
{picks[p.id].status === 'Approved' ? <CheckCircle2 size={13} /> :
picks[p.id].status === 'Rejected' ? <X size={13} /> :
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
{picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
</span>
<div className="flex gap-1">
{picks[p.id].status === 'Pending' && (
<button onClick={(e) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }} title="Edit Quantity" className="px-2 h-7 rounded-lg text-amber-600 hover:bg-amber-100 bg-white border border-amber-100 flex items-center justify-center transition-colors shadow-sm text-[10px] font-bold">Edit</button>
)}
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className={`w-7 h-7 rounded-lg bg-white border flex items-center justify-center transition-colors shadow-sm ${
picks[p.id].status === 'Approved' ? 'text-emerald-500 hover:bg-emerald-50 hover:text-emerald-600 border-emerald-100' :
picks[p.id].status === 'Rejected' ? 'text-rose-500 hover:bg-rose-50 hover:text-rose-600 border-rose-100' :
'text-amber-500 hover:bg-amber-50 hover:text-amber-600 border-amber-100'
}`}><X size={14} /></button>
</div>
</div>
) : (
<button
onClick={(e) => { e.stopPropagation(); setTempQty(1); setActiveQtyProduct(p); }}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[11px] font-bold transition-all bg-white text-purple-700 hover:bg-purple-600 hover:text-white hover:shadow-md border border-purple-200 hover:border-purple-600"
>
<Plus size={14} /> Add to Store
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)
)}
{/* ── My Requests ── */}
{view === 'requests' && (
<div className="flex flex-col gap-4">
{(!stockRequestsQ.data || stockRequestsQ.data.length === 0) ? (
<CenterState
icon={<Inbox size={34} />}
title="No stock requested yet"
sub="Browse the catalogue and add items to your store to request stock."
/>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse" style={{ minWidth: 800 }}>
<thead>
<tr className="bg-slate-50/80 border-b border-slate-200">
{['Requested At', 'Store', 'Product', 'Qty', 'Status', 'Resolved At', ''].map((h, i) => (
<th key={i} className="px-5 py-4 text-[10px] font-black uppercase tracking-widest text-slate-500 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(stockRequestsQ.data || []).map((data: any) => {
const pid = String(data.productid);
const prod = products.find(p => String(p.id) === pid);
const isApproved = data.status === 'Approved';
const isRejected = data.status === 'Rejected';
const isCancelled = data.status === 'Cancelled';
return (
<tr key={data.requestid} className="transition-all duration-200 hover:bg-slate-50/80 group">
<td className="px-5 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-semibold text-slate-900">
{data.created ? new Date(data.created).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '—'}
</span>
<span className="text-xs font-medium text-slate-500">
{data.created ? new Date(data.created).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-indigo-50/50 text-indigo-700 border border-indigo-100/50">
<Store size={14} className="text-indigo-500" />
<span className="font-bold text-xs">{storeName}</span>
</div>
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<>
<div className="relative w-10 h-10 rounded-xl overflow-hidden border border-slate-200 bg-white shadow-sm shrink-0 group-hover:shadow transition-shadow">
<img src={data.productimage || prod?.image || PLACEHOLDER} alt={data.productname || prod?.name || 'Product'} className="w-full h-full object-cover" />
</div>
<div className="min-w-0 flex-1">
<p className="font-bold text-sm text-slate-900 truncate group-hover:text-indigo-700 transition-colors">{data.productname || prod?.name}</p>
<p className="text-xs font-medium text-slate-500 truncate mt-0.5">{prod?.sku || `SKU-${data.productid}`}</p>
</div>
</>
</div>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="inline-flex items-center justify-center min-w-[2rem] px-2 py-1 rounded-md bg-slate-100 text-slate-700 font-bold text-sm border border-slate-200/60">
{data.qty || '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold border ${
isApproved ? 'bg-emerald-50 text-emerald-700 border-emerald-200' :
isRejected ? 'bg-rose-50 text-rose-700 border-rose-200' :
isCancelled ? 'bg-slate-50 text-slate-700 border-slate-200' :
'bg-amber-50 text-amber-700 border-amber-200'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${
isApproved ? 'bg-emerald-500' :
isRejected ? 'bg-rose-500' :
isCancelled ? 'bg-slate-400' :
'bg-amber-500 animate-pulse'
}`} />
{data.status || '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap">
<span className="text-xs font-medium text-slate-500">
{data.updated ? new Date(data.updated).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
</span>
</td>
<td className="px-5 py-4 whitespace-nowrap text-right">
{isApproved && (
<button
onClick={(e) => {
e.stopPropagation();
updateRequestMutation.mutate({
tenantid,
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
productid: Number(data.productid),
requestid: data.requestid,
status: 'Received'
});
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold text-white bg-indigo-600 hover:bg-indigo-700 transition-colors shadow-sm"
>
<CheckCircle2 size={14} /> Mark as Received
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</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-[#531e6a] transition shadow-sm cursor-pointer">
<X size={13} /> Clear filters
</button>
}
/>
) : (
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 ${!isLocalSidebarOpen ? (!isSidebarOpen ? 'xl:grid-cols-5 2xl:grid-cols-6' : 'xl:grid-cols-4 2xl:grid-cols-5') : (!isSidebarOpen ? 'xl:grid-cols-4 2xl:grid-cols-5' : 'xl:grid-cols-3 2xl:grid-cols-4')} gap-4 pb-8`}>
{finalFilteredInventory.map((it, i) => (
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="cursor-pointer bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-[#662582]/40 hover:-translate-y-1 transition-all duration-300 relative group overflow-hidden">
{/* Image Bento Block */}
<div className="w-full h-28 bg-zinc-50 relative overflow-hidden shrink-0">
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 ease-in-out" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="absolute top-2 right-2 bg-white/90 backdrop-blur-md border border-slate-200 px-1.5 py-0.5 rounded shadow-sm flex items-center gap-1 z-10" style={{ color: it.color }}>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
<span className="text-[8px] font-extrabold uppercase tracking-wider">{it.label}</span>
</div>
<div className="absolute top-2 left-2 z-10 flex flex-col items-start gap-1">
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shadow-sm ${
it.category.startsWith('Staples') ? 'bg-amber-100 text-amber-800' :
it.category.startsWith('Groceries') ? 'bg-emerald-100 text-emerald-800' :
it.category.startsWith('Beverages') ? 'bg-sky-100 text-sky-800' :
'bg-rose-100 text-rose-800'
}`}>
{it.category.split(' / ')[0]}
</span>
</div>
</div>
{/* Details Bento Block */}
<div className="p-3 flex flex-col flex-1 gap-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0 pb-2">
<h4 className="font-bold text-[#0f172a] text-xs leading-snug group-hover:text-[#662582] transition-colors line-clamp-2">{it.name}</h4>
<p className="text-[10px] text-zinc-500 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
</div>
</div>
<div className="mt-auto bg-slate-50/80 rounded-xl p-2.5 border border-slate-100 text-center">
<span className="text-[9px] text-zinc-400 block uppercase tracking-wider font-extrabold mb-0.5">Live Stock</span>
<span className="font-extrabold font-mono text-sm" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
</div>
</div>
</div>
))}
</div>
)
)}
</div>
</div>
{/* ── Auto-Submit Toast ── */}
{notice && (
<div className="fixed bottom-6 right-6 z-[120]">
<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 transition-colors">Clear All</button>
</div>
<AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" />
</div>
</div>
)}
{/* QUANTITY SELECTION CENTERED MODAL */}
{activeQtyProduct && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300"
onClick={() => setActiveQtyProduct(null)}
/>
<div className="relative w-[340px] max-w-[95vw] bg-white rounded-2xl shadow-[0_24px_60px_rgba(0,0,0,0.15)] border border-white/50 p-5 animate-in zoom-in-[0.97] duration-300 flex flex-col gap-4">
{/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-slate-100/80">
<div>
<h2 className="text-base font-bold text-slate-900 tracking-tight">Request Stock</h2>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Bulk Order</p>
</div>
<button
onClick={() => setActiveQtyProduct(null)}
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors"
>
<X size={14} strokeWidth={2.5} />
</button>
</div>
{/* Product Card summary */}
<div className="flex gap-3 items-center bg-gradient-to-r from-slate-50 to-white p-2.5 rounded-xl border border-slate-100 shadow-sm">
<div className="w-12 h-12 rounded-lg bg-white border border-slate-200 overflow-hidden shrink-0 shadow-sm">
<img src={activeQtyProduct.image} alt={activeQtyProduct.name} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 text-xs leading-tight truncate">{activeQtyProduct.name}</h3>
<p className="text-[9px] font-mono font-medium text-slate-400 mt-0.5">{activeQtyProduct.sku}</p>
<div className="mt-1 inline-flex items-center gap-1 bg-purple-50 px-1.5 py-0.5 rounded text-[9px] font-semibold text-purple-700 border border-purple-100/50">
<span>{activeQtyProduct.price.toLocaleString('en-IN')}</span>
<span className="text-purple-300">/</span>
<span>{activeQtyProduct.unit || 'Pc'}</span>
</div>
</div>
</div>
{/* Quantity Selector */}
<div className="flex flex-col gap-4">
<div>
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Quick Select</label>
<div className="grid grid-cols-4 gap-2">
{[10, 25, 50, 100].map(qty => (
<button
key={qty}
onClick={() => setTempQty(qty)}
className={`relative overflow-hidden py-1.5 rounded-lg text-[11px] font-bold transition-all duration-300 ${
tempQty === qty
? 'bg-[#662582] text-white shadow-sm ring-1 ring-[#662582] ring-offset-1'
: 'bg-white text-slate-600 border border-slate-200 hover:border-[#662582]/40 hover:bg-purple-50/50'
}`}
>
{qty}
</button>
))}
</div>
</div>
<div>
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Custom Amount</label>
<div className="relative group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
<Boxes size={14} className="text-slate-400 group-focus-within:text-[#662582] transition-colors" />
</div>
<input
type="number"
min="1"
value={tempQty}
onChange={(e) => setTempQty(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner"
/>
<div className="absolute inset-y-0 right-3 flex items-center pointer-events-none">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest pl-2 border-l border-slate-200">Units</span>
</div>
</div>
</div>
</div>
{/* Total & Submit */}
<div className="mt-1 pt-4 border-t border-slate-100">
<div className="flex justify-between items-end mb-4 px-1">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Estimated Total</span>
<span className="text-xl font-bold text-slate-900 tracking-tight">
{(tempQty * activeQtyProduct.price).toLocaleString('en-IN')}
</span>
</div>
<button
onClick={() => {
setPickQty(activeQtyProduct.id, tempQty);
setActiveQtyProduct(null);
setNotice(true);
setTimeout(() => setNotice(false), 3000);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-bold transition-all bg-gradient-to-r from-[#662582] to-purple-800 text-white shadow-[0_4px_12px_rgba(102,37,130,0.2)] hover:shadow-[0_6px_16px_rgba(102,37,130,0.3)] hover:-translate-y-0.5"
>
<CheckCircle2 size={14} strokeWidth={2.5} /> Confirm Request
</button>
</div>
</div>
</div>
)}
{/* Floating FMCG Details Panel */}
{hoveredProduct && (
<div className="fixed bottom-6 right-6 z-[200] pointer-events-none">
<div className="relative w-[320px] max-w-[90vw] shadow-[0_24px_60px_rgba(0,0,0,0.15)] rounded-2xl animate-in slide-in-from-bottom-4 fade-in duration-200">
<FMCGHoverOverlay productId={hoveredProduct.id} category={hoveredProduct.category} productName={hoveredProduct.name} />
</div>
</div>
)}
{/* ── Slide Drawer for Product Details ── */}
<SlideDrawer
isOpen={selectedProduct !== null}
onClose={() => setSelectedProduct(null)}
title="Product Details"
>
{selectedProduct && (() => {
const stocked = inStore.has(selectedProduct.id);
const pick = picks[selectedProduct.id];
const isPending = pick != null && pick.status === 'Pending';
const isApproved = pick != null && pick.status === 'Approved';
const isRejected = pick != null && pick.status === 'Rejected';
const isRequested = pick != null && pick.status !== 'Cancelled';
return (
<div className="flex flex-col gap-8 pb-8">
{/* Clean Image Container */}
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4">
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
</div>
{/* Title & Basics */}
<div>
<p className="text-xs font-semibold text-slate-500 tracking-wider mb-1">SKU: {selectedProduct.sku}</p>
<h3 className="text-2xl font-bold text-slate-900 leading-tight mb-3">{selectedProduct.name}</h3>
<div className="flex flex-wrap items-center gap-2">
<span className={`px-2.5 py-1 rounded bg-slate-100 text-slate-600 text-[10px] font-semibold uppercase tracking-widest border border-slate-200`}>
{String(selectedProduct.category || '').split(' / ')[0]}
</span>
{stocked && (
<span className="px-2.5 py-1 rounded bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-widest border border-emerald-200 flex items-center gap-1.5">
<CheckCircle2 size={12} strokeWidth={2.5} /> In Store
</span>
)}
</div>
</div>
{/* Clean Pricing Card */}
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 flex items-center justify-between">
<div>
<span className="text-xs font-semibold text-slate-500 tracking-wider uppercase block mb-1">Store Price</span>
<div className="flex items-end gap-1.5">
<span className="text-3xl font-bold text-slate-900 tracking-tight">{selectedProduct.price?.toLocaleString('en-IN') || 0}</span>
<span className="text-sm font-semibold text-slate-500 mb-1">/ {selectedProduct.unit || 'Pc'}</span>
</div>
</div>
</div>
{/* Action Area */}
<div className="space-y-4 pt-4 border-t border-slate-100">
<h4 className="text-sm font-semibold text-slate-800">Stock Management</h4>
{isApproved ? (
<div className="flex items-center justify-center gap-2 p-4 bg-emerald-50 rounded-xl border border-emerald-200">
<CheckCircle2 size={18} className="text-emerald-600" />
<span className="text-sm font-semibold text-emerald-800">Approved and stocked in your inventory</span>
</div>
) : isRejected ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center gap-2 p-4 bg-rose-50 rounded-xl border border-rose-200">
<X size={18} className="text-rose-600" />
<span className="text-sm font-semibold text-rose-800">Stock request was rejected</span>
</div>
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-slate-700 hover:bg-slate-50 shadow-sm border border-slate-200 font-semibold text-sm transition-colors">
Clear Request
</button>
</div>
) : isPending ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between p-4 bg-amber-50 rounded-xl border border-amber-200">
<div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<span className="text-sm font-semibold text-amber-800">Requested <span className="font-bold">({pick.qty} units)</span></span>
</div>
<button onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} className="px-4 py-2 rounded-lg text-amber-700 bg-white hover:bg-amber-100 border border-amber-200 text-sm font-semibold transition-colors shadow-sm">
Edit
</button>
</div>
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-rose-600 hover:bg-rose-50 border border-slate-200 font-semibold text-sm transition-colors">
<X size={18} strokeWidth={2} /> Cancel Request
</button>
</div>
) : (
<button onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-colors bg-[#662582] text-white hover:bg-[#531e6a]">
<Plus size={18} strokeWidth={2} /> Request Stock
</button>
)}
</div>
{/* Retail Packaging Info */}
<div className="mt-4 pt-4 border-t border-slate-100">
<h4 className="text-sm font-semibold text-slate-800 mb-3">Retail Packaging Info</h4>
<FMCGHoverOverlay productId={selectedProduct.id} category={selectedProduct.category} productName={selectedProduct.name} />
</div>
</div>
);
})()}
</SlideDrawer>
</div>
);
}
function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; title: string; sub?: string; action?: React.ReactNode }) {
return (
<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-[#662582]/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-24 -left-20 w-60 h-60 rounded-full bg-[#662582]/10 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-[#662582]/15 blur-xl" />
<span className="relative flex items-center justify-center w-20 h-20 rounded-3xl bg-[#662582] text-white shadow-lg shadow-[#662582]/20 ring-8 ring-white">
{icon}
</span>
</div>
<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>}
</div>
</div>
);
}