changes on global catalogue
This commit is contained in:
605
src/App.tsx
605
src/App.tsx
@@ -50,6 +50,7 @@ import StoreDetailView from './components/StoreDetailView';
|
|||||||
import LoginView from './components/LoginView';
|
import LoginView from './components/LoginView';
|
||||||
import UserStorePage from './components/UserStorePage';
|
import UserStorePage from './components/UserStorePage';
|
||||||
import AwaitingApi from './components/AwaitingApi';
|
import AwaitingApi from './components/AwaitingApi';
|
||||||
|
import ComparisonModal from './components/ComparisonModal';
|
||||||
import type { AuthUser } from './services/auth';
|
import type { AuthUser } from './services/auth';
|
||||||
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
import ragulStoreCover from './assets/images/store_front_view_1780299351800.png';
|
||||||
|
|
||||||
@@ -249,314 +250,314 @@ export default function App() {
|
|||||||
const handleLogout = () => setAuthUser(null);
|
const handleLogout = () => setAuthUser(null);
|
||||||
|
|
||||||
// Define secondary sections (Stores, Logistics, Staffing, Settings) within main body
|
// Define secondary sections (Stores, Logistics, Staffing, Settings) within main body
|
||||||
const renderSecondarySection = () => {
|
const renderStoresSection = () => {
|
||||||
switch (currentSection) {
|
const isSoleStore = !selectedStore && storesList.length === 1;
|
||||||
case 'stores': {
|
const activeStore = selectedStore ?? (isSoleStore ? storesList[0] : null);
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (activeStore) {
|
if (activeStore) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-md animate-in fade-in duration-300">
|
<div className="space-y-md animate-in fade-in duration-300">
|
||||||
{isSoleStore && (
|
{isSoleStore && (
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-4 border-b border-zinc-205">
|
<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">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
<h1 className="font-sans font-bold text-2xl tracking-tight text-[#0f172a]">
|
||||||
Stores Registry
|
Store Console
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 font-sans text-xs mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<StoreDetailView
|
||||||
|
store={activeStore}
|
||||||
|
onBack={selectedStore ? () => setSelectedStore(null) : undefined}
|
||||||
|
tenantId={tenantId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Filter control bar */}
|
return (
|
||||||
<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">
|
<div className="space-y-lg animate-in fade-in duration-300">
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
{/* 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">
|
{/* ── Search & Filter Toolbar ── */}
|
||||||
<button
|
<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">
|
||||||
onClick={() => setStoresFilter('ALL')}
|
{/* Search Input */}
|
||||||
className={`flex-1 md:flex-none px-4 py-1.5 rounded-md text-xs font-bold transition-all ${
|
<div className="relative flex-1 w-full group">
|
||||||
storesFilter === 'ALL'
|
<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">
|
||||||
? 'bg-white text-[#662582] shadow-sm'
|
<Search className="w-5 h-5" />
|
||||||
: '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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
{/* Empty States */}
|
type="text"
|
||||||
{filteredStoresList.length === 0 && (
|
placeholder="Search store by name, zone, or manager..."
|
||||||
<div className="text-center py-12 text-zinc-400 text-xs border border-dashed border-[#e2e8f0] rounded-xl bg-white p-8">
|
value={storesSearch}
|
||||||
{locationsQ.isLoading ? (
|
onChange={(e) => setStoresSearch(e.target.value)}
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
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="w-6 h-6 border-2 border-[#662582] border-t-transparent rounded-full animate-spin" />
|
/>
|
||||||
<span>Loading live store locations…</span>
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
</div>
|
{storesSearch && (
|
||||||
) : (
|
<button
|
||||||
<span>No store locations found matching your filter criteria.</span>
|
onClick={() => setStoresSearch('')}
|
||||||
)}
|
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
|
||||||
</div>
|
>
|
||||||
)}
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
{/* 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>
|
||||||
</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':
|
case 'settings':
|
||||||
return <SettingsView tenantId={tenantId} user={authUser ?? undefined} />;
|
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">
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
{/* Nav content routing */}
|
{/* Nav content routing */}
|
||||||
{currentSection === 'dashboard' && (
|
{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' && (
|
{currentSection === 'inventory' && (
|
||||||
@@ -647,8 +657,8 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Handle alternative sections: Stores, Settings */}
|
{/* Handle alternative sections: Settings */}
|
||||||
{['stores', 'settings'].includes(currentSection) &&
|
{['settings'].includes(currentSection) &&
|
||||||
renderSecondarySection()
|
renderSecondarySection()
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -787,6 +797,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ComparisonModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
144
src/components/BulkCartDrawer.tsx
Normal file
144
src/components/BulkCartDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
src/components/ComparisonModal.tsx
Normal file
174
src/components/ComparisonModal.tsx
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -232,152 +232,7 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '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: '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: '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 (
|
return (
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
|
import { TrendingUp, Clock, CheckCircle2, IndianRupee, Bike, Truck, Calendar, Store } from 'lucide-react';
|
||||||
import { useFiestaLocationSummary, useFiestaFleetSummary, useFiestaOrderSummary, useFiestaAllOrders, useFiestaRevenueSummary } from '../services/fiestaQueries';
|
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 { FIESTA_TENANT_ID, num as fnum, str as fstr, ymd, type Row } from '../services/fiestaApi';
|
||||||
import {
|
import {
|
||||||
GradientHeader, KpiStrip, Pill, StatusChip, MetricPill, FilterBar, TH_STYLE, TableShell,
|
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,
|
DELIVERY_STATUS, statusColor, BRAND, BRAND_LIGHT, TEXT, TEXT_2, TEXT_3, BORDER, DIVIDER, SURFACE_ALT, tint, soft, edge, ring,
|
||||||
|
Skeleton, Tooltip
|
||||||
} from './consoleUi';
|
} from './consoleUi';
|
||||||
|
|
||||||
type ReportTab = 'orders-summary' | 'riders-summary';
|
type ReportTab = 'orders-summary' | 'riders-summary';
|
||||||
@@ -50,35 +51,34 @@ export default function DeliveryReportsView({ searchQuery = '', tenantId = FIEST
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-in fade-in duration-300">
|
<div className="animate-in fade-in duration-300">
|
||||||
<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">
|
<FilterBar className="mb-4">
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3">
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 overflow-x-auto shrink-0">
|
||||||
<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>
|
{TABS.map((t) => {
|
||||||
{presets.map((p) => (
|
const Icon = t.icon;
|
||||||
<React.Fragment key={p.key}><Pill active={activePreset === p.key} color={BRAND} onClick={() => { setFromdate(p.from); setTodate(p.to); }}>{p.label}</Pill></React.Fragment>
|
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>
|
||||||
<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' }} />
|
<div className="flex items-center gap-4 flex-wrap lg:justify-end min-w-0">
|
||||||
<span style={{ color: TEXT_3 }}>→</span>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<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' }} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
@@ -143,35 +143,147 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
|||||||
|
|
||||||
const metricColor = metric === 'revenue' ? '#4f46e5' : '#0ea5e9';
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 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'}`}>
|
{/* Revenue */}
|
||||||
<div className={`p-3 rounded-xl ${metric === 'revenue' ? 'bg-indigo-100 text-indigo-600' : 'bg-slate-50 text-slate-400'}`}><IndianRupee size={20} /></div>
|
<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>
|
<div className="flex justify-between items-start w-full">
|
||||||
<p className="text-sm font-medium text-slate-500">Revenue</p>
|
<Tooltip content="Total revenue collected across all orders (excluding cancelled) in this period." position="bottom">
|
||||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">₹{totalRevenue.toLocaleString('en-IN')}</p>
|
<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>
|
</div>
|
||||||
</button>
|
</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>
|
{/* Orders */}
|
||||||
<div>
|
<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'}`}>
|
||||||
<p className="text-sm font-medium text-slate-500">Total Orders</p>
|
<div className="flex justify-between items-start w-full">
|
||||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.total.toLocaleString('en-IN')}</p>
|
<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>
|
</div>
|
||||||
</button>
|
</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>
|
{/* AOV */}
|
||||||
<div>
|
<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">
|
||||||
<p className="text-sm font-medium text-slate-500">Pending</p>
|
<div className="flex justify-between items-start w-full">
|
||||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.pending.toLocaleString('en-IN')}</p>
|
<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>
|
</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>
|
{/* Delivered */}
|
||||||
<div>
|
<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">
|
||||||
<p className="text-sm font-medium text-slate-500">Delivered</p>
|
<div className="flex justify-between items-start w-full">
|
||||||
<p className="text-2xl font-bold tracking-tight text-slate-900 mt-0.5">{totals.delivered.toLocaleString('en-IN')}</p>
|
<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>
|
</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 w-12">#</th>
|
||||||
<th className="px-5 py-4">Outlet</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">Revenue</th>
|
||||||
<th className="px-5 py-4 text-right">All</th>
|
<th className="px-5 py-4 text-right">AOV</th>
|
||||||
<th className="px-5 py-4 text-right">Created</th>
|
<th className="px-5 py-4 text-right">Orders</th>
|
||||||
<th className="px-5 py-4 text-right">Pending</th>
|
<th className="px-5 py-4 text-right">Fulfillment</th>
|
||||||
<th className="px-5 py-4 text-right">Processing</th>
|
<th className="px-5 py-4 text-right">Cancel Rate</th>
|
||||||
<th className="px-5 py-4 text-right">Delivered</th>
|
|
||||||
<th className="px-5 py-4 text-right">Cancelled</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<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>
|
{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={9} className="px-5 py-12 text-center text-slate-500 font-medium">No outlet data available.</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) => (
|
: rows.map((r, i) => {
|
||||||
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
|
const rowRev = locationRevenueMap.get(fnum(r.locationid)) ?? 0;
|
||||||
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
|
const rowAov = r.total > 0 ? rowRev / r.total : 0;
|
||||||
<td className="px-5 py-3 font-semibold text-slate-900">{r.locationname || `Location ${r.locationid}`}</td>
|
const rowFulfill = r.total > 0 ? (r.delivered / r.total) * 100 : 0;
|
||||||
<td className="px-5 py-3 text-right font-bold text-indigo-600">₹{(locationRevenueMap.get(fnum(r.locationid)) ?? 0).toLocaleString('en-IN')}</td>
|
const rowCancel = r.total > 0 ? (r.cancelled / r.total) * 100 : 0;
|
||||||
<td className="px-5 py-3 text-right font-bold text-slate-900">{r.total.toLocaleString('en-IN')}</td>
|
return (
|
||||||
<td className="px-5 py-3 text-right"><Cnt n={r.created} color="#0ea5e9" /></td>
|
<tr key={r.locationid || i} className="hover:bg-slate-50 transition-colors">
|
||||||
<td className="px-5 py-3 text-right"><Cnt n={r.pending} color="#f59e0b" /></td>
|
<td className="px-5 py-3 text-slate-400 font-medium">{i + 1}</td>
|
||||||
<td className="px-5 py-3 text-right"><Cnt n={r.processing} color="#6366f1" /></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"><Cnt n={r.delivered} color="#10b981" /></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"><Cnt n={r.cancelled} color="#ef4444" /></td>
|
<td className="px-5 py-3 text-right text-slate-600 font-medium">₹{Math.round(rowAov).toLocaleString('en-IN')}</td>
|
||||||
</tr>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,58 +347,119 @@ function OrdersSummaryReport({ tenantId, locationid, fromdate, todate }: { tenan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trend Chart */}
|
{/* Insights Bento */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
|
||||||
<TrendingUp size={20} className="text-indigo-500" />
|
{/* Trend Chart */}
|
||||||
<h3 className="text-base font-bold text-slate-900 tracking-tight">Trend Analysis</h3>
|
<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>
|
||||||
|
|
||||||
<div className="w-full h-80">
|
{/* Breakdown Stack */}
|
||||||
{ordersQ.isLoading ? (
|
<div className="flex flex-col gap-6">
|
||||||
<div className="w-full h-full flex items-center justify-center text-sm font-medium text-slate-500">Loading trend data...</div>
|
{/* Status Breakdown */}
|
||||||
) : chartData.length === 0 ? (
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-5 flex flex-col flex-1">
|
||||||
<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>
|
<div className="flex items-center gap-2 mb-4">
|
||||||
) : (
|
<CheckCircle2 size={16} className="text-emerald-500" />
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<h3 className="text-xs font-bold text-slate-900 tracking-tight uppercase">Order Status</h3>
|
||||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
</div>
|
||||||
<defs>
|
{statusData.length > 0 ? (
|
||||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
<div className="w-full h-40">
|
||||||
<stop offset="5%" stopColor={metricColor} stopOpacity={0.2} />
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<stop offset="95%" stopColor={metricColor} stopOpacity={0} />
|
<PieChart>
|
||||||
</linearGradient>
|
<Pie data={statusData} cx="50%" cy="50%" innerRadius={35} outerRadius={60} dataKey="value" paddingAngle={3} stroke="none">
|
||||||
</defs>
|
{statusData.map((entry, index) => (
|
||||||
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="#e2e8f0" />
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} dy={10}
|
))}
|
||||||
tickFormatter={formatLabel} />
|
</Pie>
|
||||||
<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 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' }} />
|
||||||
<RechartsTooltip
|
</PieChart>
|
||||||
cursor={{ stroke: metricColor, strokeWidth: 1, strokeDasharray: '4 4' }}
|
</ResponsiveContainer>
|
||||||
content={({ active, payload, label }: any) => {
|
</div>
|
||||||
if (active && payload && payload.length) {
|
) : (
|
||||||
return (
|
<div className="flex-1 flex items-center justify-center text-xs text-slate-400">No data</div>
|
||||||
<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-wrap justify-center gap-x-4 gap-y-2 mt-auto">
|
||||||
<div className="flex flex-col gap-1">
|
{statusData.map(s => (
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-600 capitalize">
|
<div key={s.name} className="flex items-center gap-1.5 text-[10px] font-bold text-slate-600">
|
||||||
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: metricColor }}></span>
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: s.color }} /> {s.name} ({s.value})
|
||||||
{metric}
|
</div>
|
||||||
</span>
|
))}
|
||||||
<span className="text-lg font-bold text-slate-900">
|
</div>
|
||||||
{metric === 'revenue' ? `₹${payload[0].value.toLocaleString('en-IN')}` : payload[0].value.toLocaleString('en-IN')}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
{/* Hourly Heatmap (Bar) */}
|
||||||
</div>
|
<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" />
|
||||||
return null;
|
<h3 className="text-xs font-bold text-slate-900 tracking-tight uppercase">Peak Hours</h3>
|
||||||
}}
|
</div>
|
||||||
/>
|
<div className="w-full h-40 mt-auto">
|
||||||
<Area type="monotone" dataKey={metric} stroke={metricColor} strokeWidth={2.5} fillOpacity={1} fill="url(#colorGradient)" activeDot={{ r: 6, strokeWidth: 2, stroke: '#fff', fill: metricColor }} />
|
{ordersQ.isLoading ? (
|
||||||
</AreaChart>
|
<Skeleton className="w-full h-[240px] rounded-xl" />
|
||||||
</ResponsiveContainer>
|
) : (
|
||||||
)}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -301,7 +487,7 @@ function RidersSummaryReport({ fromdate, todate, tenantId, locationid }: { fromd
|
|||||||
<KpiStrip items={kpis} loading={q.isLoading} />
|
<KpiStrip items={kpis} loading={q.isLoading} />
|
||||||
<TableShell minWidth={820} head={['#', 'Rider', 'Orders', 'Pending', 'Cancelled', 'Delivered', 'KMs', 'Amount']}
|
<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}>
|
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>
|
: 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.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) => (
|
: mapped.map((r, i) => (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -140,10 +140,10 @@ export default function OrdersDeliveriesView({
|
|||||||
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
const pct = (n: number) => (total > 0 ? Math.round((n / total) * 100) : 0);
|
||||||
|
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: 'Created Orders', value: countFor('created').toLocaleString('en-IN'), color: '#475569', icon: <ShoppingBag size={20} />, badge: `${pct(countFor('created'))}% 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: '#9a6700', icon: <Clock size={20} />, badge: `${pct(countFor('pending'))}% 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: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(countFor('delivered'))}% 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: '#b42318', icon: <XCircle size={20} />, badge: `${pct(countFor('cancelled'))}% 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
|
// CSV export
|
||||||
|
|||||||
@@ -198,10 +198,10 @@ export default function OrdersView({ searchQuery = '', locationid, tenantId = FI
|
|||||||
// Restrained, professional palette — deep muted tones (not neon) so the KPI
|
// Restrained, professional palette — deep muted tones (not neon) so the KPI
|
||||||
// strip reads as a serious business dashboard rather than a colourful one.
|
// strip reads as a serious business dashboard rather than a colourful one.
|
||||||
const kpis = [
|
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: '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: '#9a6700', icon: <Clock size={20} />, badge: `${pct(summary?.pending ?? 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: '#15803d', icon: <CheckCircle2 size={20} />, badge: `${pct(summary?.delivered ?? 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: '#b42318', icon: <XCircle size={20} />, badge: `${pct(summary?.cancelled ?? 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 }>) => {
|
const setScope = (next: Partial<{ status: StatusKey; from: string; to: string }>) => {
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Calendar,
|
Calendar,
|
||||||
Trophy,
|
Trophy,
|
||||||
Store
|
Store,
|
||||||
|
CheckCircle2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LeaderboardNode } from '../types';
|
import { LeaderboardNode } from '../types';
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
useFiestaOrderSummary,
|
useFiestaOrderSummary,
|
||||||
useFiestaRevenueSummary,
|
useFiestaRevenueSummary,
|
||||||
useFiestaTimeSeries,
|
useFiestaTimeSeries,
|
||||||
|
useFiestaStoresTimeSeries,
|
||||||
} from '../services/fiestaQueries';
|
} from '../services/fiestaQueries';
|
||||||
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, FIESTA_PRIMARY_LOCATION_ID, num as fnum, str as fstr, ymd } from '../services/fiestaApi';
|
||||||
import { stockRowToProduct } from '../services/fiestaMappers';
|
import { stockRowToProduct } from '../services/fiestaMappers';
|
||||||
@@ -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[] = (() => {
|
const leaderboard: LeaderboardNode[] = (() => {
|
||||||
let rows = [...filteredLocations];
|
const currentMonthIdx = new Date().getMonth();
|
||||||
rows = rows.sort((a, b) => b.total - a.total).slice(0, 4);
|
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;
|
const max = rows.length ? rows[0].total : 0;
|
||||||
return rows.map((r, i) => ({
|
return rows.map((r, i) => ({
|
||||||
rank: String(i + 1).padStart(2, '0'),
|
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,
|
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`,
|
revenue: `${r.total.toLocaleString()} ord`,
|
||||||
}));
|
}));
|
||||||
})();
|
})();
|
||||||
const currentLeaderboard = leaderboard;
|
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(
|
const heatmapMax = Math.max(
|
||||||
1,
|
1,
|
||||||
...insightRows.flatMap((row) => MONTH_KEYS.map((k) => fnum(row.months[k]))),
|
...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);
|
return ymd(d);
|
||||||
}, [chartTimeframe]);
|
}, [chartTimeframe]);
|
||||||
|
|
||||||
const tsQ = useFiestaTimeSeries({
|
const tsQ_all = useFiestaTimeSeries({
|
||||||
tenantid: tenantId,
|
tenantid: tenantId,
|
||||||
granularity: tsGranularity,
|
granularity: tsGranularity,
|
||||||
fromdate: tsFromDate,
|
fromdate: tsFromDate,
|
||||||
todate: todate,
|
todate: todate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tsQ_stores = useFiestaStoresTimeSeries(
|
||||||
|
tenantId,
|
||||||
|
mappedLocations,
|
||||||
|
tsGranularity,
|
||||||
|
tsFromDate,
|
||||||
|
todate
|
||||||
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
const data = tsQ.data || [];
|
const data = tsQ_all.data || [];
|
||||||
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
|
if (data.length === 0) return [{ label: 'No Data', value: 0 }];
|
||||||
|
|
||||||
return data.map((d: any) => {
|
return data.map((d: any) => {
|
||||||
@@ -461,49 +481,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
value: val
|
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]);
|
const maxChartVal = useMemo(() => Math.max(...chartData.map(d => d.value), 1), [chartData]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Primary KPI Row - 4 Key Tab buttons with Sparklines */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-gutter text-xs font-sans relative z-10">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-gutter text-xs font-sans relative z-10">
|
||||||
@@ -519,91 +503,76 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
|
|
||||||
if (kpi.color === 'indigo') {
|
if (kpi.color === 'indigo') {
|
||||||
activeStyles = isActive
|
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'
|
? 'border-indigo-200 bg-white shadow-[0_8px_24px_rgba(99,102,241,0.12)] ring-1 ring-indigo-500/20 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-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';
|
strokeColor = '#6366f1';
|
||||||
IconComponent = ShoppingBag;
|
IconComponent = ShoppingBag;
|
||||||
iconBg = 'bg-indigo-50 border border-indigo-100 shadow-[0_0_12px_rgba(99,102,241,0.2)]';
|
iconBg = 'bg-indigo-50 text-indigo-600 ring-1 ring-indigo-100';
|
||||||
iconTextColor = 'text-indigo-600';
|
|
||||||
} else if (kpi.color === 'emerald') {
|
} else if (kpi.color === 'emerald') {
|
||||||
activeStyles = isActive
|
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'
|
? 'border-emerald-200 bg-white shadow-[0_8px_24px_rgba(16,185,129,0.12)] ring-1 ring-emerald-500/20 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-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';
|
strokeColor = '#10b981';
|
||||||
IconComponent = Activity;
|
IconComponent = Activity;
|
||||||
iconBg = 'bg-emerald-50 border border-emerald-100 shadow-[0_0_12px_rgba(16,185,129,0.2)]';
|
iconBg = 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100';
|
||||||
iconTextColor = 'text-emerald-600';
|
|
||||||
} else if (kpi.color === 'rose') {
|
} else if (kpi.color === 'rose') {
|
||||||
activeStyles = isActive
|
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'
|
? 'border-rose-200 bg-white shadow-[0_8px_24px_rgba(244,63,94,0.12)] ring-1 ring-rose-500/20 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-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';
|
strokeColor = '#f43f5e';
|
||||||
IconComponent = AlertTriangle;
|
IconComponent = AlertTriangle;
|
||||||
iconBg = 'bg-rose-50 border border-rose-100 shadow-[0_0_12px_rgba(244,63,94,0.2)]';
|
iconBg = 'bg-rose-50 text-rose-600 ring-1 ring-rose-100';
|
||||||
iconTextColor = 'text-rose-600';
|
|
||||||
} else {
|
} else {
|
||||||
activeStyles = isActive
|
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'
|
? 'border-sky-200 bg-white shadow-[0_8px_24px_rgba(14,165,233,0.12)] ring-1 ring-sky-500/20 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-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';
|
strokeColor = '#0ea5e9';
|
||||||
IconComponent = Package;
|
IconComponent = Package;
|
||||||
iconBg = 'bg-sky-50 border border-sky-100 shadow-[0_0_12px_rgba(14,165,233,0.2)]';
|
iconBg = 'bg-sky-50 text-sky-600 ring-1 ring-sky-100';
|
||||||
iconTextColor = 'text-sky-600';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={kpi.id}
|
key={kpi.id}
|
||||||
onClick={() => setChartMetric(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 */}
|
<div className="flex justify-between items-start w-full">
|
||||||
{isActive && (
|
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">{kpi.title}</span>
|
||||||
<div className="absolute top-0 left-0 right-0 h-1" style={{ backgroundColor: strokeColor }} />
|
<div className="flex items-center gap-2">
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
{kpi.trend && (
|
{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'
|
<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.isPositive ? '▲' : '▼'}
|
|
||||||
{kpi.trend}
|
{kpi.trend}
|
||||||
</span>
|
</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>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Bottom Sparkline & Subtext segment */}
|
<div className="mt-2 flex justify-between items-end w-full">
|
||||||
<div className="flex items-center justify-between mt-auto pt-2 w-full border-t border-[#f1f5f9]">
|
<div>
|
||||||
<span className="text-[10px] text-zinc-550 font-bold uppercase tracking-wider">{kpi.status}</span>
|
{kpi.awaiting ? (
|
||||||
|
<div className="mt-1">
|
||||||
{/* KPI Mini Sparkline Chart */}
|
<AwaitingApi label="Revenue" api="[R1]" compact />
|
||||||
<div className="w-16 h-7 select-none shrink-0">
|
</div>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-lg font-extrabold tracking-tight font-mono text-slate-900">
|
||||||
|
{kpi.value}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">{kpi.status}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-12 h-6 select-none shrink-0 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
<svg className="w-full h-full" viewBox="0 0 64 32">
|
<svg className="w-full h-full" viewBox="0 0 64 32">
|
||||||
<path
|
<path
|
||||||
d={getSparkPath(kpi.spark, 64, 32)}
|
d={getSparkPath(kpi.spark, 64, 32)}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={strokeColor}
|
stroke={strokeColor}
|
||||||
strokeWidth="2"
|
strokeWidth="2.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -614,143 +583,33 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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) */}
|
{/* 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">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-gutter text-xs font-sans relative z-10">
|
||||||
|
|
||||||
{/* Revenue Heatmap table - 8 Cols */}
|
{/* 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">
|
<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-[#0f172a] uppercase tracking-widest block">
|
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||||
Monthly Order Distribution
|
Monthly Order Distribution
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-[9px] font-bold text-zinc-400 uppercase tracking-tight">
|
<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-[#662582] animate-pulse"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-purple-600 animate-pulse"></span>
|
||||||
<span>Busiest Month</span>
|
<span>Busiest Month</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-md flex-1 overflow-x-auto">
|
<div className="p-5 flex-1 overflow-x-auto">
|
||||||
{selectedCell && (
|
{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="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} />
|
<Info size={14} />
|
||||||
</div>
|
</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
|
{selectedCell.day} · {selectedCell.hour} : {selectedCell.val} orders
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,16 +623,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{insightRows.length === 0 ? (
|
{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.'}
|
{insightQ.isLoading ? 'Loading monthly order distribution…' : 'No order insight available for this region.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-center border-collapse text-xs font-sans">
|
<table className="w-full text-center border-collapse text-xs font-sans">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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) => (
|
{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}
|
{m}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
@@ -782,7 +641,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<tbody>
|
<tbody>
|
||||||
{insightRows.map((row, idx) => (
|
{insightRows.map((row, idx) => (
|
||||||
<tr key={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}
|
{row.name}
|
||||||
</td>
|
</td>
|
||||||
{MONTH_KEYS.map((key, mIdx) => {
|
{MONTH_KEYS.map((key, mIdx) => {
|
||||||
@@ -807,16 +666,16 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard nodes bar list - 4 Cols */}
|
{/* 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="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-[#f8fafc] border-b border-[#e2e8f0] px-md py-sm select-none">
|
<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-[#0f172a] uppercase tracking-widest block">
|
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
||||||
Top Performing Nodes
|
Top Performing Nodes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{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.
|
No active nodes in this region.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -825,34 +684,41 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
let badgeContent: React.ReactNode = node.rank;
|
let badgeContent: React.ReactNode = node.rank;
|
||||||
|
|
||||||
if (index === 0) {
|
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 = '🥇';
|
badgeContent = '🥇';
|
||||||
} else if (index === 1) {
|
} 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 = '🥈';
|
badgeContent = '🥈';
|
||||||
} else if (index === 2) {
|
} 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 = '🥉';
|
badgeContent = '🥉';
|
||||||
} else {
|
} else {
|
||||||
badgeStyle = 'bg-slate-100 text-zinc-505 border-slate-200';
|
badgeStyle = 'bg-slate-100 text-slate-500 border border-slate-200';
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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}
|
{badgeContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex justify-between items-center font-medium mb-1">
|
<div className="flex justify-between items-center font-medium mb-1.5">
|
||||||
<span className="text-[#0f172a] font-bold truncate max-w-[12rem]">{node.name}</span>
|
<span className="text-slate-900 font-bold truncate max-w-[12rem] flex items-center gap-1.5">
|
||||||
<span className="text-[#662582] font-mono font-bold">{node.revenue}</span>
|
{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>
|
||||||
|
|
||||||
<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
|
<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}%` }}
|
style={{ width: `${node.percentage}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -997,13 +863,6 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{filter}
|
{filter}
|
||||||
</button>
|
</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>
|
||||||
</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">Product Name</th>
|
||||||
<th className="px-md py-sm">SKU ID</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">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-right">Stock Status</th>
|
||||||
<th className="px-md py-sm text-center">Trend index</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[#f1f5f9]">
|
<tbody className="divide-y divide-[#f1f5f9]">
|
||||||
{paginatedProducts.length === 0 ? (
|
{paginatedProducts.length === 0 ? (
|
||||||
<tr>
|
<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.
|
No matching items matching stock filter criteria.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1076,33 +934,88 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{prod.stockStatus}
|
{prod.stockStatus}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
|
|
||||||
{/* Expanded details row */}
|
{/* Expanded details row */}
|
||||||
{isExpanded && (
|
{isExpanded && (() => {
|
||||||
<tr className="bg-slate-50/20">
|
const storeBreakdown = activeRegionStock.map(store => {
|
||||||
<td colSpan={7} className="p-0 border-b border-[#e2e8f0]">
|
if (!store.rows) return null;
|
||||||
<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">
|
const rawProdId = prod.sku.replace('SKU-', '');
|
||||||
{/* Per-product stock & location breakdown has no live
|
const row = store.rows.find(r =>
|
||||||
API ([R3]); the previously fabricated unit counts,
|
r.productid?.toString() === rawProdId ||
|
||||||
hub split, bin code, audit date and barcode are
|
r.productname === prod.name
|
||||||
replaced with the awaiting-backend placeholder. */}
|
);
|
||||||
<AwaitingApi label="Per-product stock & location detail" api="[R3]" compact />
|
if (!row) return null;
|
||||||
</div>
|
|
||||||
</td>
|
const closing = Number(row.closing || 0);
|
||||||
</tr>
|
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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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">
|
<div className="space-y-lg animate-in fade-in duration-300 relative font-sans text-slate-700">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="pb-4 border-b border-slate-100">
|
<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">
|
<div className="mt-3">
|
||||||
{tenantsQ.isLoading ? (
|
{tenantsQ.isLoading ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
<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>
|
<h2 className="text-xl font-bold text-slate-900 mt-1">Identity & Contacts</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo / Identity Row */}
|
{/* Identity Row */}
|
||||||
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center pb-6 border-b border-slate-100">
|
<div className="flex flex-col 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>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 leading-relaxed">
|
<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-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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export default function Sidebar({
|
|||||||
// Navigation elements
|
// Navigation elements
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard' as MainSection, label: 'Dashboard', icon: LayoutDashboard },
|
{ 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: 'inventory' as MainSection, label: 'Products', icon: Layers },
|
||||||
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
{ id: 'reports' as MainSection, label: 'Reports', icon: TrendingUp },
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings }
|
||||||
|
|||||||
@@ -20,12 +20,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Search, Boxes, Layers, Plus, Minus, Check, CheckCircle2, X, Store, PackageSearch } 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 { useFiestaStockStatement, FIESTA_TENANT_ID } from '../services/fiestaQueries';
|
||||||
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
import { num as fnum, str as fstr, type Row } from '../services/fiestaApi';
|
||||||
import { categoryName } from '../services/fiestaMappers';
|
import { categoryName } from '../services/fiestaMappers';
|
||||||
import { useStoreCatalogue } from '../services/storeCatalogue';
|
import { useStoreCatalogue } from '../services/storeCatalogue';
|
||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
|
import { SlideDrawer } from './consoleUi';
|
||||||
|
import { useCompare } from '../contexts/CompareContext';
|
||||||
|
|
||||||
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
const PLACEHOLDER = 'https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&q=80&w=200';
|
||||||
|
|
||||||
@@ -52,10 +54,13 @@ function catBadgeClass(category: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
export default function StoreCatalogView({ locationid, storeName = 'your store', tenantId = FIESTA_TENANT_ID }: StoreCatalogViewProps) {
|
||||||
|
const { selectedProducts, toggleProduct, setIsComparing } = useCompare();
|
||||||
const tenantid = tenantId;
|
const tenantid = tenantId;
|
||||||
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
|
const [view, setView] = useState<'catalogue' | 'inventory'>('catalogue');
|
||||||
const [search, setSearch] = useState('');
|
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);
|
const [notice, setNotice] = useState(false);
|
||||||
|
|
||||||
// The admin-curated catalogue (what the user is allowed to pick from).
|
// The admin-curated catalogue (what the user is allowed to pick from).
|
||||||
@@ -107,7 +112,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
const inventory = useMemo(
|
const inventory = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(stockQ.data ?? []).map((r: Row) => {
|
(stockQ.data ?? []).map((r: Row) => {
|
||||||
const closing = fnum(r.closing);
|
const closing = fnum(r.closing) ?? 0;
|
||||||
return {
|
return {
|
||||||
id: fstr(r.productid),
|
id: fstr(r.productid),
|
||||||
name: fstr(r.productname) || 'Unnamed product',
|
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));
|
return inventory.filter((it) => it.name.toLowerCase().includes(term) || it.category.toLowerCase().includes(term) || it.id.toLowerCase().includes(term));
|
||||||
}, [inventory, search]);
|
}, [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 filtered = useMemo(() => {
|
||||||
const term = search.toLowerCase();
|
const term = search.toLowerCase();
|
||||||
return products.filter((p) => {
|
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;
|
if (!term) return true;
|
||||||
return p.name.toLowerCase().includes(term) || p.category.toLowerCase().includes(term) || p.id.toLowerCase().includes(term);
|
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 ──────────────────────────────────────────────────────────
|
// ── Integration point ──────────────────────────────────────────────────────────
|
||||||
// Replace with the real request/stock POST (selected productids + quantities),
|
// Replace with the real request/stock POST (selected productids + quantities),
|
||||||
@@ -143,13 +166,7 @@ export default function StoreCatalogView({ locationid, storeName = 'your store',
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
<div className="space-y-lg animate-in fade-in duration-300 font-sans pb-28">
|
||||||
{/* 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 */}
|
{/* 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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
<div className="flex flex-col md:flex-row gap-6 items-start mt-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center gap-md bg-white border border-[#eceef2] p-md rounded-2xl shadow-sm">
|
{/* Sticky Sidebar Filter */}
|
||||||
<div className="relative w-full md:w-80 md:shrink-0">
|
<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">
|
||||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
<div>
|
||||||
<input
|
<h3 className="text-[11px] font-extrabold text-slate-400 uppercase tracking-widest mb-3 flex items-center gap-1.5">
|
||||||
type="text"
|
<Search size={14} className="text-[#662582]" /> Search
|
||||||
placeholder={view === 'catalogue' ? 'Search catalogue products…' : 'Search your stock…'}
|
</h3>
|
||||||
value={search}
|
<div className="relative">
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<input
|
||||||
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"
|
type="text"
|
||||||
/>
|
placeholder="Product name or SKU..."
|
||||||
{search && (
|
value={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>
|
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>
|
</div>
|
||||||
{view === 'catalogue' && categories.length > 0 && (
|
|
||||||
<div className="flex items-center gap-sm flex-wrap">
|
{/* Product Grid Area */}
|
||||||
<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>
|
<div className="flex-1 min-w-0 max-h-[850px] overflow-y-auto custom-scrollbar pr-4">
|
||||||
<select
|
{/* Mobile Search Input (Visible only on mobile) */}
|
||||||
value={category}
|
<div className="md:hidden mb-4">
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
<div className="relative">
|
||||||
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"
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" />
|
||||||
>
|
<input
|
||||||
<option value="ALL">All categories</option>
|
type="text"
|
||||||
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
placeholder={view === 'catalogue' ? 'Search catalogue...' : 'Search your stock...'}
|
||||||
</select>
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-9 py-3 border border-[#e2e8f0] rounded-xl text-sm font-semibold outline-none bg-white focus:ring-2 focus:ring-purple-500/20 shadow-sm"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 cursor-pointer">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="md:ml-auto text-[11px] font-semibold text-zinc-400">
|
{/* Results Summary & Toolbar */}
|
||||||
{view === 'catalogue' ? `${filtered.length} products` : `${inventory.length} stocked`}
|
<div className="flex justify-between items-end mb-4 px-1">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-slate-900">
|
||||||
|
{view === 'catalogue' ? 'Global Catalogue' : 'My Inventory'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Showing {view === 'catalogue' ? filtered.length : finalFilteredInventory.length} results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Can add sorting dropdown here if needed */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Browse Catalogue ── */}
|
||||||
|
{view === 'catalogue' && (
|
||||||
|
products.length === 0 ? (
|
||||||
|
<CenterState
|
||||||
|
icon={<PackageSearch size={34} />}
|
||||||
|
title="No products published yet"
|
||||||
|
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
|
||||||
|
/>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<CenterState
|
||||||
|
icon={<Boxes size={34} />}
|
||||||
|
title="No products match your search"
|
||||||
|
sub="Try a different keyword or clear the filters to see the full catalogue."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8">
|
||||||
|
{filtered.map((p) => {
|
||||||
|
const stocked = inStore.has(p.id);
|
||||||
|
const picked = picks[p.id] != null;
|
||||||
|
return (
|
||||||
|
<div key={p.id} onClick={() => setSelectedProduct(p)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer">
|
||||||
|
{/* Image Bento Block */}
|
||||||
|
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
||||||
|
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
||||||
|
|
||||||
|
{stocked && (
|
||||||
|
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10">
|
||||||
|
<CheckCircle2 size={12} className="text-emerald-500" />
|
||||||
|
<span className="text-[9px] font-black uppercase text-emerald-700 tracking-wider">In Store</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(p.category)}`}>
|
||||||
|
{p.category.split(' / ')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Bento Block */}
|
||||||
|
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{p.name}</h4>
|
||||||
|
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{p.sku}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleProduct({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
sku: p.sku,
|
||||||
|
category: p.category.split(' / ')[0],
|
||||||
|
price: p.price,
|
||||||
|
image: p.image,
|
||||||
|
unit: p.unit
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="relative flex items-center justify-center cursor-pointer group/cb">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="peer appearance-none w-5 h-5 bg-slate-50 border border-slate-300 rounded-md checked:bg-[#662582] checked:border-[#662582] transition-all"
|
||||||
|
checked={selectedProducts.some(sp => String(sp.id) === String(p.id))}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto bg-slate-50 rounded-xl p-2.5 border border-slate-100 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Price</span>
|
||||||
|
<span className="font-extrabold text-slate-800 font-mono text-sm">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-0.5">Unit</span>
|
||||||
|
<span className="font-bold text-slate-600 text-[11px] bg-white px-1.5 py-0.5 rounded shadow-sm border border-slate-100">{p.unit || 'Pc'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Bento Block */}
|
||||||
|
<div className="pt-1">
|
||||||
|
{picked ? (
|
||||||
|
<div className="flex items-center justify-between gap-1 p-1.5 bg-emerald-50 rounded-xl border border-emerald-200 shadow-inner">
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-emerald-100 text-emerald-600"><Check size={14} /></span>
|
||||||
|
<div className="flex items-center gap-1 bg-white rounded-lg p-0.5 shadow-sm border border-emerald-100">
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); setPickQty(p.id, picks[p.id] - 1); }} className="w-6 h-6 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-600 font-bold flex items-center justify-center transition-colors"><Minus size={12} /></button>
|
||||||
|
<span className="w-6 text-center font-mono font-bold text-xs text-slate-900">{picks[p.id]}</span>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); setPickQty(p.id, picks[p.id] + 1); }} className="w-6 h-6 rounded-md bg-slate-50 hover:bg-slate-100 text-slate-600 font-bold flex items-center justify-center transition-colors"><Plus size={12} /></button>
|
||||||
|
</div>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); togglePick(p.id); }} title="Remove" className="w-7 h-7 rounded-lg text-rose-500 hover:bg-rose-50 hover:text-rose-600 bg-white border border-rose-100 flex items-center justify-center transition-colors shadow-sm ml-1"><X size={14} /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); togglePick(p.id); }}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-[11px] font-bold transition-all bg-white text-purple-700 hover:bg-purple-600 hover:text-white hover:shadow-md border border-purple-200 hover:border-purple-600"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Add to Store
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── My Store Inventory ── */}
|
||||||
|
{view === 'inventory' && (
|
||||||
|
stockQ.isLoading ? (
|
||||||
|
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
|
||||||
|
) : !locationid ? (
|
||||||
|
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
|
||||||
|
) : inventory.length === 0 ? (
|
||||||
|
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
|
||||||
|
) : finalFilteredInventory.length === 0 ? (
|
||||||
|
<CenterState
|
||||||
|
icon={<Boxes size={34} />}
|
||||||
|
title="No stock matches your filters"
|
||||||
|
sub="Try a different keyword or clear the sidebar filters to find an item in your store."
|
||||||
|
action={
|
||||||
|
<button onClick={() => { setSearch(''); setSelectedCategories([]); setStockHealthFilter([]); }} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
|
||||||
|
<X size={13} /> Clear filters
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{finalFilteredInventory.map((it, i) => (
|
||||||
|
<div key={it.id || i} onClick={() => setSelectedProduct(it)} className="bg-white border border-slate-200 rounded-2xl flex flex-col shadow-sm hover:shadow-xl hover:border-purple-300 hover:-translate-y-1.5 transition-all duration-300 relative group cursor-pointer">
|
||||||
|
{/* Image Bento Block */}
|
||||||
|
<div className="w-full h-32 bg-slate-50 rounded-t-2xl relative overflow-hidden shrink-0 border-b border-slate-100 p-2">
|
||||||
|
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover rounded-xl group-hover:scale-105 transition-transform duration-500 ease-out" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 rounded-t-2xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="absolute top-3 right-3 bg-white/90 backdrop-blur-md px-1.5 py-1 rounded-md shadow flex items-center gap-1 z-10" style={{ color: it.color }}>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-wider">{it.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-1 rounded-md text-[9px] font-black uppercase shadow-sm tracking-wider ${catBadgeClass(it.category)}`}>
|
||||||
|
{it.category.split(' / ')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Bento Block */}
|
||||||
|
<div className="p-3.5 flex flex-col flex-1 gap-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-slate-900 text-xs leading-snug group-hover:text-purple-700 transition-colors line-clamp-2">{it.name}</h4>
|
||||||
|
<p className="text-[10px] text-slate-400 font-bold font-mono tracking-tight mt-1">{it.sku}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleProduct({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
sku: it.sku,
|
||||||
|
category: it.category.split(' / ')[0],
|
||||||
|
price: it.price,
|
||||||
|
image: it.image,
|
||||||
|
closing: it.closing,
|
||||||
|
label: it.label,
|
||||||
|
color: it.color,
|
||||||
|
unit: it.unit
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="relative flex items-center justify-center cursor-pointer group/cb">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="peer appearance-none w-5 h-5 bg-slate-50 border border-slate-300 rounded-md checked:bg-[#662582] checked:border-[#662582] transition-all"
|
||||||
|
checked={selectedProducts.some(sp => String(sp.id) === String(it.id))}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<Check size={12} className="absolute text-white opacity-0 peer-checked:opacity-100 pointer-events-none" strokeWidth={3} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto bg-slate-50 rounded-xl p-3 border border-slate-100 text-center">
|
||||||
|
<span className="text-[9px] text-slate-400 block uppercase tracking-wider font-extrabold mb-1">Live Stock</span>
|
||||||
|
<span className="font-black font-mono text-xl" style={{ color: it.color }}>{(it.closing ?? 0).toLocaleString('en-IN')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Browse Catalogue ── */}
|
{/* ── Selection FAB ── */}
|
||||||
{view === 'catalogue' && (
|
|
||||||
products.length === 0 ? (
|
|
||||||
<CenterState
|
|
||||||
icon={<PackageSearch size={34} />}
|
|
||||||
title="No products published yet"
|
|
||||||
sub="Your admin hasn't added any products to the catalogue. Once they do, they'll appear here automatically for you to select."
|
|
||||||
/>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<CenterState
|
|
||||||
icon={<Boxes size={34} />}
|
|
||||||
title="No products match your search"
|
|
||||||
sub="Try a different keyword or clear the filters to see the full catalogue."
|
|
||||||
action={
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearch(''); setCategory('ALL'); }}
|
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<X size={13} /> Clear filters
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
|
||||||
{filtered.map((p) => {
|
|
||||||
const stocked = inStore.has(p.id);
|
|
||||||
const picked = picks[p.id] != null;
|
|
||||||
return (
|
|
||||||
<div key={p.id} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
|
|
||||||
<div className="flex gap-md">
|
|
||||||
{/* Thumbnail with hover zoom */}
|
|
||||||
<div className="w-32 h-32 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
|
|
||||||
<img src={p.image} alt={p.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
|
||||||
{stocked && (
|
|
||||||
<span className="absolute top-1 right-1 inline-flex items-center justify-center w-4 h-4 rounded-full bg-emerald-500 text-white shadow" title="In your store"><CheckCircle2 size={10} /></span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#662582] transition-colors">{p.name}</h4>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{p.sku}</span>
|
|
||||||
</div>
|
|
||||||
{/* Category pill badge */}
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(p.category)}`}>
|
|
||||||
{p.category.split(' / ')[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Price</span>
|
|
||||||
<span className="font-extrabold text-zinc-700 font-mono text-xs">{p.price > 0 ? `₹${p.price.toLocaleString('en-IN')}` : '—'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stocked-status row (mirrors the admin card's status line) */}
|
|
||||||
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight ${stocked ? 'text-emerald-600' : 'text-zinc-400'}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${stocked ? 'bg-emerald-500 animate-pulse' : 'bg-zinc-300'}`} />
|
|
||||||
{stocked ? 'In Your Store' : 'Not stocked yet'}
|
|
||||||
</span>
|
|
||||||
{p.unit && <span className="text-[9px] text-zinc-400 font-semibold">{p.unit}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pick action: quantity stepper when selected, else add button */}
|
|
||||||
{picked ? (
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-2.5 border-t border-[#f1f5f9] mt-1">
|
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-[#662582]"><Check size={12} /> Selected</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button onClick={() => setPickQty(p.id, picks[p.id] - 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Minus size={12} /></button>
|
|
||||||
<span className="w-8 text-center font-mono font-bold text-xs text-[#0f172a]">{picks[p.id]}</span>
|
|
||||||
<button onClick={() => setPickQty(p.id, picks[p.id] + 1)} className="w-6 h-6 rounded-lg border border-[#e2e8f0] text-zinc-500 hover:bg-zinc-50 font-bold cursor-pointer leading-none flex items-center justify-center"><Plus size={12} /></button>
|
|
||||||
<button onClick={() => togglePick(p.id)} title="Remove" className="ml-1 w-6 h-6 rounded-lg text-rose-500 hover:bg-rose-50 flex items-center justify-center cursor-pointer"><X size={13} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => togglePick(p.id)}
|
|
||||||
className="w-full flex items-center justify-center gap-1.5 pt-2.5 mt-1 border-t border-[#f1f5f9] text-[11px] font-bold text-[#662582] hover:text-purple-800 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Plus size={13} /> Add to Store
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── My Store Inventory ── (card grid — same design as Browse Catalogue) */}
|
|
||||||
{view === 'inventory' && (
|
|
||||||
stockQ.isLoading ? (
|
|
||||||
<CenterState icon={<Store size={34} />} title="Loading your stock…" sub="Fetching the latest stock levels for your store." />
|
|
||||||
) : !locationid ? (
|
|
||||||
<CenterState icon={<Store size={34} />} title="No store linked yet" sub="Your account isn't linked to a store outlet, so there's no inventory to show." />
|
|
||||||
) : inventory.length === 0 ? (
|
|
||||||
<CenterState icon={<PackageSearch size={34} />} title="No products stocked yet" sub="Add products from the catalogue and they'll appear here with live stock levels." />
|
|
||||||
) : filteredInventory.length === 0 ? (
|
|
||||||
<CenterState
|
|
||||||
icon={<Boxes size={34} />}
|
|
||||||
title="No stock matches your search"
|
|
||||||
sub="Try a different keyword to find an item in your store."
|
|
||||||
action={
|
|
||||||
<button onClick={() => setSearch('')} className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-xs font-bold text-white bg-[#662582] hover:bg-purple-800 transition shadow-sm cursor-pointer">
|
|
||||||
<X size={13} /> Clear search
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-md">
|
|
||||||
{filteredInventory.map((it, i) => (
|
|
||||||
<div key={it.id || i} className="bg-white/80 backdrop-blur-md border border-[#e2e8f0] rounded-2xl p-md flex flex-col justify-between gap-sm shadow-sm hover:shadow-[0_12px_24px_rgba(99,102,241,0.06)] hover:border-purple-200 hover:-translate-y-0.5 transition-all duration-300 relative group">
|
|
||||||
<div className="flex gap-md">
|
|
||||||
{/* Thumbnail with status corner dot */}
|
|
||||||
<div className="w-32 h-32 rounded-xl border border-zinc-100 shrink-0 overflow-hidden bg-zinc-50 relative">
|
|
||||||
<img src={it.image} alt={it.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
|
||||||
<span className="absolute top-1 right-1 w-3 h-3 rounded-full border-2 border-white shadow" style={{ background: it.color }} title={it.label} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-1 min-w-0">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="font-bold text-[#0f172a] leading-tight text-xs truncate group-hover:text-[#662582] transition-colors">{it.name}</h4>
|
|
||||||
<span className="text-[10px] text-zinc-400 font-bold font-mono tracking-tight">{it.sku}</span>
|
|
||||||
</div>
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-[8px] font-extrabold uppercase shrink-0 ${catBadgeClass(it.category)}`}>
|
|
||||||
{it.category.split(' / ')[0]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-2">
|
|
||||||
<div>
|
|
||||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">In Stock</span>
|
|
||||||
<span className="font-black font-mono text-xs" style={{ color: it.color }}>{it.closing.toLocaleString('en-IN')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-[8px] text-zinc-400 block uppercase tracking-wider font-extrabold">Status</span>
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border" style={{ background: `${it.color}14`, color: it.color, borderColor: `${it.color}40` }}>{it.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status line (mirrors the catalogue card's footer) */}
|
|
||||||
<div className="flex justify-between items-center pt-2.5 border-t border-[#f1f5f9] mt-1 select-none">
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold tracking-tight" style={{ color: it.color }}>
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: it.color }} />
|
|
||||||
{it.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] text-zinc-400 font-semibold">{it.category.split(' / ')[0]}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Selection action bar ── */}
|
|
||||||
{view === 'catalogue' && pickCount > 0 && (
|
{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="fixed bottom-6 right-6 z-[120]">
|
||||||
<div className="bg-[#0f172a] text-white rounded-2xl shadow-2xl border border-white/10 px-4 py-3">
|
{notice ? (
|
||||||
{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="space-y-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested</span>
|
||||||
<span className="text-xs font-bold">{pickCount} product{pickCount > 1 ? 's' : ''} requested for {storeName}</span>
|
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
||||||
<button onClick={() => { setPicks({}); setNotice(false); }} className="text-[11px] font-semibold text-purple-200 hover:text-white cursor-pointer">Clear</button>
|
|
||||||
</div>
|
|
||||||
<AwaitingApi label="Submitting your store request" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<AwaitingApi label="Submitting to store" api="stock-request API" compact className="bg-white/5 border-white/15 text-purple-100" />
|
||||||
<div className="flex items-center justify-between gap-3">
|
</div>
|
||||||
<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>
|
<button
|
||||||
<div className="min-w-0">
|
onClick={commitSelectionToStore}
|
||||||
<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>
|
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"
|
||||||
<p className="text-[10px] text-purple-200">Selected for {storeName}</p>
|
>
|
||||||
</div>
|
<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">
|
||||||
</div>
|
{pickCount}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="text-left">
|
||||||
</div>
|
<span className="block text-sm font-bold uppercase tracking-wider">Save Picks</span>
|
||||||
|
<span className="block text-[10px] text-purple-200">{Object.values(picks).reduce((a: number, b: number) => a + b, 0)} items total</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 w-8 h-8 rounded-full bg-white text-purple-700 flex items-center justify-center shadow-md">
|
||||||
|
<Check size={16} strokeWidth={3} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ADD/EDIT PRODUCT MODAL (simulated) */}
|
||||||
|
<SlideDrawer
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -416,28 +619,11 @@ function CenterState({ icon, title, sub, action }: { icon: React.ReactNode; titl
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight">{title}</h3>
|
<h3 className="font-bold text-lg text-[#0f172a] tracking-tight text-center">{title}</h3>
|
||||||
{sub && <p className="text-sm text-zinc-500 mt-2 max-w-md leading-relaxed">{sub}</p>}
|
{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>}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"
|
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} />
|
<ArrowLeft size={14} />
|
||||||
<span>Back to Registry</span>
|
<span>Back to Dashboard</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
234
src/components/TrialBatchDrawer.tsx
Normal file
234
src/components/TrialBatchDrawer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ import DispatchHubView from './DispatchHubView';
|
|||||||
import DeliveryReportsView from './DeliveryReportsView';
|
import DeliveryReportsView from './DeliveryReportsView';
|
||||||
import StoreQRView from './StoreQRView';
|
import StoreQRView from './StoreQRView';
|
||||||
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
import UserStoreSidebar, { type UserNavItem } from './UserStoreSidebar';
|
||||||
|
import ComparisonModal from './ComparisonModal';
|
||||||
|
|
||||||
interface UserStorePageProps {
|
interface UserStorePageProps {
|
||||||
/** Returns to the login screen. */
|
/** Returns to the login screen. */
|
||||||
@@ -352,6 +353,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ComparisonModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
Bike,
|
Bike,
|
||||||
Clock
|
Clock
|
||||||
} from 'lucide-react';
|
} 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 { useAppRoles } from '../services/queries';
|
||||||
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
import { FIESTA_TENANT_ID, str as fstr, num as fnum, roleName } from '../services/fiestaApi';
|
||||||
import AddressAutocomplete, { type AddressResult } from './AddressAutocomplete';
|
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) {
|
export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUserRole = 4 }: UsersPanelProps) {
|
||||||
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
const usersQ = useFiestaUsers({ tenantid: tenantId, pagesize: 100 });
|
||||||
const createUserMut = useFiestaCreateUser();
|
const createUserMut = useFiestaCreateUser();
|
||||||
|
const updateUserMut = useFiestaUpdateUser();
|
||||||
const rolesQ = useAppRoles();
|
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,
|
// 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.
|
// 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.
|
// 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) {
|
if (!newUser.locationid && storeOptions.length === 1) {
|
||||||
setNewUser((u) => ({ ...u, locationid: storeOptions[0].locationid, applocationid: storeOptions[0].applocationid }));
|
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
|
// 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.
|
// 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 roleOptions = React.useMemo(() => {
|
||||||
const roles = new Set(users.map((u) => u.role));
|
const roles = new Set(users.map((u) => u.role));
|
||||||
|
roles.add('Admin');
|
||||||
roles.add('Rider');
|
roles.add('Rider');
|
||||||
return Array.from(roles);
|
return Array.from(roles);
|
||||||
}, [users]);
|
}, [users]);
|
||||||
@@ -280,25 +306,25 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filter Utility Bar */}
|
{/* 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="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-md shrink-0">
|
<div className="relative w-full md:max-w-sm 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">
|
<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-4 flex items-center pointer-events-none text-slate-400 transition-colors">
|
<span className="pl-1 flex items-center pointer-events-none text-slate-400">
|
||||||
<Search size={18} strokeWidth={2.5} />
|
<Search size={16} strokeWidth={3} />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search team members..."
|
placeholder="Search team members..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
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 && (
|
{search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearch('')}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +359,7 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Directory Grid */}
|
{/* 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 ? (
|
{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">
|
<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.'}
|
{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) => {
|
filteredUsers.map((u) => {
|
||||||
const roleInfo = ROLE_THEMES[u.roleid] || { bg: 'bg-slate-55', text: 'text-slate-700', border: 'border-slate-100', label: u.role };
|
const roleInfo = ROLE_THEMES[u.roleid] || { bg: 'bg-slate-55', text: 'text-slate-700', border: 'border-slate-100', label: u.role };
|
||||||
return (
|
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 */}
|
{/* 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>
|
||||||
<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 */}
|
{/* User Avatar with status indicator ring */}
|
||||||
<div className="relative shrink-0 select-none">
|
<div className="relative shrink-0 select-none">
|
||||||
<img
|
<img
|
||||||
src={u.avatar}
|
src={u.avatar}
|
||||||
alt={u.name}
|
alt={u.name}
|
||||||
referrerPolicy="no-referrer"
|
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'
|
u.status.toLowerCase() === 'active' ? 'bg-emerald-500' : 'bg-slate-350'
|
||||||
}`} />
|
}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata fields */}
|
{/* Metadata fields */}
|
||||||
<div className="mt-5 space-y-2.5 border-t border-slate-100/75 pt-4 text-xs">
|
<div className="mt-4 space-y-2 border-t border-slate-100/75 pt-3 text-[11px]">
|
||||||
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
|
<div className="flex items-center gap-2 text-slate-600 font-medium">
|
||||||
<Phone size={14} className="text-slate-400 shrink-0" />
|
<Phone size={13} className="text-slate-400 shrink-0" />
|
||||||
<span className="font-mono">{u.contact}</span>
|
<span className="font-mono">{u.contact}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
|
<div className="flex items-center gap-2 text-slate-600 font-medium">
|
||||||
<MapPin size={14} className="text-slate-400 shrink-0" />
|
<MapPin size={13} className="text-slate-400 shrink-0" />
|
||||||
<span className="truncate">{u.location}</span>
|
<span className="truncate">{u.location}</span>
|
||||||
</div>
|
</div>
|
||||||
{u.shift && u.shift !== '—' && (
|
{u.shift && u.shift !== '—' && (
|
||||||
<div className="flex items-center gap-2.5 text-slate-600 font-medium">
|
<div className="flex items-center gap-2 text-slate-600 font-medium">
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-extrabold">Shift</span>
|
<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.5 py-0.5 rounded-lg border border-slate-150 text-xs">{u.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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 pt-4 border-t border-slate-100/75 flex justify-between items-center select-none">
|
<div className="mt-4 pt-3 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}`}>
|
<div className="flex items-center gap-2">
|
||||||
{u.roleid === 1 && <ShieldAlert size={12} />}
|
<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 === 2 && <Shield size={12} />}
|
{u.roleid === 1 && <ShieldAlert size={10} />}
|
||||||
{u.roleid === 3 && <SlidersHorizontal size={12} />}
|
{u.roleid === 2 && <Shield size={10} />}
|
||||||
{u.roleid === 4 && <User size={12} />}
|
{u.roleid === 3 && <SlidersHorizontal size={10} />}
|
||||||
{u.roleid === 5 && <Bike size={12} />}
|
{u.roleid === 4 && <User size={10} />}
|
||||||
{u.roleid === 6 && <Coins size={12} />}
|
{u.roleid === 5 && <Bike size={10} />}
|
||||||
{roleInfo.label}
|
{u.roleid === 6 && <Coins size={10} />}
|
||||||
</span>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -615,6 +656,99 @@ export default function UsersPanel({ tenantId = FIESTA_TENANT_ID, defaultNewUser
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
* source's MUI `sx`) while layout/spacing use Tailwind.
|
* 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 ────────────────────────────────────────────────────────────────
|
// ── Design tokens ────────────────────────────────────────────────────────────────
|
||||||
export const BRAND = '#662582';
|
export const BRAND = '#662582';
|
||||||
@@ -141,32 +142,28 @@ export function KpiStrip({ items, loading }: { items: KpiItem[]; loading?: boole
|
|||||||
<div
|
<div
|
||||||
key={it.label}
|
key={it.label}
|
||||||
onClick={it.onClick}
|
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' : ''}`}
|
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' : ''}`}
|
||||||
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'; }}
|
|
||||||
>
|
>
|
||||||
<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 justify-between items-start w-full">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">
|
||||||
<div className="min-w-0">
|
{it.label}
|
||||||
<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}
|
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -328,3 +325,91 @@ export function TableShell({ head, children, minWidth, footer }: { head: string[
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
84
src/contexts/CompareContext.tsx
Normal file
84
src/contexts/CompareContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@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;
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
|
||||||
--spacing-xs: 4px;
|
--spacing-xs: 4px;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client';
|
|||||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import { CompareProvider } from './contexts/CompareContext.tsx';
|
||||||
|
|
||||||
// How often every page silently re-syncs with the backend. Orders/deliveries
|
// How often every page silently re-syncs with the backend. Orders/deliveries
|
||||||
// statuses change out-of-band (riders accept/pick/deliver, customers place
|
// statuses change out-of-band (riders accept/pick/deliver, customers place
|
||||||
@@ -35,7 +36,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<CompareProvider>
|
||||||
|
<App />
|
||||||
|
</CompareProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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). */
|
/** Best-effort role label from the numeric roleid (roles aren't fully resolvable for every config). */
|
||||||
export function roleName(roleid: number): string {
|
export function roleName(roleid: number): string {
|
||||||
const map: Record<number, string> = {
|
const map: Record<number, string> = {
|
||||||
|
[-1]: 'Unassigned',
|
||||||
0: 'Unassigned',
|
0: 'Unassigned',
|
||||||
1: 'Owner',
|
1: 'Owner',
|
||||||
2: 'Manager',
|
2: 'Manager',
|
||||||
@@ -914,6 +915,9 @@ export interface UpdateUserInput {
|
|||||||
state?: string;
|
state?: string;
|
||||||
postcode?: string;
|
postcode?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
roleid?: number | null;
|
||||||
|
locationid?: number | null;
|
||||||
|
applocationid?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PUT /users/update — update an existing web staff user. */
|
/** PUT /users/update — update an existing web staff user. */
|
||||||
|
|||||||
Reference in New Issue
Block a user