changes on global catalogue

This commit is contained in:
2026-06-20 18:45:25 +05:30
parent 2e9ffb15bb
commit 8707004405
22 changed files with 3137 additions and 2070 deletions

View File

@@ -50,6 +50,7 @@ import StoreDetailView from './components/StoreDetailView';
import LoginView from './components/LoginView';
import UserStorePage from './components/UserStorePage';
import AwaitingApi from './components/AwaitingApi';
import ComparisonModal from './components/ComparisonModal';
import type { AuthUser } from './services/auth';
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
@@ -249,314 +250,314 @@ export default function App() {
const handleLogout = () => setAuthUser(null);
// Define secondary sections (Stores, Logistics, Staffing, Settings) within main body
const renderSecondarySection = () => {
switch (currentSection) {
case 'stores': {
// A single-store merchant has no branches, so skip the one-card registry
// and open that store directly. Add a branch (2+ stores) and the grid
// returns automatically. Clicking a card in multi-store mode also opens
// the console, with a Back-to-registry button.
const isSoleStore = !selectedStore && storesList.length === 1;
const activeStore = selectedStore ?? (isSoleStore ? storesList[0] : null);
const renderStoresSection = () => {
const isSoleStore = !selectedStore && storesList.length === 1;
const activeStore = selectedStore ?? (isSoleStore ? storesList[0] : null);
if (activeStore) {
return (
<div className="space-y-md animate-in fade-in duration-300">
{isSoleStore && (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Store Console
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
This merchant operates a single store. Add a branch to manage multiple outlets.
</p>
</div>
</div>
)}
<StoreDetailView
store={activeStore}
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
tenantId={tenantId}
/>
</div>
);
}
return (
<div className="space-y-lg animate-in fade-in duration-300">
{/* Simple and elegant premium header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-6 border-b border-zinc-205 mb-6">
if (activeStore) {
return (
<div className="space-y-md animate-in fade-in duration-300">
{isSoleStore && (
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
Stores Registry
Store Console
</h1>
<p className="text-zinc-500 font-sans text-xs mt-1">
Local nodes registry, active manager assignments, and live dispatch and grocery delivery fulfillment statistics.
This merchant operates a single store. Add a branch to manage multiple outlets.
</p>
</div>
</div>
)}
<StoreDetailView
store={activeStore}
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
tenantId={tenantId}
/>
</div>
);
}
{/* Filter control bar */}
<div className="flex flex-col md:flex-row items-center justify-between gap-4 bg-white p-4 rounded-xl border border-[#e2e8f0] shadow-sm mb-6">
{/* Search input with search icon */}
<div className="relative w-full md:max-w-md">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-zinc-400">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Search stores by name, zone, or manager..."
value={storesSearch}
onChange={(e) => setStoresSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-zinc-50 border border-zinc-200 rounded-lg text-xs font-medium text-zinc-800 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-[#662582]/20 focus:border-[#662582] transition-all"
/>
{storesSearch && (
<button
onClick={() => setStoresSearch('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
return (
<div className="space-y-lg animate-in fade-in duration-300">
{/* Filter tabs */}
<div className="flex items-center bg-zinc-100/80 p-1 rounded-lg border border-zinc-200/50 w-full md:w-auto">
<button
onClick={() => setStoresFilter('ALL')}
className={`flex-1 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-all ${
storesFilter === 'ALL'
? 'bg-white text-[#662582] shadow-sm'
: 'text-zinc-500 hover:text-zinc-800 hover:bg-white/40'
}`}
>
All Nodes ({storesList.length})
</button>
<button
onClick={() => setStoresFilter('ACTIVE')}
className={`flex-1 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-all ${
storesFilter === 'ACTIVE'
? 'bg-white text-emerald-600 shadow-sm'
: 'text-zinc-500 hover:text-emerald-600 hover:bg-white/40'
}`}
>
Active ({storesList.filter(s => s.status.toLowerCase() === 'active').length})
</button>
<button
onClick={() => setStoresFilter('INACTIVE')}
className={`flex-1 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-all ${
storesFilter === 'INACTIVE'
? 'bg-white text-rose-600 shadow-sm'
: 'text-zinc-500 hover:text-rose-650 hover:bg-white/40'
}`}
>
Inactive ({storesList.filter(s => s.status.toLowerCase() !== 'active').length})
</button>
</div>
{/* ── Search & Filter Toolbar ── */}
<div className="flex flex-col lg:flex-row items-center gap-3 bg-white p-1.5 rounded-2xl border border-slate-200/70 shadow-sm mb-8 w-full">
{/* Search Input */}
<div className="relative flex-1 w-full group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-purple-600 transition-colors">
<Search className="w-5 h-5" />
</div>
{/* Empty States */}
{filteredStoresList.length === 0 && (
<div className="text-center py-12 text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white p-8">
{locationsQ.isLoading ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="w-6 h-6 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
<span>Loading live store locations…</span>
</div>
) : (
<span>No store locations found matching your filter criteria.</span>
)}
</div>
)}
{/* Immersive Background Blur Blobs */}
<div className="relative">
<div className="absolute top-10 left-10 w-72 h-72 bg-purple-400/10 rounded-full blur-[100px] pointer-events-none -z-10 animate-pulse" />
<div className="absolute bottom-10 right-10 w-80 h-80 bg-indigo-400/10 rounded-full blur-[100px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '6s' }} />
{/* Store Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-gutter relative z-10">
{filteredStoresList.map((st, i) => {
const totalOrders = st.orders ?? st.deliveries ?? 0;
const fulfillmentRate = totalOrders > 0 ? Math.min(100, Math.round((st.deliveries / totalOrders) * 100)) : 100;
return (
<div
key={st.locationid || i}
onClick={() => setSelectedStore(st)}
className={`group relative overflow-hidden bg-white/70 backdrop-blur-md border border-zinc-200/80 rounded-2xl shadow-sm hover:shadow-[0_20px_40px_rgba(88,28,135,0.12)] transition-all duration-500 cursor-pointer flex flex-col ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'hover:border-rose-300'
: 'hover:border-emerald-300'
: 'hover:border-amber-300'
}`}
>
{/* Card Cover Image with Zoom effect */}
<div className="relative h-32 w-full overflow-hidden flex-shrink-0">
<img
src={getStoreCover(st.name)}
alt={st.name}
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-700 ease-out"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/90 via-slate-900/20 to-transparent" />
{/* Status Badge */}
<div className="absolute top-3 right-3">
<span className={`px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider flex items-center gap-1 border backdrop-blur-md ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'text-rose-200 bg-rose-950/60 border-rose-500/30'
: 'text-emerald-200 bg-emerald-950/60 border-emerald-500/30'
: 'text-amber-200 bg-amber-950/60 border-amber-500/30'
}`}>
<span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" />
{st.status.toLowerCase() === 'active' && st.deliveries > 40 ? 'High Load' : st.status}
</span>
</div>
{/* Zone & Title */}
<div className="absolute bottom-3 left-3 right-3 text-white">
<p className="text-[9px] text-purple-200 font-bold uppercase tracking-widest leading-none mb-1">{st.zone}</p>
<h3 className="font-sans font-bold text-sm leading-tight text-white group-hover:text-purple-200 transition-colors">{st.name}</h3>
</div>
</div>
{/* Card Content Area */}
<div className="p-5 flex-1 flex flex-col justify-between">
{/* Metrics Row & Progress Circle */}
<div className="flex items-center justify-between gap-4">
<div className="grid grid-cols-2 gap-4 flex-1">
<div>
<span className="text-[9px] text-zinc-400 uppercase tracking-widest font-bold block">Deliveries</span>
<p className="font-extrabold text-base text-slate-900 mt-0.5 font-mono">{st.deliveries.toLocaleString()}</p>
<span className="text-[8px] text-emerald-600 font-semibold block mt-0.5">Dispatched Today</span>
</div>
<div>
<span className="text-[9px] text-zinc-400 uppercase tracking-widest font-bold block">Total Orders</span>
<p className="font-extrabold text-base text-[#662582] mt-0.5 font-mono">{totalOrders.toLocaleString()}</p>
<span className="text-[8px] text-purple-600 font-semibold block mt-0.5">Incoming Volume</span>
</div>
</div>
{/* Circular Progress Ring */}
<div className="relative flex items-center justify-center flex-shrink-0" title={`Fulfillment Rate: ${fulfillmentRate}%`}>
<svg className="w-12 h-12 transform -rotate-90">
<circle
cx="24"
cy="24"
r="18"
className="stroke-zinc-100"
strokeWidth="3.5"
fill="transparent"
/>
<circle
cx="24"
cy="24"
r="18"
className={`transition-all duration-500 ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'stroke-rose-500'
: 'stroke-emerald-500'
: 'stroke-amber-500'
}`}
strokeWidth="3.5"
fill="transparent"
strokeDasharray="113"
strokeDashoffset={113 - (113 * fulfillmentRate) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-extrabold text-zinc-700 font-mono">
{fulfillmentRate}%
</span>
</div>
</div>
{/* Live Sparkline Trend Histogram */}
<div className="mt-4 pt-3.5 border-t border-zinc-100">
<div className="flex justify-between items-center text-[8px] text-zinc-400 font-bold uppercase tracking-widest mb-1.5">
<span>Speed Index (Live Feed)</span>
<span className="text-[#662582] flex items-center gap-0.5 text-[8px] font-bold">
<Activity className="w-2.5 h-2.5 animate-pulse" /> Live
</span>
</div>
<div className="h-6 flex items-end gap-1">
{[30, 48, 25, 62, 54, 75, 42, 80, Math.min(95, Math.max(15, st.deliveries * 1.8))].map((val, idx) => (
<div key={idx} className="flex-1 bg-zinc-100 rounded-t-sm h-full relative overflow-hidden group/bar">
<div
style={{ height: `${val}%` }}
className={`absolute bottom-0 left-0 right-0 rounded-t-sm transition-all duration-500 ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'bg-rose-500/80 group-hover/bar:bg-rose-500'
: 'bg-[#662582]/70 group-hover/bar:bg-[#662582]'
: 'bg-amber-500/70 group-hover/bar:bg-amber-500'
}`}
/>
</div>
))}
</div>
</div>
{/* Lead Manager Profile block */}
<div className="flex justify-between items-center text-xs text-zinc-650 bg-zinc-50/80 rounded-xl p-2.5 border border-zinc-100/80 mt-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-gradient-to-tr from-purple-600 to-indigo-600 text-white flex items-center justify-center font-bold text-[10px] border border-white shadow-sm flex-shrink-0">
{st.staff.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<span className="text-[8px] text-zinc-400 block font-semibold leading-none mb-0.5">Node Lead</span>
<span className="font-bold text-[#0f172a] text-xs truncate max-w-[100px] block leading-none">{st.staff}</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
alert(`Routing communications channel directly to manager ${st.staff}...`);
}}
className="p-1.5 rounded-lg bg-white border border-zinc-200 hover:border-[#662582] hover:text-[#662582] text-zinc-500 transition-colors shadow-sm"
title="Communicate with Node Lead"
>
<Phone className="w-3.5 h-3.5" />
</button>
</div>
{/* Card footer - enter console */}
<div className="flex items-center justify-between text-[10px] font-bold text-[#662582] mt-4 pt-3 border-t border-zinc-100/80">
<button
onClick={(e) => {
e.stopPropagation();
const newStatus = st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active';
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s));
if (st.locationid) {
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() });
}
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${st.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-zinc-300'}`}
title={`Toggle store to ${st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active'}`}
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${st.status.toLowerCase() === 'active' ? 'translate-x-5' : 'translate-x-1'}`} />
</button>
<div className="flex items-center gap-1 uppercase tracking-wider">
<span>Enter Terminal Console</span>
<ArrowRight className="w-3.5 h-3.5 transform group-hover:translate-x-1.5 transition-transform duration-350" />
</div>
</div>
</div>
</div>
);
})}
</div>
<input
type="text"
placeholder="Search store by name, zone, or manager..."
value={storesSearch}
onChange={(e) => setStoresSearch(e.target.value)}
className="w-full pl-11 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-0 transition-all duration-300"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
{storesSearch && (
<button
onClick={() => setStoresSearch('')}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
);
}
{/* Vertical Divider (Hidden on Mobile) */}
<div className="hidden lg:block w-px h-8 bg-slate-200" />
{/* Filter Segmented Control */}
<div className="flex items-center p-1 bg-slate-50/80 rounded-xl border border-slate-200/50 w-full lg:w-auto shrink-0">
<button
onClick={() => setStoresFilter('ALL')}
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
storesFilter === 'ALL'
? 'bg-white text-slate-800 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5'
: 'text-slate-500 hover:text-slate-700 hover:bg-white/50'
}`}
>
All Nodes
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'ALL' ? 'bg-slate-100 text-slate-700' : 'bg-slate-200/50 text-slate-400'}`}>
{storesList.length}
</span>
</button>
<button
onClick={() => setStoresFilter('ACTIVE')}
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
storesFilter === 'ACTIVE'
? 'bg-white text-emerald-600 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-emerald-500/20'
: 'text-slate-500 hover:text-emerald-600 hover:bg-emerald-50/50'
}`}
>
Active
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'ACTIVE' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-200/50 text-slate-400'}`}>
{storesList.filter(s => s.status.toLowerCase() === 'active').length}
</span>
</button>
<button
onClick={() => setStoresFilter('INACTIVE')}
className={`flex-1 lg:flex-none px-5 py-2 rounded-lg text-xs font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
storesFilter === 'INACTIVE'
? 'bg-white text-slate-700 shadow-[0_1px_3px_rgba(0,0,0,0.1)] ring-1 ring-slate-900/5'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'
}`}
>
Inactive
<span className={`text-[10px] px-2 py-0.5 rounded-full font-bold ${storesFilter === 'INACTIVE' ? 'bg-slate-100 text-slate-700' : 'bg-slate-200/50 text-slate-400'}`}>
{storesList.filter(s => s.status.toLowerCase() !== 'active').length}
</span>
</button>
</div>
</div>
{/* Empty States */}
{filteredStoresList.length === 0 && (
<div className="text-center py-12 text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white p-8">
{locationsQ.isLoading ? (
<div className="flex flex-col items-center justify-center gap-2">
<div className="w-6 h-6 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
<span>Loading live store locations…</span>
</div>
) : (
<span>No store locations found matching your filter criteria.</span>
)}
</div>
)}
{/* Immersive Background Blur Blobs */}
<div className="relative">
<div className="absolute top-10 left-10 w-72 h-72 bg-purple-400/10 rounded-full blur-[100px] pointer-events-none -z-10 animate-pulse" />
<div className="absolute bottom-10 right-10 w-80 h-80 bg-indigo-400/10 rounded-full blur-[100px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '6s' }} />
{/* Store Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-gutter relative z-10">
{filteredStoresList.map((st, i) => {
const totalOrders = st.orders ?? st.deliveries ?? 0;
const fulfillmentRate = totalOrders > 0 ? Math.min(100, Math.round((st.deliveries / totalOrders) * 100)) : 100;
return (
<div
key={st.locationid || i}
onClick={() => setSelectedStore(st)}
className={`group relative overflow-hidden bg-white/70 backdrop-blur-md border border-zinc-200/80 rounded-2xl shadow-sm hover:shadow-[0_20px_40px_rgba(88,28,135,0.12)] transition-all duration-500 cursor-pointer flex flex-col ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'hover:border-rose-300'
: 'hover:border-emerald-300'
: 'hover:border-amber-300'
}`}
>
{/* Card Cover Image with Zoom effect */}
<div className="relative h-32 w-full overflow-hidden flex-shrink-0">
<img
src={getStoreCover(st.name)}
alt={st.name}
className="w-full h-full object-cover group-hover:scale-108 transition-transform duration-700 ease-out"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/90 via-slate-900/20 to-transparent" />
{/* Status Badge */}
<div className="absolute top-3 right-3">
<span className={`px-2.5 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider flex items-center gap-1 border backdrop-blur-md ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'text-rose-200 bg-rose-950/60 border-rose-500/30'
: 'text-emerald-200 bg-emerald-950/60 border-emerald-500/30'
: 'text-amber-200 bg-amber-950/60 border-amber-500/30'
}`}>
<span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" />
{st.status.toLowerCase() === 'active' && st.deliveries > 40 ? 'High Load' : st.status}
</span>
</div>
{/* Zone & Title */}
<div className="absolute bottom-3 left-3 right-3 text-white">
<p className="text-[9px] text-purple-200 font-bold uppercase tracking-widest leading-none mb-1">{st.zone}</p>
<h3 className="font-sans font-bold text-sm leading-tight text-white group-hover:text-purple-200 transition-colors">{st.name}</h3>
</div>
</div>
{/* Card Content Area */}
<div className="p-5 flex-1 flex flex-col justify-between">
{/* Metrics Row & Progress Circle */}
<div className="flex items-center justify-between gap-4">
<div className="grid grid-cols-2 gap-4 flex-1">
<div>
<span className="text-[9px] text-zinc-400 uppercase tracking-widest font-bold block">Deliveries</span>
<p className="font-extrabold text-base text-slate-900 mt-0.5 font-mono">{st.deliveries.toLocaleString()}</p>
<span className="text-[8px] text-emerald-600 font-semibold block mt-0.5">Dispatched Today</span>
</div>
<div>
<span className="text-[9px] text-zinc-400 uppercase tracking-widest font-bold block">Total Orders</span>
<p className="font-extrabold text-base text-[#662582] mt-0.5 font-mono">{totalOrders.toLocaleString()}</p>
<span className="text-[8px] text-purple-600 font-semibold block mt-0.5">Incoming Volume</span>
</div>
</div>
{/* Circular Progress Ring */}
<div className="relative flex items-center justify-center flex-shrink-0" title={`Fulfillment Rate: ${fulfillmentRate}%`}>
<svg className="w-12 h-12 transform -rotate-90">
<circle
cx="24"
cy="24"
r="18"
className="stroke-zinc-100"
strokeWidth="3.5"
fill="transparent"
/>
<circle
cx="24"
cy="24"
r="18"
className={`transition-all duration-500 ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'stroke-rose-500'
: 'stroke-emerald-500'
: 'stroke-amber-500'
}`}
strokeWidth="3.5"
fill="transparent"
strokeDasharray="113"
strokeDashoffset={113 - (113 * fulfillmentRate) / 100}
strokeLinecap="round"
/>
</svg>
<span className="absolute text-[9px] font-extrabold text-zinc-700 font-mono">
{fulfillmentRate}%
</span>
</div>
</div>
{/* Live Sparkline Trend Histogram */}
<div className="mt-4 pt-3.5 border-t border-zinc-100">
<div className="flex justify-between items-center text-[8px] text-zinc-400 font-bold uppercase tracking-widest mb-1.5">
<span>Speed Index (Live Feed)</span>
<span className="text-[#662582] flex items-center gap-0.5 text-[8px] font-bold">
<Activity className="w-2.5 h-2.5 animate-pulse" /> Live
</span>
</div>
<div className="h-6 flex items-end gap-1">
{[30, 48, 25, 62, 54, 75, 42, 80, Math.min(95, Math.max(15, st.deliveries * 1.8))].map((val, idx) => (
<div key={idx} className="flex-1 bg-zinc-100 rounded-t-sm h-full relative overflow-hidden group/bar">
<div
style={{ height: `${val}%` }}
className={`absolute bottom-0 left-0 right-0 rounded-t-sm transition-all duration-500 ${
st.status.toLowerCase() === 'active'
? st.deliveries > 40
? 'bg-rose-500/80 group-hover/bar:bg-rose-500'
: 'bg-[#662582]/70 group-hover/bar:bg-[#662582]'
: 'bg-amber-500/70 group-hover/bar:bg-amber-500'
}`}
/>
</div>
))}
</div>
</div>
{/* Lead Manager Profile block */}
<div className="flex justify-between items-center text-xs text-zinc-650 bg-zinc-50/80 rounded-xl p-2.5 border border-zinc-100/80 mt-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-gradient-to-tr from-purple-600 to-indigo-600 text-white flex items-center justify-center font-bold text-[10px] border border-white shadow-sm flex-shrink-0">
{st.staff.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<span className="text-[8px] text-zinc-400 block font-semibold leading-none mb-0.5">Node Lead</span>
<span className="font-bold text-[#0f172a] text-xs truncate max-w-[100px] block leading-none">{st.staff}</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
alert(`Routing communications channel directly to manager ${st.staff}...`);
}}
className="p-1.5 rounded-lg bg-white border border-zinc-200 hover:border-[#662582] hover:text-[#662582] text-zinc-500 transition-colors shadow-sm"
title="Communicate with Node Lead"
>
<Phone className="w-3.5 h-3.5" />
</button>
</div>
{/* Card footer - enter console */}
<div className="flex items-center justify-between text-[10px] font-bold text-[#662582] mt-4 pt-3 border-t border-zinc-100/80">
<button
onClick={(e) => {
e.stopPropagation();
const newStatus = st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active';
setStoresList(stores => stores.map(s => s.locationid === st.locationid ? { ...s, status: newStatus } : s));
if (st.locationid) {
updateLocation.mutate({ locationid: st.locationid, status: newStatus.toLowerCase() });
}
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${st.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-zinc-300'}`}
title={`Toggle store to ${st.status.toLowerCase() === 'active' ? 'Inactive' : 'Active'}`}
>
<span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${st.status.toLowerCase() === 'active' ? 'translate-x-5' : 'translate-x-1'}`} />
</button>
<div className="flex items-center gap-1 uppercase tracking-wider">
<span>Enter Terminal Console</span>
<ArrowRight className="w-3.5 h-3.5 transform group-hover:translate-x-1.5 transition-transform duration-350" />
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
const renderSecondarySection = () => {
switch (currentSection) {
case 'settings':
return <SettingsView tenantId={tenantId} user={authUser ?? undefined} />;
@@ -626,7 +627,16 @@ export default function App() {
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
{/* Nav content routing */}
{currentSection === 'dashboard' && (
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
selectedStore ? (
renderStoresSection()
) : (
<div className="space-y-6">
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
<div>
{renderStoresSection()}
</div>
</div>
)
)}
{currentSection === 'inventory' && (
@@ -647,8 +657,8 @@ export default function App() {
)}
{/* Handle alternative sections: Stores, Settings */}
{['stores', 'settings'].includes(currentSection) &&
{/* Handle alternative sections: Settings */}
{['settings'].includes(currentSection) &&
renderSecondarySection()
}
</div>
@@ -787,6 +797,7 @@ export default function App() {
</div>
)}
<ComparisonModal />
</div>
);
}

View File

@@ -0,0 +1,144 @@
import React from 'react';
import { ShoppingCart, X, Package, Trash2, Plus, ArrowRight, Layers } from 'lucide-react';
import { useCompare } from '../contexts/CompareContext';
import { useStoreCatalogue } from '../services/storeCatalogue';
interface BulkCartDrawerProps {
isOpen: boolean;
onClose: () => void;
onRequestSamples: () => void;
}
export default function BulkCartDrawer({ isOpen, onClose, onRequestSamples }: BulkCartDrawerProps) {
const { selectedProducts, removeProduct, clearSelection, setIsComparing } = useCompare();
const storeCat = useStoreCatalogue();
if (!isOpen) return null;
const handleBulkAdd = () => {
let addedCount = 0;
selectedProducts.forEach(prod => {
if (prod.verified && !storeCat.has(prod.id)) {
storeCat.add({
productid: String(prod.id),
name: prod.name,
image: prod.image,
category: prod.category,
sku: prod.sku,
price: prod.price,
unit: 'All Outlets',
qty: 1
});
addedCount++;
}
});
alert(addedCount > 0 ? `Successfully added ${addedCount} product(s) to the store catalogue!` : 'Selected products are already in the catalogue or not verified.');
if (addedCount > 0) onClose();
};
const handleBulkRemove = () => {
let removedCount = 0;
selectedProducts.forEach(prod => {
if (storeCat.has(String(prod.id))) {
storeCat.remove(String(prod.id));
removedCount++;
}
});
alert(removedCount > 0 ? `Successfully removed ${removedCount} product(s) from the store catalogue!` : 'None of the selected products were in the catalogue.');
if (removedCount > 0) onClose();
};
return (
<div className="fixed inset-0 z-[150] flex justify-end pointer-events-none">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm z-0 pointer-events-auto animate-in fade-in duration-300"
onClick={onClose}
/>
{/* Drawer */}
<div className="w-[450px] bg-slate-50 h-full z-50 flex flex-col shadow-2xl pointer-events-auto animate-in slide-in-from-right duration-300 border-l border-slate-200 relative">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200 bg-white z-10 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600 shadow-sm border border-indigo-100">
<ShoppingCart size={20} />
</div>
<div>
<h2 className="text-xl font-extrabold text-slate-900 tracking-tight">Selection Cart</h2>
<p className="text-xs text-slate-500 font-medium">{selectedProducts.length} items ready for action</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => { clearSelection(); onClose(); }}
className="text-xs font-bold text-slate-500 hover:text-rose-500 px-3 py-2 rounded-lg hover:bg-rose-50 transition-colors"
>
Clear All
</button>
<button
onClick={onClose}
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-slate-100 text-slate-500 transition-colors border border-transparent hover:border-slate-200"
>
<X size={20} />
</button>
</div>
</div>
{/* Content (Scrollable List) */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-3">
{selectedProducts.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400 gap-4 opacity-50">
<ShoppingCart size={48} strokeWidth={1} />
<p className="text-sm font-semibold">Your cart is empty.</p>
</div>
) : (
selectedProducts.map((prod) => (
<div key={prod.id} className="bg-white rounded-2xl p-3 flex gap-4 items-center border border-slate-200 shadow-sm relative group hover:border-[#662582]/40 transition-colors">
<div className="w-16 h-16 rounded-xl bg-slate-100 overflow-hidden shrink-0 border border-slate-200">
<img src={prod.image} alt={prod.name} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 pr-8">
<span className="text-[9px] font-black uppercase tracking-wider bg-slate-100 text-slate-600 px-2 py-0.5 rounded-md inline-block mb-1">
{prod.category}
</span>
<h3 className="font-bold text-slate-800 text-sm truncate">{prod.name}</h3>
<p className="text-[10px] text-slate-400 font-mono tracking-tight mt-0.5">{prod.sku}</p>
</div>
<button
onClick={() => removeProduct(prod.id)}
className="absolute right-4 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-rose-50 text-rose-500 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all hover:bg-rose-100 scale-90 group-hover:scale-100"
title="Remove from cart"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Action Footer */}
{selectedProducts.length > 0 && (
<div className="p-6 bg-white border-t border-slate-200 shadow-[0_-10px_40px_rgba(0,0,0,0.05)] shrink-0 flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleBulkAdd}
className="flex items-center justify-center gap-2 py-3.5 rounded-xl font-bold text-sm bg-indigo-50 text-indigo-700 hover:bg-indigo-600 hover:text-white transition-all border border-indigo-100 hover:border-indigo-600"
>
<Plus size={16} /> Bulk Add
</button>
<button
onClick={handleBulkRemove}
className="flex items-center justify-center gap-2 py-3.5 rounded-xl font-bold text-sm bg-rose-50 text-rose-600 hover:bg-rose-500 hover:text-white transition-all border border-rose-100 hover:border-rose-500"
>
<Trash2 size={16} /> Bulk Remove
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { X, CheckCircle2, ArrowRight } from 'lucide-react';
import { useCompare } from '../contexts/CompareContext';
export default function ComparisonModal() {
const { selectedProducts, isComparing, setIsComparing, removeProduct, clearSelection, hideCompareBar } = useCompare();
if (selectedProducts.length === 0) return null;
return createPortal(
<>
{/* Sticky Comparison Bar */}
{!isComparing && !hideCompareBar && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] bg-white border border-slate-200 shadow-[0_12px_40px_rgba(0,0,0,0.12)] rounded-full px-4 py-3 flex items-center gap-4 transition-all duration-300">
<div className="flex items-center gap-2">
<span className="bg-[#662582] text-white w-6 h-6 flex items-center justify-center rounded-full font-bold text-xs">
{selectedProducts.length}
</span>
<span className="text-sm font-bold text-slate-800 hidden sm:inline">items selected</span>
</div>
<div className="flex items-center -space-x-3 pr-4 border-r border-slate-200">
{selectedProducts.map((p) => (
<img key={p.id} src={p.image} alt={p.name} className="w-8 h-8 rounded-full border-2 border-white bg-slate-100 object-cover shadow-sm" />
))}
</div>
<div className="flex items-center gap-3 pl-2">
<button
onClick={clearSelection}
className="text-xs font-bold text-slate-500 hover:text-slate-800 transition-colors"
>
Clear
</button>
<button
onClick={() => setIsComparing(true)}
className="bg-[#662582] hover:bg-purple-800 text-white px-5 py-2 rounded-full font-bold text-sm shadow-md transition-colors flex items-center gap-2"
>
Compare Now <ArrowRight size={14} />
</button>
</div>
</div>
)}
{/* Comparison Drawer */}
{isComparing && (
<div className="fixed inset-0 bg-[#0f172a]/60 backdrop-blur-md z-[250] flex flex-col justify-end">
<div className="bg-white border-t border-[#e2e8f0] w-full h-[80vh] sm:h-[70vh] shadow-[0_-20px_50px_rgba(0,0,0,0.15)] rounded-t-3xl overflow-hidden flex flex-col animate-in slide-in-from-bottom duration-300">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b border-slate-100 bg-slate-50 shrink-0">
<div className="flex items-center gap-3">
<h2 className="text-xl font-extrabold text-slate-900 tracking-tight">Product Comparison</h2>
<span className="bg-[#662582] text-white text-xs font-bold px-2 py-0.5 rounded-full">
{selectedProducts.length} items
</span>
</div>
<div className="flex items-center gap-3">
{selectedProducts.length < 5 && (
<button
onClick={() => setIsComparing(false)}
className="text-xs font-bold text-[#662582] hover:bg-purple-50 bg-white px-3 py-1.5 rounded-lg border border-purple-200 shadow-sm transition-colors flex items-center gap-1.5"
>
<span className="text-lg leading-none">+</span> Add Product
</button>
)}
<button
onClick={clearSelection}
className="text-xs font-bold text-rose-500 hover:text-rose-600 bg-rose-50 px-3 py-1.5 rounded-lg border border-rose-100 transition-colors"
>
Clear All
</button>
<button
onClick={() => setIsComparing(false)}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-200 text-slate-500 transition-colors"
>
<X size={20} />
</button>
</div>
</div>
{/* Comparison Table / Grid */}
<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>
{/* Product Columns */}
{selectedProducts.map((prod) => (
<div key={prod.id} className="w-64 shrink-0 flex flex-col gap-4 relative group">
{/* 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>
</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>
<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>
<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>
))}
{/* Empty placeholders removed */}
</div>
</div>
</div>
</div>
)}
</>,
document.body
);
}

View File

@@ -232,152 +232,7 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
})}
</div>
{/* Order status + store locations */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-gutter">
{/* Store Node Status donut (live) */}
<div className="bg-white border border-slate-200/70 rounded-2xl p-lg flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
<div className="flex items-start gap-2.5">
<div className="h-9 w-9 rounded-xl bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100 flex items-center justify-center shrink-0">
<Activity size={17} />
</div>
<div>
<h3 className="font-sans font-bold text-base text-slate-900 tracking-tight">Store Outlet Status</h3>
<p className="text-slate-500 text-xs font-sans mt-0.5">Active share of all registered nodes.</p>
</div>
</div>
<div className="my-lg flex justify-center items-center">
<div className="relative w-44 h-44 flex items-center justify-center">
{/* soft glow behind the ring */}
<div className="absolute w-32 h-32 rounded-full bg-emerald-400/10 blur-2xl" />
<svg className="w-full h-full transform -rotate-90 relative" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="transparent" stroke="#eef2f6" strokeWidth="9" />
<circle
cx="50"
cy="50"
r="40"
fill="transparent"
stroke="url(#dashGradient)"
strokeWidth="9"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
strokeLinecap="round"
className="transition-all duration-700"
/>
<defs>
<linearGradient id="dashGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#0d9488" />
</linearGradient>
</defs>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="font-sans font-extrabold text-4xl text-slate-900 tracking-tight">{activePct}%</span>
<span className="text-[10px] text-emerald-600 uppercase tracking-widest font-bold mt-1">Active</span>
</div>
</div>
</div>
<div className="divide-y divide-slate-100 text-xs">
{statusRows.map((r) => (
<div key={r.label} className="flex justify-between items-center py-2.5">
<span className="flex items-center gap-2 text-slate-500 font-medium">
<span className={`w-2.5 h-2.5 rounded-full ${r.dot}`} />
{r.label}
</span>
<span className="font-mono font-bold text-slate-700">{r.value.toLocaleString('en-IN')}</span>
</div>
))}
<div className="flex justify-between items-center pt-2.5">
<span className="text-slate-600 font-bold">Total Nodes</span>
<span className="font-mono font-extrabold text-purple-650">{totalStoresCount.toLocaleString('en-IN')}</span>
</div>
</div>
</div>
{/* Store locations (live) */}
<div className="lg:col-span-2 bg-white border border-slate-200/70 rounded-2xl p-lg shadow-[0_1px_3px_rgba(16,24,40,0.05)]">
<div className="flex justify-between items-center mb-md pb-sm border-b border-slate-100">
<h3 className="font-sans font-bold text-base text-slate-900 flex items-center gap-2.5 tracking-tight">
<span className="h-9 w-9 rounded-xl bg-purple-50 text-purple-650 ring-1 ring-purple-100 flex items-center justify-center">
<Store size={17} />
</span>
Store Locations
</h3>
<span className="text-[10px] text-purple-650 uppercase font-bold bg-purple-50 px-2.5 py-1 rounded-lg tracking-wide border border-purple-100">
{locationsQ.isLoading ? 'Loading…' : `${locations.length} Outlet${locations.length === 1 ? '' : 's'}`}
</span>
</div>
{locationsQ.isLoading ? (
<div className="text-center py-xl text-slate-400 text-xs">Loading store locations…</div>
) : locations.length === 0 ? (
<div className="text-center py-xl text-slate-400 text-xs">No store locations found for this tenant.</div>
) : (
<div className="space-y-sm max-h-80 overflow-y-auto pr-1">
{locations.map((loc, i) => {
const sum = summaries.find((s) => s.locationid === Number(loc.locationid));
const deliveries = sum?.delivered ?? 0;
const orders = Math.max(sum?.delivered ?? 0, sum?.total ?? 0);
const isActive = str(loc.status).toLowerCase() === 'active';
const name = str(loc.locationname);
return (
<div
key={str(loc.locationid) || i}
className="group p-3.5 border border-slate-200/70 rounded-xl bg-slate-50/40 flex justify-between items-start gap-md hover:border-purple-200 hover:bg-white hover:shadow-[0_6px_18px_rgba(16,24,40,0.06)] transition-all animate-in fade-in"
>
<div className="flex items-start gap-3 min-w-0">
{/* Outlet initial badge */}
<div className="h-9 w-9 shrink-0 rounded-lg bg-gradient-to-br from-purple-500 to-indigo-500 text-white flex items-center justify-center font-bold text-xs uppercase shadow-sm">
{name.slice(0, 2) || '—'}
</div>
<div className="min-w-0">
<p className="font-sans font-semibold text-sm text-slate-900 truncate">{name}</p>
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
<MapPin size={11} className="shrink-0 text-slate-400" />
<span className="truncate">{str(loc.address) || `${str(loc.suburb)}, ${str(loc.city)}`}</span>
</p>
{str(loc.contactno) && (
<p className="text-[11px] text-slate-500 mt-0.5 flex items-center gap-1">
<Phone size={11} className="shrink-0 text-slate-400" />
{str(loc.contactno)}
</p>
)}
{/* Node-specific Orders and Dispatches */}
<div className="flex items-center gap-2 mt-2.5 flex-wrap">
<span className="text-[10px] bg-purple-50 text-purple-650 font-bold px-2 py-0.5 rounded-md border border-purple-100/60">
{orders} Orders
</span>
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-bold px-2 py-0.5 rounded-md border border-emerald-100/60">
{deliveries} Dispatched
</span>
{orders > 0 && (
<span className="text-[10px] text-slate-400 font-semibold">
{Math.round((deliveries / orders) * 100)}% Fulfilled
</span>
)}
</div>
</div>
</div>
<span
className={`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wide ${
isActive
? 'text-emerald-600 bg-emerald-50 border border-emerald-100'
: 'text-slate-500 bg-slate-100 border border-slate-200'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${isActive ? 'bg-emerald-500' : 'bg-slate-400'}`} />
{str(loc.status) || '—'}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -109,7 +109,7 @@ export default function DeliveriesView({ searchQuery = '', locationid, tenantId
{ label: 'Total Deliveries', value: total.toLocaleString('en-IN'), color: '#6366f1', icon: <Truck size={20} />, badge: undefined },
{ label: 'Pending', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: pct(summary?.pending ?? 0) },
{ label: 'Delivered', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: pct(summary?.delivered ?? 0) },
{ label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#ef4444', icon: <XCircle size={20} />, badge: pct(summary?.cancelled ?? 0) },
{ label: 'Cancelled', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: pct(summary?.cancelled ?? 0) },
];
return (

View File

@@ -16,11 +16,12 @@
import React, { useMemo, useState } from 'react';
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaOrderSummary, useFiestaAllOrders, useFiestaRevenueSummary } from '../services/fiestaQueries';
import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid } from 'recharts';
import { ResponsiveContainer, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, Tooltip as RechartsTooltip, CartesianGrid, Legend } from 'recharts';
import { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
import {
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE, TableShell,
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
Skeleton, Tooltip
} from './consoleUi';
type ReportTab = 'orders-summary' | 'riders-summary';
@@ -50,35 +51,34 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
return (
<div className="animate-in fade-in duration-300">
<GradientHeader title="Reports" subtitle="Operational analytics across outlets, riders, and the full order lifecycle." />
{/* Tab nav */}
<FilterBar className="mb-4">
<div className="flex items-center gap-2 overflow-x-auto">
{TABS.map((t) => {
const Icon = t.icon;
return (
<React.Fragment key={t.key}>
<Pill active={tab === t.key} color={BRAND} onClick={() => setTab(t.key)}><Icon size={14} /> {t.label}</Pill>
</React.Fragment>
);
})}
</div>
</FilterBar>
{/* Shared date range */}
{/* Tab nav & Date range combined */}
<FilterBar className="mb-4">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="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); }}>{p.label}</Pill></React.Fragment>
))}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-2 overflow-x-auto shrink-0">
{TABS.map((t) => {
const Icon = t.icon;
return (
<React.Fragment key={t.key}>
<Pill active={tab === t.key} color={BRAND} onClick={() => setTab(t.key)}><Icon size={14} /> {t.label}</Pill>
</React.Fragment>
);
})}
</div>
<div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
<span style={{ color: TEXT_3 }}>→</span>
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
<div className="flex items-center gap-4 flex-wrap lg:justify-end min-w-0">
<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); }}>{p.label}</Pill></React.Fragment>
))}
</div>
<div className="flex items-center gap-2 text-xs">
<input type="date" value={fromdate} max={todate} onChange={(e) => setFromdate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
<span style={{ color: TEXT_3 }}>→</span>
<input type="date" value={todate} min={fromdate} max={ymd(today)} onChange={(e) => setTodate(e.target.value)} className="rounded-full outline-none font-semibold w-28 sm:w-auto" style={{ padding: '6px 12px', border: `1.5px solid ${edge('#f59e0b')}`, background: tint('#f59e0b'), color: '#b45309' }} />
</div>
</div>
</div>
</FilterBar>
@@ -143,35 +143,147 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
const metricColor = metric === 'revenue' ? '#4f46e5' : '#0ea5e9';
// Computed Advanced Metrics
const aov = totals.total > 0 ? totalRevenue / totals.total : 0;
const cancelRate = totals.total > 0 ? (totals.cancelled / totals.total) * 100 : 0;
const hourlyData = useMemo(() => {
const hours = new Array(24).fill(0);
for (const r of (ordersQ.data ?? [])) {
const dateVal = fstr(r.orderdate) || fstr(r.deliverydate) || fstr(r.createdat);
if (!dateVal) continue;
const timePart = dateVal.split('T')[1];
if (timePart) {
// Adjust based on the actual timezone if needed, assuming UTC -> local roughly or just raw hour
const hour = parseInt(timePart.split(':')[0], 10);
if (!isNaN(hour) && hour >= 0 && hour <= 23) {
hours[hour] += 1;
}
}
}
// Combine hours into something more readable or just raw 24h
return hours.map((count, hour) => {
const ampm = hour >= 12 ? 'PM' : 'AM';
const h12 = hour % 12 || 12;
return { hourLabel: `${h12}${ampm}`, count };
});
}, [ordersQ.data]);
const statusData = [
{ name: 'Delivered', value: totals.delivered, color: '#10b981' },
{ name: 'Processing', value: totals.total - totals.delivered - totals.pending - totals.cancelled, color: '#6366f1' },
{ name: 'Pending', value: totals.pending, color: '#f59e0b' },
{ name: 'Cancelled', value: totals.cancelled, color: '#ef4444' },
].filter(d => d.value > 0);
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button onClick={() => setMetric('revenue')} className={`text-left p-5 bg-white rounded-2xl border transition-all duration-200 cursor-pointer flex items-center gap-4 ${metric === 'revenue' ? 'border-indigo-500 shadow-md ring-1 ring-indigo-500' : 'border-slate-200 shadow-sm hover:shadow-md'}`}>
<div className={`p-3 rounded-xl ${metric === 'revenue' ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-50 text-slate-400'}`}><IndianRupee size={20} /></div>
<div>
<p className="text-sm font-medium text-slate-500">Revenue</p>
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">₹{totalRevenue.toLocaleString('en-IN')}</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{/* Revenue */}
<button onClick={() => setMetric('revenue')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'revenue' ? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100'}`}>
<div className="flex justify-between items-start w-full">
<Tooltip content="Total revenue collected across all orders (excluding cancelled) in this period." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Revenue</span>
</Tooltip>
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 ${metric === 'revenue' ? 'bg-indigo-100 text-indigo-700' : 'bg-indigo-50 text-indigo-600'}`}>
<IndianRupee className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-2 w-full">
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
₹{totalRevenue.toLocaleString('en-IN')}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Total collected</p>
</div>
</button>
<button onClick={() => setMetric('orders')} className={`text-left p-5 bg-white rounded-2xl border transition-all duration-200 cursor-pointer flex items-center gap-4 ${metric === 'orders' ? 'border-sky-500 shadow-md ring-1 ring-sky-500' : 'border-slate-200 shadow-sm hover:shadow-md'}`}>
<div className={`p-3 rounded-xl ${metric === 'orders' ? 'bg-sky-100 text-sky-600' : 'bg-slate-50 text-slate-400'}`}><TrendingUp size={20} /></div>
<div>
<p className="text-sm font-medium text-slate-500">Total Orders</p>
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.total.toLocaleString('en-IN')}</p>
{/* Orders */}
<button onClick={() => setMetric('orders')} className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${metric === 'orders' ? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10' : 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100'}`}>
<div className="flex justify-between items-start w-full">
<Tooltip content="Total number of orders placed in this period, regardless of current status." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Total Orders</span>
</Tooltip>
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 ${metric === 'orders' ? 'bg-sky-100 text-sky-700' : 'bg-sky-50 text-sky-600'}`}>
<TrendingUp className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-2 w-full">
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
{totals.total.toLocaleString('en-IN')}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">All statuses</p>
</div>
</button>
<div className="p-5 bg-white rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-amber-50 text-amber-600"><Clock size={20} /></div>
<div>
<p className="text-sm font-medium text-slate-500">Pending</p>
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.pending.toLocaleString('en-IN')}</p>
{/* AOV */}
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-violet-200 hover:shadow-[0_8px_24px_rgba(139,92,246,0.06)] opacity-70 hover:opacity-100">
<div className="flex justify-between items-start w-full">
<Tooltip content="Average revenue generated per order (Total Revenue ÷ Total Orders)." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Avg Order Value</span>
</Tooltip>
<div className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 bg-violet-50 text-violet-600">
<TrendingUp className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-2 w-full">
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
₹{Math.round(aov).toLocaleString('en-IN')}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Per order</p>
</div>
</div>
<div className="p-5 bg-white rounded-2xl border border-slate-200 shadow-sm flex items-center gap-4">
<div className="p-3 rounded-xl bg-emerald-50 text-emerald-600"><CheckCircle2 size={20} /></div>
<div>
<p className="text-sm font-medium text-slate-500">Delivered</p>
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.delivered.toLocaleString('en-IN')}</p>
{/* Delivered */}
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-emerald-200 hover:shadow-[0_8px_24px_rgba(16,185,129,0.06)] opacity-70 hover:opacity-100">
<div className="flex justify-between items-start w-full">
<Tooltip content="Total number of orders successfully delivered to the customer." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Delivered</span>
</Tooltip>
<div className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 bg-emerald-50 text-emerald-600">
<CheckCircle2 className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-2 w-full">
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
{totals.delivered.toLocaleString('en-IN')}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Successfully fulfilled</p>
</div>
</div>
{/* Pending */}
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-amber-200 hover:shadow-[0_8px_24px_rgba(245,158,11,0.06)] opacity-70 hover:opacity-100">
<div className="flex justify-between items-start w-full">
<Tooltip content="Total number of orders currently being prepared, picked up, or in transit." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Pending</span>
</Tooltip>
<div className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 bg-amber-50 text-amber-600">
<Clock className="w-3.5 h-3.5" />
</div>
</div>
<div className="mt-2 w-full">
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
{totals.pending.toLocaleString('en-IN')}
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">In progress</p>
</div>
</div>
{/* Cancel Rate */}
<div className="rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border border-slate-200/70 bg-white hover:border-rose-200 hover:shadow-[0_8px_24px_rgba(244,63,94,0.06)] opacity-70 hover:opacity-100">
<div className="flex justify-between items-start w-full">
<Tooltip content="Percentage of total orders that were cancelled (Cancelled ÷ Total Orders × 100)." position="bottom">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Cancel Rate</span>
</Tooltip>
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0 ${cancelRate > 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-50 text-slate-500'}`}>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
</div>
</div>
<div className="mt-2 w-full">
<h3 className={`text-lg font-extrabold tracking-tight font-mono truncate ${cancelRate > 10 ? 'text-rose-600' : 'text-slate-900'}`}>
{cancelRate.toFixed(1)}%
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Cancelled orders</p>
</div>
</div>
</div>
@@ -184,30 +296,43 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
<th className="px-5 py-4 w-12">#</th>
<th className="px-5 py-4">Outlet</th>
<th className="px-5 py-4 text-right">Revenue</th>
<th className="px-5 py-4 text-right">All</th>
<th className="px-5 py-4 text-right">Created</th>
<th className="px-5 py-4 text-right">Pending</th>
<th className="px-5 py-4 text-right">Processing</th>
<th className="px-5 py-4 text-right">Delivered</th>
<th className="px-5 py-4 text-right">Cancelled</th>
<th className="px-5 py-4 text-right">AOV</th>
<th className="px-5 py-4 text-right">Orders</th>
<th className="px-5 py-4 text-right">Fulfillment</th>
<th className="px-5 py-4 text-right">Cancel Rate</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={9} className="px-5 py-12 text-center text-slate-500 font-medium">Loading outlet summary…</td></tr>
: rows.length === 0 ? <tr><td colSpan={9} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
: rows.map((r, i) => (
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{(locationRevenueMap.get(fnum(r.locationid)) ?? 0).toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
<td className="px-5 py-3 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
<td className="px-5 py-3 text-right"><Cnt n={r.processing} color="#6366f1" /></td>
<td className="px-5 py-3 text-right"><Cnt n={r.delivered} color="#10b981" /></td>
<td className="px-5 py-3 text-right"><Cnt n={r.cancelled} color="#ef4444" /></td>
</tr>
))}
{q.isLoading || revenueQ.isLoading ? <tr><td colSpan={7} className="px-5 py-6"><div className="space-y-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div></td></tr>
: rows.length === 0 ? <tr><td colSpan={7} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</td></tr>
: rows.map((r, i) => {
const rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0;
const rowAov = r.total > 0 ? rowRev / r.total : 0;
const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0;
const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0;
return (
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{rowRev.toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right text-slate-600 font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<span className="text-xs font-bold text-emerald-600">{rowFulfill.toFixed(0)}%</span>
<div className="w-16 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full" style={{ width: `${rowFulfill}%` }} />
</div>
</div>
</td>
<td className="px-5 py-3 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-bold ${rowCancel > 10 ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-600'}`}>
{rowCancel.toFixed(1)}%
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -222,58 +347,119 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
)}
</div>
{/* Trend Chart */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
<div className="flex items-center gap-2 mb-6">
<TrendingUp size={20} className="text-indigo-500" />
<h3 className="text-base font-bold text-slate-900 tracking-tight">Trend Analysis</h3>
{/* Insights Bento */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Trend Chart */}
<div className="xl:col-span-2 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-indigo-500" />
<h3 className="text-sm font-bold text-slate-900 tracking-tight uppercase">Performance Trend</h3>
</div>
<div className="w-full h-72">
{ordersQ.isLoading ? (
<Skeleton className="w-full h-[240px] rounded-xl" />
) : chartData.length === 0 ? (
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">No trend 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="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={metricColor} stopOpacity={0.2} />
<stop offset="95%" stopColor={metricColor} 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) => metric === 'revenue' ? `₹${v.toLocaleString('en-IN')}` : v.toLocaleString('en-IN')} />
<RechartsTooltip
cursor={{ stroke: metricColor, 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" style={{ backgroundColor: metricColor }}></span>
{metric}
</span>
<span className="text-base font-black text-slate-900">
{metric === 'revenue' ? `₹${payload[0].value.toLocaleString('en-IN')}` : payload[0].value.toLocaleString('en-IN')}
</span>
</div>
</div>
);
}
return null;
}}
/>
<Area type="monotone" dataKey={metric} stroke={metricColor} strokeWidth={2.5} fillOpacity={1} fill="url(#colorGradient)" activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff', fill: metricColor }} />
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className="w-full h-80">
{ordersQ.isLoading ? (
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">Loading trend data...</div>
) : chartData.length === 0 ? (
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">No trend 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="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={metricColor} stopOpacity={0.2} />
<stop offset="95%" stopColor={metricColor} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} dy={10}
tickFormatter={formatLabel} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} dx={-10} tickFormatter={(v) => metric === 'revenue' ? `₹${v.toLocaleString('en-IN')}` : v.toLocaleString('en-IN')} />
<RechartsTooltip
cursor={{ stroke: metricColor, strokeWidth: 1, strokeDasharray: '4 4' }}
content={({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-white p-4 rounded-xl border border-slate-200 shadow-lg">
<p className="text-xs 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-2 text-sm font-medium text-slate-600 capitalize">
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: metricColor }}></span>
{metric}
</span>
<span className="text-lg font-bold text-slate-900">
{metric === 'revenue' ? `₹${payload[0].value.toLocaleString('en-IN')}` : payload[0].value.toLocaleString('en-IN')}
</span>
</div>
</div>
);
}
return null;
}}
/>
<Area type="monotone" dataKey={metric} stroke={metricColor} strokeWidth={2.5} fillOpacity={1} fill="url(#colorGradient)" activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff', fill: metricColor }} />
</AreaChart>
</ResponsiveContainer>
)}
{/* Breakdown Stack */}
<div className="flex flex-col gap-6">
{/* Status Breakdown */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 flex flex-col flex-1">
<div className="flex items-center gap-2 mb-4">
<CheckCircle2 size={16} className="text-emerald-500" />
<h3 className="text-xs font-bold text-slate-900 tracking-tight uppercase">Order Status</h3>
</div>
{statusData.length > 0 ? (
<div className="w-full h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={statusData} cx="50%" cy="50%" innerRadius={35} outerRadius={60} dataKey="value" paddingAngle={3} stroke="none">
{statusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<RechartsTooltip formatter={(value: number) => value.toLocaleString('en-IN')} contentStyle={{ borderRadius: '12px', border: '1px solid #e2e8f0', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }} itemStyle={{ fontWeight: 'bold' }} />
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-xs text-slate-400">No data</div>
)}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 mt-auto">
{statusData.map(s => (
<div key={s.name} className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: s.color }} /> {s.name} ({s.value})
</div>
))}
</div>
</div>
{/* Hourly Heatmap (Bar) */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 flex flex-col flex-1">
<div className="flex items-center gap-2 mb-2">
<Clock size={16} className="text-amber-500" />
<h3 className="text-xs font-bold text-slate-900 tracking-tight uppercase">Peak Hours</h3>
</div>
<div className="w-full h-40 mt-auto">
{ordersQ.isLoading ? (
<Skeleton className="w-full h-[240px] rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={hourlyData} margin={{ top: 10, right: 0, left: -25, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis dataKey="hourLabel" axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8', fontWeight: 600 }} interval="preserveStartEnd" minTickGap={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 9, fill: '#94a3b8' }} />
<RechartsTooltip cursor={{ fill: '#f8fafc' }} contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1)' }} />
<Bar dataKey="count" fill="#6366f1" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
</div>
</div>
);
@@ -301,7 +487,7 @@ function RidersSummaryReport({ fromdate, todate, tenantId, locationid }: { fromd
<KpiStrip items={kpis} loading={q.isLoading} />
<TableShell minWidth={820} head={['#', 'Rider', 'Orders', 'Pending', 'Cancelled', 'Delivered', 'KMs', 'Amount']}
footer={mapped.length > 0 ? <TotalBar chips={[{ label: `${totals.orders} orders`, color: BRAND }, { label: `${totals.delivered} delivered`, color: '#10b981' }]} grand={`₹${totals.amount.toLocaleString('en-IN')}`} /> : undefined}>
{q.isLoading ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>Loading rider summary…</td></tr>
{q.isLoading ? <tr><td colSpan={8} className="px-5 py-6"><div className="space-y-3"><Skeleton className="h-5 w-full" /><Skeleton className="h-5 w-full opacity-70" /><Skeleton className="h-5 w-full opacity-40" /></div></td></tr>
: q.isError ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: '#ef4444' }}>Rider summary unavailable for this period.</td></tr>
: mapped.length === 0 ? <tr><td colSpan={8} className="px-3 py-12 text-center text-xs" style={{ color: TEXT_3 }}>No rider activity in this period.</td></tr>
: mapped.map((r, i) => (

File diff suppressed because it is too large Load Diff

View File

@@ -140,10 +140,10 @@ export default function OrdersDeliveriesView({
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
const kpis = [
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% of total` },
{ label: 'Pending Orders', value: countFor('pending').toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% of total` },
{ label: 'Delivered Orders', value: countFor('delivered').toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% of total` },
{ label: 'Cancelled Orders', value: countFor('cancelled').toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% of total` },
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% of total` },
{ label: 'Pending Orders', value: countFor('pending').toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% of total` },
{ label: 'Delivered Orders', value: countFor('delivered').toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% of total` },
{ label: 'Cancelled Orders', value: countFor('cancelled').toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% of total` },
];
// CSV export

View File

@@ -198,10 +198,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
// Restrained, professional palette — deep muted tones (not neon) so the KPI
// strip reads as a serious business dashboard rather than a colourful one.
const kpis = [
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
{ label: 'Created Orders', value: (summary?.created ?? 0).toLocaleString('en-IN'), color: '#6366f1', icon: <ShoppingBag size={20} />, badge: `${pct(summary?.created ?? 0)}% of total` },
{ label: 'Pending Orders', value: (summary?.pending ?? 0).toLocaleString('en-IN'), color: '#f59e0b', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 0)}% of total` },
{ label: 'Delivered Orders', value: (summary?.delivered ?? 0).toLocaleString('en-IN'), color: '#10b981', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 0)}% of total` },
{ label: 'Cancelled Orders', value: (summary?.cancelled ?? 0).toLocaleString('en-IN'), color: '#f43f5e', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 0)}% of total` },
];
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {

View File

@@ -23,7 +23,8 @@ import {
MapPin,
Calendar,
Trophy,
Store
Store,
CheckCircle2
} from 'lucide-react';
import { LeaderboardNode } from '../types';
import {
@@ -33,6 +34,7 @@ import {
useFiestaOrderSummary,
useFiestaRevenueSummary,
useFiestaTimeSeries,
useFiestaStoresTimeSeries,
} from '../services/fiestaQueries';
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
import { stockRowToProduct } from '../services/fiestaMappers';
@@ -283,38 +285,48 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
// Leaderboard — outlets ranked by total live orders.
// Monthly order distribution per outlet — includes all stores.
// Stores with no orders will simply show 0s across the board.
const insightRows = (() => {
return filteredLocations.map(loc => {
const locName = loc.locationname || `Location ${loc.locationid}`;
// Find the corresponding row from insightQ.data
const insightData = (insightQ.data ?? []).find(
r => (fstr(r.locationname) || `Location ${fstr(r.locationid)}`) === locName
);
return {
name: locName,
months: (insightData?.ordermonths ?? {}) as Record<string, unknown>,
};
});
})();
// Leaderboard — outlets ranked by current month's live orders.
const leaderboard: LeaderboardNode[] = (() => {
let rows = [...filteredLocations];
rows = rows.sort((a, b) => b.total - a.total).slice(0, 4);
const currentMonthIdx = new Date().getMonth();
const currentMonthKey = MONTH_KEYS[currentMonthIdx];
let rows = filteredLocations.map(loc => {
const name = loc.locationname || `Location ${loc.locationid}`;
// Find the corresponding insight row for this store
const insightRow = insightRows.find(r => r.name === name);
const total = insightRow ? fnum(insightRow.months[currentMonthKey]) : 0;
return { name, total };
});
rows = rows.sort((a, b) => b.total - a.total);
const max = rows.length ? rows[0].total : 0;
return rows.map((r, i) => ({
rank: String(i + 1).padStart(2, '0'),
name: r.locationname || `Location ${r.locationid}`,
name: r.name,
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
// Live order count drives the ranking/bar. No per-node revenue API yet, so the
// label shows the real order count (not fabricated rupees) — revenue lands with [R1].
revenue: `${r.total.toLocaleString()} ord`,
}));
})();
const currentLeaderboard = leaderboard;
// Monthly order distribution per outlet — live only (useFiestaOrderInsight
// already covers all the tenant's locations). The Chennai/Bangalore stub rows
// were removed; selecting those regions filters the live rows to none.
const insightRows = (() => {
let rows = (insightQ.data ?? []);
if (selectedRegion === 'coimbatore') {
rows = rows.filter(r => isCoimbatoreNode(fstr(r.locationname)));
} else if (selectedRegion === 'chennai' || selectedRegion === 'bangalore') {
rows = [];
}
return rows.map((r) => ({
name: fstr(r.locationname) || `Location ${fstr(r.locationid)}`,
months: (r.ordermonths ?? {}) as Record<string, unknown>,
}));
})();
const heatmapMax = Math.max(
1,
...insightRows.flatMap((row) => MONTH_KEYS.map((k) => fnum(row.months[k]))),
@@ -426,15 +438,23 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
return ymd(d);
}, [chartTimeframe]);
const tsQ = useFiestaTimeSeries({
const tsQ_all = useFiestaTimeSeries({
tenantid: tenantId,
granularity: tsGranularity,
fromdate: tsFromDate,
todate: todate,
});
const tsQ_stores = useFiestaStoresTimeSeries(
tenantId,
mappedLocations,
tsGranularity,
tsFromDate,
todate
);
const chartData = useMemo(() => {
const data = tsQ.data || [];
const data = tsQ_all.data || [];
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
return data.map((d: any) => {
@@ -461,49 +481,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
value: val
};
});
}, [tsQ.data, chartMetric, tsGranularity]);
}, [tsQ_all.data, tsQ_stores.data, isAllRegions, chartMetric, tsGranularity]);
const maxChartVal = useMemo(() => Math.max(...chartData.map(d => d.value), 1), [chartData]);
return (
<div className="space-y-lg animate-in fade-in duration-500 font-sans relative">
<div className="space-y-lg animate-in fade-in duration-500 font-sans">
{/* Immersive Background Blur Blobs */}
<div className="absolute top-10 left-1/4 w-96 h-96 bg-purple-400/5 rounded-full blur-[120px] pointer-events-none -z-10 animate-pulse" />
<div className="absolute top-40 right-1/4 w-[28rem] h-[28rem] bg-indigo-400/5 rounded-full blur-[140px] pointer-events-none -z-10 animate-pulse" style={{ animationDuration: '8s' }} />
{/* ── Immersive Analytics Banner (With Data Cover Image & Slate Gradient Overlay) ── */}
<div className="relative rounded-2xl p-6 md:p-8 text-white shadow-xl border border-purple-500/20 mb-8 animate-in fade-in duration-300 z-40">
{/* Cover Image Background & Decor (wrapped in overflow-hidden to keep rounded corner clip, while allowing dropdown overflow) */}
<div className="absolute inset-0 z-0 overflow-hidden rounded-2xl">
<img
src="https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1200&q=80"
alt="Reports Dashboard Banner"
className="w-full h-full object-cover object-center opacity-40"
/>
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-900/90 to-purple-950/80" />
{/* Background decorative glowing circles */}
<div className="absolute top-0 right-0 w-72 h-72 bg-purple-500/10 rounded-full blur-3xl -mr-20 -mt-20 pointer-events-none z-0" />
<div className="absolute bottom-0 left-0 w-56 h-56 bg-slate-500/5 rounded-full blur-2xl -ml-20 -mb-20 pointer-events-none z-0" />
</div>
{/* Content Row */}
<div className="relative z-20 flex flex-col md:flex-row md:items-center justify-between gap-lg">
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-white flex items-center gap-2">
Business Intelligence Center
<span className="text-[10px] text-purple-200 font-bold bg-purple-900/60 border border-purple-500/30 px-2 py-0.5 rounded-full uppercase tracking-wider animate-pulse">
Live Core
</span>
</h1>
</div>
</div>
</div>
{/* 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">
@@ -519,91 +503,76 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
if (kpi.color === 'indigo') {
activeStyles = isActive
? 'border-indigo-500 bg-indigo-50/10 shadow-[0_12px_30px_rgba(99,102,241,0.15)] ring-1 ring-indigo-500 scale-102 z-10'
: 'hover:border-indigo-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(99,102,241,0.05)]';
? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 z-10'
: 'border-slate-200/70 bg-white hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.06)] opacity-70 hover:opacity-100';
strokeColor = '#6366f1';
IconComponent = ShoppingBag;
iconBg = 'bg-indigo-50 border border-indigo-100 shadow-[0_0_12px_rgba(99,102,241,0.2)]';
iconTextColor = 'text-indigo-600';
iconBg = 'bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100';
} else if (kpi.color === 'emerald') {
activeStyles = isActive
? 'border-emerald-500 bg-emerald-50/10 shadow-[0_12px_30px_rgba(16,185,129,0.15)] ring-1 ring-emerald-500 scale-102 z-10'
: 'hover:border-emerald-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(16,185,129,0.05)]';
? 'border-emerald-200 bg-white shadow-[0_8px_24px_rgba(16,185,129,0.12)] ring-1 ring-emerald-500/20 z-10'
: 'border-slate-200/70 bg-white hover:border-emerald-200 hover:shadow-[0_8px_24px_rgba(16,185,129,0.06)] opacity-70 hover:opacity-100';
strokeColor = '#10b981';
IconComponent = Activity;
iconBg = 'bg-emerald-50 border border-emerald-100 shadow-[0_0_12px_rgba(16,185,129,0.2)]';
iconTextColor = 'text-emerald-600';
iconBg = 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100';
} else if (kpi.color === 'rose') {
activeStyles = isActive
? 'border-rose-500 bg-rose-50/10 shadow-[0_12px_30px_rgba(244,63,94,0.15)] ring-1 ring-rose-500 scale-102 z-10'
: 'hover:border-rose-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(244,63,94,0.05)]';
? 'border-rose-200 bg-white shadow-[0_8px_24px_rgba(244,63,94,0.12)] ring-1 ring-rose-500/20 z-10'
: 'border-slate-200/70 bg-white hover:border-rose-200 hover:shadow-[0_8px_24px_rgba(244,63,94,0.06)] opacity-70 hover:opacity-100';
strokeColor = '#f43f5e';
IconComponent = AlertTriangle;
iconBg = 'bg-rose-50 border border-rose-100 shadow-[0_0_12px_rgba(244,63,94,0.2)]';
iconTextColor = 'text-rose-600';
iconBg = 'bg-rose-50 text-rose-600 ring-1 ring-rose-100';
} else {
activeStyles = isActive
? 'border-sky-500 bg-sky-50/10 shadow-[0_12px_30px_rgba(14,165,233,0.15)] ring-1 ring-sky-500 scale-102 z-10'
: 'hover:border-sky-200 border-[#e2e8f0] bg-white/70 hover:bg-white hover:shadow-[0_8px_20px_rgba(14,165,233,0.05)]';
? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 z-10'
: 'border-slate-200/70 bg-white hover:border-sky-200 hover:shadow-[0_8px_24px_rgba(14,165,233,0.06)] opacity-70 hover:opacity-100';
strokeColor = '#0ea5e9';
IconComponent = Package;
iconBg = 'bg-sky-50 border border-sky-100 shadow-[0_0_12px_rgba(14,165,233,0.2)]';
iconTextColor = 'text-sky-600';
iconBg = 'bg-sky-50 text-sky-600 ring-1 ring-sky-100';
}
return (
<button
key={kpi.id}
onClick={() => setChartMetric(kpi.id)}
className={`border backdrop-blur-md rounded-2xl p-3 flex flex-col justify-between hover:shadow-lg transition-all duration-300 hover:-translate-y-1.5 text-left cursor-pointer outline-none relative overflow-hidden group ${activeStyles}`}
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left cursor-pointer outline-none relative group border ${activeStyles}`}
>
{/* Subtle top glowing line when active */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-1" style={{ backgroundColor: strokeColor }} />
)}
{/* Header Row */}
<div className="flex justify-between items-center w-full">
<span className="text-[10px] font-sans font-bold text-zinc-400 uppercase tracking-widest block">
{kpi.title}
</span>
<div className={`w-7 h-7 rounded-full flex items-center justify-center transition-transform duration-300 group-hover:scale-110 ${iconBg} ${iconTextColor}`}>
<IconComponent size={13} />
</div>
</div>
{/* Main Metric Value and Trend Badge */}
{kpi.awaiting ? (
<div className="mt-1">
<AwaitingApi label="Revenue" api="[R1]" compact />
</div>
) : (
<div className="mt-1.5 flex items-baseline gap-2">
<div className="font-sans font-black text-slate-900 text-2xl tracking-tight leading-none">
{kpi.value}
</div>
<div className="flex justify-between items-start w-full">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
<div className="flex items-center gap-2">
{kpi.trend && (
<span className={`text-[9px] font-bold px-2 py-0.5 rounded-full flex items-center gap-0.5 leading-none h-4 ${kpi.isPositive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100/50' : 'bg-rose-50 text-rose-600 border border-rose-100/50'
}`}>
{kpi.isPositive ? '▲' : '▼'}
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-md flex items-center gap-1 leading-none ${kpi.isPositive ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-700'}`}>
{kpi.isPositive ? <TrendingUp size={10} /> : <TrendingDown size={10} />}
{kpi.trend}
</span>
)}
<div className={`p-1.5 rounded-lg transition-transform group-hover:scale-110 ${iconBg.replace('ring-1', '').replace(/ring-\w+-100/, '').trim()}`}>
<IconComponent className="w-3.5 h-3.5" />
</div>
</div>
)}
{/* Bottom Sparkline & Subtext segment */}
<div className="flex items-center justify-between mt-auto pt-2 w-full border-t border-[#f1f5f9]">
<span className="text-[10px] text-zinc-550 font-bold uppercase tracking-wider">{kpi.status}</span>
{/* KPI Mini Sparkline Chart */}
<div className="w-16 h-7 select-none shrink-0">
</div>
<div className="mt-2 flex justify-between items-end w-full">
<div>
{kpi.awaiting ? (
<div className="mt-1">
<AwaitingApi label="Revenue" api="[R1]" compact />
</div>
) : (
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900">
{kpi.value}
</h3>
)}
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">{kpi.status}</p>
</div>
<div className="w-12 h-6 select-none shrink-0 opacity-80 group-hover:opacity-100 transition-opacity">
<svg className="w-full h-full" viewBox="0 0 64 32">
<path
d={getSparkPath(kpi.spark, 64, 32)}
fill="none"
stroke={strokeColor}
strokeWidth="2"
strokeWidth="2.5"
strokeLinecap="round"
/>
</svg>
@@ -614,143 +583,33 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
})}
</div>
{/* Main Interactive Charts & Insights Bento Segment */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
{/* Interactive Main Graph Card — Full width (12 cols) */}
<div className="lg:col-span-12 bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-3xl p-lg shadow-sm flex flex-col justify-between relative overflow-hidden group">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-lg border-b border-[#f1f5f9] pb-md">
<div>
<span className="text-[10px] text-purple-650 font-bold bg-purple-50 px-2 py-0.5 rounded-full uppercase tracking-wider">
Interactive Graph
</span>
<h3 className="font-sans font-bold text-base text-[#0f172a] mt-1.5">
{chartMetric === 'orders' ? 'Total Orders Velocity Trend' :
chartMetric === 'revenue' ? 'Revenue Expansion Trajectory' :
chartMetric === 'cancelled' ? 'Order Cancellation Frequency' :
'Catalogue Active SKUs Growth'}
</h3>
</div>
<div className="flex gap-2 items-center">
<div className="flex bg-slate-100/50 p-1 rounded-xl border border-slate-200/60 hidden sm:flex">
{(['day', 'week', 'month'] as const).map((tf) => (
<button
key={tf}
onClick={() => setChartTimeframe(tf)}
className={`px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${
chartTimeframe === tf
? 'bg-white text-purple-700 shadow-sm border border-purple-100'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/50'
}`}
>
{tf}
</button>
))}
</div>
<span className="text-[10px] font-mono text-zinc-555 font-bold bg-zinc-50 border border-zinc-200/60 px-3 py-1.5 rounded-xl flex items-center gap-1.5 ml-2">
<span className="w-1.5 h-1.5 rounded-full bg-current animate-pulse" style={{ color: theme.activeLine }} />
<span>Live Sync</span>
</span>
</div>
</div>
{/* Plotted Area */}
<div className="relative h-64 select-none w-full flex items-end pt-8 pb-4 px-2">
<svg className="absolute inset-0 w-full h-full preserve-3d" preserveAspectRatio="none">
<defs>
<linearGradient id="chartGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={theme.activeLine} stopOpacity={0.4} />
<stop offset="100%" stopColor={theme.activeLine} stopOpacity={0.01} />
</linearGradient>
</defs>
<path
d={`M 0,100 ` + chartData.map((d, i) => {
const x = (i / (chartData.length - 1)) * 100;
const y = 100 - ((d.value / maxChartVal) * 80);
return `L ${x},${y} `;
}).join('') + `L 100,100 Z`}
fill="url(#chartGradient)"
vectorEffect="non-scaling-stroke"
/>
<path
d={chartData.map((d, i) => {
const x = (i / (chartData.length - 1)) * 100;
const y = 100 - ((d.value / maxChartVal) * 80);
return `${i === 0 ? 'M' : 'L'} ${x},${y}`;
}).join(' ')}
fill="none"
stroke={theme.activeLine}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
className="drop-shadow-[0_4px_6px_rgba(168,85,247,0.4)]"
/>
</svg>
<div className="absolute inset-0 flex justify-between items-end pb-4 px-2">
{chartData.map((d, i) => (
<div key={i} className="group relative flex flex-col items-center flex-1 h-full justify-end cursor-crosshair">
{/* Hover vertical line */}
<div className="absolute top-0 bottom-6 w-px bg-slate-200/0 group-hover:bg-slate-300/50 transition-colors" />
{/* Invisible hit area */}
<div className="absolute inset-0 w-full h-full z-10" />
{/* Tooltip */}
<div className="absolute bottom-full mb-2 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-slate-900 text-white text-[10px] px-3 py-2 rounded-xl shadow-xl pointer-events-none whitespace-nowrap z-20 flex flex-col items-center">
<span className="font-bold text-xs text-purple-300">{d.label}</span>
<span className="font-extrabold tracking-wide mt-0.5">
{chartMetric === 'revenue' ? `₹${d.value.toLocaleString()}` : d.value.toLocaleString()}
</span>
{/* Tooltip caret */}
<div className="absolute top-full w-2 h-2 bg-slate-900 rotate-45 -mt-1" />
</div>
{/* Dot on line */}
<div
className="absolute w-3 h-3 rounded-full border-2 border-white bg-purple-600 shadow-md opacity-0 group-hover:opacity-100 transition-all duration-200 z-10 scale-50 group-hover:scale-100"
style={{ bottom: `calc(${((d.value / maxChartVal) * 80)}% + 1.5rem)`, backgroundColor: theme.activeLine }}
/>
{/* X-axis label */}
<span className="text-[9px] font-bold text-slate-400 mt-2 rotate-0 truncate max-w-full px-1">{d.label}</span>
</div>
))}
</div>
</div>
</div>
</div>
{/* Bento split maps (Heatmap grid & Leaderboard) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
{/* Revenue Heatmap table - 8 Cols */}
<div className="lg:col-span-8 bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-2xl overflow-hidden flex flex-col justify-between shadow-sm">
<div className="lg:col-span-8 bg-white border border-slate-200/70 rounded-2xl overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm flex justify-between items-center select-none">
<span className="text-[11px] font-sans font-bold text-[#0f172a] uppercase tracking-widest block">
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
Monthly Order Distribution
</span>
<div className="flex items-center gap-2 text-[9px] font-bold text-zinc-400 uppercase tracking-tight">
<span className="w-1.5 h-1.5 rounded-full bg-[#662582] animate-pulse"></span>
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
<span className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></span>
<span>Busiest Month</span>
</div>
</div>
<div className="p-md flex-1 overflow-x-auto">
<div className="p-5 flex-1 overflow-x-auto">
{selectedCell && (
<div className="mb-4 p-md bg-purple-500/10 border border-purple-500/20 backdrop-blur-md rounded-2xl text-xs flex justify-between items-center animate-in slide-in-from-top-2 duration-300">
<div className="mb-4 p-4 bg-purple-50 border border-purple-100 rounded-xl text-xs flex justify-between items-center animate-in slide-in-from-top-2 duration-300">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-purple-500/20 flex items-center justify-center text-purple-650 shrink-0">
<div className="w-7 h-7 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 shrink-0">
<Info size={14} />
</div>
<span className="font-sans text-purple-955 font-semibold">
<span className="font-sans text-purple-900 font-semibold">
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
</span>
</div>
@@ -764,16 +623,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
)}
{insightRows.length === 0 ? (
<div className="py-xl text-center text-zinc-400 text-xs">
<div className="py-12 text-center text-slate-400 text-xs">
{insightQ.isLoading ? 'Loading monthly order distribution…' : 'No order insight available for this region.'}
</div>
) : (
<table className="w-full text-center border-collapse text-xs font-sans">
<thead>
<tr>
<th className="p-xs text-[9px] font-bold text-zinc-400 w-32 uppercase italic text-left pl-md">Outlet Name</th>
<th className="p-2 text-[9px] font-bold text-slate-400 w-32 uppercase italic text-left pl-4">Outlet Name</th>
{MONTH_LABELS.map((m) => (
<th key={m} className="p-sm text-[10px] font-bold text-[#0f172a] border-b border-[#f1f5f9]">
<th key={m} className="p-2 text-[10px] font-bold text-slate-800 border-b border-slate-100">
{m}
</th>
))}
@@ -782,7 +641,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<tbody>
{insightRows.map((row, idx) => (
<tr key={idx}>
<td className="p-sm text-left font-bold text-[#0f172a] tracking-wide truncate max-w-[8rem] border-r border-[#f1f5f9] pl-md">
<td className="p-2 text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100 pl-4">
{row.name}
</td>
{MONTH_KEYS.map((key, mIdx) => {
@@ -807,16 +666,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div>
{/* Leaderboard nodes bar list - 4 Cols */}
<div className="lg:col-span-4 bg-white/70 backdrop-blur-md border border-[#e2e8f0] rounded-2xl flex flex-col shadow-sm">
<div className="bg-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm select-none">
<span className="text-[11px] font-sans font-bold text-[#0f172a] uppercase tracking-widest block">
<div className="lg:col-span-4 bg-white border border-slate-200/70 rounded-2xl flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 select-none">
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
Top Performing Nodes
</span>
</div>
<div className="p-md flex-1 space-y-md flex flex-col justify-center">
<div className="p-5 flex-1 space-y-4 flex flex-col justify-center">
{currentLeaderboard.length === 0 ? (
<div className="text-center py-8 text-zinc-450 font-medium">
<div className="text-center py-8 text-slate-400 font-medium">
No active nodes in this region.
</div>
) : (
@@ -825,34 +684,41 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
let badgeContent: React.ReactNode = node.rank;
if (index === 0) {
badgeStyle = 'bg-gradient-to-tr from-amber-300 via-yellow-400 to-amber-500 text-amber-955 border-amber-200 shadow-md shadow-amber-500/20';
badgeStyle = 'bg-gradient-to-tr from-amber-300 via-yellow-400 to-amber-500 text-amber-950 shadow-md shadow-amber-500/20';
badgeContent = '🥇';
} else if (index === 1) {
badgeStyle = 'bg-gradient-to-tr from-slate-200 via-zinc-300 to-slate-400 text-zinc-800 border-zinc-100 shadow-md shadow-zinc-400/10';
badgeStyle = 'bg-gradient-to-tr from-slate-200 via-slate-300 to-slate-400 text-slate-800 shadow-md shadow-slate-400/10';
badgeContent = '🥈';
} else if (index === 2) {
badgeStyle = 'bg-gradient-to-tr from-amber-600 via-amber-700 to-orange-850 text-amber-50 border-amber-500 shadow-md shadow-amber-800/20';
badgeStyle = 'bg-gradient-to-tr from-amber-600 via-amber-700 to-orange-800 text-white shadow-md shadow-amber-800/20';
badgeContent = '🥉';
} else {
badgeStyle = 'bg-slate-100 text-zinc-505 border-slate-200';
badgeStyle = 'bg-slate-100 text-slate-500 border border-slate-200';
}
return (
<div key={node.rank} className="flex items-center gap-md text-xs hover:bg-slate-50/50 p-2.5 rounded-xl transition-all duration-200 group">
<div key={node.rank} className="flex items-center gap-4 text-xs hover:bg-slate-50 p-2.5 rounded-xl transition-all duration-200 group">
{/* Ranking circle avatar */}
<div className={`w-9 h-9 rounded-full border font-black flex items-center justify-center font-mono shrink-0 select-none text-sm transition-all duration-300 group-hover:scale-105 ${badgeStyle}`}>
<div className={`w-9 h-9 rounded-full font-black flex items-center justify-center font-mono shrink-0 select-none text-sm transition-transform duration-300 group-hover:scale-105 ${badgeStyle}`}>
{badgeContent}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center font-medium mb-1">
<span className="text-[#0f172a] font-bold truncate max-w-[12rem]">{node.name}</span>
<span className="text-[#662582] font-mono font-bold">{node.revenue}</span>
<div className="flex justify-between items-center font-medium mb-1.5">
<span className="text-slate-900 font-bold truncate max-w-[12rem] flex items-center gap-1.5">
{node.name}
{index < 3 && (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[8px] font-black uppercase tracking-widest bg-emerald-50 text-emerald-600 border border-emerald-100" title="Verified Top Performer">
<CheckCircle2 size={10} /> Verified
</span>
)}
</span>
<span className="text-purple-700 font-mono font-bold">{node.revenue}</span>
</div>
<div className="w-full bg-slate-100 h-2 rounded-full overflow-hidden">
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
<div
className="bg-gradient-to-r from-purple-500 via-indigo-600 to-[#662582] h-full rounded-full transition-all duration-500 ease-out origin-left group-hover:brightness-110"
className="bg-gradient-to-r from-purple-500 to-purple-700 h-full rounded-full transition-all duration-500 ease-out origin-left"
style={{ width: `${node.percentage}%` }}
/>
</div>
@@ -997,13 +863,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{filter}
</button>
))}
<button
onClick={() => startExportSim('CSV')}
className="px-3 py-1.5 bg-[#0f172a] text-white rounded-lg cursor-pointer hover:bg-zinc-805 border border-[#0f172a] transition-colors shadow-sm"
>
Export CSV
</button>
</div>
</div>
@@ -1016,15 +875,14 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<th className="px-md py-sm">Product Name</th>
<th className="px-md py-sm">SKU ID</th>
<th className="px-md py-sm text-right">Units Sold</th>
<th className="px-md py-sm text-right">Revenue</th>
<th className="px-md py-sm text-right">Overall Revenue</th>
<th className="px-md py-sm text-right">Stock Status</th>
<th className="px-md py-sm text-center">Trend index</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f1f5f9]">
{paginatedProducts.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-zinc-400 font-medium">
<td colSpan={6} className="text-center py-8 text-zinc-400 font-medium">
No matching items matching stock filter criteria.
</td>
</tr>
@@ -1076,33 +934,88 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{prod.stockStatus}
</span>
</td>
<td className="px-md py-md text-center">
<span className="inline-block p-1 bg-[#f8fafc] border border-zinc-100/85 rounded-full select-none">
{prod.trend === 'up' ? (
<TrendUp size={13} className="text-emerald-505" />
) : prod.trend === 'down' ? (
<TrendDown size={13} className="text-rose-505" />
) : (
<TrendFlat size={13} className="text-zinc-400" />
)}
</span>
</td>
</tr>
{/* Expanded details row */}
{isExpanded && (
<tr className="bg-slate-50/20">
<td colSpan={7} className="p-0 border-b border-[#e2e8f0]">
<div className="px-lg py-md bg-gradient-to-r from-slate-50/50 to-purple-50/10 border-t border-[#e2e8f0] animate-in slide-in-from-top-2 duration-300">
{/* Per-product stock & location breakdown has no live
API ([R3]); the previously fabricated unit counts,
hub split, bin code, audit date and barcode are
replaced with the awaiting-backend placeholder. */}
<AwaitingApi label="Per-product stock & location detail" api="[R3]" compact />
</div>
</td>
</tr>
)}
{isExpanded && (() => {
const storeBreakdown = activeRegionStock.map(store => {
if (!store.rows) return null;
const rawProdId = prod.sku.replace('SKU-', '');
const row = store.rows.find(r =>
r.productid?.toString() === rawProdId ||
r.productname === prod.name
);
if (!row) return null;
const closing = Number(row.closing || 0);
const debit = Math.max(0, Number(row.debit || 0));
const retail = Number(row.retailprice) || Number(row.productcost) || 0;
const rev = debit * retail;
let status = 'Healthy';
if (closing <= 0) status = 'Critical';
else if (closing < 25) status = 'Critical';
else if (closing < 120) status = 'Low Stock';
return {
locationname: store.locationname || `Store ${store.locationid}`,
closing,
debit,
revenue: rev,
status
};
}).filter(Boolean);
return (
<tr className="bg-slate-50/50 border-b border-[#e2e8f0]">
<td colSpan={6} className="p-0">
<div className="px-8 py-6 bg-white border-y border-slate-100 shadow-[inset_0_4px_6px_-4px_rgba(0,0,0,0.05)] animate-in slide-in-from-top-2 duration-300">
<h4 className="text-xs font-bold text-slate-800 mb-4 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></div>
Store-by-Store Breakdown
</h4>
{storeBreakdown.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-xs font-medium">No store data available for this product.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{storeBreakdown.map((b: any, idx) => (
<div key={idx} className="bg-white border border-slate-200/80 rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow hover:border-purple-200 group">
<div className="flex justify-between items-start mb-3">
<div className="font-bold text-slate-800 text-xs group-hover:text-purple-700 transition-colors">{b.locationname}</div>
<span className={`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wide ${
b.status === 'Healthy' ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' :
b.status === 'Low Stock' ? 'bg-amber-50 text-amber-600 border border-amber-100' :
'bg-rose-50 text-rose-600 border border-rose-100'
}`}>
{b.status}
</span>
</div>
<div className="flex items-center justify-between text-[11px]">
<div>
<div className="text-slate-400 font-semibold mb-0.5 uppercase tracking-wider text-[9px]">In Stock</div>
<div className="font-mono font-bold text-slate-700">{b.closing.toLocaleString()}</div>
</div>
<div className="w-px h-6 bg-slate-100"></div>
<div>
<div className="text-slate-400 font-semibold mb-0.5 uppercase tracking-wider text-[9px]">Units Sold</div>
<div className="font-mono font-bold text-purple-600">{b.debit.toLocaleString()}</div>
</div>
<div className="w-px h-6 bg-slate-100"></div>
<div className="text-right">
<div className="text-slate-400 font-semibold mb-0.5 uppercase tracking-wider text-[9px]">Revenue</div>
<div className="font-mono font-bold text-emerald-600">₹{b.revenue.toLocaleString()}</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</td>
</tr>
);
})()}
</React.Fragment>
);
})

View File

@@ -213,10 +213,7 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700">
{/* Header */}
<div className="pb-4 border-b border-slate-100">
<h1 className="font-sans font-bold text-3xl tracking-tight text-[#0f172a]">Settings</h1>
<p className="text-slate-500 text-sm mt-2">
Manage your store profile, outlet hubs, delivery configurations, payments, and team preferences.
</p>
<div className="mt-3">
{tenantsQ.isLoading ? (
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
@@ -294,24 +291,12 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
<h2 className="text-xl font-bold text-slate-900 mt-1">Identity & Contacts</h2>
</div>
{/* Logo / Identity Row */}
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-6 border-b border-slate-100">
{/* Visual Drag and Drop Logo Uploader Placeholder */}
<div className="w-28 h-28 rounded-2xl border-2 border-dashed border-slate-200 hover:border-purple-500 bg-slate-50/40 hover:bg-purple-50/10 flex flex-col items-center justify-center text-center cursor-pointer transition-all duration-300 relative group shrink-0 select-none shadow-sm animate-in zoom-in-95">
<div className="p-3 rounded-xl bg-slate-100 group-hover:bg-purple-100 text-slate-400 group-hover:text-purple-600 transition-all duration-300">
<Building2 size={24} />
</div>
<span className="text-[10px] text-slate-500 font-extrabold mt-2 tracking-wider uppercase group-hover:text-purple-700 transition-colors">Upload Logo</span>
<div className="absolute inset-0 bg-slate-950/85 text-white text-[10px] font-bold flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-300 rounded-2xl gap-1">
<Plus size={18} />
<span>Drop Image</span>
</div>
</div>
{/* Identity Row */}
<div className="flex flex-col pb-6 border-b border-slate-100">
<div className="min-w-0 flex-1 leading-relaxed">
<span className="text-base font-bold text-slate-900 mr-2">{user?.name || fstr(tenant?.tenantname) || 'Nearle Store'}</span>
<span className="text-slate-500 text-xs font-medium">
— Set up your store logo, customer service email, and contact number. Official registration details are synced with your primary credentials.
— View your customer service email and contact number. Official registration details are synced with your primary credentials.
</span>
</div>
</div>

View File

@@ -34,7 +34,6 @@ export default function Sidebar({
// Navigation elements
const navItems = [
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
{ id: 'stores' as MainSection, label: 'Stores', icon: Store },
{ id: 'inventory' as MainSection, label: 'Products', icon: Layers },
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }

View File

@@ -20,12 +20,14 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } from 'lucide-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 { categoryName } from '../services/fiestaMappers';
import { useStoreCatalogue } from '../services/storeCatalogue';
import AwaitingApi from './AwaitingApi';
import { SlideDrawer } from './consoleUi';
import { useCompare } from '../contexts/CompareContext';
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
@@ -52,10 +54,13 @@ 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 [search, setSearch] = useState('');
const [category, setCategory] = useState('ALL');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [stockHealthFilter, setStockHealthFilter] = useState<string[]>([]);
const [selectedProduct, setSelectedProduct] = useState<any>(null);
const [notice, setNotice] = useState(false);
// The admin-curated catalogue (what the user is allowed to pick from).
@@ -107,7 +112,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
const inventory = useMemo(
() =>
(stockQ.data ?? []).map((r: Row) => {
const closing = fnum(r.closing);
const closing = fnum(r.closing) ?? 0;
return {
id: fstr(r.productid),
name: fstr(r.productname) || 'Unnamed product',
@@ -126,15 +131,33 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term));
}, [inventory, search]);
const categories = useMemo(() => [...new Set(products.map((p) => p.category))].sort(), [products]);
const categories = useMemo(() => [...new Set(products.map((p) => p.category.split(' / ')[0]))].sort(), [products]);
const filtered = useMemo(() => {
const term = search.toLowerCase();
return products.filter((p) => {
if (category !== 'ALL' && p.category !== category) return false;
const pCat = p.category.split(' / ')[0];
if (selectedCategories.length > 0 && !selectedCategories.includes(pCat)) return false;
if (!term) return true;
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
});
}, [products, search, category]);
}, [products, search, selectedCategories]);
const toggleCategory = (cat: string) => {
setSelectedCategories(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat]);
};
const toggleStockHealth = (health: string) => {
setStockHealthFilter(prev => prev.includes(health) ? prev.filter(h => h !== health) : [...prev, health]);
};
const finalFilteredInventory = useMemo(() => {
return filteredInventory.filter(it => {
if (selectedCategories.length > 0 && !selectedCategories.includes(it.category.split(' / ')[0])) return false;
if (stockHealthFilter.length > 0 && !stockHealthFilter.includes(it.label)) return false;
return true;
});
}, [filteredInventory, selectedCategories, stockHealthFilter]);
// ── Integration point ──────────────────────────────────────────────────────────
// Replace with the real request/stock POST (selected productids + quantities),
@@ -143,13 +166,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
return (
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
{/* Header */}
<div>
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">Product Catalogue</h1>
<p className="text-zinc-500 text-xs mt-1">
Products your admin published for <span className="font-semibold text-[#662582]">{storeName}</span> — choose what you need and set quantities.
</p>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 bg-zinc-100/80 p-1 rounded-xl border border-zinc-200/60 w-full sm:w-auto sm:inline-flex">
@@ -171,231 +188,417 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
</button>
</div>
{/* Filters */}
<div className="flex flex-col md:flex-row md:items-center gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
<div className="relative w-full md:w-80 md:shrink-0">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-9 py-2.5 border border-[#e2e8f0] rounded-xl text-xs outline-none bg-[#f8fafc] focus:bg-white focus:ring-1 focus:ring-[#662582] transition-all"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"><X size={13} /></button>
<div 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>
<div className="relative">
<input
type="text"
placeholder="Product name or SKU..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-3 pr-8 py-2.5 border border-[#e2e8f0] rounded-xl text-xs font-semibold outline-none bg-slate-50 focus:bg-white focus:ring-2 focus:ring-purple-500/20 focus:border-purple-500 transition-all shadow-sm"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
<X size={14} />
</button>
)}
</div>
</div>
<div className="w-full h-px bg-slate-100" />
<div>
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
<Layers size={14} className="text-[#662582]" /> Categories
</h3>
<div className="space-y-2">
{categories.map((c) => (
<label key={c} className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={selectedCategories.includes(c)}
onChange={() => toggleCategory(c)}
className="sr-only"
/>
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${selectedCategories.includes(c) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
{selectedCategories.includes(c) && <Check size={10} className="text-white" strokeWidth={3} />}
</div>
<span className="text-xs font-semibold text-slate-700 group-hover:text-slate-900">{c}</span>
</label>
))}
</div>
</div>
{view === 'inventory' && (
<>
<div className="w-full h-px bg-slate-100" />
<div>
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
<Activity size={14} className="text-[#662582]" /> Stock Health
</h3>
<div className="space-y-2">
{['Healthy', 'Low', 'Critical', 'Out of stock'].map((h) => (
<label key={h} className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={stockHealthFilter.includes(h)}
onChange={() => toggleStockHealth(h)}
className="sr-only"
/>
<div className={`w-4 h-4 rounded-[4px] border flex items-center justify-center transition-colors ${stockHealthFilter.includes(h) ? 'bg-[#662582] border-[#662582]' : 'bg-slate-50 border-slate-300 group-hover:border-purple-400'}`}>
{stockHealthFilter.includes(h) && <Check size={10} className="text-white" strokeWidth={3} />}
</div>
<span className="text-xs font-semibold text-slate-700 group-hover:text-slate-900">{h}</span>
</label>
))}
</div>
</div>
</>
)}
{(selectedCategories.length > 0 || stockHealthFilter.length > 0 || search) && (
<button
onClick={() => { setSelectedCategories([]); setStockHealthFilter([]); setSearch(''); }}
className="mt-2 w-full py-2 bg-slate-100 text-slate-600 rounded-xl text-[10px] font-bold uppercase tracking-wider hover:bg-slate-200 transition-colors cursor-pointer"
>
Clear All Filters
</button>
)}
</div>
{view === 'catalogue' && categories.length > 0 && (
<div className="flex items-center gap-sm flex-wrap">
<span className="flex items-center gap-1.5 text-[10px] font-bold text-zinc-400 uppercase tracking-widest"><Layers size={13} className="text-[#662582]" /> Filter</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="border border-[#e2e8f0] rounded-lg px-3 py-2 text-xs font-semibold text-zinc-700 bg-[#f8fafc] outline-none focus:ring-1 focus:ring-[#662582] cursor-pointer"
>
<option value="ALL">All categories</option>
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
{/* Product Grid Area */}
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
{/* Mobile Search Input (Visible only on mobile) */}
<div className="md:hidden mb-4">
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
<input
type="text"
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-9 pr-9 py-3 border border-[#e2e8f0] rounded-xl text-sm font-semibold outline-none bg-white focus:ring-2 focus:ring-purple-500/20 shadow-sm"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
<X size={14} />
</button>
)}
</div>
</div>
)}
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
{/* Results Summary & Toolbar */}
<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'}
</h2>
<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>
{/* ── Browse Catalogue ── */}
{view === 'catalogue' && (
products.length === 0 ? (
<CenterState
icon={<PackageSearch size={34} />}
title="No products published yet"
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
/>
) : filtered.length === 0 ? (
<CenterState
icon={<Boxes size={34} />}
title="No products match your search"
sub="Try a different keyword or clear the filters to see the full catalogue."
/>
) : (
<div className="grid grid-cols-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;
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">
{/* Image Bento Block */}
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
{stocked && (
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10">
<CheckCircle2 size={12} className="text-emerald-500" />
<span className="text-[9px] font-black uppercase text-emerald-700 tracking-wider">In Store</span>
</div>
)}
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(p.category)}`}>
{p.category.split(' / ')[0]}
</span>
</div>
</div>
{/* Details Bento Block */}
<div className="p-3.5 flex flex-col flex-1 gap-3">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{p.name}</h4>
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
</div>
<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">
<div>
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
<span className="font-extrabold text-slate-800 font-mono text-sm">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
</div>
<div className="text-right">
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
<span className="font-bold text-slate-600 text-[11px] bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
</div>
</div>
{/* 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>
<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); }}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[11px] font-bold transition-all bg-white text-purple-700 hover:bg-purple-600 hover:text-white hover:shadow-md border border-purple-200 hover:border-purple-600"
>
<Plus size={14} /> Add to Store
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)
)}
{/* ── My Store Inventory ── */}
{view === 'inventory' && (
stockQ.isLoading ? (
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
) : !locationid ? (
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
) : inventory.length === 0 ? (
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
) : finalFilteredInventory.length === 0 ? (
<CenterState
icon={<Boxes size={34} />}
title="No stock matches your filters"
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
action={
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
<X size={13} /> Clear filters
</button>
}
/>
) : (
<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">
{/* Image Bento Block */}
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10" style={{ color: it.color }}>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
<span className="text-[9px] font-black uppercase tracking-wider">{it.label}</span>
</div>
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(it.category)}`}>
{it.category.split(' / ')[0]}
</span>
</div>
</div>
{/* Details Bento Block */}
<div className="p-3.5 flex flex-col flex-1 gap-3">
<div className="flex items-start justify-between gap-2">
<div>
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{it.name}</h4>
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
</div>
<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">
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-1">Live Stock</span>
<span className="font-black font-mono text-xl" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
</div>
</div>
</div>
))}
</div>
)
)}
</div>
</div>
{/* ── Browse Catalogue ── */}
{view === 'catalogue' && (
products.length === 0 ? (
<CenterState
icon={<PackageSearch size={34} />}
title="No products published yet"
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
/>
) : filtered.length === 0 ? (
<CenterState
icon={<Boxes size={34} />}
title="No products match your search"
sub="Try a different keyword or clear the filters to see the full catalogue."
action={
<button
onClick={() => { setSearch(''); setCategory('ALL'); }}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer"
>
<X size={13} /> Clear filters
</button>
}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
{filtered.map((p) => {
const stocked = inStore.has(p.id);
const picked = picks[p.id] != null;
return (
<div key={p.id} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
<div className="flex gap-md">
{/* Thumbnail with hover zoom */}
<div className="w-32 h-32 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
{stocked && (
<span className="absolute top-1 right-1 inline-flex items-center justify-center w-4 h-4 rounded-full bg-emerald-500 text-white shadow" title="In your store"><CheckCircle2 size={10} /></span>
)}
</div>
<div className="flex-1 space-y-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#662582] transition-colors">{p.name}</h4>
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{p.sku}</span>
</div>
{/* Category pill badge */}
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(p.category)}`}>
{p.category.split(' / ')[0]}
</span>
</div>
<div className="flex justify-between items-center pt-2">
<div>
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
<span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
</div>
</div>
</div>
</div>
{/* Stocked-status row (mirrors the admin card's status line) */}
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${stocked ? 'text-emerald-600' : 'text-zinc-400'}`}>
<span className={`w-1.5 h-1.5 rounded-full ${stocked ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
{stocked ? 'In Your Store' : 'Not stocked yet'}
</span>
{p.unit && <span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>}
</div>
{/* Pick action: quantity stepper when selected, else add button */}
{picked ? (
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#662582]"><Check size={12} /> Selected</span>
<div className="flex items-center gap-1">
<button onClick={() => setPickQty(p.id, picks[p.id] - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Minus size={12} /></button>
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{picks[p.id]}</span>
<button onClick={() => setPickQty(p.id, picks[p.id] + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Plus size={12} /></button>
<button onClick={() => togglePick(p.id)} title="Remove" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
</div>
</div>
) : (
<button
onClick={() => togglePick(p.id)}
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#662582] hover:text-purple-800 cursor-pointer"
>
<Plus size={13} /> Add to Store
</button>
)}
</div>
);
})}
</div>
)
)}
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
{view === 'inventory' && (
stockQ.isLoading ? (
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
) : !locationid ? (
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
) : inventory.length === 0 ? (
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
) : filteredInventory.length === 0 ? (
<CenterState
icon={<Boxes size={34} />}
title="No stock matches your search"
sub="Try a different keyword to find an item in your store."
action={
<button onClick={() => setSearch('')} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
<X size={13} /> Clear search
</button>
}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
{filteredInventory.map((it, i) => (
<div key={it.id || i} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
<div className="flex gap-md">
{/* Thumbnail with status corner dot */}
<div className="w-32 h-32 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<span className="absolute top-1 right-1 w-3 h-3 rounded-full border-2 border-white shadow" style={{ background: it.color }} title={it.label} />
</div>
<div className="flex-1 space-y-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#662582] transition-colors">{it.name}</h4>
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{it.sku}</span>
</div>
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(it.category)}`}>
{it.category.split(' / ')[0]}
</span>
</div>
<div className="flex justify-between items-center pt-2">
<div>
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">In Stock</span>
<span className="font-black font-mono text-xs" style={{ color: it.color }}>{it.closing.toLocaleString('en-IN')}</span>
</div>
<div className="text-right">
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Status</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>{it.label}</span>
</div>
</div>
</div>
</div>
{/* Status line (mirrors the catalogue card's footer) */}
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight" style={{ color: it.color }}>
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
{it.label}
</span>
<span className="text-[9px] text-zinc-400 font-semibold">{it.category.split(' / ')[0]}</span>
</div>
</div>
))}
</div>
)
)}
{/* ── Selection action bar ── */}
{/* ── Selection FAB ── */}
{view === 'catalogue' && pickCount > 0 && (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-[120] w-[min(680px,calc(100vw-2rem))]">
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
{notice ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</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 your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
<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>
) : (
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center shrink-0"><Boxes size={15} /></span>
<div className="min-w-0">
<p className="text-xs font-bold truncate">{pickCount} product{pickCount > 1 ? 's' : ''} · {Object.values(picks).reduce((a: number, b: number) => a + b, 0)} units</p>
<p className="text-[10px] text-purple-200">Selected for {storeName}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => setPicks({})} className="px-3 py-2 rounded-xl text-[11px] font-bold text-purple-200 hover:text-white hover:bg-white/10 transition cursor-pointer">Clear</button>
<button onClick={commitSelectionToStore} className="px-4 py-2 rounded-xl text-[11px] font-bold bg-white text-[#662582] hover:bg-purple-50 transition cursor-pointer flex items-center gap-1.5">
<Check size={13} /> Request for Store
</button>
</div>
<AwaitingApi label="Submitting to store" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
</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>
<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>
)}
{/* 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>
</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>
</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>
</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>
)}
</SlideDrawer>
</div>
);
}
@@ -416,28 +619,11 @@ function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; titl
</span>
</div>
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight">{title}</h3>
{sub && <p className="text-sm text-zinc-500 mt-2 max-w-md leading-relaxed">{sub}</p>}
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight text-center">{title}</h3>
{sub && <p className="text-sm text-zinc-500 mt-2 text-center whitespace-nowrap overflow-hidden text-ellipsis">{sub}</p>}
{action && <div className="mt-6">{action}</div>}
{/* Ghost preview cards — hint at what will appear here */}
<div className="mt-9 flex items-end justify-center gap-3 sm:gap-4 opacity-70 select-none" aria-hidden>
{[0, 1, 2].map((i) => (
<div
key={i}
className={`w-24 sm:w-28 rounded-2xl border border-[#eceef2] bg-white/70 p-3 shadow-sm ${i === 1 ? 'scale-110' : 'opacity-80'}`}
>
<div className="w-full h-10 rounded-lg bg-gradient-to-br from-zinc-100 to-zinc-200/70 mb-2" />
<div className="h-2 w-3/4 rounded-full bg-zinc-200 mb-1.5" />
<div className="h-2 w-1/2 rounded-full bg-zinc-100" />
</div>
))}
</div>
<div className="mt-6 inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" /> Syncs automatically
</div>
</div>
</div>
);

View File

@@ -389,7 +389,7 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
className="flex items-center gap-xs text-xs font-bold text-[#662582] hover:text-[#4c1d95] bg-purple-50 hover:bg-purple-100/80 px-xl py-2 rounded-lg transition-all shadow-sm border border-purple-100 cursor-pointer"
>
<ArrowLeft size={14} />
<span>Back to Registry</span>
<span>Back to Dashboard</span>
</button>
</div>
)}

View File

@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import { Package, MapPin, Truck, ChevronDown, Check, X } from 'lucide-react';
import { useFiestaTenantLocations } from '../services/fiestaQueries';
import { str as fstr } from '../services/fiestaApi';
export interface TrialProduct {
id: string | number;
name: string;
sku: string;
category: string;
price: number;
image: string;
}
interface TrialBatchDrawerProps {
isOpen: boolean;
onClose: () => void;
product: TrialProduct | null;
tenantId?: number;
}
export default function TrialBatchDrawer({ isOpen, onClose, product, tenantId }: TrialBatchDrawerProps) {
const [qty, setQty] = useState(5);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([]);
const locationsQ = useFiestaTenantLocations(tenantId);
const stores = locationsQ.data || [];
// Reset state when drawer opens with a new product
useEffect(() => {
if (isOpen) {
setQty(5);
setIsSubmitting(false);
setIsSuccess(false);
if (stores.length > 0 && selectedLocationIds.length === 0) {
setSelectedLocationIds([String(stores[0].locationid)]);
}
}
}, [isOpen, product, stores]);
if (!isOpen || !product) return null;
const sampleDiscount = 0.5; // 50% discount for sample
const baseCost = qty * (product.price || 0) * Math.max(1, selectedLocationIds.length);
const discountedCost = baseCost * (1 - sampleDiscount);
const handleSubmit = () => {
if (selectedLocationIds.length === 0) {
alert("Please select at least one store delivery hub.");
return;
}
setIsSubmitting(true);
// Simulate API call
setTimeout(() => {
setIsSubmitting(false);
setIsSuccess(true);
// Auto close after showing success
setTimeout(() => {
onClose();
}, 2000);
}, 1500);
};
return (
<div className="fixed inset-0 z-[200]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm z-0"
onClick={onClose}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 z-50 w-[400px] bg-white shadow-2xl flex flex-col border-l border-slate-200">
{/* 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 gap-2.5">
<div className="w-8 h-8 rounded-full bg-indigo-50 text-indigo-600 flex items-center justify-center">
<Package size={16} />
</div>
<h2 className="text-lg font-extrabold text-slate-900 tracking-tight">Request Sample</h2>
</div>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-500 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
{/* Product Preview Card */}
<div className="bg-slate-50 border border-slate-100 rounded-2xl p-3 flex gap-4 items-center">
<div className="w-20 h-20 bg-white rounded-xl overflow-hidden shrink-0 border border-slate-200">
<img src={product.image} alt={product.name} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<span className="px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-wider bg-slate-200 text-slate-700 mb-1 inline-block">
{product.category}
</span>
<h3 className="font-bold text-slate-900 text-sm leading-snug line-clamp-2">{product.name}</h3>
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{product.sku}</p>
</div>
</div>
{/* Form */}
<div className="space-y-5">
{/* Quantity Selector */}
<div>
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-2">Sample Quantity</label>
<div className="flex items-center gap-3">
{[1, 5, 10].map((amount) => (
<button
key={amount}
onClick={() => setQty(amount)}
className={`flex-1 py-2.5 rounded-xl font-bold text-sm transition-all border ${
qty === amount
? 'bg-indigo-50 border-indigo-200 text-indigo-700 ring-1 ring-indigo-500/20'
: 'bg-white border-slate-200 text-slate-600 hover:border-indigo-300'
}`}
>
{amount} units
</button>
))}
</div>
</div>
{/* Delivery Locations (Multi-Select) */}
<div>
<label className="block text-[11px] font-bold text-slate-500 uppercase tracking-wider mb-2">Delivery Hubs (Select Multiple)</label>
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar pr-2">
{locationsQ.isLoading ? (
<div className="text-sm text-slate-500 italic p-2 border border-slate-100 rounded-lg bg-slate-50">Loading stores...</div>
) : stores.length > 0 ? (
stores.map((store) => {
const sid = String(store.locationid);
const isSelected = selectedLocationIds.includes(sid);
return (
<div
key={sid}
onClick={() => {
if (isSelected) {
setSelectedLocationIds(selectedLocationIds.filter(id => id !== sid));
} else {
setSelectedLocationIds([...selectedLocationIds, sid]);
}
}}
className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
isSelected
? 'bg-indigo-50 border-indigo-200 shadow-sm'
: 'bg-white border-slate-200 hover:border-indigo-300'
}`}
>
<div className={`w-5 h-5 rounded flex items-center justify-center shrink-0 border transition-colors ${
isSelected ? 'bg-indigo-600 border-indigo-600 text-white' : 'border-slate-300 bg-white'
}`}>
{isSelected && <Check size={12} strokeWidth={3} />}
</div>
<div className="flex-1 min-w-0">
<h4 className={`font-bold text-sm truncate ${isSelected ? 'text-indigo-900' : 'text-slate-900'}`}>
{fstr(store.locationname)}
</h4>
<p className="text-xs text-slate-500 truncate mt-0.5">
{fstr(store.suburb) || fstr(store.city) || 'Main Outlet'}
</p>
</div>
</div>
);
})
) : (
<div className="text-sm text-slate-500 italic p-2 border border-slate-100 rounded-lg bg-slate-50">No stores found</div>
)}
</div>
</div>
</div>
</div>
{/* Footer actions */}
<div className="p-6 border-t border-slate-100 bg-white shrink-0">
<div className="mb-4 bg-slate-50 rounded-xl p-3 border border-slate-100">
<div className="flex justify-between text-xs mb-1.5">
<span className="text-slate-500 font-medium">Base Price ({qty} × ₹{product.price || 0})</span>
<span className="font-mono text-slate-700">₹{baseCost.toLocaleString('en-IN')}</span>
</div>
<div className="flex justify-between text-xs mb-2">
<span className="text-indigo-600 font-bold">Sample Discount (50%)</span>
<span className="font-mono text-indigo-600 font-bold">-₹{(baseCost * sampleDiscount).toLocaleString('en-IN')}</span>
</div>
<div className="pt-2 border-t border-slate-200 flex justify-between items-end mt-2">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Total Est. Cost</span>
<span className="text-xl font-black text-slate-900 font-mono">₹{discountedCost.toLocaleString('en-IN')}</span>
</div>
</div>
<button
onClick={handleSubmit}
disabled={isSubmitting || isSuccess}
className={`w-full relative overflow-hidden flex items-center justify-center gap-2 py-3.5 rounded-xl text-sm font-bold transition-all shadow-md ${
isSuccess
? 'bg-emerald-500 text-white hover:bg-emerald-600 shadow-emerald-500/20'
: 'bg-[#0f172a] text-white hover:bg-slate-800 hover:-translate-y-0.5 shadow-slate-900/20'
}`}
>
{isSubmitting ? (
<div className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
Processing...
</div>
) : isSuccess ? (
<div className="flex items-center gap-2 animate-in fade-in zoom-in duration-300">
<Check size={18} strokeWidth={3} />
Request Sent
</div>
) : (
<>
<Truck size={18} /> Request Trial Batch
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -34,6 +34,7 @@ import DispatchHubView from './DispatchHubView';
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. */
@@ -352,6 +353,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
</div>,
document.body,
)}
<ComparisonModal />
</div>
);
}

View File

@@ -29,7 +29,7 @@ import {
Bike,
Clock
} from 'lucide-react';
import { useFiestaUsers, useFiestaCreateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
import { useFiestaUsers, useFiestaCreateUser, useFiestaUpdateUser, useFiestaRiderShifts, useFiestaTenantLocations } from '../services/fiestaQueries';
import { useAppRoles } from '../services/queries';
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
@@ -76,8 +76,30 @@ const FALLBACK_ROLE_CHOICES = [
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
const createUserMut = useFiestaCreateUser();
const updateUserMut = useFiestaUpdateUser();
const rolesQ = useAppRoles();
const [assignUserId, setAssignUserId] = useState<number | null>(null);
const [assignLocationId, setAssignLocationId] = useState<number>(0);
const [assignRoleId, setAssignRoleId] = useState<number>(4);
const handleToggleAssign = async (userid: number, currentRoleId: number) => {
try {
if (currentRoleId <= 0) {
// Open the assign modal
setAssignUserId(userid);
if (storeOptions.length === 1) {
setAssignLocationId(storeOptions[0].locationid);
}
} else {
// Unassign immediately. We use -1 because the Go backend ignores 0 (zero value) during updates.
await updateUserMut.mutateAsync({ userid, roleid: -1, locationid: -1, applocationid: -1 });
}
} catch (err) {
alert(`Could not update user role: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
};
// Selectable roles for the Add User modal — driven by the live app-roles API,
// matched to local icon/desc styling by roleid; falls back to the static list.
// Selectable roles for the Add User modal - limited to Staff and Rider.
@@ -130,7 +152,10 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
if (!newUser.locationid && storeOptions.length === 1) {
setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid }));
}
}, [storeOptions.length, newUser.locationid]);
if (!assignLocationId && storeOptions.length === 1) {
setAssignLocationId(storeOptions[0].locationid);
}
}, [storeOptions.length, newUser.locationid, assignLocationId]);
// Rider-shift picker — shown only when a rider role is selected (parity with the
// merchant_web create form). Shifts come from the live partners/getridershifts.
@@ -189,6 +214,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
const roleOptions = React.useMemo(() => {
const roles = new Set(users.map((u) => u.role));
roles.add('Admin');
roles.add('Rider');
return Array.from(roles);
}, [users]);
@@ -280,25 +306,25 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
{/* Search & Filter Utility Bar */}
<div className="bg-white border border-slate-200/80 p-3 rounded-2xl flex flex-col md:flex-row gap-4 items-stretch md:items-center justify-between shadow-sm">
<div className="relative w-full md:max-w-md shrink-0">
<div className="relative flex w-full items-center bg-slate-50 border border-slate-200/80 rounded-xl transition-all duration-300 focus-within:bg-white focus-within:ring-4 focus-within:ring-purple-500/10 focus-within:border-purple-500 group-search shadow-inner">
<span className="pl-4 flex items-center pointer-events-none text-slate-400 transition-colors">
<Search size={18} strokeWidth={2.5} />
<div className="flex flex-col md:flex-row gap-6 items-stretch md:items-center justify-between mb-4">
<div className="relative w-full md:max-w-sm shrink-0">
<div className="relative flex w-full items-center border-b-2 border-slate-200 focus-within:border-purple-600 transition-colors duration-300">
<span className="pl-1 flex items-center pointer-events-none text-slate-400">
<Search size={16} strokeWidth={3} />
</span>
<input
type="text"
placeholder="Search team members..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-3 pr-12 py-3 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
className="w-full pl-3 pr-10 py-2.5 bg-transparent border-none text-sm font-semibold text-slate-800 placeholder-slate-400 focus:outline-none outline-none"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 hover:bg-slate-200/50 active:scale-95 transition-all p-1.5 rounded-lg flex items-center justify-center"
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 active:scale-95 transition-all p-1.5 flex items-center justify-center"
>
<X size={16} strokeWidth={2.5} />
<X size={14} strokeWidth={3} />
</button>
)}
</div>
@@ -333,7 +359,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
{/* Directory Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredUsers.length === 0 ? (
<div className="col-span-full bg-white border border-slate-200/60 rounded-2xl p-12 text-center text-slate-500 font-bold text-base">
{usersQ.isLoading ? 'Loading team list…' : 'No matches found in team list.'}
@@ -342,62 +368,77 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
filteredUsers.map((u) => {
const roleInfo = ROLE_THEMES[u.roleid] || { bg: 'bg-slate-55', text: 'text-slate-700', border: 'border-slate-100', label: u.role };
return (
<div key={u.userid} className="bg-white border border-slate-200/60 hover:border-purple-300 hover:shadow-md rounded-2xl p-5 transition-all duration-300 flex flex-col justify-between group relative overflow-hidden">
<div key={u.userid} className="bg-white border border-slate-200/60 hover:border-purple-300 hover:shadow-md rounded-2xl p-4 transition-all duration-300 flex flex-col justify-between group relative overflow-hidden">
{/* Background aura gradient effect on hover */}
<div className="absolute top-0 right-0 w-28 h-28 bg-purple-500/5 rounded-full blur-xl -mr-8 -mt-8 pointer-events-none transition-all group-hover:bg-purple-500/10" />
<div className="absolute top-0 right-0 w-24 h-24 bg-purple-500/5 rounded-full blur-xl -mr-6 -mt-6 pointer-events-none transition-all group-hover:bg-purple-500/10" />
<div>
<div className="flex items-start gap-4 relative z-10">
<div className="flex items-start gap-3 relative z-10">
{/* User Avatar with status indicator ring */}
<div className="relative shrink-0 select-none">
<img
src={u.avatar}
alt={u.name}
referrerPolicy="no-referrer"
className="w-14 h-14 object-cover rounded-2xl border border-slate-200"
className="w-10 h-10 object-cover rounded-[10px] border border-slate-200"
/>
<span className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-3 border-white ${
<span className={`absolute -bottom-1 -right-1 w-3.5 h-3.5 rounded-full border-[2.5px] border-white ${
u.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-slate-350'
}`} />
</div>
<div className="min-w-0 flex-1">
<h4 className="font-bold text-slate-800 text-sm truncate group-hover:text-purple-950 transition-colors leading-tight">{u.name}</h4>
<p className="text-xs text-slate-450 mt-1.5 truncate font-medium">{u.email}</p>
<p className="text-[11px] text-slate-450 mt-1 truncate font-medium">{u.email}</p>
</div>
</div>
{/* Metadata fields */}
<div className="mt-5 space-y-2.5 border-t border-slate-100/75 pt-4 text-xs">
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
<Phone size={14} className="text-slate-400 shrink-0" />
<div className="mt-4 space-y-2 border-t border-slate-100/75 pt-3 text-[11px]">
<div className="flex items-center gap-2 text-slate-600 font-medium">
<Phone size={13} className="text-slate-400 shrink-0" />
<span className="font-mono">{u.contact}</span>
</div>
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
<MapPin size={14} className="text-slate-400 shrink-0" />
<div className="flex items-center gap-2 text-slate-600 font-medium">
<MapPin size={13} className="text-slate-400 shrink-0" />
<span className="truncate">{u.location}</span>
</div>
{u.shift && u.shift !== '—' && (
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-extrabold">Shift</span>
<span className="font-bold text-slate-700 bg-slate-50 px-2.5 py-0.5 rounded-lg border border-slate-150 text-xs">{u.shift}</span>
<div className="flex items-center gap-2 text-slate-600 font-medium">
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-extrabold">Shift</span>
<span className="font-bold text-slate-700 bg-slate-50 px-2 py-0.5 rounded-md border border-slate-150">{u.shift}</span>
</div>
)}
</div>
</div>
<div className="mt-5 pt-4 border-t border-slate-100/75 flex justify-between items-center select-none">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-extrabold uppercase border ${roleInfo.bg} ${roleInfo.text} ${roleInfo.border}`}>
{u.roleid === 1 && <ShieldAlert size={12} />}
{u.roleid === 2 && <Shield size={12} />}
{u.roleid === 3 && <SlidersHorizontal size={12} />}
{u.roleid === 4 && <User size={12} />}
{u.roleid === 5 && <Bike size={12} />}
{u.roleid === 6 && <Coins size={12} />}
{roleInfo.label}
</span>
<div className="mt-4 pt-3 border-t border-slate-100/75 flex justify-between items-center select-none">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-[8px] text-[10px] font-extrabold uppercase border ${roleInfo.bg} ${roleInfo.text} ${roleInfo.border}`}>
{u.roleid === 1 && <ShieldAlert size={10} />}
{u.roleid === 2 && <Shield size={10} />}
{u.roleid === 3 && <SlidersHorizontal size={10} />}
{u.roleid === 4 && <User size={10} />}
{u.roleid === 5 && <Bike size={10} />}
{u.roleid === 6 && <Coins size={10} />}
{roleInfo.label}
</span>
{u.roleid !== 1 && (
<button
onClick={() => handleToggleAssign(u.userid, u.roleid)}
disabled={updateUserMut.isPending}
className={`px-2 py-0.5 rounded-[8px] text-[9px] font-extrabold uppercase transition-colors cursor-pointer border ${
u.roleid <= 0
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100'
: 'bg-slate-50 text-slate-500 border-slate-200 hover:bg-slate-100 hover:text-slate-700'
} ${updateUserMut.isPending ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{u.roleid <= 0 ? 'Assign' : 'Unassign'}
</button>
)}
</div>
<span className="text-xs font-mono text-slate-400 font-bold">ID: #{u.userid}</span>
<span className="text-[10px] font-mono text-slate-400 font-bold">#{u.userid}</span>
</div>
</div>
);
@@ -615,6 +656,99 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
</div>
</div>
)}
{/* ASSIGN USER MODAL */}
{assignUserId !== null && (
<div
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[200] flex items-center justify-center p-md select-none"
onClick={(e) => { if (e.target === e.currentTarget) setAssignUserId(null); }}
>
<div className="bg-white border border-slate-200/80 rounded-2xl w-full max-w-[28rem] flex flex-col shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 text-sm font-sans cursor-default">
<div className="p-5 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center shrink-0">
<h4 className="font-bold text-slate-900 flex items-center gap-2.5 text-base">
<div className="w-8 h-8 rounded-lg bg-emerald-50 text-emerald-650 flex items-center justify-center">
<UserCheck size={16} />
</div>
Assign Team Member
</h4>
<button
onClick={() => setAssignUserId(null)}
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-600 cursor-pointer transition-colors"
>
<X size={18} />
</button>
</div>
<form onSubmit={async (e) => {
e.preventDefault();
const store = storeOptions.find(s => s.locationid === assignLocationId);
try {
await updateUserMut.mutateAsync({
userid: assignUserId,
roleid: assignRoleId,
locationid: assignLocationId,
applocationid: store?.applocationid ?? 1
});
setAssignUserId(null);
} catch (err) {
alert(`Could not assign user: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}} className="p-6 space-y-5">
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">ROLE (*)</label>
<div className="relative">
<select
value={assignRoleId}
onChange={(e) => setAssignRoleId(Number(e.target.value))}
className="w-full border border-slate-200 rounded-xl px-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
>
{roleChoices.map((r) => (
<option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="font-bold text-slate-500 uppercase tracking-widest text-[10px]">STORE / LOCATION (*)</label>
<div className="relative">
<span className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
<Store size={14} />
</span>
<select
value={assignLocationId}
onChange={(e) => setAssignLocationId(Number(e.target.value))}
className="w-full border border-slate-200 rounded-xl pl-10 pr-4 py-3 bg-slate-50/40 hover:bg-slate-100 focus:bg-white outline-none focus:border-purple-500 focus:ring-4 focus:ring-purple-500/10 transition-all text-slate-800 font-semibold text-sm shadow-sm cursor-pointer"
required
>
<option value={0} disabled>{locationsQ.isLoading ? 'Loading stores…' : 'Select a store…'}</option>
{storeOptions.map((s) => (
<option key={s.locationid} value={s.locationid}>{s.name}{s.address ? ` — ${s.address}` : ''}</option>
))}
</select>
</div>
</div>
<div className="pt-4 border-t border-slate-100 flex justify-end gap-sm mt-6">
<button
type="button"
onClick={() => setAssignUserId(null)}
className="px-5 py-2.5 border border-slate-200 hover:bg-slate-100 rounded-xl font-bold text-slate-500 cursor-pointer active:scale-95 transition-all text-sm"
>
Cancel
</button>
<button
type="submit"
disabled={updateUserMut.isPending || !assignLocationId}
className="px-6 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold cursor-pointer shadow-sm disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all flex items-center gap-1.5 border-none text-sm"
>
{updateUserMut.isPending ? 'Assigning…' : 'Assign User'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@@ -15,7 +15,8 @@
* source's MUI `sx`) while layout/spacing use Tailwind.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { X, Info } from 'lucide-react';
// ── Design tokens ────────────────────────────────────────────────────────────────
export const BRAND = '#662582';
@@ -141,32 +142,28 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
<div
key={it.label}
onClick={it.onClick}
className={`relative overflow-hidden rounded-2xl border bg-white p-3.5 sm:p-5 transition-all duration-200 ${it.onClick ? 'cursor-pointer hover:-translate-y-0.5' : ''}`}
style={{ borderColor: it.active ? it.color : BORDER, boxShadow: it.active ? `0 0 0 1px ${it.color}, ${SHADOW_MD}` : 'none' }}
onMouseEnter={(e) => { if (!it.active && it.onClick) e.currentTarget.style.boxShadow = SHADOW_MD; }}
onMouseLeave={(e) => { if (!it.active) e.currentTarget.style.boxShadow = 'none'; }}
className={`rounded-xl p-3 flex flex-col justify-between transition-all duration-300 text-left relative group border bg-white ${it.active ? 'border-indigo-500 opacity-100 shadow-[0_8px_24px_rgba(99,102,241,0.06)]' : 'border-slate-200/70 opacity-70 hover:opacity-100'} ${it.onClick ? 'cursor-pointer' : ''}`}
>
<div className="absolute top-0 left-0 right-0" style={{ height: 3, background: `linear-gradient(90deg, ${it.color} 0%, ${soft(it.color)} 100%)` }} />
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[10px] sm:text-[11px] font-bold uppercase tracking-wide truncate" style={{ color: TEXT_2, letterSpacing: 0.4 }}>
{it.label}
</p>
<p className="font-extrabold leading-none mt-1.5 text-[1.4rem] sm:text-[1.6rem]" style={{ color: TEXT }}>
{loading ? '—' : it.value}
</p>
{it.badge && (
<span className="inline-flex items-center mt-1.5 rounded-full font-extrabold" style={{ padding: '1px 8px', fontSize: 10.5, background: soft(it.color), color: it.color }}>
{it.badge}
</span>
)}
</div>
<span
className="shrink-0 rounded-full flex items-center justify-center"
style={{ width: 46, height: 46, background: soft(it.color), color: it.color, boxShadow: `inset 0 0 0 1px ${edge(it.color)}` }}
>
{it.icon}
<div className="flex justify-between items-start w-full">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">
{it.label}
</span>
<div
className="p-1.5 rounded-lg transition-transform group-hover:scale-110 flex items-center justify-center shrink-0"
style={{ background: soft(it.color), color: it.color }}
>
{React.cloneElement(it.icon as React.ReactElement, { className: 'w-3.5 h-3.5' })}
</div>
</div>
<div className="mt-2 w-full">
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900 truncate">
{loading ? '—' : it.value}
</h3>
{it.badge && (
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">
{it.badge}
</p>
)}
</div>
</div>
))}
@@ -328,3 +325,91 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[
</div>
);
}
// ── Spinny UX Components ─────────────────────────────────────────────────────────
/**
* Beautiful Skeleton Loader for empty/loading states.
* Uses a pulsing animation to match shape before data arrives.
*/
export function Skeleton({ className = '', style }: { className?: string; style?: React.CSSProperties }) {
return (
<div
className={`animate-pulse bg-slate-200/60 rounded-md ${className}`}
style={style}
/>
);
}
/**
* Interactive Tooltip (Jargon Buster).
* Wraps children and shows a sleek dark explanation box on hover.
*/
export function Tooltip({ children, content, position = 'top' }: { children: React.ReactNode; content: React.ReactNode; position?: 'top' | 'bottom' }) {
return (
<div className="relative flex items-center group cursor-help w-max">
{children}
<div className={`absolute ${position === 'top' ? 'bottom-full mb-2' : 'top-full mt-2'} left-1/2 -translate-x-1/2 hidden group-hover:block w-max max-w-[250px] z-[100] animate-in fade-in zoom-in-95 duration-200`}>
<div className="bg-[#0f172a] text-white text-[10px] font-medium p-2.5 rounded-xl shadow-xl border border-white/10 text-center leading-relaxed">
{content}
</div>
<div className={`absolute ${position === 'top' ? 'top-full border-t-[#0f172a] border-x-transparent border-b-transparent' : 'bottom-full border-b-[#0f172a] border-x-transparent border-t-transparent'} left-1/2 -translate-x-1/2 border-[5px]`} />
</div>
</div>
);
}
/**
* "Quick Look" Slide-out Drawer.
* Slides in from the right edge of the screen.
*/
export function SlideDrawer({
isOpen,
onClose,
title,
children
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
// Prevent body scroll when open
useEffect(() => {
if (isOpen) document.body.style.overflow = 'hidden';
else document.body.style.overflow = '';
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[200] flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-slate-900/30 backdrop-blur-sm animate-in fade-in duration-300"
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>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-slate-100 text-slate-500 transition-colors"
>
<X size={18} />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5">
{children}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
export interface CompareProduct {
id: string | number;
name: string;
sku: string;
category: string;
price: number;
image: string;
unitsSold?: number;
// Admin-specific
verified?: boolean;
// User-specific
closing?: number;
unit?: string;
color?: string;
label?: string;
}
interface CompareContextType {
selectedProducts: CompareProduct[];
toggleProduct: (product: CompareProduct) => void;
removeProduct: (id: string | number) => void;
clearSelection: () => void;
isComparing: boolean;
setIsComparing: (val: boolean) => void;
hideCompareBar: boolean;
setHideCompareBar: (val: boolean) => void;
}
const CompareContext = createContext<CompareContextType | undefined>(undefined);
export function CompareProvider({ children }: { children: ReactNode }) {
const [selectedProducts, setSelectedProducts] = useState<CompareProduct[]>([]);
const [isComparing, setIsComparing] = useState(false);
const [hideCompareBar, setHideCompareBar] = useState(false);
const toggleProduct = (product: CompareProduct) => {
setSelectedProducts((prev) => {
const exists = prev.find((p) => String(p.id) === String(product.id));
if (exists) {
return prev.filter((p) => String(p.id) !== String(product.id));
}
if (prev.length >= 5) {
return prev; // Max 5 items
}
return [...prev, product];
});
};
const removeProduct = (id: string | number) => {
setSelectedProducts((prev) => prev.filter((p) => String(p.id) !== String(id)));
};
const clearSelection = () => {
setSelectedProducts([]);
setIsComparing(false);
};
return (
<CompareContext.Provider
value={{
selectedProducts,
toggleProduct,
removeProduct,
clearSelection,
isComparing,
setIsComparing,
hideCompareBar,
setHideCompareBar,
}}
>
{children}
</CompareContext.Provider>
);
}
export function useCompare() {
const context = useContext(CompareContext);
if (!context) {
throw new Error('useCompare must be used within a CompareProvider');
}
return context;
}

View File

@@ -1,8 +1,8 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Jost:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Jost", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--spacing-xs: 4px;

View File

@@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import App from './App.tsx';
import './index.css';
import { CompareProvider } from './contexts/CompareContext.tsx';
// How often every page silently re-syncs with the backend. Orders/deliveries
// statuses change out-of-band (riders accept/pick/deliver, customers place
@@ -35,7 +36,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<CompareProvider>
<App />
</CompareProvider>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -809,6 +809,7 @@ export async function getProductSubcategories(opts: {
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
export function roleName(roleid: number): string {
const map: Record<number, string> = {
[-1]: 'Unassigned',
0: 'Unassigned',
1: 'Owner',
2: 'Manager',
@@ -914,6 +915,9 @@ export interface UpdateUserInput {
state?: string;
postcode?: string;
status?: string;
roleid?: number | null;
locationid?: number | null;
applocationid?: number | null;
}
/** PUT /users/update — update an existing web staff user. */