stock request
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
|
||||
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, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||
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 { categoryName } from '../services/fiestaMappers';
|
||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||
@@ -82,54 +82,29 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
[storeCat.items],
|
||||
);
|
||||
|
||||
// The user's picks: productid → request data. Persisted per store.
|
||||
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
|
||||
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected'; requestedAt: string; resolvedAt?: string }>>(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
// Migrate old format (Record<string, number>) to new format
|
||||
const migrated: Record<string, any> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === 'number') {
|
||||
migrated[k] = { qty: v, status: 'Pending', requestedAt: new Date().toISOString() };
|
||||
} else {
|
||||
migrated[k] = v;
|
||||
if (migrated[k].status === 'Approve') migrated[k].status = 'Approved';
|
||||
if (migrated[k].status === 'Reject') migrated[k].status = 'Rejected';
|
||||
const stockRequestsQ = useFiestaGetStockRequests({ tenantid, locationid: locationid ?? 0, pagesize: 500 });
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
} catch {
|
||||
return {};
|
||||
});
|
||||
setPicks(map);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for storage events so approvals from Admin update dynamically
|
||||
useEffect(() => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key === storageKey) {
|
||||
if (e.newValue) {
|
||||
const parsed = JSON.parse(e.newValue);
|
||||
for (const key of Object.keys(parsed)) {
|
||||
if (parsed[key].status === 'Approve') parsed[key].status = 'Approved';
|
||||
if (parsed[key].status === 'Reject') parsed[key].status = 'Rejected';
|
||||
}
|
||||
setPicks(parsed);
|
||||
}
|
||||
else setPicks({});
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
return () => window.removeEventListener('storage', handler);
|
||||
}, [storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(picks));
|
||||
}, [picks, storageKey]);
|
||||
}, [stockRequestsQ.data]);
|
||||
|
||||
const createRequestMutation = useFiestaCreateStockRequest();
|
||||
const updateRequestMutation = useFiestaUpdateStockRequest();
|
||||
|
||||
const togglePick = (id: string) => {
|
||||
setNotice(false);
|
||||
@@ -271,14 +246,14 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
// The request is saved to localStorage automatically via the useEffect on `picks`.
|
||||
|
||||
return (
|
||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
||||
<div className="animate-in fade-in duration-300 font-sans pb-28">
|
||||
|
||||
|
||||
{/* 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('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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -286,7 +261,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -294,7 +269,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('requests')}
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -302,7 +277,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start mt-6">
|
||||
<div className="flex flex-col md:flex-row gap-6 items-start mt-4">
|
||||
{/* Sticky Sidebar Filter */}
|
||||
{view !== 'requests' && (
|
||||
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
||||
@@ -439,49 +414,53 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] 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="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer overflow-hidden">
|
||||
<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-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
||||
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
||||
<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-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10">
|
||||
<CheckCircle2 size={12} className="text-emerald-500" />
|
||||
<span className="text-[9px] font-black uppercase text-emerald-700 tracking-wider">In Store</span>
|
||||
<div 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-3 left-3 z-10 flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(p.category)}`}>
|
||||
<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.5 flex flex-col flex-1 gap-3">
|
||||
<div className="p-3 flex flex-col flex-1 gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{p.name}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
|
||||
<div 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="mt-auto bg-slate-50 rounded-xl p-2.5 border border-slate-100 flex justify-between items-center">
|
||||
<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-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
|
||||
<span className="font-extrabold text-slate-800 font-mono text-sm">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||
<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-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
|
||||
<span className="font-bold text-slate-600 text-[11px] bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
|
||||
<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>
|
||||
|
||||
@@ -545,7 +524,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
<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', 'Product', 'Qty', 'Status', 'Resolved At'].map((h, i) => (
|
||||
{['Requested At', '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>
|
||||
@@ -612,6 +591,25 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
{data.resolvedAt ? new Date(data.resolvedAt).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(pid),
|
||||
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>
|
||||
);
|
||||
})}
|
||||
@@ -642,38 +640,42 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-4">
|
||||
{finalFilteredInventory.map((it, i) => (
|
||||
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer overflow-hidden">
|
||||
<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-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
||||
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
||||
<div className="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-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10" style={{ color: it.color }}>
|
||||
<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-[9px] font-black uppercase tracking-wider">{it.label}</span>
|
||||
<span className="text-[8px] font-extrabold uppercase tracking-wider">{it.label}</span>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(it.category)}`}>
|
||||
<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.5 flex flex-col flex-1 gap-3">
|
||||
<div className="p-3 flex flex-col flex-1 gap-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{it.name}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
|
||||
<div 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 rounded-xl p-3 border border-slate-100 text-center">
|
||||
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-1">Live Stock</span>
|
||||
<span className="font-black font-mono text-xl" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
|
||||
<div 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>
|
||||
|
||||
Reference in New Issue
Block a user