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>
)}
</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>
<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>
{/* KPI Mini Sparkline Chart */}
<div className="w-16 h-7 select-none shrink-0">
<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>
</div>
)}
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
</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>
{/* 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>
{/* 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>
{/* 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]}
</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 className="flex justify-between items-center pt-2">
</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>
<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>
<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>
</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 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>
{/* 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>
)
)}
)
)}
</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. */