implemented the sales and revenue report

This commit is contained in:
2026-06-23 19:18:01 +05:30
parent 8707004405
commit 93df333df1
17 changed files with 2410 additions and 712 deletions

View File

@@ -623,8 +623,8 @@ export default function App() {
/>
{/* 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'}`}>
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
<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 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 */}
{currentSection === 'dashboard' && (
selectedStore ? (

View File

@@ -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 gap-8 min-w-max pb-8">
{/* 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>
<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>
{(() => {
const isGlobalMode = selectedProducts.some(p => p.isGlobal);
{/* Product Columns */}
{selectedProducts.map((prod) => (
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
return (
<>
<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 */}
<button
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>
{/* 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">
<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="flex-1 min-w-0">
<h3 className="font-extrabold text-slate-900 text-sm leading-snug line-clamp-3">{prod.name}</h3>
</div>
{isGlobalMode ? (
<>
<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>
<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>
<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>
<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>
</>
) : (
<>
<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>
</div>
{/* Metrics */}
<div className="h-12 flex items-center border-b border-slate-200/60">
<span className="font-bold text-slate-700 text-sm">{prod.category || '—'}</span>
</div>
{/* Product Columns */}
{selectedProducts.map((prod) => (
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
<div className="h-12 flex items-center border-b border-slate-200/60">
<span className="font-mono text-slate-500 text-xs font-bold tracking-tight">{prod.sku || '—'}</span>
</div>
{/* Remove button overlay */}
<button
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">
<span className="font-black text-slate-900 text-base">
{prod.price > 0 ? `${prod.price.toLocaleString('en-IN')}` : '—'}
</span>
</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">
<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="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">
<span className="font-bold text-[#662582] font-mono text-sm">
{prod.unitsSold != null ? prod.unitsSold.toLocaleString('en-IN') : '—'}
</span>
</div>
{/* Metrics */}
<div className="h-12 flex items-center border-b border-slate-200/60">
<span className="font-bold text-slate-700 text-sm">{prod.category || '—'}</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>
<div className="h-12 flex items-center border-b border-slate-200/60">
<span className="font-mono text-slate-500 text-xs font-bold tracking-tight">{prod.sku || '—'}</span>
</div>
{isGlobalMode ? (
<>
<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
</span>
<>
<div className="h-12 flex items-center border-b border-slate-200/60">
<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>
) : 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 */}

View File

@@ -24,6 +24,8 @@ import {
Skeleton, Tooltip
} from './consoleUi';
import SalesRevenueReport from './SalesRevenueReport';
type ReportTab = 'orders-summary' | 'riders-summary';
const TABS: Array<{ key: ReportTab; label: string; icon: typeof TrendingUp }> = [
{ 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; }
export default function DeliveryReportsView({ searchQuery = '', tenantId = FIESTA_TENANT_ID, locationid }: DeliveryReportsViewProps) {
const [activeTab, setActiveTab] = useState<'overview' | 'sales_revenue'>('overview');
const today = new Date();
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const [fromdate, setFromdate] = useState<string>(ymd(monthStart));
@@ -51,8 +55,23 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
return (
<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 */}
<FilterBar className="mb-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 === 'riders-summary' && <RidersSummaryReport fromdate={fromdate} todate={todate} tenantId={tenantId} locationid={locationid} />}
</>
) : (
<SalesRevenueReport tenantId={tenantId} locationid={locationid} />
)}
</div>
);
}

View 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

View File

@@ -39,6 +39,7 @@ import {
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { stockRowToProduct } from '../services/fiestaMappers';
import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport';
interface ReportsViewProps {
searchQuery: string;
@@ -61,6 +62,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
const [expandedProductId, setExpandedProductId] = useState<string | null>(null);
const [exportingFormat, setExportingFormat] = useState<'PDF' | 'CSV' | null>(null);
const [exportProgress, setExportProgress] = useState(0);
const [activeTab, setActiveTab] = useState<'overview' | 'sales_revenue'>('overview');
// Dropdown open states
const [showTimeframeDropdown, setShowTimeframeDropdown] = useState(false);
@@ -487,8 +489,23 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
return (
<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 */}
<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) => {
@@ -1077,7 +1094,10 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div>
</div>
)}
</>
) : (
<SalesRevenueReport tenantId={tenantId} />
)}
</div>
);
}

View 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>
);
}

View File

@@ -20,14 +20,14 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity } from 'lucide-react';
import { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch, Activity, Info, Inbox } from 'lucide-react';
import { useFiestaStockStatement, useFiestaCreateStockRequest, FIESTA_TENANT_ID } from '../services/fiestaQueries';
import { num as fnum, str as fstr, type Row, FIESTA_PRIMARY_LOCATION_ID } from '../services/fiestaApi';
import { categoryName } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer } from './consoleUi';
import { useCompare } from '../contexts/CompareContext';
import { SlideDrawer, StatusChip, TH_STYLE, SURFACE_ALT, TEXT, TEXT_2, TEXT_3, BORDER, BRAND } from './consoleUi';
import FMCGHoverOverlay from './FMCGHoverOverlay';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
@@ -46,7 +46,7 @@ function stockStatus(closing: number): { label: string; color: string } {
/** Category → pill badge classes (mirrors the admin Global Catalogue card). */
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.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';
@@ -54,13 +54,15 @@ function catBadgeClass(category: string): string {
}
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
const { selectedProducts, toggleProduct, setIsComparing } = useCompare();
const tenantid = tenantId;
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
const [view, setView] = useState<'catalogue' | 'inventory' | 'requests'>('catalogue');
const [search, setSearch] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
const [selectedProduct, setSelectedProduct] = useState<any>(null);
const [hoveredProduct, setHoveredProduct] = useState<any>(null);
const [activeQtyProduct, setActiveQtyProduct] = useState<any>(null);
const [tempQty, setTempQty] = useState(1);
const [notice, setNotice] = useState(false);
// The admin-curated catalogue (what the user is allowed to pick from).
@@ -80,38 +82,109 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
[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 [picks, setPicks] = useState<Record<string, number>>(() => {
const [picks, setPicks] = useState<Record<string, { qty: number; status: 'Pending' | 'Approved' | 'Rejected'; requestedAt: string; resolvedAt?: string }>>(() => {
try {
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 {
return {};
}
});
// Listen for storage events so approvals from Admin update dynamically
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]);
const createRequestMutation = useFiestaCreateStockRequest();
const togglePick = (id: string) => {
setNotice(false);
setPicks((prev) => {
const next = { ...prev };
if (next[id] != null) delete next[id];
else next[id] = 1;
if (next[id] != null && next[id].status !== 'Cancelled') {
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;
});
};
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;
// Store inventory (live stock) for the "My Store Inventory" tab + "In Store" tags.
const stockQ = useFiestaStockStatement({ tenantid, locationid: locationid ?? 0, pagesize: 200 });
const inStore = useMemo(() => 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(
() =>
(stockQ.data ?? []).map((r: Row) => {
() => {
const baseInventory = (stockQ.data ?? []).map((r: Row) => {
const closing = fnum(r.closing) ?? 0;
return {
id: fstr(r.productid),
@@ -121,10 +194,45 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
category: categoryName(fnum(r.categoryid)),
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 term = search.toLowerCase();
if (!term) return inventory;
@@ -160,9 +268,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
}, [filteredInventory, selectedCategories, stockHealthFilter]);
// ── Integration point ──────────────────────────────────────────────────────────
// Replace with the real request/stock POST (selected productids + quantities),
// then invalidate stockQ.
const commitSelectionToStore = () => setNotice(true);
// The request is saved to localStorage automatically via the useEffect on `picks`.
return (
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
@@ -186,15 +292,24 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
>
<Store size={14} /> My Store Inventory ({inventory.length})
</button>
<button
onClick={() => setView('requests')}
className={`flex-1 sm:flex-none flex items-center justify-center gap-1.5 px-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 className="flex flex-col md:flex-row gap-6 items-start mt-6">
{/* 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">
<div>
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
<Search size={14} className="text-[#662582]" /> Search
</h3>
{view !== 'requests' && (
<div className="w-full md:w-64 shrink-0 bg-white border border-slate-200 rounded-2xl p-5 sticky top-24 shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] z-10 hidden md:flex flex-col gap-6">
<div>
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
<Search size={14} className="text-[#662582]" /> Search
</h3>
<div className="relative">
<input
type="text"
@@ -271,6 +386,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</button>
)}
</div>
)}
{/* Product Grid Area */}
<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>
<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>
<p className="text-xs text-slate-500 mt-0.5">
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
</p>
{view !== 'requests' && (
<p className="text-xs text-slate-500 mt-0.5">
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
</p>
)}
</div>
{/* Can add sorting dropdown here if needed */}
</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">
{filtered.map((p) => {
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 (
<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 */}
<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" />
@@ -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>
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
</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 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 */}
<div className="pt-1">
{picked ? (
<div className="flex items-center justify-between gap-1 p-1.5 bg-emerald-50 rounded-xl border border-emerald-200 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>
<div className="flex items-center gap-1 bg-white rounded-lg p-0.5 shadow-sm border border-emerald-100">
<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>
<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>
<div className={`flex items-center justify-between gap-1 p-1.5 rounded-xl border shadow-inner ${
picks[p.id].status === 'Approved' ? 'bg-emerald-50 border-emerald-200' :
picks[p.id].status === 'Rejected' ? 'bg-rose-50 border-rose-200' :
'bg-amber-50 border-amber-200'
}`}>
<span className={`inline-flex items-center gap-1.5 px-2 text-[11px] font-bold ${
picks[p.id].status === 'Approved' ? 'text-emerald-700' :
picks[p.id].status === 'Rejected' ? 'text-rose-700' :
'text-amber-700'
}`}>
{picks[p.id].status === 'Approved' ? <CheckCircle2 size={13} /> :
picks[p.id].status === 'Rejected' ? <X size={13} /> :
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />}
{picks[p.id].status === 'Pending' ? 'Requested' : picks[p.id].status} ({picks[p.id].qty})
</span>
<div className="flex gap-1">
{picks[p.id].status === 'Pending' && (
<button onClick={(e) => { e.stopPropagation(); setTempQty(picks[p.id].qty); setActiveQtyProduct(p); }} title="Edit Quantity" className="px-2 h-7 rounded-lg text-amber-600 hover:bg-amber-100 bg-white border border-amber-100 flex items-center justify-center transition-colors shadow-sm text-[10px] font-bold">Edit</button>
)}
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className={`w-7 h-7 rounded-lg bg-white border flex items-center justify-center transition-colors shadow-sm ${
picks[p.id].status === 'Approved' ? 'text-emerald-500 hover:bg-emerald-50 hover:text-emerald-600 border-emerald-100' :
picks[p.id].status === 'Rejected' ? 'text-rose-500 hover:bg-rose-50 hover:text-rose-600 border-rose-100' :
'text-amber-500 hover:bg-amber-50 hover:text-amber-600 border-amber-100'
}`}><X size={14} /></button>
</div>
<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>
) : (
<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"
>
<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 ── */}
{view === 'inventory' && (
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">
{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 */}
<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" />
@@ -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>
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
</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 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>
{/* ── Selection FAB ── */}
{view === 'catalogue' && pickCount > 0 && (
{/* ── Auto-Submit Toast ── */}
{notice && (
<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="flex items-center justify-between mb-3">
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
</div>
<AwaitingApi label="Submitting to store" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-5 py-4 w-80 animate-in slide-in-from-bottom-4 duration-300">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer transition-colors">Clear All</button>
</div>
) : (
<button
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>
)}
<AwaitingApi label="Request submitted to Admin" api="stock-request API" compact className="bg-white/5 border-white/15 text-emerald-300" />
</div>
</div>
)}
{/* ADD/EDIT PRODUCT MODAL (simulated) */}
<SlideDrawer
isOpen={!!selectedProduct}
onClose={() => setSelectedProduct(null)}
title={view === 'catalogue' ? 'Catalogue Product Details' : 'Store Inventory Status'}
>
{selectedProduct && (
<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">
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-full h-full object-cover" />
<div className="absolute top-3 left-3">
<span className={`px-2.5 py-1.5 rounded-lg text-[10px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(selectedProduct.category)}`}>
{selectedProduct.category.split(' / ')[0]}
</span>
{/* QUANTITY SELECTION CENTERED MODAL */}
{activeQtyProduct && (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-900/60 backdrop-blur-md animate-in fade-in duration-300"
onClick={() => setActiveQtyProduct(null)}
/>
<div className="relative w-[340px] max-w-[95vw] bg-white rounded-2xl shadow-[0_24px_60px_rgba(0,0,0,0.15)] border border-white/50 p-5 animate-in zoom-in-[0.97] duration-300 flex flex-col gap-4">
{/* Header */}
<div className="flex justify-between items-center pb-2 border-b border-slate-100/80">
<div>
<h2 className="text-base font-bold text-slate-900 tracking-tight">Request Stock</h2>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">Bulk Order</p>
</div>
<button
onClick={() => setActiveQtyProduct(null)}
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-100 text-slate-400 hover:text-slate-700 transition-colors"
>
<X size={14} strokeWidth={2.5} />
</button>
</div>
{/* Product Card summary */}
<div className="flex gap-3 items-center bg-gradient-to-r from-slate-50 to-white p-2.5 rounded-xl border border-slate-100 shadow-sm">
<div className="w-12 h-12 rounded-lg bg-white border border-slate-200 overflow-hidden shrink-0 shadow-sm">
<img src={activeQtyProduct.image} alt={activeQtyProduct.name} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 text-xs leading-tight truncate">{activeQtyProduct.name}</h3>
<p className="text-[9px] font-mono font-medium text-slate-400 mt-0.5">{activeQtyProduct.sku}</p>
<div className="mt-1 inline-flex items-center gap-1 bg-purple-50 px-1.5 py-0.5 rounded text-[9px] font-semibold text-purple-700 border border-purple-100/50">
<span>{activeQtyProduct.price.toLocaleString('en-IN')}</span>
<span className="text-purple-300">/</span>
<span>{activeQtyProduct.unit || 'Pc'}</span>
</div>
</div>
</div>
<div>
<h3 className="text-xl font-black text-slate-900 leading-tight mb-2">{selectedProduct.name}</h3>
<p className="text-xs font-bold text-slate-400 font-mono tracking-wider">{selectedProduct.sku}</p>
{/* Quantity Selector */}
<div className="flex flex-col gap-4">
<div>
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Quick Select</label>
<div className="grid grid-cols-4 gap-2">
{[10, 25, 50, 100].map(qty => (
<button
key={qty}
onClick={() => setTempQty(qty)}
className={`relative overflow-hidden py-1.5 rounded-lg text-[11px] font-bold transition-all duration-300 ${
tempQty === qty
? 'bg-[#662582] text-white shadow-sm ring-1 ring-[#662582] ring-offset-1'
: 'bg-white text-slate-600 border border-slate-200 hover:border-[#662582]/40 hover:bg-purple-50/50'
}`}
>
{qty}
</button>
))}
</div>
</div>
<div>
<label className="text-[9px] font-bold uppercase tracking-widest text-slate-400 block mb-2">Custom Amount</label>
<div className="relative group">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
<Boxes size={14} className="text-slate-400 group-focus-within:text-[#662582] transition-colors" />
</div>
<input
type="number"
min="1"
value={tempQty}
onChange={(e) => setTempQty(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full h-10 pl-9 pr-12 text-right text-lg font-bold font-mono text-[#662582] bg-slate-50/50 rounded-lg border border-slate-200 outline-none focus:border-[#662582] focus:bg-white transition-all shadow-inner"
/>
<div className="absolute inset-y-0 right-3 flex items-center pointer-events-none">
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest pl-2 border-l border-slate-200">Units</span>
</div>
</div>
</div>
</div>
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 flex justify-between items-center">
<div>
<span className="text-[10px] font-extrabold uppercase tracking-widest text-slate-400 block mb-1">Pricing</span>
<span className="text-2xl font-black font-mono text-slate-900">
{selectedProduct.price > 0 ? `${selectedProduct.price.toLocaleString('en-IN')}` : '—'}
</span>
</div>
<div className="text-right">
<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>
</div>
{/* Total & Submit */}
<div className="mt-1 pt-4 border-t border-slate-100">
<div className="flex justify-between items-end mb-4 px-1">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Estimated Total</span>
<span className="text-xl font-bold text-slate-900 tracking-tight">
{(tempQty * activeQtyProduct.price).toLocaleString('en-IN')}
</span>
</div>
<button
onClick={() => {
setPickQty(activeQtyProduct.id, tempQty);
setActiveQtyProduct(null);
setNotice(true);
setTimeout(() => setNotice(false), 3000);
}}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl text-xs font-bold transition-all bg-gradient-to-r from-[#662582] to-purple-800 text-white shadow-[0_4px_12px_rgba(102,37,130,0.2)] hover:shadow-[0_6px_16px_rgba(102,37,130,0.3)] hover:-translate-y-0.5"
>
<CheckCircle2 size={14} strokeWidth={2.5} /> Confirm Request
</button>
</div>
{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>
)}
{/* Floating FMCG Details Panel */}
{hoveredProduct && (
<div className="fixed bottom-6 right-6 z-[200] pointer-events-none">
<div className="relative w-[320px] max-w-[90vw] shadow-[0_24px_60px_rgba(0,0,0,0.15)] rounded-2xl animate-in slide-in-from-bottom-4 fade-in duration-200">
<FMCGHoverOverlay productId={hoveredProduct.id} category={hoveredProduct.category} productName={hoveredProduct.name} />
</div>
</div>
)}
{/* ── Slide Drawer for Product Details ── */}
<SlideDrawer
isOpen={selectedProduct !== null}
onClose={() => setSelectedProduct(null)}
title="Product Details"
>
{selectedProduct && (() => {
const stocked = inStore.has(selectedProduct.id);
const pick = picks[selectedProduct.id];
const isPending = pick != null && pick.status === 'Pending';
const isApproved = pick != null && pick.status === 'Approved';
const isRejected = pick != null && pick.status === 'Rejected';
const isRequested = pick != null && pick.status !== 'Cancelled';
return (
<div className="flex flex-col gap-8 pb-8">
{/* Clean Image Container */}
<div className="w-full h-64 bg-white rounded-2xl border border-slate-200 overflow-hidden relative flex items-center justify-center p-4">
<img src={selectedProduct.image} alt={selectedProduct.name} className="w-auto h-full max-w-full object-contain mix-blend-multiply" />
</div>
{/* Title & Basics */}
<div>
<p className="text-xs font-semibold text-slate-500 tracking-wider mb-1">SKU: {selectedProduct.sku}</p>
<h3 className="text-2xl font-bold text-slate-900 leading-tight mb-3">{selectedProduct.name}</h3>
<div className="flex flex-wrap items-center gap-2">
<span className={`px-2.5 py-1 rounded bg-slate-100 text-slate-600 text-[10px] font-semibold uppercase tracking-widest border border-slate-200`}>
{String(selectedProduct.category || '').split(' / ')[0]}
</span>
{stocked && (
<span className="px-2.5 py-1 rounded bg-emerald-50 text-emerald-700 text-[10px] font-semibold uppercase tracking-widest border border-emerald-200 flex items-center gap-1.5">
<CheckCircle2 size={12} strokeWidth={2.5} /> In Store
</span>
)}
</div>
</div>
{/* Clean Pricing Card */}
<div className="bg-slate-50 p-6 rounded-2xl border border-slate-200 flex items-center justify-between">
<div>
<span className="text-xs font-semibold text-slate-500 tracking-wider uppercase block mb-1">Store Price</span>
<div className="flex items-end gap-1.5">
<span className="text-3xl font-bold text-slate-900 tracking-tight">{selectedProduct.price?.toLocaleString('en-IN') || 0}</span>
<span className="text-sm font-semibold text-slate-500 mb-1">/ {selectedProduct.unit || 'Pc'}</span>
</div>
</div>
</div>
{/* Action Area */}
<div className="space-y-4 pt-4 border-t border-slate-100">
<h4 className="text-sm font-semibold text-slate-800">Stock Management</h4>
{isApproved ? (
<div className="flex items-center justify-center gap-2 p-4 bg-emerald-50 rounded-xl border border-emerald-200">
<CheckCircle2 size={18} className="text-emerald-600" />
<span className="text-sm font-semibold text-emerald-800">Approved and stocked in your inventory</span>
</div>
) : isRejected ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center gap-2 p-4 bg-rose-50 rounded-xl border border-rose-200">
<X size={18} className="text-rose-600" />
<span className="text-sm font-semibold text-rose-800">Stock request was rejected</span>
</div>
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-slate-700 hover:bg-slate-50 shadow-sm border border-slate-200 font-semibold text-sm transition-colors">
Clear Request
</button>
</div>
) : isPending ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between p-4 bg-amber-50 rounded-xl border border-amber-200">
<div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
<span className="text-sm font-semibold text-amber-800">Requested <span className="font-bold">({pick.qty} units)</span></span>
</div>
<button onClick={() => { setSelectedProduct(null); setTempQty(pick.qty); setActiveQtyProduct(selectedProduct); }} className="px-4 py-2 rounded-lg text-amber-700 bg-white hover:bg-amber-100 border border-amber-200 text-sm font-semibold transition-colors shadow-sm">
Edit
</button>
</div>
<button onClick={() => { setSelectedProduct(null); togglePick(selectedProduct.id); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl bg-white text-rose-600 hover:bg-rose-50 border border-slate-200 font-semibold text-sm transition-colors">
<X size={18} strokeWidth={2} /> Cancel Request
</button>
</div>
) : (
<button onClick={() => { setSelectedProduct(null); setTempQty(1); setActiveQtyProduct(selectedProduct); }} className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-semibold transition-colors bg-[#662582] text-white hover:bg-[#531e6a]">
<Plus size={18} strokeWidth={2} /> Request Stock
</button>
)}
</div>
{/* Retail Packaging Info */}
<div className="mt-4 pt-4 border-t border-slate-100">
<h4 className="text-sm font-semibold text-slate-800 mb-3">Retail Packaging Info</h4>
<FMCGHoverOverlay productId={selectedProduct.id} category={selectedProduct.category} productName={selectedProduct.name} />
</div>
</div>
);
})()}
</SlideDrawer>
</div>
);
}

View File

@@ -17,9 +17,10 @@ interface TrialBatchDrawerProps {
onClose: () => void;
product: TrialProduct | null;
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 [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -56,6 +57,9 @@ export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId }:
setTimeout(() => {
setIsSubmitting(false);
setIsSuccess(true);
if (onSuccess && product) {
onSuccess(product);
}
// Auto close after showing success
setTimeout(() => {
onClose();

View File

@@ -18,6 +18,7 @@ import {
ClipboardList,
Layers,
Users,
TrendingUp,
X,
} from 'lucide-react';
import {
@@ -35,7 +36,6 @@ import DeliveryReportsView from './DeliveryReportsView';
import StoreQRView from './StoreQRView';
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
import ComparisonModal from './ComparisonModal';
interface UserStorePageProps {
/** Returns to the login screen. */
onLogout: () => void;

View File

@@ -16,6 +16,7 @@
*/
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Info } from 'lucide-react';
// ── Design tokens ────────────────────────────────────────────────────────────────
@@ -385,28 +386,32 @@ export function SlideDrawer({
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[200] flex justify-end">
{/* Backdrop */}
<div className="fixed inset-0 z-[200]">
{/* Muted Backdrop */}
<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}
/>
{/* 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">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-slate-100 bg-white z-10 shrink-0">
<h2 className="text-lg font-extrabold text-slate-900 tracking-tight">{title}</h2>
{/* Pure, Clean Drawer Panel */}
<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">
{/* Minimalist Header */}
<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-bold text-slate-800 tracking-tight">{title}</h2>
<button
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>
</div>
{/* 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}
</div>
</div>

View File

@@ -15,6 +15,13 @@ export interface CompareProduct {
unit?: string;
color?: string;
label?: string;
// Global Marketing Metrics
wholesalePrice?: number;
mrp?: number;
profitMargin?: number;
rating?: number;
globalSales?: number;
isGlobal?: boolean;
}
interface CompareContextType {

View File

@@ -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. */
export async function getProductCategories(): Promise<Row[]> {
return toRows(await fiestaGet('products/getproductcategories', {}));

View File

@@ -61,6 +61,13 @@ import {
CreateTenantLocationInput,
updateTenantLocation,
UpdateTenantLocationInput,
createProductLocation,
createStockRequest,
getStockRequests,
updateStockRequest,
CreateProductLocationInput,
StockRequestInput,
UpdateStockRequestInput,
} from './fiestaApi';
export const fiestaKeys = {
@@ -97,6 +104,7 @@ export const fiestaKeys = {
fleetSummary: (params: Record<string, unknown>) => ['fiesta', 'fleetSummary', params] as const,
users: (params: Record<string, unknown>) => ['fiesta', 'users', params] as const,
user: (userid: number) => ['fiesta', 'user', userid] as const,
stockRequests: (params: Record<string, unknown>) => ['fiesta', 'stockRequests', params] as const,
};
// ── 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 ─────────────────────────────────────────────────────────────────────
export function useFiestaUsers(opts: {
tenantid: number;

View File

@@ -18,6 +18,7 @@
*/
import { useEffect, useState } from 'react';
import { createProductLocation, FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID } from './fiestaApi';
export interface StoreCatalogueItem {
productid: string;
@@ -72,10 +73,28 @@ export function useStoreCatalogue() {
const has = (id: string) => items.some((i) => i.productid === id);
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 setQty = (id: string, qty: number) =>
write(read().map((i) => (i.productid === id ? { ...i, qty: Math.max(1, Math.round(qty) || 1) } : i)));
const setQty = (id: string, qty: number) => {
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 };
}

View File

@@ -3,7 +3,7 @@
* 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 {
title: string;
@@ -48,6 +48,7 @@ export interface ProductMatrixItem {
exposure: string;
verified: boolean;
isNew?: boolean;
isSample?: boolean;
}
export interface InventoryItem {

93
src/utils/fmcgUtils.ts Normal file
View 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
};
}