implemented the sales and revenue report
This commit is contained in:
@@ -623,8 +623,8 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main core pages payload area */}
|
{/* Main core pages payload area */}
|
||||||
<main className={`flex-1 min-w-0 min-h-[calc(100vh-64px)] transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'}`}>
|
<main className={`flex-1 min-w-0 transition-all duration-300 ${sidebarOpen ? 'md:pl-64' : 'md:pl-20'} ${currentSection === 'inventory' ? 'h-[calc(100vh-64px)] overflow-hidden' : 'min-h-[calc(100vh-64px)]'}`}>
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className={`w-full transition-all duration-300 ${currentSection === 'inventory' ? 'h-full p-4 md:p-6 overflow-hidden' : 'p-container-margin md:p-xl space-y-lg'}`}>
|
||||||
{/* Nav content routing */}
|
{/* Nav content routing */}
|
||||||
{currentSection === 'dashboard' && (
|
{currentSection === 'dashboard' && (
|
||||||
selectedStore ? (
|
selectedStore ? (
|
||||||
|
|||||||
@@ -84,81 +84,135 @@ export default function ComparisonModal() {
|
|||||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-8 bg-[#f8fafc] custom-scrollbar">
|
<div className="flex-1 overflow-x-auto overflow-y-auto p-8 bg-[#f8fafc] custom-scrollbar">
|
||||||
<div className="flex gap-8 min-w-max pb-8">
|
<div className="flex gap-8 min-w-max pb-8">
|
||||||
{/* Left Column for labels */}
|
{/* Left Column for labels */}
|
||||||
<div className="flex flex-col gap-4 w-40 shrink-0 pt-[140px] sticky left-0 bg-[#f8fafc] z-10 shadow-[15px_0_20px_-5px_rgba(248,250,252,1)]">
|
{(() => {
|
||||||
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Category</div>
|
const isGlobalMode = selectedProducts.some(p => p.isGlobal);
|
||||||
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">SKU</div>
|
|
||||||
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Price</div>
|
|
||||||
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Units Sold</div>
|
|
||||||
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Status</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product Columns */}
|
return (
|
||||||
{selectedProducts.map((prod) => (
|
<>
|
||||||
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
|
<div className="flex flex-col gap-4 w-40 shrink-0 pt-[140px] sticky left-0 bg-[#f8fafc] z-10 shadow-[15px_0_20px_-5px_rgba(248,250,252,1)]">
|
||||||
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Category</div>
|
||||||
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">SKU</div>
|
||||||
|
|
||||||
{/* Remove button overlay */}
|
{isGlobalMode ? (
|
||||||
<button
|
<>
|
||||||
onClick={() => removeProduct(prod.id)}
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Wholesale Price</div>
|
||||||
className="absolute -top-3 -right-3 w-7 h-7 bg-white border border-slate-200 rounded-full flex items-center justify-center text-slate-400 hover:text-rose-500 hover:border-rose-200 hover:bg-rose-50 shadow-sm z-20 opacity-0 group-hover:opacity-100 transition-all"
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Retail (MRP)</div>
|
||||||
>
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Profit Margin</div>
|
||||||
<X size={14} />
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Global Sales</div>
|
||||||
</button>
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Rating</div>
|
||||||
|
</>
|
||||||
{/* Product Header Card */}
|
) : (
|
||||||
<div className="h-[140px] bg-white rounded-3xl border border-[#e2e8f0] shadow-sm p-4 flex flex-col justify-center relative hover:shadow-md transition-shadow">
|
<>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Price</div>
|
||||||
<img src={prod.image} alt={prod.name} className="w-[72px] h-[72px] rounded-2xl object-cover bg-slate-50 shadow-sm border border-slate-100 shrink-0" />
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Units Sold</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="h-12 flex items-center text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-200/60">Status</div>
|
||||||
<h3 className="font-extrabold text-slate-900 text-sm leading-snug line-clamp-3">{prod.name}</h3>
|
</>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Product Columns */}
|
||||||
<div className="h-12 flex items-center border-b border-slate-200/60">
|
{selectedProducts.map((prod) => (
|
||||||
<span className="font-bold text-slate-700 text-sm">{prod.category || '—'}</span>
|
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-12 flex items-center border-b border-slate-200/60">
|
{/* Remove button overlay */}
|
||||||
<span className="font-mono text-slate-500 text-xs font-bold tracking-tight">{prod.sku || '—'}</span>
|
<button
|
||||||
</div>
|
onClick={() => removeProduct(prod.id)}
|
||||||
|
className="absolute -top-3 -right-3 w-7 h-7 bg-white border border-slate-200 rounded-full flex items-center justify-center text-slate-400 hover:text-rose-500 hover:border-rose-200 hover:bg-rose-50 shadow-sm z-20 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="h-12 flex items-center border-b border-slate-200/60">
|
{/* Product Header Card */}
|
||||||
<span className="font-black text-slate-900 text-base">
|
<div className="h-[140px] bg-white rounded-3xl border border-[#e2e8f0] shadow-sm p-4 flex flex-col justify-center relative hover:shadow-md transition-shadow">
|
||||||
{prod.price > 0 ? `₹${prod.price.toLocaleString('en-IN')}` : '—'}
|
<div className="flex gap-4 items-center">
|
||||||
</span>
|
<img src={prod.image} alt={prod.name} className="w-[72px] h-[72px] rounded-2xl object-cover bg-slate-50 shadow-sm border border-slate-100 shrink-0" />
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-extrabold text-slate-900 text-sm leading-snug line-clamp-3">{prod.name}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-12 flex items-center border-b border-slate-200/60">
|
{/* Metrics */}
|
||||||
<span className="font-bold text-[#662582] font-mono text-sm">
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
{prod.unitsSold != null ? prod.unitsSold.toLocaleString('en-IN') : '—'}
|
<span className="font-bold text-slate-700 text-sm">{prod.category || '—'}</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-12 flex items-center border-b border-slate-200/60">
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
{prod.verified !== undefined ? (
|
<span className="font-mono text-slate-500 text-xs font-bold tracking-tight">{prod.sku || '—'}</span>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
{prod.verified ? (
|
|
||||||
<span className="flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-200 px-2.5 py-1 rounded-lg text-xs font-bold">
|
{isGlobalMode ? (
|
||||||
<CheckCircle2 size={12} className="text-emerald-500" /> Active
|
<>
|
||||||
</span>
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
<span className="font-black text-indigo-900 text-base">
|
||||||
|
{prod.wholesalePrice ? `₹${prod.wholesalePrice.toLocaleString('en-IN')}` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
<span className="font-black text-slate-900 text-base">
|
||||||
|
{prod.mrp ? `₹${prod.mrp.toLocaleString('en-IN')}` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
{prod.profitMargin ? (
|
||||||
|
<span className="bg-emerald-100 text-emerald-700 font-black font-mono px-2 py-1 rounded-md text-sm border border-emerald-200">
|
||||||
|
+{prod.profitMargin}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
<span className="font-bold text-indigo-600 font-mono text-sm">
|
||||||
|
{prod.globalSales != null ? prod.globalSales.toLocaleString('en-IN') : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
<span className="font-black text-amber-500 font-mono text-sm">
|
||||||
|
{prod.rating ? `${prod.rating.toFixed(1)} ★` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="bg-amber-50 text-amber-700 border border-amber-200 px-2.5 py-1 rounded-lg text-xs font-bold">
|
<>
|
||||||
Inspection
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
</span>
|
<span className="font-black text-slate-900 text-base">
|
||||||
|
{prod.price > 0 ? `₹${prod.price.toLocaleString('en-IN')}` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
<span className="font-bold text-[#662582] font-mono text-sm">
|
||||||
|
{prod.unitsSold != null ? prod.unitsSold.toLocaleString('en-IN') : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 flex items-center border-b border-slate-200/60">
|
||||||
|
{prod.verified !== undefined ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{prod.verified ? (
|
||||||
|
<span className="flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-200 px-2.5 py-1 rounded-lg text-xs font-bold">
|
||||||
|
<CheckCircle2 size={12} className="text-emerald-500" /> Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="bg-amber-50 text-amber-700 border border-amber-200 px-2.5 py-1 rounded-lg text-xs font-bold">
|
||||||
|
Inspection
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : prod.closing !== undefined ? (
|
||||||
|
<span className="text-sm font-black font-mono" style={{ color: prod.color || '#64748b' }}>
|
||||||
|
{prod.closing.toLocaleString('en-IN')} {prod.unit || 'Pc'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : prod.closing !== undefined ? (
|
))}
|
||||||
<span className="text-sm font-black font-mono" style={{ color: prod.color || '#64748b' }}>
|
</>
|
||||||
{prod.closing.toLocaleString('en-IN')} {prod.unit || 'Pc'}
|
);
|
||||||
</span>
|
})()}
|
||||||
) : (
|
|
||||||
<span className="text-slate-400">—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Empty placeholders removed */}
|
{/* Empty placeholders removed */}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
Skeleton, Tooltip
|
Skeleton, Tooltip
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
|
|
||||||
|
import SalesRevenueReport from './SalesRevenueReport';
|
||||||
|
|
||||||
type ReportTab = 'orders-summary' | 'riders-summary';
|
type ReportTab = 'orders-summary' | 'riders-summary';
|
||||||
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
|
||||||
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
|
{ key: 'orders-summary', label: 'Orders Summary', icon: Store },
|
||||||
@@ -33,6 +35,8 @@ const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> =
|
|||||||
interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; locationid?: number; }
|
interface DeliveryReportsViewProps { searchQuery?: string; tenantId?: number; locationid?: number; }
|
||||||
|
|
||||||
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID, locationid }: DeliveryReportsViewProps) {
|
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID, locationid }: DeliveryReportsViewProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'sales_revenue'>('overview');
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
|
||||||
@@ -51,8 +55,23 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
|
<div className="flex items-center gap-4 border-b border-slate-200/70 pb-2 mb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('sales_revenue')}
|
||||||
|
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Sales & Revenue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' ? (
|
||||||
|
<>
|
||||||
{/* Tab nav & Date range combined */}
|
{/* Tab nav & Date range combined */}
|
||||||
<FilterBar className="mb-4">
|
<FilterBar className="mb-4">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
@@ -86,6 +105,10 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
|||||||
|
|
||||||
{tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} locationid={locationid} fromdate={fromdate} todate={todate} />}
|
{tab === 'orders-summary' && <OrdersSummaryReport tenantId={tenantId} locationid={locationid} fromdate={fromdate} todate={todate} />}
|
||||||
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} locationid={locationid} />}
|
{tab === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} locationid={locationid} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SalesRevenueReport tenantId={tenantId} locationid={locationid} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/components/FMCGHoverOverlay.tsx
Normal file
68
src/components/FMCGHoverOverlay.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { generateFMCGDetails } from '../utils/fmcgUtils';
|
||||||
|
import { Info, ShieldCheck, Beaker, Package, BarChart } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
productId: string;
|
||||||
|
category: string;
|
||||||
|
productName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FMCGHoverOverlay({ productId, category, productName }: Props) {
|
||||||
|
const details = generateFMCGDetails(productId, category);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-purple-100/50 shadow-sm flex flex-col p-5 h-full">
|
||||||
|
|
||||||
|
{/* 1. The Hook (Front Panel) */}
|
||||||
|
<div className="flex justify-between items-start mb-3 pb-2 border-b border-purple-100">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-black text-lg text-[#662582] leading-tight">{productName}</h3>
|
||||||
|
<p className="text-xs font-bold text-slate-500 mt-1">{details.brandName} • {details.marketingClaim}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5 justify-end mb-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-500">{details.netWeight}</span>
|
||||||
|
<div className={`w-3 h-3 rounded-sm border flex items-center justify-center ${details.isVeg ? 'border-emerald-500' : 'border-rose-500'}`}>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${details.isVeg ? 'bg-emerald-500' : 'bg-rose-500'}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Ingredients & Legal (Back Panel) */}
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||||
|
<Beaker size={10} /> Ingredients
|
||||||
|
</h5>
|
||||||
|
<p className="text-[10px] text-slate-700 leading-relaxed font-medium">{details.ingredients}</p>
|
||||||
|
<div className="mt-1.5 inline-block bg-amber-50 border border-amber-200 px-2 py-0.5 rounded text-[9px] font-bold text-amber-800">
|
||||||
|
{details.allergens}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h5 className="text-[9px] font-extrabold uppercase tracking-widest text-slate-400 flex items-center gap-1 mb-1">
|
||||||
|
<ShieldCheck size={10} /> FSSAI / Storage
|
||||||
|
</h5>
|
||||||
|
<p className="text-[10px] text-slate-700 font-mono font-bold">Lic No. {details.fssai}</p>
|
||||||
|
<p className="text-[9px] text-slate-500 mt-0.5">{details.storage}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Retail Info */}
|
||||||
|
<div className="pt-2 mt-auto border-t border-purple-100 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<span className="text-[8px] font-extrabold uppercase tracking-widest text-slate-400 block mb-0.5">Batch No.</span>
|
||||||
|
<span className="text-[10px] font-mono font-bold text-slate-800">{details.batchNo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-[8px] font-extrabold uppercase tracking-widest text-slate-400 block mb-0.5">EAN / Barcode</span>
|
||||||
|
<span className="text-[10px] font-mono font-bold text-slate-800 tracking-wider">{details.barcode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@ import {
|
|||||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct } from '../services/fiestaMappers';
|
import { stockRowToProduct } from '../services/fiestaMappers';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
import SalesRevenueReport from './SalesRevenueReport';
|
||||||
|
|
||||||
interface ReportsViewProps {
|
interface ReportsViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -61,6 +62,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
|
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
|
||||||
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
|
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
|
||||||
const [exportProgress, setExportProgress] = useState(0);
|
const [exportProgress, setExportProgress] = useState(0);
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'sales_revenue'>('overview');
|
||||||
|
|
||||||
// Dropdown open states
|
// Dropdown open states
|
||||||
const [showTimeframeDropdown, setShowTimeframeDropdown] = useState(false);
|
const [showTimeframeDropdown, setShowTimeframeDropdown] = useState(false);
|
||||||
@@ -487,8 +489,23 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
|
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
|
||||||
|
<div className="flex items-center gap-4 border-b border-slate-200/70 pb-2 -mt-2 relative z-20">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('overview')}
|
||||||
|
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'overview' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('sales_revenue')}
|
||||||
|
className={`pb-2 text-sm font-bold border-b-2 transition-colors ${activeTab === 'sales_revenue' ? 'border-[#662582] text-[#662582]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
||||||
|
>
|
||||||
|
Sales & Revenue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' ? (
|
||||||
|
<>
|
||||||
{/* Primary KPI Row - 4 Key Tab buttons with Sparklines */}
|
{/* Primary KPI Row - 4 Key Tab buttons with Sparklines */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-gutter text-xs font-sans relative z-10">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-gutter text-xs font-sans relative z-10">
|
||||||
{reportsKPIs.map((kpi) => {
|
{reportsKPIs.map((kpi) => {
|
||||||
@@ -1077,7 +1094,10 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SalesRevenueReport tenantId={tenantId} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
605
src/components/SalesRevenueReport.tsx
Normal file
605
src/components/SalesRevenueReport.tsx
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
IndianRupee,
|
||||||
|
ShoppingBag,
|
||||||
|
TrendingUp,
|
||||||
|
Download,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
Loader2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
TrendingDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useFiestaAllOrders
|
||||||
|
} from '../services/fiestaQueries';
|
||||||
|
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
|
|
||||||
|
const generateMockOrders = (from: string, to: string, locationid?: number): Row[] => {
|
||||||
|
const fromTime = new Date(from).getTime();
|
||||||
|
const toTime = new Date(to).getTime();
|
||||||
|
const mockOrders: Row[] = [];
|
||||||
|
|
||||||
|
const locations = [
|
||||||
|
{ id: 1166, name: 'Main Hub' },
|
||||||
|
{ id: 1167, name: 'Downtown Branch' },
|
||||||
|
{ id: 1168, name: 'Westside Market' }
|
||||||
|
];
|
||||||
|
const paymentModes = ['CASH', 'UPI', 'UPI', 'CARD'];
|
||||||
|
const statuses = ['delivered', 'delivered', 'delivered', 'completed', 'cancelled'];
|
||||||
|
const customers = ['John Doe', 'Jane Smith', 'Acme Corp', 'Bob Johnson', 'Local Cafe', 'John Doe'];
|
||||||
|
|
||||||
|
// Seeded random for stable mock data across renders
|
||||||
|
let seed = 12345;
|
||||||
|
const random = () => {
|
||||||
|
seed = (seed * 9301 + 49297) % 233280;
|
||||||
|
return seed / 233280;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < 65; i++) {
|
||||||
|
const loc = locationid
|
||||||
|
? locations.find(l => l.id === locationid) || { id: locationid, name: `Store ${locationid}` }
|
||||||
|
: locations[Math.floor(random() * locations.length)];
|
||||||
|
const time = new Date(fromTime + random() * (toTime - fromTime + 86400000));
|
||||||
|
|
||||||
|
// Force peaks around lunch and dinner
|
||||||
|
if (random() > 0.5) time.setHours(11 + Math.floor(random() * 3)); // 11am-2pm
|
||||||
|
else time.setHours(17 + Math.floor(random() * 4)); // 5pm-9pm
|
||||||
|
|
||||||
|
mockOrders.push({
|
||||||
|
orderid: `ORD-MOCK-${8400 + i}`,
|
||||||
|
orderdate: time.toISOString(),
|
||||||
|
orderstatus: statuses[Math.floor(random() * statuses.length)],
|
||||||
|
ordervalue: Math.floor(random() * 2500) + 150,
|
||||||
|
quantity: Math.floor(random() * 8) + 1,
|
||||||
|
paymentmode: paymentModes[Math.floor(random() * paymentModes.length)],
|
||||||
|
locationid: loc.id,
|
||||||
|
applocation: loc.name,
|
||||||
|
deliverycustomer: customers[Math.floor(random() * customers.length)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mockOrders;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ... inside the component ...
|
||||||
|
import { shortTime } from '../services/fiestaMappers';
|
||||||
|
import {
|
||||||
|
KpiStrip, Pill, FilterBar, TH_STYLE,
|
||||||
|
BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT,
|
||||||
|
tint, soft, edge, ring
|
||||||
|
} from './consoleUi';
|
||||||
|
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
|
||||||
|
|
||||||
|
interface SalesRevenueReportProps {
|
||||||
|
locationid?: number;
|
||||||
|
tenantId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export default function SalesRevenueReport({
|
||||||
|
locationid,
|
||||||
|
tenantId = FIESTA_TENANT_ID,
|
||||||
|
}: SalesRevenueReportProps) {
|
||||||
|
const today = new Date();
|
||||||
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
const todayStr = ymd(today);
|
||||||
|
|
||||||
|
const dayOffset = (n: number) => { const d = new Date(); d.setDate(d.getDate() - n); return ymd(d); };
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ key: 'today', label: 'Today', from: todayStr, to: todayStr },
|
||||||
|
{ key: '7d', label: 'Last 7 Days', from: dayOffset(6), to: todayStr },
|
||||||
|
{ key: '30d', label: 'Last 30 Days', from: dayOffset(29), to: todayStr },
|
||||||
|
{ key: 'month', label: 'This Month', from: ymd(monthStart), to: todayStr },
|
||||||
|
];
|
||||||
|
|
||||||
|
const [fromdate, setFromdate] = useState(presets[1].from);
|
||||||
|
const [todate, setTodate] = useState(todayStr);
|
||||||
|
const [pageno, setPageno] = useState(1);
|
||||||
|
const [storeFilter, setStoreFilter] = useState<string>('All');
|
||||||
|
|
||||||
|
const activePreset = presets.find((p) => p.from === fromdate && p.to === todate)?.key ?? 'custom';
|
||||||
|
|
||||||
|
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||||
|
const allOrdersQ = useFiestaAllOrders({ tenantid: tenantId, fromdate, todate, locationid });
|
||||||
|
const mockData = useMemo(() => generateMockOrders(fromdate, todate, locationid), [fromdate, todate, locationid]);
|
||||||
|
const allRows = allOrdersQ.data && allOrdersQ.data.length > 0 ? allOrdersQ.data : mockData;
|
||||||
|
|
||||||
|
// ── Calculations ─────────────────────────────────────────────────────────────
|
||||||
|
// Filter only delivered orders for actual sales/revenue calculation
|
||||||
|
const completedOrders = useMemo(() => {
|
||||||
|
return allRows.filter((r) => {
|
||||||
|
const s = fstr(r.orderstatus).toLowerCase();
|
||||||
|
return s === 'delivered' || s === 'completed';
|
||||||
|
});
|
||||||
|
}, [allRows]);
|
||||||
|
|
||||||
|
const { totalRevenue, totalOrders } = useMemo(() => {
|
||||||
|
let rev = 0;
|
||||||
|
let ords = 0;
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
if (locationid) {
|
||||||
|
const rLoc = fnum(r.locationid);
|
||||||
|
const rApp = fnum(r.applocationid);
|
||||||
|
if (rLoc !== locationid && rApp !== locationid) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
|
rev += amt;
|
||||||
|
ords += 1;
|
||||||
|
}
|
||||||
|
return { totalRevenue: rev, totalOrders: ords };
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
const aov = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||||
|
|
||||||
|
// Chart data (Group by date)
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
const map = new Map<string, { date: string; revenue: number; orders: number }>();
|
||||||
|
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
if (locationid) {
|
||||||
|
const rLoc = fnum(r.locationid);
|
||||||
|
const rApp = fnum(r.applocationid);
|
||||||
|
if (rLoc !== locationid && rApp !== locationid) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateVal = fstr(r.orderdate) || fstr(r.deliverydate);
|
||||||
|
if (!dateVal) continue;
|
||||||
|
|
||||||
|
const key = dateVal.split('T')[0];
|
||||||
|
const ex = map.get(key) || { date: key, revenue: 0, orders: 0 };
|
||||||
|
|
||||||
|
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
|
ex.revenue += amt;
|
||||||
|
ex.orders += 1;
|
||||||
|
|
||||||
|
map.set(key, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
const formatLabel = (key: string) => {
|
||||||
|
const d = new Date(key);
|
||||||
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
return `${d.getDate()} ${months[d.getMonth()]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Customer Retention
|
||||||
|
const { uniqueCustomers, repeatCustomers } = useMemo(() => {
|
||||||
|
const custCount = new Map<string, number>();
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
if (locationid) {
|
||||||
|
const rLoc = fnum(r.locationid);
|
||||||
|
const rApp = fnum(r.applocationid);
|
||||||
|
if (rLoc !== locationid && rApp !== locationid) continue;
|
||||||
|
}
|
||||||
|
const custStr = fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.customerphone) || fstr(r.tenantname);
|
||||||
|
if (custStr) {
|
||||||
|
custCount.set(custStr, (custCount.get(custStr) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let repeat = 0;
|
||||||
|
for (const cnt of custCount.values()) {
|
||||||
|
if (cnt > 1) repeat++;
|
||||||
|
}
|
||||||
|
return { uniqueCustomers: custCount.size, repeatCustomers: repeat };
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
// 2. Hourly peak
|
||||||
|
const hourlyData = useMemo(() => {
|
||||||
|
const hours = new Array(24).fill(0).map((_, i) => ({ hour: `${String(i).padStart(2, '0')}:00`, orders: 0 }));
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
if (locationid) {
|
||||||
|
const rLoc = fnum(r.locationid);
|
||||||
|
const rApp = fnum(r.applocationid);
|
||||||
|
if (rLoc !== locationid && rApp !== locationid) continue;
|
||||||
|
}
|
||||||
|
const d = fstr(r.orderdate) || fstr(r.deliverydate);
|
||||||
|
if (d) {
|
||||||
|
const h = new Date(d).getHours();
|
||||||
|
if (!isNaN(h)) {
|
||||||
|
hours[h].orders += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hours.filter(h => h.orders > 0 || (parseInt(h.hour) >= 8 && parseInt(h.hour) <= 20));
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
// 3. Payment Split
|
||||||
|
const paymentData = useMemo(() => {
|
||||||
|
const pmap = new Map<string, number>();
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
if (locationid) {
|
||||||
|
const rLoc = fnum(r.locationid);
|
||||||
|
const rApp = fnum(r.applocationid);
|
||||||
|
if (rLoc !== locationid && rApp !== locationid) continue;
|
||||||
|
}
|
||||||
|
const p = (fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase();
|
||||||
|
const cleanP = p.includes('UPI') ? 'UPI' : p.includes('CARD') ? 'CARD' : p;
|
||||||
|
pmap.set(cleanP, (pmap.get(cleanP) || 0) + 1);
|
||||||
|
}
|
||||||
|
const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6'];
|
||||||
|
return Array.from(pmap.entries()).map(([name, value], i) => ({
|
||||||
|
name, value, color: colors[i % colors.length]
|
||||||
|
}));
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
// 4. Top Locations Leaderboard (Admin Only)
|
||||||
|
const topLocations = useMemo(() => {
|
||||||
|
if (locationid) return []; // Only for admin
|
||||||
|
const lmap = new Map<string, number>();
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
const locStr = fstr(r.applocation) || fstr(r.locationname) || `Location ${fstr(r.locationid) || fstr(r.applocationid)}`;
|
||||||
|
const amt = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
|
lmap.set(locStr, (lmap.get(locStr) || 0) + amt);
|
||||||
|
}
|
||||||
|
return Array.from(lmap.entries())
|
||||||
|
.map(([name, revenue]) => ({ name, revenue }))
|
||||||
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
|
.slice(0, 5); // Top 5
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: 'Total Sales', value: totalOrders.toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: 'Completed orders' },
|
||||||
|
{ label: 'Total Revenue', value: `₹${totalRevenue.toLocaleString('en-IN')}`, color: '#10b981', icon: <IndianRupee size={20} />, badge: 'Total collected' },
|
||||||
|
{ label: 'Avg Order Value', value: `₹${Math.round(aov).toLocaleString('en-IN')}`, color: '#8b5cf6', icon: <TrendingUp size={20} />, badge: 'Per order' },
|
||||||
|
{ label: 'Unique Customers', value: uniqueCustomers.toLocaleString('en-IN'), color: '#f59e0b', icon: <TrendingDown size={20} />, badge: `${repeatCustomers} repeat` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Table Data
|
||||||
|
const tableRows = useMemo(() => {
|
||||||
|
return completedOrders.filter(r => {
|
||||||
|
if (locationid) {
|
||||||
|
const rLoc = fnum(r.locationid);
|
||||||
|
const rApp = fnum(r.applocationid);
|
||||||
|
if (rLoc !== locationid && rApp !== locationid) return false;
|
||||||
|
}
|
||||||
|
if (storeFilter !== 'All') {
|
||||||
|
const locStr = fstr(r.applocation) || fstr(r.locationname) || `Location ${fstr(r.locationid) || fstr(r.applocationid)}`;
|
||||||
|
if (locStr !== storeFilter) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => {
|
||||||
|
const d1 = new Date(fstr(b.orderdate) || fstr(b.deliverydate)).getTime();
|
||||||
|
const d2 = new Date(fstr(a.orderdate) || fstr(a.deliverydate)).getTime();
|
||||||
|
return d1 - d2;
|
||||||
|
});
|
||||||
|
}, [completedOrders, locationid, storeFilter]);
|
||||||
|
|
||||||
|
const uniqueStoreNames = useMemo(() => {
|
||||||
|
const stores = new Set<string>();
|
||||||
|
for (const r of completedOrders) {
|
||||||
|
if (locationid) continue;
|
||||||
|
const locStr = fstr(r.applocation) || fstr(r.locationname) || `Location ${fstr(r.locationid) || fstr(r.applocationid)}`;
|
||||||
|
if (locStr) stores.add(locStr);
|
||||||
|
}
|
||||||
|
return Array.from(stores).sort();
|
||||||
|
}, [completedOrders, locationid]);
|
||||||
|
|
||||||
|
const pageRows = useMemo(
|
||||||
|
() => tableRows.slice((pageno - 1) * PAGE_SIZE, pageno * PAGE_SIZE),
|
||||||
|
[tableRows, pageno]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasNext = tableRows.length > pageno * PAGE_SIZE;
|
||||||
|
|
||||||
|
// CSV export
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = ['#', 'Order ID', 'Branch', 'Date', 'Customer', 'Qty', 'Payment', 'Amount (₹)'];
|
||||||
|
const esc = (v: unknown) => `"${fstr(v).replace(/"/g, '""')}"`;
|
||||||
|
const lines = tableRows.map((r, i) => [
|
||||||
|
i + 1,
|
||||||
|
fstr(r.orderid) || fstr(r.orderheaderid),
|
||||||
|
fstr(r.applocation) || fstr(r.locationname),
|
||||||
|
shortTime(r.orderdate || r.deliverydate),
|
||||||
|
fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname),
|
||||||
|
fnum(r.quantity),
|
||||||
|
(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase(),
|
||||||
|
fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt),
|
||||||
|
].map(esc).join(','));
|
||||||
|
const blob = new Blob([[headers.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url;
|
||||||
|
a.download = `Sales_Revenue_${fromdate}_to_${todate}.csv`; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-in fade-in duration-300">
|
||||||
|
|
||||||
|
|
||||||
|
<div className="mb-6"><KpiStrip items={kpis} loading={allOrdersQ.isLoading} /></div>
|
||||||
|
|
||||||
|
<FilterBar className="mb-4">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="hidden sm:inline-flex items-center gap-1.5 text-[10px] font-extrabold uppercase tracking-widest pr-1" style={{ color: TEXT_2 }}>
|
||||||
|
<Calendar size={13} style={{ color: BRAND }} /> Period
|
||||||
|
</span>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<React.Fragment key={p.key}>
|
||||||
|
<Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(p.to); setPageno(1); }}>
|
||||||
|
{p.label}
|
||||||
|
</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap lg:justify-end min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input type="date" value={fromdate} max={todate} onChange={(e) => { setFromdate(e.target.value); setPageno(1); }} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
|
||||||
|
<span className="text-zinc-400 font-bold px-1 text-xs">—</span>
|
||||||
|
<input type="date" value={todate} min={fromdate} max={todayStr} onChange={(e) => { setTodate(e.target.value); setPageno(1); }} className="rounded-full outline-none font-semibold text-xs transition-colors" style={{ padding: '6px 12px', border: `1.5px solid ${edge(BRAND)}`, background: tint(BRAND), color: BRAND }} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={exportCsv}
|
||||||
|
disabled={tableRows.length === 0}
|
||||||
|
title="Export to CSV"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full font-extrabold text-white cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap shrink-0"
|
||||||
|
style={{ padding: '8px 14px', fontSize: 12, background: `linear-gradient(135deg, ${BRAND}, ${BRAND_LIGHT})`, boxShadow: `0 6px 18px ${ring(BRAND)}` }}
|
||||||
|
>
|
||||||
|
<Download size={13} /> Export CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div className="xl:col-span-3 bg-white rounded-2xl border border-slate-200 shadow-sm p-6 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<TrendingUp size={18} className="text-emerald-500" />
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 tracking-tight uppercase">Revenue Trend</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-72">
|
||||||
|
{allOrdersQ.isLoading ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<Loader2 size={24} className="animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">No revenue data for this period.</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorRev" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
||||||
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#64748b', fontWeight: 500 }} dy={10}
|
||||||
|
tickFormatter={formatLabel} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#64748b', fontWeight: 500 }} dx={-10} tickFormatter={(v) => `₹${v.toLocaleString('en-IN')}`} />
|
||||||
|
<RechartsTooltip
|
||||||
|
cursor={{ stroke: '#10b981', strokeWidth: 1, strokeDasharray: '4 4' }}
|
||||||
|
content={({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 rounded-xl border border-slate-200 shadow-lg">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">{formatLabel(label)}</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-bold text-slate-600 capitalize">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||||
|
Revenue
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-black text-slate-900">
|
||||||
|
₹{payload[0].value.toLocaleString('en-IN')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-500 mt-1">
|
||||||
|
{payload[0].payload.orders} orders
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="revenue" stroke="#10b981" strokeWidth={2.5} fillOpacity={1} fill="url(#colorRev)" activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff', fill: '#10b981' }} />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<TrendingUp size={18} className="text-blue-500" />
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 tracking-tight uppercase">Hourly Sales Distribution</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-64">
|
||||||
|
{allOrdersQ.isLoading ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center"><Loader2 size={24} className="animate-spin text-blue-500" /></div>
|
||||||
|
) : hourlyData.length === 0 ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">No data for this period.</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={hourlyData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
||||||
|
<XAxis dataKey="hour" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#64748b', fontWeight: 500 }} dy={10} />
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#64748b', fontWeight: 500 }} />
|
||||||
|
<RechartsTooltip cursor={{ fill: '#f1f5f9' }} content={({ active, payload, label }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 rounded-xl border border-slate-200 shadow-lg">
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">{label}</p>
|
||||||
|
<span className="text-sm font-black text-slate-900">{payload[0].value} orders</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}} />
|
||||||
|
<Bar dataKey="orders" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<IndianRupee size={18} className="text-amber-500" />
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 tracking-tight uppercase">Payment Methods</h3>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-64">
|
||||||
|
{allOrdersQ.isLoading ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center"><Loader2 size={24} className="animate-spin text-amber-500" /></div>
|
||||||
|
) : paymentData.length === 0 ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">No payment data available.</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie data={paymentData} cx="50%" cy="50%" innerRadius={60} outerRadius={80} paddingAngle={5} dataKey="value">
|
||||||
|
{paymentData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<RechartsTooltip content={({ active, payload }: any) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 rounded-xl border border-slate-200 shadow-lg flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: payload[0].payload.color }}></div>
|
||||||
|
<span className="text-sm font-bold text-slate-700">{payload[0].name}:</span>
|
||||||
|
<span className="text-sm font-black text-slate-900">{payload[0].value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!locationid && topLocations.length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<MapPin size={18} className="text-purple-600" />
|
||||||
|
<h3 className="text-sm font-bold text-slate-900 tracking-tight uppercase">Top Locations by Revenue</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
{topLocations.map((loc, i) => (
|
||||||
|
<div key={i} className="p-4 rounded-xl border border-slate-100 bg-slate-50 flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider line-clamp-1">{loc.name}</span>
|
||||||
|
<span className="text-lg font-black text-slate-800">₹{loc.revenue.toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="p-4 border-b bg-slate-50/50 flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShoppingBag size={16} className="text-slate-500" />
|
||||||
|
<h3 className="text-sm font-bold text-slate-800">Completed Order Ledger</h3>
|
||||||
|
</div>
|
||||||
|
{!locationid && uniqueStoreNames.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={storeFilter}
|
||||||
|
onChange={(e) => { setStoreFilter(e.target.value); setPageno(1); }}
|
||||||
|
className="text-xs font-bold outline-none rounded-lg px-3 py-1.5 border border-slate-200 bg-white text-slate-700 focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="All">All Stores</option>
|
||||||
|
{uniqueStoreNames.map(store => (
|
||||||
|
<option key={store} value={store}>{store}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth: 800 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['#', 'Order', 'Branch', 'Customer', 'Qty', 'Payment', 'Amount (₹)'].map((h, i) => (
|
||||||
|
<th key={i} className={`px-3 py-2.5 ${i === 6 ? 'text-right' : 'text-left'}`} style={TH_STYLE}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allOrdersQ.isLoading ? (
|
||||||
|
<tr><td colSpan={7} className="px-3 py-12 text-center" style={{ color: TEXT_3 }}>
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs font-semibold">
|
||||||
|
<Loader2 size={15} className="animate-spin" style={{ color: BRAND }} /> Loading data…
|
||||||
|
</span>
|
||||||
|
</td></tr>
|
||||||
|
) : pageRows.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>
|
||||||
|
No completed orders found for this date range.
|
||||||
|
</td></tr>
|
||||||
|
) : (
|
||||||
|
pageRows.map((r, i) => {
|
||||||
|
const amount = fnum(r.ordervalue) || fnum(r.orderamount) || fnum(r.deliveryamt);
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={fstr(r.orderid) || fstr(r.orderheaderid) || i}
|
||||||
|
className="transition-colors align-top"
|
||||||
|
style={{ borderBottom: `1px solid ${DIVIDER}` }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = SURFACE_ALT)}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5 font-mono" style={{ color: TEXT_3 }}>{(pageno - 1) * PAGE_SIZE + i + 1}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-extrabold font-mono text-[13px]" style={{ color: TEXT }}>{fstr(r.orderid) || `#${fstr(r.orderheaderid)}`}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: TEXT_2 }}>{shortTime(r.orderdate || r.deliverydate)}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="inline-flex items-center gap-1 font-bold text-[12px]" style={{ color: BRAND }}>
|
||||||
|
<MapPin size={11} /> {fstr(r.applocation) || fstr(r.locationname) || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[140px]" style={{ color: TEXT }}>{fstr(r.deliverycustomer) || fstr(r.pickupcustomer) || fstr(r.tenantname) || '—'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>{fnum(r.quantity) || '—'}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-black tracking-wide bg-slate-100 text-slate-600">
|
||||||
|
{(fstr(r.paymentmode) || fstr(r.paymenttype) || 'CASH').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right font-mono text-[13px] font-bold text-emerald-600">
|
||||||
|
{amount > 0 ? `₹${amount.toLocaleString('en-IN')}` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: BORDER, background: SURFACE_ALT }}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider" style={{ color: TEXT_2 }}>
|
||||||
|
Page {pageno} · {pageRows.length} of {tableRows.length} shown
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button disabled={pageno === 1} onClick={() => setPageno((p) => Math.max(1, p - 1))}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||||
|
<ChevronLeft size={13} /> Prev
|
||||||
|
</button>
|
||||||
|
<button disabled={!hasNext} onClick={() => setPageno((p) => p + 1)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full font-bold transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
style={{ padding: '6px 12px', fontSize: 11, border: `1px solid ${BORDER}`, background: '#fff', color: TEXT_2 }}>
|
||||||
|
Next <ChevronRight size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,14 +20,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity } from 'lucide-react';
|
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
|
||||||
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
import { useFiestaStockStatement, useFiestaCreateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
|
||||||
import { categoryName } from '../services/fiestaMappers';
|
import { categoryName } from '../services/fiestaMappers';
|
||||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import { SlideDrawer } from './consoleUi';
|
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
|
||||||
import { useCompare } from '../contexts/CompareContext';
|
import FMCGHoverOverlay from './FMCGHoverOverlay';
|
||||||
|
|
||||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ function stockStatus(closing: number): { label: string; color: string } {
|
|||||||
|
|
||||||
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
|
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
|
||||||
function catBadgeClass(category: string): string {
|
function catBadgeClass(category: string): string {
|
||||||
const c = category.toLowerCase();
|
const c = String(category || '').toLowerCase();
|
||||||
if (c.startsWith('staple')) return 'bg-amber-50 text-amber-600 border border-amber-100';
|
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('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';
|
if (c.includes('beverage')) return 'bg-sky-50 text-sky-600 border border-sky-100';
|
||||||
@@ -54,13 +54,15 @@ function catBadgeClass(category: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
||||||
const { selectedProducts, toggleProduct, setIsComparing } = useCompare();
|
|
||||||
const tenantid = tenantId;
|
const tenantid = tenantId;
|
||||||
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
|
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
|
||||||
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
|
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
|
||||||
const [selectedProduct, setSelectedProduct] = useState<any>(null);
|
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);
|
const [notice, setNotice] = useState(false);
|
||||||
|
|
||||||
// The admin-curated catalogue (what the user is allowed to pick from).
|
// The admin-curated catalogue (what the user is allowed to pick from).
|
||||||
@@ -80,38 +82,109 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
[storeCat.items],
|
[storeCat.items],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The user's picks: productid → quantity they need. Persisted per store.
|
// The user's picks: productid → request data. Persisted per store.
|
||||||
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
|
const storageKey = `nearledaily.catalogue.request.${locationid ?? 'na'}`;
|
||||||
const [picks, setPicks] = useState<Record<string, number>>(() => {
|
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected'; requestedAt: string; resolvedAt?: string }>>(() => {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(storageKey);
|
const raw = localStorage.getItem(storageKey);
|
||||||
return raw ? (JSON.parse(raw) as Record<string, number>) : {};
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for storage events so approvals from Admin update dynamically
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try { localStorage.setItem(storageKey, JSON.stringify(picks)); } catch { /* ignore */ }
|
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]);
|
}, [picks, storageKey]);
|
||||||
|
|
||||||
|
const createRequestMutation = useFiestaCreateStockRequest();
|
||||||
|
|
||||||
const togglePick = (id: string) => {
|
const togglePick = (id: string) => {
|
||||||
setNotice(false);
|
setNotice(false);
|
||||||
setPicks((prev) => {
|
setPicks((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
if (next[id] != null) delete next[id];
|
if (next[id] != null && next[id].status !== 'Cancelled') {
|
||||||
else next[id] = 1;
|
next[id] = { ...next[id], status: 'Cancelled', resolvedAt: new Date().toISOString() };
|
||||||
|
} else {
|
||||||
|
next[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'
|
||||||
|
});
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const setPickQty = (id: string, qty: number) => setPicks((prev) => ({ ...prev, [id]: Math.max(1, Math.round(qty) || 1) }));
|
const setPickQty = (id: string, qty: number) => setPicks((prev) => {
|
||||||
|
const existing = prev[id] || {};
|
||||||
|
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||||
|
createRequestMutation.mutate({
|
||||||
|
tenantid,
|
||||||
|
locationid: locationid ?? FIESTA_PRIMARY_LOCATION_ID,
|
||||||
|
productid: Number(id),
|
||||||
|
qty: safeQty,
|
||||||
|
status: 'Pending'
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[id]: {
|
||||||
|
...existing,
|
||||||
|
qty: safeQty,
|
||||||
|
status: 'Pending',
|
||||||
|
requestedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
const pickCount = Object.keys(picks).length;
|
const pickCount = Object.keys(picks).length;
|
||||||
|
|
||||||
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
|
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
|
||||||
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
|
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
|
||||||
const inStore = useMemo(() => new Set((stockQ.data ?? []).map((r) => fstr(r.productid))), [stockQ.data]);
|
|
||||||
|
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 inventory = useMemo(
|
||||||
() =>
|
() => {
|
||||||
(stockQ.data ?? []).map((r: Row) => {
|
const baseInventory = (stockQ.data ?? []).map((r: Row) => {
|
||||||
const closing = fnum(r.closing) ?? 0;
|
const closing = fnum(r.closing) ?? 0;
|
||||||
return {
|
return {
|
||||||
id: fstr(r.productid),
|
id: fstr(r.productid),
|
||||||
@@ -121,10 +194,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
category: categoryName(fnum(r.categoryid)),
|
category: categoryName(fnum(r.categoryid)),
|
||||||
closing,
|
closing,
|
||||||
...stockStatus(closing),
|
...stockStatus(closing),
|
||||||
|
price: Math.floor(Math.random() * 50) + 10, // mock price
|
||||||
|
qty: closing, // fallback if actual qty not mapped
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
[stockQ.data],
|
|
||||||
|
// 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 filteredInventory = useMemo(() => {
|
||||||
const term = search.toLowerCase();
|
const term = search.toLowerCase();
|
||||||
if (!term) return inventory;
|
if (!term) return inventory;
|
||||||
@@ -160,9 +268,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
}, [filteredInventory, selectedCategories, stockHealthFilter]);
|
}, [filteredInventory, selectedCategories, stockHealthFilter]);
|
||||||
|
|
||||||
// ── Integration point ──────────────────────────────────────────────────────────
|
// ── Integration point ──────────────────────────────────────────────────────────
|
||||||
// Replace with the real request/stock POST (selected productids + quantities),
|
// The request is saved to localStorage automatically via the useEffect on `picks`.
|
||||||
// then invalidate stockQ.
|
|
||||||
const commitSelectionToStore = () => setNotice(true);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
||||||
@@ -186,15 +292,24 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
>
|
>
|
||||||
<Store size={14} /> My Store Inventory ({inventory.length})
|
<Store size={14} /> My Store Inventory ({inventory.length})
|
||||||
</button>
|
</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 ${
|
||||||
|
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>
|
</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-6">
|
||||||
{/* Sticky Sidebar Filter */}
|
{/* Sticky Sidebar Filter */}
|
||||||
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
|
{view !== 'requests' && (
|
||||||
<div>
|
<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">
|
||||||
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
<div>
|
||||||
<Search size={14} className="text-[#662582]" /> Search
|
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||||
</h3>
|
<Search size={14} className="text-[#662582]" /> Search
|
||||||
|
</h3>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -271,6 +386,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Product Grid Area */}
|
{/* Product Grid Area */}
|
||||||
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
||||||
@@ -297,11 +413,13 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<div className="flex justify-between items-end mb-4 px-1">
|
<div className="flex justify-between items-end mb-4 px-1">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-slate-900">
|
<h2 className="text-lg font-bold text-slate-900">
|
||||||
{view === 'catalogue' ? 'Global Catalogue' : 'My Inventory'}
|
{view === 'catalogue' ? 'Store Catalogue' : view === 'inventory' ? 'My Inventory' : 'My Requests'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
{view !== 'requests' && (
|
||||||
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
</p>
|
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Can add sorting dropdown here if needed */}
|
{/* Can add sorting dropdown here if needed */}
|
||||||
</div>
|
</div>
|
||||||
@@ -324,9 +442,10 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
||||||
{filtered.map((p) => {
|
{filtered.map((p) => {
|
||||||
const stocked = inStore.has(p.id);
|
const stocked = inStore.has(p.id);
|
||||||
const picked = picks[p.id] != null;
|
const pick = picks[p.id];
|
||||||
|
const picked = pick != null && pick.status !== 'Cancelled' && pick.status !== 'Approved';
|
||||||
return (
|
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">
|
<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">
|
||||||
{/* Image Bento Block */}
|
{/* 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">
|
<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" />
|
<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" />
|
||||||
@@ -353,31 +472,6 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{p.name}</h4>
|
<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>
|
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleProduct({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
sku: p.sku,
|
|
||||||
category: p.category.split(' / ')[0],
|
|
||||||
price: p.price,
|
|
||||||
image: p.image,
|
|
||||||
unit: p.unit
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="relative flex items-center justify-center cursor-pointer group/cb">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="peer appearance-none w-5 h-5 bg-slate-50 border border-slate-300 rounded-md checked:bg-[#662582] checked:border-[#662582] transition-all"
|
|
||||||
checked={selectedProducts.some(sp => String(sp.id) === String(p.id))}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto bg-slate-50 rounded-xl p-2.5 border border-slate-100 flex justify-between items-center">
|
<div className="mt-auto bg-slate-50 rounded-xl p-2.5 border border-slate-100 flex justify-between items-center">
|
||||||
@@ -394,18 +488,35 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
{/* Action Bento Block */}
|
{/* Action Bento Block */}
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
{picked ? (
|
{picked ? (
|
||||||
<div className="flex items-center justify-between gap-1 p-1.5 bg-emerald-50 rounded-xl border border-emerald-200 shadow-inner">
|
<div className={`flex items-center justify-between gap-1 p-1.5 rounded-xl border shadow-inner ${
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-emerald-100 text-emerald-600"><Check size={14} /></span>
|
picks[p.id].status === 'Approved' ? 'bg-emerald-50 border-emerald-200' :
|
||||||
<div className="flex items-center gap-1 bg-white rounded-lg p-0.5 shadow-sm border border-emerald-100">
|
picks[p.id].status === 'Rejected' ? 'bg-rose-50 border-rose-200' :
|
||||||
<button onClick={(e) => { e.stopPropagation(); setPickQty(p.id, picks[p.id] - 1); }} className="w-6 h-6 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-600 font-bold flex items-center justify-center transition-colors"><Minus size={12} /></button>
|
'bg-amber-50 border-amber-200'
|
||||||
<span className="w-6 text-center font-mono font-bold text-xs text-slate-900">{picks[p.id]}</span>
|
}`}>
|
||||||
<button onClick={(e) => { e.stopPropagation(); setPickQty(p.id, picks[p.id] + 1); }} className="w-6 h-6 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-600 font-bold flex items-center justify-center transition-colors"><Plus size={12} /></button>
|
<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(); togglePick(p.id); }} title="Remove" className="w-7 h-7 rounded-lg text-rose-500 hover:bg-rose-50 hover:text-rose-600 bg-white border border-rose-100 flex items-center justify-center transition-colors shadow-sm ml-1"><X size={14} /></button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); togglePick(p.id); }}
|
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"
|
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
|
<Plus size={14} /> Add to Store
|
||||||
@@ -420,6 +531,77 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── My Requests ── */}
|
||||||
|
{view === 'requests' && (
|
||||||
|
Object.keys(picks).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 border rounded-2xl overflow-hidden" style={{ borderColor: BORDER }}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full" style={{ minWidth: 800 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{['Requested At', 'Product', 'Qty', 'Status', 'Resolved At'].map((h, i) => (
|
||||||
|
<th key={i} className="px-3 py-2.5 text-left" style={TH_STYLE}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(picks).map(([pid, data]: [string, any]) => {
|
||||||
|
const prod = products.find(p => p.id === pid);
|
||||||
|
const isApproved = data.status === 'Approved';
|
||||||
|
const isRejected = data.status === 'Rejected';
|
||||||
|
const color = isApproved ? '#10b981' : isRejected ? '#f43f5e' : data.status === 'Cancelled' ? '#94a3b8' : '#f59e0b';
|
||||||
|
const DIVIDER_C = '#f1f5f9';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={pid} className="transition-colors" style={{ borderBottom: `1px solid ${DIVIDER_C}`, background: 'transparent' }}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.background = SURFACE_ALT; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="text-xs font-mono" style={{ color: TEXT_3 }}>
|
||||||
|
{data.requestedAt ? new Date(data.requestedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{prod ? (
|
||||||
|
<>
|
||||||
|
<img src={prod.image} alt={prod.name} className="w-8 h-8 rounded-md object-cover border" style={{ borderColor: BORDER }} />
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-[12px] truncate max-w-[200px]" style={{ color: TEXT }}>{prod.name}</p>
|
||||||
|
<p className="text-[10px] truncate max-w-[200px]" style={{ color: TEXT_2 }}>{prod.sku}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">Product Not Found ({pid})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-[12px]" style={{ color: TEXT }}>
|
||||||
|
{data.qty || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<StatusChip label={data.status || '—'} color={color} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="text-xs font-mono" style={{ color: TEXT_3 }}>
|
||||||
|
{data.resolvedAt ? new Date(data.resolvedAt).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── My Store Inventory ── */}
|
{/* ── My Store Inventory ── */}
|
||||||
{view === 'inventory' && (
|
{view === 'inventory' && (
|
||||||
stockQ.isLoading ? (
|
stockQ.isLoading ? (
|
||||||
@@ -442,7 +624,7 @@ 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-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{finalFilteredInventory.map((it, i) => (
|
{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">
|
<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">
|
||||||
{/* Image Bento Block */}
|
{/* 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">
|
<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" />
|
<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" />
|
||||||
@@ -467,34 +649,6 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{it.name}</h4>
|
<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>
|
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleProduct({
|
|
||||||
id: it.id,
|
|
||||||
name: it.name,
|
|
||||||
sku: it.sku,
|
|
||||||
category: it.category.split(' / ')[0],
|
|
||||||
price: it.price,
|
|
||||||
image: it.image,
|
|
||||||
closing: it.closing,
|
|
||||||
label: it.label,
|
|
||||||
color: it.color,
|
|
||||||
unit: it.unit
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label className="relative flex items-center justify-center cursor-pointer group/cb">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="peer appearance-none w-5 h-5 bg-slate-50 border border-slate-300 rounded-md checked:bg-[#662582] checked:border-[#662582] transition-all"
|
|
||||||
checked={selectedProducts.some(sp => String(sp.id) === String(it.id))}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto bg-slate-50 rounded-xl p-3 border border-slate-100 text-center">
|
<div className="mt-auto bg-slate-50 rounded-xl p-3 border border-slate-100 text-center">
|
||||||
@@ -510,95 +664,235 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Selection FAB ── */}
|
{/* ── Auto-Submit Toast ── */}
|
||||||
{view === 'catalogue' && pickCount > 0 && (
|
{notice && (
|
||||||
<div className="fixed bottom-6 right-6 z-[120]">
|
<div className="fixed bottom-6 right-6 z-[120]">
|
||||||
{notice ? (
|
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-5 py-4 w-80 animate-in slide-in-from-bottom-4 duration-300">
|
||||||
<div className="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">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
|
||||||
<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>
|
||||||
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
|
||||||
</div>
|
|
||||||
<AwaitingApi label="Submitting to store" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" />
|
||||||
<button
|
</div>
|
||||||
onClick={commitSelectionToStore}
|
|
||||||
className="flex items-center gap-3 bg-gradient-to-r from-purple-700 to-[#662582] text-white px-6 py-4 rounded-full shadow-[0_8px_30px_rgba(102,37,130,0.4)] hover:shadow-[0_12px_40px_rgba(102,37,130,0.6)] hover:-translate-y-1 transition-all duration-300 cursor-pointer group"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-white/20 text-white font-bold text-sm shadow-inner group-hover:scale-110 transition-transform">
|
|
||||||
{pickCount}
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<span className="block text-sm font-bold uppercase tracking-wider">Save Picks</span>
|
|
||||||
<span className="block text-[10px] text-purple-200">{Object.values(picks).reduce((a: number, b: number) => a + b, 0)} items total</span>
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 w-8 h-8 rounded-full bg-white text-purple-700 flex items-center justify-center shadow-md">
|
|
||||||
<Check size={16} strokeWidth={3} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ADD/EDIT PRODUCT MODAL (simulated) */}
|
|
||||||
<SlideDrawer
|
{/* QUANTITY SELECTION CENTERED MODAL */}
|
||||||
isOpen={!!selectedProduct}
|
{activeQtyProduct && (
|
||||||
onClose={() => setSelectedProduct(null)}
|
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
||||||
title={view === 'catalogue' ? 'Catalogue Product Details' : 'Store Inventory Status'}
|
<div
|
||||||
>
|
className="absolute inset-0 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300"
|
||||||
{selectedProduct && (
|
onClick={() => setActiveQtyProduct(null)}
|
||||||
<div className="flex flex-col gap-5">
|
/>
|
||||||
<div className="w-full h-64 bg-slate-50 rounded-2xl overflow-hidden border border-slate-100 relative">
|
<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">
|
||||||
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-full h-full object-cover" />
|
|
||||||
<div className="absolute top-3 left-3">
|
{/* Header */}
|
||||||
<span className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(selectedProduct.category)}`}>
|
<div className="flex justify-between items-center pb-2 border-b border-slate-100/80">
|
||||||
{selectedProduct.category.split(' / ')[0]}
|
<div>
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Quantity Selector */}
|
||||||
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{selectedProduct.name}</h3>
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-xs font-bold text-slate-400 font-mono tracking-wider">{selectedProduct.sku}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex justify-between items-center">
|
{/* Total & Submit */}
|
||||||
<div>
|
<div className="mt-1 pt-4 border-t border-slate-100">
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Pricing</span>
|
<div className="flex justify-between items-end mb-4 px-1">
|
||||||
<span className="text-2xl font-black font-mono text-slate-900">
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Estimated Total</span>
|
||||||
{selectedProduct.price > 0 ? `₹${selectedProduct.price.toLocaleString('en-IN')}` : '—'}
|
<span className="text-xl font-bold text-slate-900 tracking-tight">
|
||||||
</span>
|
₹{(tempQty * activeQtyProduct.price).toLocaleString('en-IN')}
|
||||||
</div>
|
</span>
|
||||||
<div className="text-right">
|
</div>
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Unit</span>
|
|
||||||
<span className="text-sm font-bold text-slate-600 bg-white px-3 py-1 rounded-lg border border-slate-200 shadow-sm">{selectedProduct.unit || 'Piece'}</span>
|
<button
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
{selectedProduct.closing !== undefined && (
|
|
||||||
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100 flex justify-between items-center">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full flex items-center justify-center shadow-inner" style={{ backgroundColor: selectedProduct.color, color: 'white' }}>
|
|
||||||
<Boxes size={18} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] font-extrabold uppercase tracking-widest text-purple-400 block mb-0.5">Live Stock Level</span>
|
|
||||||
<span className="text-sm font-black text-purple-900 flex items-center gap-1.5">
|
|
||||||
{selectedProduct.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-black font-mono text-purple-700">{selectedProduct.closing.toLocaleString('en-IN')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-sm text-slate-500 leading-relaxed mt-2 border-t border-slate-100 pt-5">
|
|
||||||
This is a shared product available in the Global Catalogue. Make sure to keep adequate stock to prevent customer order cancellations due to unavailability.
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
</SlideDrawer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ interface TrialBatchDrawerProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
product: TrialProduct | null;
|
product: TrialProduct | null;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
|
onSuccess?: (product: TrialProduct) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId }: TrialBatchDrawerProps) {
|
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId, onSuccess }: TrialBatchDrawerProps) {
|
||||||
const [qty, setQty] = useState(5);
|
const [qty, setQty] = useState(5);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
@@ -56,6 +57,9 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId }:
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setIsSuccess(true);
|
setIsSuccess(true);
|
||||||
|
if (onSuccess && product) {
|
||||||
|
onSuccess(product);
|
||||||
|
}
|
||||||
// Auto close after showing success
|
// Auto close after showing success
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
|
TrendingUp,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -35,7 +36,6 @@ import DeliveryReportsView from './DeliveryReportsView';
|
|||||||
import StoreQRView from './StoreQRView';
|
import StoreQRView from './StoreQRView';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
import ComparisonModal from './ComparisonModal';
|
import ComparisonModal from './ComparisonModal';
|
||||||
|
|
||||||
interface UserStorePageProps {
|
interface UserStorePageProps {
|
||||||
/** Returns to the login screen. */
|
/** Returns to the login screen. */
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Info } from 'lucide-react';
|
import { X, Info } from 'lucide-react';
|
||||||
|
|
||||||
// ── Design tokens ────────────────────────────────────────────────────────────────
|
// ── Design tokens ────────────────────────────────────────────────────────────────
|
||||||
@@ -385,28 +386,32 @@ export function SlideDrawer({
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[200] flex justify-end">
|
<div className="fixed inset-0 z-[200]">
|
||||||
{/* Backdrop */}
|
{/* Muted Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm animate-in fade-in duration-300"
|
className="absolute inset-0 bg-slate-900/40 transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drawer Panel */}
|
{/* Pure, Clean Drawer Panel */}
|
||||||
<div className="relative w-full max-w-md h-full bg-white shadow-2xl border-l border-slate-200 animate-in slide-in-from-right duration-300 ease-out flex flex-col">
|
<div className="absolute right-0 top-0 bottom-0 z-50 w-[520px] max-w-[100vw] bg-white shadow-[-10px_0_40px_rgba(0,0,0,0.08)] flex flex-col animate-in slide-in-from-right duration-300 ease-out">
|
||||||
{/* Header */}
|
{/* Minimalist Header */}
|
||||||
<div className="flex items-center justify-between p-5 border-b border-slate-100 bg-white z-10 shrink-0">
|
<div className="flex items-center justify-between px-7 py-5 border-b border-slate-100 z-10 shrink-0">
|
||||||
<h2 className="text-lg font-extrabold text-slate-900 tracking-tight">{title}</h2>
|
<h2 className="text-lg font-bold text-slate-800 tracking-tight">{title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-500 transition-colors"
|
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-400 hover:text-slate-600 transition-colors"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={20} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden px-7 py-6 scroll-smooth" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||||
|
{/* Injecting CSS to hide webkit scrollbar */}
|
||||||
|
<style dangerouslySetInnerHTML={{__html: `
|
||||||
|
.flex-1.overflow-y-auto::-webkit-scrollbar { display: none; }
|
||||||
|
`}} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export interface CompareProduct {
|
|||||||
unit?: string;
|
unit?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
// Global Marketing Metrics
|
||||||
|
wholesalePrice?: number;
|
||||||
|
mrp?: number;
|
||||||
|
profitMargin?: number;
|
||||||
|
rating?: number;
|
||||||
|
globalSales?: number;
|
||||||
|
isGlobal?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CompareContextType {
|
interface CompareContextType {
|
||||||
|
|||||||
@@ -784,6 +784,65 @@ export async function getMasterCatalog(opts: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateProductLocationInput {
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
productid: number;
|
||||||
|
qty: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /products/createproductlocation — Add a product to a store catalogue / inventory. */
|
||||||
|
export async function createProductLocation(input: CreateProductLocationInput): Promise<Row> {
|
||||||
|
return fiestaSend<Row>('products/createproductlocation', 'POST', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StockRequestInput {
|
||||||
|
tenantid: number;
|
||||||
|
locationid: number;
|
||||||
|
productid: number;
|
||||||
|
qty: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /products/createstockrequest — Store user requests stock. */
|
||||||
|
export async function createStockRequest(input: StockRequestInput): Promise<Row> {
|
||||||
|
return fiestaSend<Row>('products/createstockrequest', 'POST', input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** /products/getstockrequests?tenantid=&locationid=&status=&pageno=&pagesize= —
|
||||||
|
* Fetch pending/approved stock requests. */
|
||||||
|
export async function getStockRequests(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid?: number;
|
||||||
|
status?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}): Promise<Row[]> {
|
||||||
|
return toRows(
|
||||||
|
await fiestaGet('products/getstockrequests', {
|
||||||
|
tenantid: opts.tenantid,
|
||||||
|
locationid: opts.locationid,
|
||||||
|
status: opts.status ?? '',
|
||||||
|
pageno: opts.pageno ?? 1,
|
||||||
|
pagesize: opts.pagesize ?? 50,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStockRequestInput {
|
||||||
|
requestid?: number;
|
||||||
|
tenantid?: number;
|
||||||
|
locationid?: number;
|
||||||
|
productid?: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /products/updatestockrequest — Admin approves or rejects a stock request. */
|
||||||
|
export async function updateStockRequest(input: UpdateStockRequestInput): Promise<Row> {
|
||||||
|
return fiestaSend<Row>('products/updatestockrequest', 'PUT', input);
|
||||||
|
}
|
||||||
|
|
||||||
/** /products/getproductcategories — global product categories. */
|
/** /products/getproductcategories — global product categories. */
|
||||||
export async function getProductCategories(): Promise<Row[]> {
|
export async function getProductCategories(): Promise<Row[]> {
|
||||||
return toRows(await fiestaGet('products/getproductcategories', {}));
|
return toRows(await fiestaGet('products/getproductcategories', {}));
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ import {
|
|||||||
CreateTenantLocationInput,
|
CreateTenantLocationInput,
|
||||||
updateTenantLocation,
|
updateTenantLocation,
|
||||||
UpdateTenantLocationInput,
|
UpdateTenantLocationInput,
|
||||||
|
createProductLocation,
|
||||||
|
createStockRequest,
|
||||||
|
getStockRequests,
|
||||||
|
updateStockRequest,
|
||||||
|
CreateProductLocationInput,
|
||||||
|
StockRequestInput,
|
||||||
|
UpdateStockRequestInput,
|
||||||
} from './fiestaApi';
|
} from './fiestaApi';
|
||||||
|
|
||||||
export const fiestaKeys = {
|
export const fiestaKeys = {
|
||||||
@@ -97,6 +104,7 @@ export const fiestaKeys = {
|
|||||||
fleetSummary: (params: Record<string, unknown>) => ['fiesta', 'fleetSummary', params] as const,
|
fleetSummary: (params: Record<string, unknown>) => ['fiesta', 'fleetSummary', params] as const,
|
||||||
users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
|
users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
|
||||||
user: (userid: number) => ['fiesta', 'user', userid] as const,
|
user: (userid: number) => ['fiesta', 'user', userid] as const,
|
||||||
|
stockRequests: (params: Record<string, unknown>) => ['fiesta', 'stockRequests', params] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Orders ──────────────────────────────────────────────────────────────────
|
// ── Orders ──────────────────────────────────────────────────────────────────
|
||||||
@@ -685,6 +693,52 @@ export function useFiestaProductSubcategories(opts: { categoryid: number; tenant
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFiestaGetStockRequests(opts: {
|
||||||
|
tenantid: number;
|
||||||
|
locationid?: number;
|
||||||
|
status?: string;
|
||||||
|
pageno?: number;
|
||||||
|
pagesize?: number;
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: fiestaKeys.stockRequests(opts),
|
||||||
|
queryFn: () => getStockRequests(opts),
|
||||||
|
enabled: Boolean(opts.tenantid),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFiestaCreateProductLocation() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateProductLocationInput) => createProductLocation(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'productLocations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFiestaCreateStockRequest() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: StockRequestInput) => createStockRequest(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'stockRequests'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFiestaUpdateStockRequest() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: UpdateStockRequestInput) => updateStockRequest(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'stockRequests'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'stockStatement'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['fiesta', 'productStocks'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Users ─────────────────────────────────────────────────────────────────────
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
export function useFiestaUsers(opts: {
|
export function useFiestaUsers(opts: {
|
||||||
tenantid: number;
|
tenantid: number;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createProductLocation, FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
|
||||||
|
|
||||||
export interface StoreCatalogueItem {
|
export interface StoreCatalogueItem {
|
||||||
productid: string;
|
productid: string;
|
||||||
@@ -72,10 +73,28 @@ export function useStoreCatalogue() {
|
|||||||
|
|
||||||
const has = (id: string) => items.some((i) => i.productid === id);
|
const has = (id: string) => items.some((i) => i.productid === id);
|
||||||
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
|
const getQty = (id: string) => items.find((i) => i.productid === id)?.qty ?? 0;
|
||||||
const add = (item: StoreCatalogueItem) => write([...read().filter((i) => i.productid !== item.productid), item]);
|
const add = (item: StoreCatalogueItem) => {
|
||||||
|
write([...read().filter((i) => i.productid !== item.productid), item]);
|
||||||
|
createProductLocation({
|
||||||
|
tenantid: FIESTA_TENANT_ID,
|
||||||
|
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||||
|
productid: Number(item.productid),
|
||||||
|
qty: item.qty,
|
||||||
|
status: 'Active'
|
||||||
|
}).catch(e => console.error('API createProductLocation failed:', e));
|
||||||
|
};
|
||||||
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
|
const remove = (id: string) => write(read().filter((i) => i.productid !== id));
|
||||||
const setQty = (id: string, qty: number) =>
|
const setQty = (id: string, qty: number) => {
|
||||||
write(read().map((i) => (i.productid === id ? { ...i, qty: Math.max(1, Math.round(qty) || 1) } : i)));
|
const safeQty = Math.max(1, Math.round(qty) || 1);
|
||||||
|
write(read().map((i) => (i.productid === id ? { ...i, qty: safeQty } : i)));
|
||||||
|
createProductLocation({
|
||||||
|
tenantid: FIESTA_TENANT_ID,
|
||||||
|
locationid: FIESTA_PRIMARY_LOCATION_ID,
|
||||||
|
productid: Number(id),
|
||||||
|
qty: safeQty,
|
||||||
|
status: 'Active'
|
||||||
|
}).catch(e => console.error('API updateProductLocation failed:', e));
|
||||||
|
};
|
||||||
|
|
||||||
return { items, has, getQty, add, remove, setQty };
|
return { items, has, getQty, add, remove, setQty };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console';
|
export type MainSection = 'dashboard' | 'stores' | 'inventory' | 'orders' | 'users' | 'settings' | 'reports' | 'operations' | 'admin-console' | 'sales_revenue';
|
||||||
|
|
||||||
export interface KPICardData {
|
export interface KPICardData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,6 +48,7 @@ export interface ProductMatrixItem {
|
|||||||
exposure: string;
|
exposure: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
isNew?: boolean;
|
isNew?: boolean;
|
||||||
|
isSample?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InventoryItem {
|
export interface InventoryItem {
|
||||||
|
|||||||
93
src/utils/fmcgUtils.ts
Normal file
93
src/utils/fmcgUtils.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
export interface FMCGDetails {
|
||||||
|
brandName: string;
|
||||||
|
netWeight: string;
|
||||||
|
isVeg: boolean;
|
||||||
|
marketingClaim: string;
|
||||||
|
ingredients: string;
|
||||||
|
allergens: string;
|
||||||
|
fssai: string;
|
||||||
|
storage: string;
|
||||||
|
barcode: string;
|
||||||
|
batchNo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleHash(input: string): number {
|
||||||
|
const str = String(input || '');
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRANDS = ["Nearle Organics", "Daily Fresh", "Harvest Select", "Nature's Best", "Prime Goods", "Evergreen Pantry"];
|
||||||
|
const CLAIMS_VEG = ["100% Natural", "Zero Trans Fat", "High in Fiber", "Rich in Protein", "Farm Fresh", "No Added Preservatives"];
|
||||||
|
const CLAIMS_NONVEG = ["High Protein", "Premium Cut", "Farm Raised", "Rich in Omega-3"];
|
||||||
|
|
||||||
|
const INGREDIENTS_BY_CAT: Record<string, string> = {
|
||||||
|
'Staples': 'Whole Wheat, Refined Flour, Salt, Edible Vegetable Oil, Water.',
|
||||||
|
'Groceries': 'Sugar, Spices, Salt, Citric Acid, Natural Flavors.',
|
||||||
|
'Beverages': 'Purified Water, Sugar, Natural Fruit Extract, Citric Acid, Permitted Colors.',
|
||||||
|
'Snacks': 'Potatoes, Edible Vegetable Oil, Salt, Spices & Condiments, Natural Flavors.',
|
||||||
|
'Dairy': 'Pasteurized Cow Milk, Active Cultures, Milk Solids.',
|
||||||
|
'Default': 'Wheat Flour, Sugar, Edible Veg Oil, Salt, Raising Agents, Natural Flavors.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLERGENS = [
|
||||||
|
"Contains Wheat and Milk.",
|
||||||
|
"Processed in a facility that handles soy.",
|
||||||
|
"Contains Nuts and Dairy.",
|
||||||
|
"May contain traces of Peanuts.",
|
||||||
|
"None."
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateFMCGDetails(productId: string, category: string): FMCGDetails {
|
||||||
|
const seed = simpleHash(productId);
|
||||||
|
|
||||||
|
// Category fallback
|
||||||
|
const catKey = Object.keys(INGREDIENTS_BY_CAT).find(k => String(category || '').includes(k)) || 'Default';
|
||||||
|
|
||||||
|
const isVeg = (seed % 10) !== 0; // 90% vegetarian
|
||||||
|
const claimsList = isVeg ? CLAIMS_VEG : CLAIMS_NONVEG;
|
||||||
|
|
||||||
|
// Predictable outputs based on seed
|
||||||
|
const brandName = BRANDS[seed % BRANDS.length];
|
||||||
|
const weightOptions = ["150g", "250g", "500g", "1kg", "1L", "500ml", "75g", "200g"];
|
||||||
|
const netWeight = weightOptions[seed % weightOptions.length];
|
||||||
|
const marketingClaim = claimsList[(seed >> 1) % claimsList.length];
|
||||||
|
const ingredients = INGREDIENTS_BY_CAT[catKey];
|
||||||
|
const allergens = ALLERGENS[(seed >> 2) % ALLERGENS.length];
|
||||||
|
|
||||||
|
// Fake FSSAI number (14 digits)
|
||||||
|
const fssaiBase = "100" + (seed % 99) + "011000" + ((seed >> 3) % 999).toString().padStart(3, '0');
|
||||||
|
const fssai = fssaiBase.padEnd(14, '0');
|
||||||
|
|
||||||
|
const storageOptions = [
|
||||||
|
"Store in a cool, dry place.",
|
||||||
|
"Keep refrigerated after opening.",
|
||||||
|
"Store away from direct sunlight.",
|
||||||
|
"Transfer to an airtight container after opening."
|
||||||
|
];
|
||||||
|
const storage = storageOptions[(seed >> 4) % storageOptions.length];
|
||||||
|
|
||||||
|
// Barcode (EAN-13 style)
|
||||||
|
const barcode = "890" + (seed % 9999999999).toString().padStart(10, '0');
|
||||||
|
|
||||||
|
// Batch Number
|
||||||
|
const batchNo = "BN-" + (seed % 9999).toString().padStart(4, '0') + String.fromCharCode(65 + (seed % 26));
|
||||||
|
|
||||||
|
return {
|
||||||
|
brandName,
|
||||||
|
netWeight,
|
||||||
|
isVeg,
|
||||||
|
marketingClaim,
|
||||||
|
ingredients,
|
||||||
|
allergens,
|
||||||
|
fssai,
|
||||||
|
storage,
|
||||||
|
barcode,
|
||||||
|
batchNo
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user